diff --git a/.fixtures/README.md b/.fixtures/README.md index a30d47746..c1c605b0c 100644 --- a/.fixtures/README.md +++ b/.fixtures/README.md @@ -35,3 +35,7 @@ for the current architecture. - `runs/fixture-curation/fixture-curation-2026-06-05T104440Z/` — dev-seed-fixtures tracer proving a Bilal-derived explicit base seed can be expanded through the real `propose-graph`/`commit_graph` product path with implicit graph readback. +- `runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/` — + FE-809 tracer proving a Bilal-derived explicit base seed can drive real + `project-graph` proposal generation through `present_review_set`, public RPC + review approval, and explicit-basis graph readback. 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-snapshot.json new file mode 100644 index 000000000..8ffe2bdca --- /dev/null +++ b/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/graph-snapshot.json @@ -0,0 +1,1078 @@ +{ + "nodes": [ + { + "id": 6, + "specId": 1, + "plane": "intent", + "kind": "constraint", + "kindOrdinal": 1, + "title": "The design language explicitly prohibits generic UI patterns such as Material Design, Tailwind defaults, SaaS dashboard aesthetics, rounded…", + "body": "The design language explicitly prohibits generic UI patterns such as Material Design, Tailwind defaults, SaaS dashboard aesthetics, rounded corners, or blue primary buttons.", + "basis": "explicit", + "source": "external-observed [C6]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 8, + "specId": 1, + "plane": "intent", + "kind": "constraint", + "kindOrdinal": 2, + "title": "This brief is scoped exclusively to the macro view component and its rendering of the derivation story; other parts of the UI are explicitl…", + "body": "This brief is scoped exclusively to the macro view component and its rendering of the derivation story; other parts of the UI are explicitly out of scope.", + "basis": "explicit", + "source": "stakeholder [C5]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 11, + "specId": 1, + "plane": "intent", + "kind": "constraint", + "kindOrdinal": 3, + "title": "The macro view is strictly read-only; users can pan, zoom, and collapse/expand nodes, but cannot trigger any data mutations.", + "body": "The macro view is strictly read-only; users can pan, zoom, and collapse/expand nodes, but cannot trigger any data mutations.", + "basis": "explicit", + "source": "stakeholder [C10]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 13, + "specId": 1, + "plane": "intent", + "kind": "constraint", + "kindOrdinal": 4, + "title": "Users must manually refresh to see new derivation steps in the macro view.", + "body": "Users must manually refresh to see new derivation steps in the macro view.", + "basis": "explicit", + "source": "stakeholder [C12]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 19, + "specId": 1, + "plane": "intent", + "kind": "constraint", + "kindOrdinal": 5, + "title": "The macro view is built inside a Vite + React + Tailwind SPA.", + "body": "The macro view is built inside a Vite + React + Tailwind SPA.", + "basis": "explicit", + "source": "stakeholder [C2]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 21, + "specId": 1, + "plane": "intent", + "kind": "constraint", + "kindOrdinal": 6, + "title": "Every page load of the macro view starts fully expanded, with no memory of previously collapsed nodes.", + "body": "Every page load of the macro view starts fully expanded, with no memory of previously collapsed nodes.", + "basis": "explicit", + "source": "stakeholder [C9]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 28, + "specId": 1, + "plane": "intent", + "kind": "constraint", + "kindOrdinal": 7, + "title": "Phase color values must be expressed as oklch values within the phosphor palette.", + "body": "Phase color values must be expressed as oklch values within the phosphor palette.", + "basis": "explicit", + "source": "stakeholder [C13]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 38, + "specId": 1, + "plane": "intent", + "kind": "constraint", + "kindOrdinal": 8, + "title": "The macro view must use a manual layout algorithm rather than dagre, because the spatial grammar requires precise control over depth lanes…", + "body": "The macro view must use a manual layout algorithm rather than dagre, because the spatial grammar requires precise control over depth lanes and vertical flow.", + "basis": "explicit", + "source": "stakeholder [C3]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 50, + "specId": 1, + "plane": "intent", + "kind": "constraint", + "kindOrdinal": 9, + "title": "The CRT design language for the macro view mandates: var(--font-mono), oklch phase colors, warm-tinted dark surfaces, phosphor glow, and no…", + "body": "The CRT design language for the macro view mandates: var(--font-mono), oklch phase colors, warm-tinted dark surfaces, phosphor glow, and no generic UI patterns.", + "basis": "explicit", + "source": "stakeholder [C4]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 56, + "specId": 1, + "plane": "intent", + "kind": "constraint", + "kindOrdinal": 10, + "title": "Collapsed/expanded state in the macro view is purely ephemeral in-memory React state and is never persisted.", + "body": "Collapsed/expanded state in the macro view is purely ephemeral in-memory React state and is never persisted.", + "basis": "explicit", + "source": "stakeholder [C8]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 64, + "specId": 1, + "plane": "intent", + "kind": "constraint", + "kindOrdinal": 11, + "title": "The macro view must use React Flow (@xyflow/react) version ^12.", + "body": "The macro view must use React Flow (@xyflow/react) version ^12.", + "basis": "explicit", + "source": "stakeholder [C1]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 70, + "specId": 1, + "plane": "intent", + "kind": "constraint", + "kindOrdinal": 12, + "title": "The macro view is a snapshot-only view: it reads artifact data once on mount and does not update reactively.", + "body": "The macro view is a snapshot-only view: it reads artifact data once on mount and does not update reactively.", + "basis": "explicit", + "source": "stakeholder [C11]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 1, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 1, + "title": "In the spec-elicitation system, fan-out runs 2–3 independent clean-room derivation sessions from the same upstream context, and fan-in is a…", + "body": "In the spec-elicitation system, fan-out runs 2–3 independent clean-room derivation sessions from the same upstream context, and fan-in is a separate LLM agent that synthesizes the minimal viable set before reconciliation.", + "basis": "explicit", + "source": "external-observed [X7]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 2, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 2, + "title": "The CRT/phosphor design language is a project-wide standard defined in .impeccable.md, covering amber primary color, dark-only theme, phosp…", + "body": "The CRT/phosphor design language is a project-wide standard defined in .impeccable.md, covering amber primary color, dark-only theme, phosphor glow behavior, scanline texture, and JetBrains Mono font.", + "basis": "explicit", + "source": "external-observed [X6]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 4, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 3, + "title": "Stakeholder preference: the macro view shares the CRT phosphor design language (amber primary, JetBrains Mono, dark warm surfaces, phosphor…", + "body": "Stakeholder preference: the macro view shares the CRT phosphor design language (amber primary, JetBrains Mono, dark warm surfaces, phosphor glow, scanline textures).", + "basis": "explicit", + "source": "stakeholder [X11]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 5, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 4, + "title": "Stakeholder preference: bail reconciliation outcome visual treatment intentionally matches the failed run treatment for consistency.", + "body": "Stakeholder preference: bail reconciliation outcome visual treatment intentionally matches the failed run treatment for consistency.", + "basis": "explicit", + "source": "stakeholder [X29]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 10, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 5, + "title": "Stakeholder preference: a ReconciliationRecord with outcome='bail' is the mechanism by which a branch becomes a dead-end impasse.", + "body": "Stakeholder preference: a ReconciliationRecord with outcome='bail' is the mechanism by which a branch becomes a dead-end impasse.", + "basis": "explicit", + "source": "stakeholder [X36]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 14, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 6, + "title": "Stakeholder preference: lane widths in the macro layout scale proportionally to the number of nodes at that depth rather than being fixed.", + "body": "Stakeholder preference: lane widths in the macro layout scale proportionally to the number of nodes at that depth rather than being fixed.", + "basis": "explicit", + "source": "stakeholder [X31]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 15, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 7, + "title": "The existing micro view is built with Sigma.js, handles 700+ nodes, and answers 'what does the graph look like now?'.", + "body": "The existing micro view is built with Sigma.js, handles 700+ nodes, and answers 'what does the graph look like now?'.", + "basis": "explicit", + "source": "stakeholder [X2]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 16, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 8, + "title": "src/styles/theme.css already defines the phosphor palette as oklch tokens including --color-phase-grounding (green), --color-phase-shaping…", + "body": "src/styles/theme.css already defines the phosphor palette as oklch tokens including --color-phase-grounding (green), --color-phase-shaping (amber), --color-phase-pinning (cyan), --color-phase-defining-done (purple), plus --color-phosphor-red, surface-0..3 warm darks, and --font-mono JetBrains Mono.", + "basis": "explicit", + "source": "technical-observed [X39]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 17, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 9, + "title": "Stakeholder preference: clicking a node opens a detail panel on the right side of the screen.", + "body": "Stakeholder preference: clicking a node opens a detail panel on the right side of the screen.", + "basis": "explicit", + "source": "stakeholder [X33]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 18, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 10, + "title": "Stakeholder preference: React Flow group/parent nodes are used for phase containers in the macro view.", + "body": "Stakeholder preference: React Flow group/parent nodes are used for phase containers in the macro view.", + "basis": "explicit", + "source": "stakeholder [X18]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 20, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 11, + "title": "Stakeholder preference: the materialProgress flag is visualized as a subtle chip or checkmark inside the reconciliation node, alongside the…", + "body": "Stakeholder preference: the materialProgress flag is visualized as a subtle chip or checkmark inside the reconciliation node, alongside the outcome indicator.", + "basis": "explicit", + "source": "stakeholder [X35]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 26, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 12, + "title": "Stakeholder preference: a failed run is shown with a red color and a dimmed interior.", + "body": "Stakeholder preference: a failed run is shown with a red color and a dimmed interior.", + "basis": "explicit", + "source": "stakeholder [X27]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 27, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 13, + "title": "Stakeholder preference: the visual design must feel like CRT/terminal aesthetic while preserving modern design principles; information dens…", + "body": "Stakeholder preference: the visual design must feel like CRT/terminal aesthetic while preserving modern design principles; information density is high but not overwhelming.", + "basis": "explicit", + "source": "stakeholder [X37]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 29, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 14, + "title": "A MacroView.tsx component already exists at src/components/MacroView.tsx with a header docblock describing the intended architecture: Story…", + "body": "A MacroView.tsx component already exists at src/components/MacroView.tsx with a header docblock describing the intended architecture: Story IR → Layout → Render with React Flow custom node types; the file is otherwise a stub.", + "basis": "explicit", + "source": "technical-observed [X38]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 30, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 15, + "title": "The macro view is one specific view within the broader Spec Explorer UI.", + "body": "The macro view is one specific view within the broader Spec Explorer UI.", + "basis": "explicit", + "source": "stakeholder [X1]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 31, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 16, + "title": "Stakeholder preference: reconciliation outcome affects the whole node's visual weight: accepted=normal, retry=amber border, recurse=blue bo…", + "body": "Stakeholder preference: reconciliation outcome affects the whole node's visual weight: accepted=normal, retry=amber border, recurse=blue border, bail=red border + dimmed interior.", + "basis": "explicit", + "source": "stakeholder [X28]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 32, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 17, + "title": "Stakeholder preference: phase color assignments are grounding=blue (foundational), shaping=green (growth/emergence), pinning=amber (fixity/…", + "body": "Stakeholder preference: phase color assignments are grounding=blue (foundational), shaping=green (growth/emergence), pinning=amber (fixity/commitment), defining_done=violet (completion/closure).", + "basis": "explicit", + "source": "stakeholder [X34]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 33, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 18, + "title": "Stakeholder preference: a 'running' status is unlikely to appear but should be highlighted somehow if it does.", + "body": "Stakeholder preference: a 'running' status is unlikely to appear but should be highlighted somehow if it does.", + "basis": "explicit", + "source": "stakeholder [X26]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 34, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 19, + "title": "Stakeholder preference: derivation phases (such as reconciliation) are encoded as nodes within the graph rather than as separate UI constru…", + "body": "Stakeholder preference: derivation phases (such as reconciliation) are encoded as nodes within the graph rather than as separate UI constructs.", + "basis": "explicit", + "source": "stakeholder [X15]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 35, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 20, + "title": "The macro view is structurally different from the micro view: it shows ~20-40 rich nodes representing the derivation process rather than gr…", + "body": "The macro view is structurally different from the micro view: it shows ~20-40 rich nodes representing the derivation process rather than graph content.", + "basis": "explicit", + "source": "stakeholder [X3]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 36, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 21, + "title": "Reconciliation compares candidate nodes from re-derivation against the previous iteration's nodes for that phase and classifies each relati…", + "body": "Reconciliation compares candidate nodes from re-derivation against the previous iteration's nodes for that phase and classifies each relationship using lineage edge types.", + "basis": "explicit", + "source": "external-observed [X8]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 37, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 22, + "title": "Stakeholder preference: derivation depth in the macro layout is computed from the parentFrameId chain length.", + "body": "Stakeholder preference: derivation depth in the macro layout is computed from the parentFrameId chain length.", + "basis": "explicit", + "source": "stakeholder [X30]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 39, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 23, + "title": "Stakeholder preference: impasse nodes connect sub-trees and increase the breadth of the layout to represent the opening of a new derivation…", + "body": "Stakeholder preference: impasse nodes connect sub-trees and increase the breadth of the layout to represent the opening of a new derivation branch.", + "basis": "explicit", + "source": "stakeholder [X16]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 40, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 24, + "title": "Stakeholder preference: users can fold (collapse) any nested derivation run to hide detail.", + "body": "Stakeholder preference: users can fold (collapse) any nested derivation run to hide detail.", + "basis": "explicit", + "source": "stakeholder [X12]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 41, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 25, + "title": "Stakeholder preference: fan-in groupings render as color-coded rows inside the fan-in node — green for 'merged', amber for 'best_selected',…", + "body": "Stakeholder preference: fan-in groupings render as color-coded rows inside the fan-in node — green for 'merged', amber for 'best_selected', red for 'impasse_surfaced' — distinguished by a colored left border or chip.", + "basis": "explicit", + "source": "stakeholder [X32]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 42, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 26, + "title": "Because the macro view renders inside a React Flow canvas (not directly in the DOM), large node counts are not a DOM performance concern.", + "body": "Because the macro view renders inside a React Flow canvas (not directly in the DOM), large node counts are not a DOM performance concern.", + "basis": "explicit", + "source": "stakeholder [X9]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 44, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 27, + "title": "Stakeholder preference: layout grammar encodes timeline through verticality (t+1 / more recent appears higher) and breadth encodes onion-pe…", + "body": "Stakeholder preference: layout grammar encodes timeline through verticality (t+1 / more recent appears higher) and breadth encodes onion-peel derivation depth.", + "basis": "explicit", + "source": "stakeholder [X14]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 45, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 28, + "title": "The theme.css token assignment differs from grounding X34: theme.css maps shaping=amber and pinning=cyan, while X34 specifies shaping=green…", + "body": "The theme.css token assignment differs from grounding X34: theme.css maps shaping=amber and pinning=cyan, while X34 specifies shaping=green and pinning=amber. Reconciling this is a minor concern but theme.css is the source of truth for the existing design system.", + "basis": "explicit", + "source": "technical-observed [X41]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 46, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 29, + "title": "The onion-peel derivation structure (impasse discovery, rederivation, reconciliation, resolution) is specified in the spec-elicitation.md d…", + "body": "The onion-peel derivation structure (impasse discovery, rederivation, reconciliation, resolution) is specified in the spec-elicitation.md document.", + "basis": "explicit", + "source": "external-observed [X5]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 47, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 30, + "title": "Stakeholder preference: phantom nodes and faded nodes are informational only and carry no interactive actions.", + "body": "Stakeholder preference: phantom nodes and faded nodes are informational only and carry no interactive actions.", + "basis": "explicit", + "source": "stakeholder [X24]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 49, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 31, + "title": "Stakeholder preference: render the derivation narrative as a pannable, zoomable graph.", + "body": "Stakeholder preference: render the derivation narrative as a pannable, zoomable graph.", + "basis": "explicit", + "source": "stakeholder [X10]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 51, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 32, + "title": "Stakeholder preference: when a nested run is collapsed, sibling nodes shift to fill vacated space, triggering a layout reflow.", + "body": "Stakeholder preference: when a nested run is collapsed, sibling nodes shift to fill vacated space, triggering a layout reflow.", + "basis": "explicit", + "source": "stakeholder [X22]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 52, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 33, + "title": "Stakeholder preference: fan-in and fan-out steps are represented as nested nodes within any one phase node.", + "body": "Stakeholder preference: fan-in and fan-out steps are represented as nested nodes within any one phase node.", + "basis": "explicit", + "source": "stakeholder [X17]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 54, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 34, + "title": "Stakeholder preference: perspective nodes show which branch was taken; unchosen branches fade out to indicate they were not taken but could…", + "body": "Stakeholder preference: perspective nodes show which branch was taken; unchosen branches fade out to indicate they were not taken but could be materialized later.", + "basis": "explicit", + "source": "stakeholder [X23]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 55, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 35, + "title": "Stakeholder preference: there is no separate 'trunk' node type; the trunk is simply the outermost shell of the onion-layered layout.", + "body": "Stakeholder preference: there is no separate 'trunk' node type; the trunk is simply the outermost shell of the onion-layered layout.", + "basis": "explicit", + "source": "stakeholder [X19]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 59, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 36, + "title": "Stakeholder preference: the nudging state is indicated by a treatment inside the node body (compact labelled chip or badge such as 'NUDGING…", + "body": "Stakeholder preference: the nudging state is indicated by a treatment inside the node body (compact labelled chip or badge such as 'NUDGING' or '⚡ NUDGE' in phosphor amber) rather than as an external indicator.", + "basis": "explicit", + "source": "stakeholder [X25]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 61, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 37, + "title": "A DetailPanel.tsx component exists at src/components/DetailPanel.tsx as a sibling to MacroView.tsx, confirming reuse is structurally feasib…", + "body": "A DetailPanel.tsx component exists at src/components/DetailPanel.tsx as a sibling to MacroView.tsx, confirming reuse is structurally feasible.", + "basis": "explicit", + "source": "technical-observed [X40]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 65, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 38, + "title": "Stakeholder preference: all three frame modes (initial, rederive, grounding_enrichment) render as the same phase-group node component, with…", + "body": "Stakeholder preference: all three frame modes (initial, rederive, grounding_enrichment) render as the same phase-group node component, with mode differences expressed via color, label, or border style only.", + "basis": "explicit", + "source": "stakeholder [X20]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 67, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 39, + "title": "A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.", + "body": "A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.", + "basis": "explicit", + "source": "stakeholder [X4]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 68, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 40, + "title": "Stakeholder preference: when a nested derivation run is collapsed it shrinks to a small pill or badge node showing key stats (run count, ou…", + "body": "Stakeholder preference: when a nested derivation run is collapsed it shrinks to a small pill or badge node showing key stats (run count, outcome).", + "basis": "explicit", + "source": "stakeholder [X21]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 69, + "specId": 1, + "plane": "intent", + "kind": "context", + "kindOrdinal": 41, + "title": "Stakeholder preference: custom React Flow node types exist for each semantic role: trunk phase, impasse, phase group, fan-out, run, fan-in,…", + "body": "Stakeholder preference: custom React Flow node types exist for each semantic role: trunk phase, impasse, phase group, fan-out, run, fan-in, reconciliation, perspective, phantom, and dead-end impasse.", + "basis": "explicit", + "source": "stakeholder [X13]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 71, + "specId": 1, + "plane": "intent", + "kind": "criterion", + "kindOrdinal": 1, + "title": "Macro node visuals are independently legible", + "body": "A macro-view node is acceptable when its visible treatment communicates the derivation role and current state well enough for a user to identify the relevant phase, outcome/status, and key counts or IDs without first opening the detail panel.", + "basis": "explicit", + "createdAtLsn": 4, + "updatedAtLsn": 4 + }, + { + "id": 43, + "specId": 1, + "plane": "intent", + "kind": "goal", + "kindOrdinal": 1, + "title": "Each node's form and function must visually communicate what happened at that specific point in the derivation tree.", + "body": "Each node's form and function must visually communicate what happened at that specific point in the derivation tree.", + "basis": "explicit", + "source": "stakeholder [G4]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 57, + "specId": 1, + "plane": "intent", + "kind": "goal", + "kindOrdinal": 2, + "title": "The macro view's visual design must be beautiful, novel, and information-dense, avoiding generic dashboard or AI-generated aesthetics.", + "body": "The macro view's visual design must be beautiful, novel, and information-dense, avoiding generic dashboard or AI-generated aesthetics.", + "basis": "explicit", + "source": "stakeholder [G2]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 60, + "specId": 1, + "plane": "intent", + "kind": "goal", + "kindOrdinal": 3, + "title": "A user should be able to understand what happened at each derivation step from the node visuals alone (numbers, IDs, outcomes) without need…", + "body": "A user should be able to understand what happened at each derivation step from the node visuals alone (numbers, IDs, outcomes) without needing to open a detail panel.", + "basis": "explicit", + "source": "stakeholder [G3]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 66, + "specId": 1, + "plane": "intent", + "kind": "goal", + "kindOrdinal": 4, + "title": "The macro view answers the question 'how did the spec get here?' by visualizing the full onion-peel cycle of impasse discovery, rederivatio…", + "body": "The macro view answers the question 'how did the spec get here?' by visualizing the full onion-peel cycle of impasse discovery, rederivation, reconciliation, and resolution.", + "basis": "explicit", + "source": "stakeholder [G1]", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 72, + "specId": 1, + "plane": "intent", + "kind": "requirement", + "kindOrdinal": 1, + "title": "Macro node selection opens the reused read-only detail panel", + "body": "When a user clicks a macro-view node, the macro view opens the existing right-side detail panel for inspection while preserving the macro view's read-only constraint and avoiding mutation actions.", + "basis": "explicit", + "createdAtLsn": 4, + "updatedAtLsn": 4 + }, + { + "id": 3, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 1, + "title": "The spec-elicitation system's derivation process consists of four phases in str…", + "basis": "explicit", + "source": "external-observed [T2]", + "detail": { + "definition": "The spec-elicitation system's derivation process consists of four phases in strict dependency order: grounding, shaping, pinning, and defining_done." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 7, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 2, + "title": "The HubNode type has hubType of justification | decision | impasse | perspectiv…", + "basis": "explicit", + "source": "technical-observed [T7]", + "detail": { + "definition": "The HubNode type has hubType of justification | decision | impasse | perspective, and carries impasseStatus (open | resolved | superseded) and perspectiveStatus (open | selected | rejected)." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 9, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 3, + "title": "The onion-peel structure refers to the iterative cycle of impasse discovery, re…", + "basis": "explicit", + "source": "external-observed [T13]", + "detail": { + "definition": "The onion-peel structure refers to the iterative cycle of impasse discovery, rederivation, fan-out/fan-in synthesis, reconciliation, and resolution that builds the derivation history." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 12, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 4, + "title": "The ReconciliationRecord type captures reconciliation outcomes (accepted | retr…", + "basis": "explicit", + "source": "technical-observed [T6]", + "detail": { + "definition": "The ReconciliationRecord type captures reconciliation outcomes (accepted | retry | recurse | bail) along with candidateNodeIds, baselineNodeIds, activatedNodeIds, archivedNodeIds, triggerImpasseIds, resolvedImpasseIds, unresolvedImpasseIds, and a materialProgress flag." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 22, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 5, + "title": "The FrameRecord type includes: id, parentFrameId, baselineFrameId, entryPhase,…", + "basis": "explicit", + "source": "technical-observed [T3]", + "detail": { + "definition": "The FrameRecord type includes: id, parentFrameId, baselineFrameId, entryPhase, triggerImpasseIds, mode (initial | rederive | grounding_enrichment), attemptNumber, nudgingActive, createdAt, and summary." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 23, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 6, + "title": "The ArtifactFile type bundles all spec data into a single file: manifest, sourc…", + "basis": "explicit", + "source": "technical-observed [T8]", + "detail": { + "definition": "The ArtifactFile type bundles all spec data into a single file: manifest, sources, extractedClaims, interventions, and a graph object containing nodes, edges, frames, derivationRuns, fanInRecords, reconciliationRecords, and snapshots." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 24, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 7, + "title": "The DerivationRunRecord type includes: id, frameId, phase, runIndex, inputNodeI…", + "basis": "explicit", + "source": "technical-observed [T4]", + "detail": { + "definition": "The DerivationRunRecord type includes: id, frameId, phase, runIndex, inputNodeIds, outputCandidateIds, impassesFound, status (running | completed | failed), and createdAt." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 25, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 8, + "title": "A dead-end impasse is one whose reconciliation chose the 'bail' outcome — the s…", + "basis": "explicit", + "source": "stakeholder [T11]", + "detail": { + "definition": "A dead-end impasse is one whose reconciliation chose the 'bail' outcome — the system gave up on that branch entirely." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 48, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 9, + "title": "The FanInRecord type captures the fan-in step: id, frameId, phase, inputRunIds,…", + "basis": "explicit", + "source": "technical-observed [T5]", + "detail": { + "definition": "The FanInRecord type captures the fan-in step: id, frameId, phase, inputRunIds, groupings (with resolution: merged | best_selected | impasse_surfaced), outputCandidateIds, droppedCandidateIds, and createdAt." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 53, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 10, + "title": "materialProgress=true on a ReconciliationRecord means at least some nodes were…", + "basis": "explicit", + "source": "stakeholder [T12]", + "detail": { + "definition": "materialProgress=true on a ReconciliationRecord means at least some nodes were activated or archived during that reconciliation — real forward progress occurred." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 58, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 11, + "title": "The Phase type has four ordered values: grounding, shaping, pinning, and defini…", + "basis": "explicit", + "source": "technical-observed [T1]", + "detail": { + "definition": "The Phase type has four ordered values: grounding, shaping, pinning, and defining_done." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 62, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 12, + "title": "The macro view is the component within the Spec Explorer UI that narrates the f…", + "basis": "explicit", + "source": "stakeholder [T14]", + "detail": { + "definition": "The macro view is the component within the Spec Explorer UI that narrates the full derivation history of a spec via a pannable, zoomable React Flow graph of ~20–40 semantically typed nodes." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 63, + "specId": 1, + "plane": "intent", + "kind": "term", + "kindOrdinal": 13, + "title": "A phantom node represents the case where no perspective is selected — it appear…", + "basis": "explicit", + "source": "stakeholder [T10]", + "detail": { + "definition": "A phantom node represents the case where no perspective is selected — it appears when an alternative perspective is used." + }, + "createdAtLsn": 2, + "updatedAtLsn": 2 + } + ], + "edges": [ + { + "id": 1, + "specId": 1, + "category": "dependency", + "sourceId": 16, + "targetId": 28, + "basis": "explicit", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 2, + "specId": 1, + "category": "dependency", + "sourceId": 16, + "targetId": 50, + "basis": "explicit", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 3, + "specId": 1, + "category": "dependency", + "sourceId": 45, + "targetId": 32, + "basis": "explicit", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 4, + "specId": 1, + "category": "dependency", + "sourceId": 61, + "targetId": 67, + "basis": "explicit", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 5, + "specId": 1, + "category": "dependency", + "sourceId": 16, + "targetId": 32, + "basis": "explicit", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 6, + "specId": 1, + "category": "dependency", + "sourceId": 45, + "targetId": 16, + "basis": "explicit", + "createdAtLsn": 2, + "updatedAtLsn": 2 + }, + { + "id": 7, + "specId": 1, + "category": "support", + "sourceId": 60, + "targetId": 71, + "basis": "explicit", + "createdAtLsn": 4, + "updatedAtLsn": 4, + "stance": "for", + "rationale": "The criterion makes the existing goal of understanding derivation steps from node visuals alone reviewable." + }, + { + "id": 8, + "specId": 1, + "category": "support", + "sourceId": 43, + "targetId": 71, + "basis": "explicit", + "createdAtLsn": 4, + "updatedAtLsn": 4, + "stance": "for", + "rationale": "The criterion specifies how form and function should communicate what happened at a derivation point." + }, + { + "id": 9, + "specId": 1, + "category": "support", + "sourceId": 67, + "targetId": 72, + "basis": "explicit", + "createdAtLsn": 4, + "updatedAtLsn": 4, + "stance": "for", + "rationale": "The existing Micro View detail panel is available and should be reused by the macro view." + }, + { + "id": 10, + "specId": 1, + "category": "support", + "sourceId": 11, + "targetId": 72, + "basis": "explicit", + "createdAtLsn": 4, + "updatedAtLsn": 4, + "stance": "for", + "rationale": "The requirement constrains detail inspection to remain read-only." + } + ], + "nodeCount": 72, + "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 new file mode 100644 index 000000000..fb1d84438 --- /dev/null +++ b/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/report.json @@ -0,0 +1,127 @@ +{ + "schemaVersion": 1, + "probeId": "project-graph-review-cycle", + "runId": "2026-06-06-project-graph-review-cycle", + "generatedAt": "2026-06-06T14:02:59.202Z", + "mission": "Prove the project-graph strategy can present an exact review set and approve it through public RPC.", + "evaluationFocus": "FE-809 real agent proposal → present_review_set → session.submitExchangeResponse approval → explicit graph readback.", + "seedSet": "bilal-port-variants", + "seedSlug": "macro-view-grounded-intent", + "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-project-graph-review-XEjX3W", + "specId": 1, + "sessionId": "019e9d3e-7f21-76ae-bbc2-2955f779cdac", + "prompt": "Brunch FE-809 project-graph proof. The selected spec is seeded from \"macro-view-grounded-intent\" and already has explicit intent-plane graph truth.\n\nUse read_graph in overview mode to inspect existing node codes. Then use present_review_set exactly once to propose a small exact review set derived from the existing macro-view intent graph.\n\nProposal constraints:\n- Create one or two new intent-plane requirement or criterion nodes.\n- Include at least one edge using category \"support\" with stance \"for\" or category \"realization\".\n- When referencing existing graph truth, use existingCode strings from read_graph output, never raw ids.\n- Use schemaVersion 1, lens \"intent\", epistemicStatus \"inferred\", non-empty grounding.summary, grounding.support, pitch.title, and pitch.narrative.\n- Do not call commit_graph.\n- Do not call request_review; stop after a successful present_review_set so the external Brunch RPC reviewer can approve it.", + "runtimeState": { + "operationalMode": "elicit", + "agentStrategy": "project-graph", + "agentLens": "intent", + "agentGoal": "commit-converge" + }, + "model": "gpt-5.5", + "success": true, + "baseGraph": { + "nodeCount": 70, + "edgeCount": 6, + "lsn": 3 + }, + "finalGraph": { + "nodeCount": 72, + "edgeCount": 10, + "lsn": 4, + "explicitNodeCount": 72, + "explicitEdgeCount": 10, + "implicitNodeCount": 0, + "implicitEdgeCount": 0 + }, + "graphDelta": { + "lsnAdvanced": true, + "nodeDelta": 2, + "edgeDelta": 4 + }, + "toolEvidence": { + "presentReviewSetCount": 3, + "requestReviewCount": 1, + "successfulPresentReviewSetCount": 1, + "structuralIllegalPresentReviewSetCount": 2 + }, + "pendingReview": { + "observed": true, + "exchangeId": "fe-809-macro-view-intent-proof-1", + "nodeDraftCount": 2, + "edgeDraftCount": 4 + }, + "approval": { + "attempted": true, + "status": "approved", + "lsn": 4, + "createdNodeRefs": { + "n1": { + "id": 71, + "code": "CR1" + }, + "n2": { + "id": 72, + "code": "R1" + } + } + }, + "createdNodes": [ + { + "id": 71, + "code": "CR1", + "plane": "intent", + "kind": "criterion", + "title": "Macro node visuals are independently legible", + "basis": "explicit" + }, + { + "id": 72, + "code": "R1", + "plane": "intent", + "kind": "requirement", + "title": "Macro node selection opens the reused read-only detail panel", + "basis": "explicit" + } + ], + "productUpdates": [ + { + "topic": "workspace.snapshot", + "specId": 1, + "sessionId": "019e9d3e-7f21-76ae-bbc2-2955f779cdac" + }, + { + "topic": "session.pendingExchange", + "specId": 1, + "sessionId": "019e9d3e-7f21-76ae-bbc2-2955f779cdac" + }, + { + "topic": "session.exchanges", + "specId": 1, + "sessionId": "019e9d3e-7f21-76ae-bbc2-2955f779cdac" + }, + { + "topic": "session.runtimeState", + "specId": 1, + "sessionId": "019e9d3e-7f21-76ae-bbc2-2955f779cdac" + }, + { + "topic": "graph.overview", + "specId": 1, + "lsn": 4 + }, + { + "topic": "graph.nodeNeighborhood", + "specId": 1, + "lsn": 4 + } + ], + "friction": [], + "artifacts": { + "runDir": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle", + "sessionJsonl": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/session.jsonl", + "transcriptMarkdown": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/transcript.md", + "reportJson": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/report.json", + "graphSnapshotJson": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/graph-snapshot.json" + } +} + diff --git a/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/session.jsonl b/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/session.jsonl new file mode 100644 index 000000000..d25caff64 --- /dev/null +++ b/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/session.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":3,"id":"019e9d3e-7f21-76ae-bbc2-2955f779cdac","timestamp":"2026-06-06T14:02:59.233Z","cwd":"/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-project-graph-review-XEjX3W"} +{"type":"custom","customType":"brunch.session_binding","data":{"schemaVersion":1,"specId":1},"id":"1d2dd2e5","parentId":null,"timestamp":"2026-06-06T14:02:59.233Z"} +{"type":"session_info","id":"41b7b8b4","parentId":"1d2dd2e5","timestamp":"2026-06-06T14:02:59.233Z","name":"Macro View — grounded intent base — session 1"} +{"type":"custom","customType":"brunch.agent_runtime_state","data":{"schemaVersion":1,"reason":"switch","state":{"schemaVersion":1,"operationalMode":"elicit","agentStrategy":"project-graph","agentLens":"intent","agentGoal":"commit-converge"},"previous":{"schemaVersion":1,"operationalMode":"elicit","agentStrategy":"auto","agentLens":"auto","agentGoal":"grounding-advance"},"source":"extension"},"id":"6bb12643","parentId":"41b7b8b4","timestamp":"2026-06-06T14:02:59.234Z"} +{"type":"model_change","id":"b477f39f","parentId":"6bb12643","timestamp":"2026-06-06T14:02:59.245Z","provider":"openai-codex","modelId":"gpt-5.5"} +{"type":"thinking_level_change","id":"a605cfbe","parentId":"b477f39f","timestamp":"2026-06-06T14:02:59.245Z","thinkingLevel":"medium"} +{"type":"message","id":"ad8035b1","parentId":"a605cfbe","timestamp":"2026-06-06T14:02:59.248Z","message":{"role":"user","content":[{"type":"text","text":"Brunch FE-809 project-graph proof. The selected spec is seeded from \"macro-view-grounded-intent\" and already has explicit intent-plane graph truth.\n\nUse read_graph in overview mode to inspect existing node codes. Then use present_review_set exactly once to propose a small exact review set derived from the existing macro-view intent graph.\n\nProposal constraints:\n- Create one or two new intent-plane requirement or criterion nodes.\n- Include at least one edge using category \"support\" with stance \"for\" or category \"realization\".\n- When referencing existing graph truth, use existingCode strings from read_graph output, never raw ids.\n- Use schemaVersion 1, lens \"intent\", epistemicStatus \"inferred\", non-empty grounding.summary, grounding.support, pitch.title, and pitch.narrative.\n- Do not call commit_graph.\n- Do not call request_review; stop after a successful present_review_set so the external Brunch RPC reviewer can approve it."}],"timestamp":1780754579246}} +{"type":"message","id":"92a771d2","parentId":"ad8035b1","timestamp":"2026-06-06T14:03:04.778Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"**Figuring out resource requirements**\n\nI think I need to comply with my responsibilities here. It looks like I might need to read some resources, possibly about project graphs and manifests. The user's goals are clear, and understanding these elements is essential. I definitely want to ensure I’m thorough and getting it right. So, I'll check out the overview of the read_graph aspect to assure I grasp everything properly.","thinkingSignature":"{\"id\":\"rs_0fdf1a6645f3ebbb016a242895604c8191860b2373b604ba56\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqJCiYxOv2x4fVEv9Fz6wE3L7rYBpC-5ffak0dce1_rAKnx-RIPloigFTfvhEB5OCDYQnRgJbdtlZucJLd1dxM8N5RT1KCUf2ERyor97AMRSV1RtHWLOtMhR3lOvehFaDBk4NyCfIIXbvaKUGm7RQR6KmZbGayoHAXcSpM0iQnp7xwsg_Wv62c5D8xNuA7mUGrs5vFx2WVWakCD_fI4goZGlPcfI-Uq6TPayODoT8NFz0JDd-KBS9COGysBRmMKInFfnVXaE-KDMDCBllLkk6gJXWHGQW8H_yhCNHR8IHaf4Ez9pu4DxXpGLSXqG1ShYL8-JJdk40Qlxi1lmY_18nlGLih9s4-TZHMjJpxkSY0gZewuiDu4bZNKwVqZ_KQDMuHuQzWfB7ETEawC9a9lXqxcL60H_vSqDwY5PT4f30P7FqUhMnXdqkQ6ANJI4VEbPhdnKNUudxQhinv7DC4hvC4YtGP5jswANL0MlVpXDFUBSin2eNC_Xrcz0uonwqq3UG_IxsEah5YgfkJ5Qrn77r_v-rOlj4MZMr4ODfLc9IOODPg6mIT8Iafu10ImVQqNEXrNijfPWml530GNnhXYosGwQy4WJhxvlG0-1ppzwlmmNQq9GEBxcFmK-k4yMD7NjSYr_jpMeI-XnEO4MvAxSNx51aZvpM1SQFyg7-XeFCGW9GLjrioWST0jCVG9E_2SP7xjcROGrOQkTKW4oLUAapONIqIqWgMeap3vjWpTzBiEmBcCw0wkbJOXrP-dInwsvi_YxoBRkoNxlbHiAQ1xdaJVbyg59y_NThFh8mOEXU8JK3SvWld2-dfxgO1XzG4KTEfMVXUHJz8jU-tWBwIb8-_5vs22Ql2FBhzPPh90MCQ8dBollOU2s31WA5wXUmglCgUmHhXx8ak05D-kr_t0eMlEfWjTKA3KQbFKWAUQNSNZyOcsAFfehe0psAVjuStCnibcggUIfiYqYprVIAotequJgKQB8EgcBuXU6AZPDofHsDG9ujaPxFTCltcOjjx8svYJ8M5aTUfEkdffbcPdMPk4DU3GkRJ4mCr7l_zrQE3F2Nmq4JL4c2dVItumJy8IJHXTcpK5GYGHwwe0oswi15YC3_FZA==\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"**Figuring out resource requirements**\\n\\nI think I need to comply with my responsibilities here. It looks like I might need to read some resources, possibly about project graphs and manifests. The user's goals are clear, and understanding these elements is essential. I definitely want to ensure I’m thorough and getting it right. So, I'll check out the overview of the read_graph aspect to assure I grasp everything properly.\"}]}"},{"type":"toolCall","id":"call_bxjFEXbHWBJe2ozHZlzyi7yT|fc_0fdf1a6645f3ebbb016a242898cc508191a687914dad7b712d","name":"read_graph","arguments":{"mode":"overview"}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.5","usage":{"input":6748,"output":50,"cacheRead":0,"cacheWrite":0,"totalTokens":6798,"cost":{"input":0.033740000000000006,"output":0.0015,"cacheRead":0,"cacheWrite":0,"total":0.03524000000000001}},"stopReason":"toolUse","timestamp":1780754579255,"responseId":"resp_0fdf1a6645f3ebbb016a242894a2c88191a6e2cfce29f13cc6"}} +{"type":"message","id":"9f4a7573","parentId":"92a771d2","timestamp":"2026-06-06T14:03:04.784Z","message":{"role":"toolResult","toolCallId":"call_bxjFEXbHWBJe2ozHZlzyi7yT|fc_0fdf1a6645f3ebbb016a242898cc508191a687914dad7b712d","toolName":"read_graph","content":[{"type":"text","text":"Graph overview (LSN 3): 70 node(s), 6 edge(s).\n\n- [CON1] intent/constraint: \"The design language explicitly prohibits generic UI patterns such as Material Design, Tailwind defaults, SaaS dashboard aesthetics, rounded…\"\n- [CON2] intent/constraint: \"This brief is scoped exclusively to the macro view component and its rendering of the derivation story; other parts of the UI are explicitl…\"\n- [CON3] intent/constraint: \"The macro view is strictly read-only; users can pan, zoom, and collapse/expand nodes, but cannot trigger any data mutations.\"\n- [CON4] intent/constraint: \"Users must manually refresh to see new derivation steps in the macro view.\"\n- [CON5] intent/constraint: \"The macro view is built inside a Vite + React + Tailwind SPA.\"\n- [CON6] intent/constraint: \"Every page load of the macro view starts fully expanded, with no memory of previously collapsed nodes.\"\n- [CON7] intent/constraint: \"Phase color values must be expressed as oklch values within the phosphor palette.\"\n- [CON8] intent/constraint: \"The macro view must use a manual layout algorithm rather than dagre, because the spatial grammar requires precise control over depth lanes…\"\n- [CON9] intent/constraint: \"The CRT design language for the macro view mandates: var(--font-mono), oklch phase colors, warm-tinted dark surfaces, phosphor glow, and no…\"\n- [CON10] intent/constraint: \"Collapsed/expanded state in the macro view is purely ephemeral in-memory React state and is never persisted.\"\n- [CON11] intent/constraint: \"The macro view must use React Flow (@xyflow/react) version ^12.\"\n- [CON12] intent/constraint: \"The macro view is a snapshot-only view: it reads artifact data once on mount and does not update reactively.\"\n- [CTX1] intent/context: \"In the spec-elicitation system, fan-out runs 2–3 independent clean-room derivation sessions from the same upstream context, and fan-in is a…\"\n- [CTX2] intent/context: \"The CRT/phosphor design language is a project-wide standard defined in .impeccable.md, covering amber primary color, dark-only theme, phosp…\"\n- [CTX3] intent/context: \"Stakeholder preference: the macro view shares the CRT phosphor design language (amber primary, JetBrains Mono, dark warm surfaces, phosphor…\"\n- [CTX4] intent/context: \"Stakeholder preference: bail reconciliation outcome visual treatment intentionally matches the failed run treatment for consistency.\"\n- [CTX5] intent/context: \"Stakeholder preference: a ReconciliationRecord with outcome='bail' is the mechanism by which a branch becomes a dead-end impasse.\"\n- [CTX6] intent/context: \"Stakeholder preference: lane widths in the macro layout scale proportionally to the number of nodes at that depth rather than being fixed.\"\n- [CTX7] intent/context: \"The existing micro view is built with Sigma.js, handles 700+ nodes, and answers 'what does the graph look like now?'.\"\n- [CTX8] intent/context: \"src/styles/theme.css already defines the phosphor palette as oklch tokens including --color-phase-grounding (green), --color-phase-shaping…\"\n- [CTX9] intent/context: \"Stakeholder preference: clicking a node opens a detail panel on the right side of the screen.\"\n- [CTX10] intent/context: \"Stakeholder preference: React Flow group/parent nodes are used for phase containers in the macro view.\"\n- [CTX11] intent/context: \"Stakeholder preference: the materialProgress flag is visualized as a subtle chip or checkmark inside the reconciliation node, alongside the…\"\n- [CTX12] intent/context: \"Stakeholder preference: a failed run is shown with a red color and a dimmed interior.\"\n- [CTX13] intent/context: \"Stakeholder preference: the visual design must feel like CRT/terminal aesthetic while preserving modern design principles; information dens…\"\n- [CTX14] intent/context: \"A MacroView.tsx component already exists at src/components/MacroView.tsx with a header docblock describing the intended architecture: Story…\"\n- [CTX15] intent/context: \"The macro view is one specific view within the broader Spec Explorer UI.\"\n- [CTX16] intent/context: \"Stakeholder preference: reconciliation outcome affects the whole node's visual weight: accepted=normal, retry=amber border, recurse=blue bo…\"\n- [CTX17] intent/context: \"Stakeholder preference: phase color assignments are grounding=blue (foundational), shaping=green (growth/emergence), pinning=amber (fixity/…\"\n- [CTX18] intent/context: \"Stakeholder preference: a 'running' status is unlikely to appear but should be highlighted somehow if it does.\"\n- [CTX19] intent/context: \"Stakeholder preference: derivation phases (such as reconciliation) are encoded as nodes within the graph rather than as separate UI constru…\"\n- [CTX20] intent/context: \"The macro view is structurally different from the micro view: it shows ~20-40 rich nodes representing the derivation process rather than gr…\"\n- [CTX21] intent/context: \"Reconciliation compares candidate nodes from re-derivation against the previous iteration's nodes for that phase and classifies each relati…\"\n- [CTX22] intent/context: \"Stakeholder preference: derivation depth in the macro layout is computed from the parentFrameId chain length.\"\n- [CTX23] intent/context: \"Stakeholder preference: impasse nodes connect sub-trees and increase the breadth of the layout to represent the opening of a new derivation…\"\n- [CTX24] intent/context: \"Stakeholder preference: users can fold (collapse) any nested derivation run to hide detail.\"\n- [CTX25] intent/context: \"Stakeholder preference: fan-in groupings render as color-coded rows inside the fan-in node — green for 'merged', amber for 'best_selected',…\"\n- [CTX26] intent/context: \"Because the macro view renders inside a React Flow canvas (not directly in the DOM), large node counts are not a DOM performance concern.\"\n- [CTX27] intent/context: \"Stakeholder preference: layout grammar encodes timeline through verticality (t+1 / more recent appears higher) and breadth encodes onion-pe…\"\n- [CTX28] intent/context: \"The theme.css token assignment differs from grounding X34: theme.css maps shaping=amber and pinning=cyan, while X34 specifies shaping=green…\"\n- [CTX29] intent/context: \"The onion-peel derivation structure (impasse discovery, rederivation, reconciliation, resolution) is specified in the spec-elicitation.md d…\"\n- [CTX30] intent/context: \"Stakeholder preference: phantom nodes and faded nodes are informational only and carry no interactive actions.\"\n- [CTX31] intent/context: \"Stakeholder preference: render the derivation narrative as a pannable, zoomable graph.\"\n- [CTX32] intent/context: \"Stakeholder preference: when a nested run is collapsed, sibling nodes shift to fill vacated space, triggering a layout reflow.\"\n- [CTX33] intent/context: \"Stakeholder preference: fan-in and fan-out steps are represented as nested nodes within any one phase node.\"\n- [CTX34] intent/context: \"Stakeholder preference: perspective nodes show which branch was taken; unchosen branches fade out to indicate they were not taken but could…\"\n- [CTX35] intent/context: \"Stakeholder preference: there is no separate 'trunk' node type; the trunk is simply the outermost shell of the onion-layered layout.\"\n- [CTX36] intent/context: \"Stakeholder preference: the nudging state is indicated by a treatment inside the node body (compact labelled chip or badge such as 'NUDGING…\"\n- [CTX37] intent/context: \"A DetailPanel.tsx component exists at src/components/DetailPanel.tsx as a sibling to MacroView.tsx, confirming reuse is structurally feasib…\"\n- [CTX38] intent/context: \"Stakeholder preference: all three frame modes (initial, rederive, grounding_enrichment) render as the same phase-group node component, with…\"\n- [CTX39] intent/context: \"A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.\"\n- [CTX40] intent/context: \"Stakeholder preference: when a nested derivation run is collapsed it shrinks to a small pill or badge node showing key stats (run count, ou…\"\n- [CTX41] intent/context: \"Stakeholder preference: custom React Flow node types exist for each semantic role: trunk phase, impasse, phase group, fan-out, run, fan-in,…\"\n- [G1] intent/goal: \"Each node's form and function must visually communicate what happened at that specific point in the derivation tree.\"\n- [G2] intent/goal: \"The macro view's visual design must be beautiful, novel, and information-dense, avoiding generic dashboard or AI-generated aesthetics.\"\n- [G3] intent/goal: \"A user should be able to understand what happened at each derivation step from the node visuals alone (numbers, IDs, outcomes) without need…\"\n- [G4] intent/goal: \"The macro view answers the question 'how did the spec get here?' by visualizing the full onion-peel cycle of impasse discovery, rederivatio…\"\n- [T1] intent/term: \"The spec-elicitation system's derivation process consists of four phases in str…\" [has detail]\n- [T2] intent/term: \"The HubNode type has hubType of justification | decision | impasse | perspectiv…\" [has detail]\n- [T3] intent/term: \"The onion-peel structure refers to the iterative cycle of impasse discovery, re…\" [has detail]\n- [T4] intent/term: \"The ReconciliationRecord type captures reconciliation outcomes (accepted | retr…\" [has detail]\n- [T5] intent/term: \"The FrameRecord type includes: id, parentFrameId, baselineFrameId, entryPhase,…\" [has detail]\n- [T6] intent/term: \"The ArtifactFile type bundles all spec data into a single file: manifest, sourc…\" [has detail]\n- [T7] intent/term: \"The DerivationRunRecord type includes: id, frameId, phase, runIndex, inputNodeI…\" [has detail]\n- [T8] intent/term: \"A dead-end impasse is one whose reconciliation chose the 'bail' outcome — the s…\" [has detail]\n- [T9] intent/term: \"The FanInRecord type captures the fan-in step: id, frameId, phase, inputRunIds,…\" [has detail]\n- [T10] intent/term: \"materialProgress=true on a ReconciliationRecord means at least some nodes were…\" [has detail]\n- [T11] intent/term: \"The Phase type has four ordered values: grounding, shaping, pinning, and defini…\" [has detail]\n- [T12] intent/term: \"The macro view is the component within the Spec Explorer UI that narrates the f…\" [has detail]\n- [T13] intent/term: \"A phantom node represents the case where no perspective is selected — it appear…\" [has detail]\n\n- Edge #1: CTX8 —[dependency]→ CON7\n- Edge #2: CTX8 —[dependency]→ CON9\n- Edge #3: CTX28 —[dependency]→ CTX17\n- Edge #4: CTX37 —[dependency]→ CTX39\n- Edge #5: CTX8 —[dependency]→ CTX17\n- Edge #6: CTX28 —[dependency]→ CTX8"}],"details":{"nodes":[{"id":6,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":1,"title":"The design language explicitly prohibits generic UI patterns such as Material Design, Tailwind defaults, SaaS dashboard aesthetics, rounded…","body":"The design language explicitly prohibits generic UI patterns such as Material Design, Tailwind defaults, SaaS dashboard aesthetics, rounded corners, or blue primary buttons.","basis":"explicit","source":"external-observed [C6]","createdAtLsn":2,"updatedAtLsn":2},{"id":8,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":2,"title":"This brief is scoped exclusively to the macro view component and its rendering of the derivation story; other parts of the UI are explicitl…","body":"This brief is scoped exclusively to the macro view component and its rendering of the derivation story; other parts of the UI are explicitly out of scope.","basis":"explicit","source":"stakeholder [C5]","createdAtLsn":2,"updatedAtLsn":2},{"id":11,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":3,"title":"The macro view is strictly read-only; users can pan, zoom, and collapse/expand nodes, but cannot trigger any data mutations.","body":"The macro view is strictly read-only; users can pan, zoom, and collapse/expand nodes, but cannot trigger any data mutations.","basis":"explicit","source":"stakeholder [C10]","createdAtLsn":2,"updatedAtLsn":2},{"id":13,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":4,"title":"Users must manually refresh to see new derivation steps in the macro view.","body":"Users must manually refresh to see new derivation steps in the macro view.","basis":"explicit","source":"stakeholder [C12]","createdAtLsn":2,"updatedAtLsn":2},{"id":19,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":5,"title":"The macro view is built inside a Vite + React + Tailwind SPA.","body":"The macro view is built inside a Vite + React + Tailwind SPA.","basis":"explicit","source":"stakeholder [C2]","createdAtLsn":2,"updatedAtLsn":2},{"id":21,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":6,"title":"Every page load of the macro view starts fully expanded, with no memory of previously collapsed nodes.","body":"Every page load of the macro view starts fully expanded, with no memory of previously collapsed nodes.","basis":"explicit","source":"stakeholder [C9]","createdAtLsn":2,"updatedAtLsn":2},{"id":28,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":7,"title":"Phase color values must be expressed as oklch values within the phosphor palette.","body":"Phase color values must be expressed as oklch values within the phosphor palette.","basis":"explicit","source":"stakeholder [C13]","createdAtLsn":2,"updatedAtLsn":2},{"id":38,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":8,"title":"The macro view must use a manual layout algorithm rather than dagre, because the spatial grammar requires precise control over depth lanes…","body":"The macro view must use a manual layout algorithm rather than dagre, because the spatial grammar requires precise control over depth lanes and vertical flow.","basis":"explicit","source":"stakeholder [C3]","createdAtLsn":2,"updatedAtLsn":2},{"id":50,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":9,"title":"The CRT design language for the macro view mandates: var(--font-mono), oklch phase colors, warm-tinted dark surfaces, phosphor glow, and no…","body":"The CRT design language for the macro view mandates: var(--font-mono), oklch phase colors, warm-tinted dark surfaces, phosphor glow, and no generic UI patterns.","basis":"explicit","source":"stakeholder [C4]","createdAtLsn":2,"updatedAtLsn":2},{"id":56,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":10,"title":"Collapsed/expanded state in the macro view is purely ephemeral in-memory React state and is never persisted.","body":"Collapsed/expanded state in the macro view is purely ephemeral in-memory React state and is never persisted.","basis":"explicit","source":"stakeholder [C8]","createdAtLsn":2,"updatedAtLsn":2},{"id":64,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":11,"title":"The macro view must use React Flow (@xyflow/react) version ^12.","body":"The macro view must use React Flow (@xyflow/react) version ^12.","basis":"explicit","source":"stakeholder [C1]","createdAtLsn":2,"updatedAtLsn":2},{"id":70,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":12,"title":"The macro view is a snapshot-only view: it reads artifact data once on mount and does not update reactively.","body":"The macro view is a snapshot-only view: it reads artifact data once on mount and does not update reactively.","basis":"explicit","source":"stakeholder [C11]","createdAtLsn":2,"updatedAtLsn":2},{"id":1,"specId":1,"plane":"intent","kind":"context","kindOrdinal":1,"title":"In the spec-elicitation system, fan-out runs 2–3 independent clean-room derivation sessions from the same upstream context, and fan-in is a…","body":"In the spec-elicitation system, fan-out runs 2–3 independent clean-room derivation sessions from the same upstream context, and fan-in is a separate LLM agent that synthesizes the minimal viable set before reconciliation.","basis":"explicit","source":"external-observed [X7]","createdAtLsn":2,"updatedAtLsn":2},{"id":2,"specId":1,"plane":"intent","kind":"context","kindOrdinal":2,"title":"The CRT/phosphor design language is a project-wide standard defined in .impeccable.md, covering amber primary color, dark-only theme, phosp…","body":"The CRT/phosphor design language is a project-wide standard defined in .impeccable.md, covering amber primary color, dark-only theme, phosphor glow behavior, scanline texture, and JetBrains Mono font.","basis":"explicit","source":"external-observed [X6]","createdAtLsn":2,"updatedAtLsn":2},{"id":4,"specId":1,"plane":"intent","kind":"context","kindOrdinal":3,"title":"Stakeholder preference: the macro view shares the CRT phosphor design language (amber primary, JetBrains Mono, dark warm surfaces, phosphor…","body":"Stakeholder preference: the macro view shares the CRT phosphor design language (amber primary, JetBrains Mono, dark warm surfaces, phosphor glow, scanline textures).","basis":"explicit","source":"stakeholder [X11]","createdAtLsn":2,"updatedAtLsn":2},{"id":5,"specId":1,"plane":"intent","kind":"context","kindOrdinal":4,"title":"Stakeholder preference: bail reconciliation outcome visual treatment intentionally matches the failed run treatment for consistency.","body":"Stakeholder preference: bail reconciliation outcome visual treatment intentionally matches the failed run treatment for consistency.","basis":"explicit","source":"stakeholder [X29]","createdAtLsn":2,"updatedAtLsn":2},{"id":10,"specId":1,"plane":"intent","kind":"context","kindOrdinal":5,"title":"Stakeholder preference: a ReconciliationRecord with outcome='bail' is the mechanism by which a branch becomes a dead-end impasse.","body":"Stakeholder preference: a ReconciliationRecord with outcome='bail' is the mechanism by which a branch becomes a dead-end impasse.","basis":"explicit","source":"stakeholder [X36]","createdAtLsn":2,"updatedAtLsn":2},{"id":14,"specId":1,"plane":"intent","kind":"context","kindOrdinal":6,"title":"Stakeholder preference: lane widths in the macro layout scale proportionally to the number of nodes at that depth rather than being fixed.","body":"Stakeholder preference: lane widths in the macro layout scale proportionally to the number of nodes at that depth rather than being fixed.","basis":"explicit","source":"stakeholder [X31]","createdAtLsn":2,"updatedAtLsn":2},{"id":15,"specId":1,"plane":"intent","kind":"context","kindOrdinal":7,"title":"The existing micro view is built with Sigma.js, handles 700+ nodes, and answers 'what does the graph look like now?'.","body":"The existing micro view is built with Sigma.js, handles 700+ nodes, and answers 'what does the graph look like now?'.","basis":"explicit","source":"stakeholder [X2]","createdAtLsn":2,"updatedAtLsn":2},{"id":16,"specId":1,"plane":"intent","kind":"context","kindOrdinal":8,"title":"src/styles/theme.css already defines the phosphor palette as oklch tokens including --color-phase-grounding (green), --color-phase-shaping…","body":"src/styles/theme.css already defines the phosphor palette as oklch tokens including --color-phase-grounding (green), --color-phase-shaping (amber), --color-phase-pinning (cyan), --color-phase-defining-done (purple), plus --color-phosphor-red, surface-0..3 warm darks, and --font-mono JetBrains Mono.","basis":"explicit","source":"technical-observed [X39]","createdAtLsn":2,"updatedAtLsn":2},{"id":17,"specId":1,"plane":"intent","kind":"context","kindOrdinal":9,"title":"Stakeholder preference: clicking a node opens a detail panel on the right side of the screen.","body":"Stakeholder preference: clicking a node opens a detail panel on the right side of the screen.","basis":"explicit","source":"stakeholder [X33]","createdAtLsn":2,"updatedAtLsn":2},{"id":18,"specId":1,"plane":"intent","kind":"context","kindOrdinal":10,"title":"Stakeholder preference: React Flow group/parent nodes are used for phase containers in the macro view.","body":"Stakeholder preference: React Flow group/parent nodes are used for phase containers in the macro view.","basis":"explicit","source":"stakeholder [X18]","createdAtLsn":2,"updatedAtLsn":2},{"id":20,"specId":1,"plane":"intent","kind":"context","kindOrdinal":11,"title":"Stakeholder preference: the materialProgress flag is visualized as a subtle chip or checkmark inside the reconciliation node, alongside the…","body":"Stakeholder preference: the materialProgress flag is visualized as a subtle chip or checkmark inside the reconciliation node, alongside the outcome indicator.","basis":"explicit","source":"stakeholder [X35]","createdAtLsn":2,"updatedAtLsn":2},{"id":26,"specId":1,"plane":"intent","kind":"context","kindOrdinal":12,"title":"Stakeholder preference: a failed run is shown with a red color and a dimmed interior.","body":"Stakeholder preference: a failed run is shown with a red color and a dimmed interior.","basis":"explicit","source":"stakeholder [X27]","createdAtLsn":2,"updatedAtLsn":2},{"id":27,"specId":1,"plane":"intent","kind":"context","kindOrdinal":13,"title":"Stakeholder preference: the visual design must feel like CRT/terminal aesthetic while preserving modern design principles; information dens…","body":"Stakeholder preference: the visual design must feel like CRT/terminal aesthetic while preserving modern design principles; information density is high but not overwhelming.","basis":"explicit","source":"stakeholder [X37]","createdAtLsn":2,"updatedAtLsn":2},{"id":29,"specId":1,"plane":"intent","kind":"context","kindOrdinal":14,"title":"A MacroView.tsx component already exists at src/components/MacroView.tsx with a header docblock describing the intended architecture: Story…","body":"A MacroView.tsx component already exists at src/components/MacroView.tsx with a header docblock describing the intended architecture: Story IR → Layout → Render with React Flow custom node types; the file is otherwise a stub.","basis":"explicit","source":"technical-observed [X38]","createdAtLsn":2,"updatedAtLsn":2},{"id":30,"specId":1,"plane":"intent","kind":"context","kindOrdinal":15,"title":"The macro view is one specific view within the broader Spec Explorer UI.","body":"The macro view is one specific view within the broader Spec Explorer UI.","basis":"explicit","source":"stakeholder [X1]","createdAtLsn":2,"updatedAtLsn":2},{"id":31,"specId":1,"plane":"intent","kind":"context","kindOrdinal":16,"title":"Stakeholder preference: reconciliation outcome affects the whole node's visual weight: accepted=normal, retry=amber border, recurse=blue bo…","body":"Stakeholder preference: reconciliation outcome affects the whole node's visual weight: accepted=normal, retry=amber border, recurse=blue border, bail=red border + dimmed interior.","basis":"explicit","source":"stakeholder [X28]","createdAtLsn":2,"updatedAtLsn":2},{"id":32,"specId":1,"plane":"intent","kind":"context","kindOrdinal":17,"title":"Stakeholder preference: phase color assignments are grounding=blue (foundational), shaping=green (growth/emergence), pinning=amber (fixity/…","body":"Stakeholder preference: phase color assignments are grounding=blue (foundational), shaping=green (growth/emergence), pinning=amber (fixity/commitment), defining_done=violet (completion/closure).","basis":"explicit","source":"stakeholder [X34]","createdAtLsn":2,"updatedAtLsn":2},{"id":33,"specId":1,"plane":"intent","kind":"context","kindOrdinal":18,"title":"Stakeholder preference: a 'running' status is unlikely to appear but should be highlighted somehow if it does.","body":"Stakeholder preference: a 'running' status is unlikely to appear but should be highlighted somehow if it does.","basis":"explicit","source":"stakeholder [X26]","createdAtLsn":2,"updatedAtLsn":2},{"id":34,"specId":1,"plane":"intent","kind":"context","kindOrdinal":19,"title":"Stakeholder preference: derivation phases (such as reconciliation) are encoded as nodes within the graph rather than as separate UI constru…","body":"Stakeholder preference: derivation phases (such as reconciliation) are encoded as nodes within the graph rather than as separate UI constructs.","basis":"explicit","source":"stakeholder [X15]","createdAtLsn":2,"updatedAtLsn":2},{"id":35,"specId":1,"plane":"intent","kind":"context","kindOrdinal":20,"title":"The macro view is structurally different from the micro view: it shows ~20-40 rich nodes representing the derivation process rather than gr…","body":"The macro view is structurally different from the micro view: it shows ~20-40 rich nodes representing the derivation process rather than graph content.","basis":"explicit","source":"stakeholder [X3]","createdAtLsn":2,"updatedAtLsn":2},{"id":36,"specId":1,"plane":"intent","kind":"context","kindOrdinal":21,"title":"Reconciliation compares candidate nodes from re-derivation against the previous iteration's nodes for that phase and classifies each relati…","body":"Reconciliation compares candidate nodes from re-derivation against the previous iteration's nodes for that phase and classifies each relationship using lineage edge types.","basis":"explicit","source":"external-observed [X8]","createdAtLsn":2,"updatedAtLsn":2},{"id":37,"specId":1,"plane":"intent","kind":"context","kindOrdinal":22,"title":"Stakeholder preference: derivation depth in the macro layout is computed from the parentFrameId chain length.","body":"Stakeholder preference: derivation depth in the macro layout is computed from the parentFrameId chain length.","basis":"explicit","source":"stakeholder [X30]","createdAtLsn":2,"updatedAtLsn":2},{"id":39,"specId":1,"plane":"intent","kind":"context","kindOrdinal":23,"title":"Stakeholder preference: impasse nodes connect sub-trees and increase the breadth of the layout to represent the opening of a new derivation…","body":"Stakeholder preference: impasse nodes connect sub-trees and increase the breadth of the layout to represent the opening of a new derivation branch.","basis":"explicit","source":"stakeholder [X16]","createdAtLsn":2,"updatedAtLsn":2},{"id":40,"specId":1,"plane":"intent","kind":"context","kindOrdinal":24,"title":"Stakeholder preference: users can fold (collapse) any nested derivation run to hide detail.","body":"Stakeholder preference: users can fold (collapse) any nested derivation run to hide detail.","basis":"explicit","source":"stakeholder [X12]","createdAtLsn":2,"updatedAtLsn":2},{"id":41,"specId":1,"plane":"intent","kind":"context","kindOrdinal":25,"title":"Stakeholder preference: fan-in groupings render as color-coded rows inside the fan-in node — green for 'merged', amber for 'best_selected',…","body":"Stakeholder preference: fan-in groupings render as color-coded rows inside the fan-in node — green for 'merged', amber for 'best_selected', red for 'impasse_surfaced' — distinguished by a colored left border or chip.","basis":"explicit","source":"stakeholder [X32]","createdAtLsn":2,"updatedAtLsn":2},{"id":42,"specId":1,"plane":"intent","kind":"context","kindOrdinal":26,"title":"Because the macro view renders inside a React Flow canvas (not directly in the DOM), large node counts are not a DOM performance concern.","body":"Because the macro view renders inside a React Flow canvas (not directly in the DOM), large node counts are not a DOM performance concern.","basis":"explicit","source":"stakeholder [X9]","createdAtLsn":2,"updatedAtLsn":2},{"id":44,"specId":1,"plane":"intent","kind":"context","kindOrdinal":27,"title":"Stakeholder preference: layout grammar encodes timeline through verticality (t+1 / more recent appears higher) and breadth encodes onion-pe…","body":"Stakeholder preference: layout grammar encodes timeline through verticality (t+1 / more recent appears higher) and breadth encodes onion-peel derivation depth.","basis":"explicit","source":"stakeholder [X14]","createdAtLsn":2,"updatedAtLsn":2},{"id":45,"specId":1,"plane":"intent","kind":"context","kindOrdinal":28,"title":"The theme.css token assignment differs from grounding X34: theme.css maps shaping=amber and pinning=cyan, while X34 specifies shaping=green…","body":"The theme.css token assignment differs from grounding X34: theme.css maps shaping=amber and pinning=cyan, while X34 specifies shaping=green and pinning=amber. Reconciling this is a minor concern but theme.css is the source of truth for the existing design system.","basis":"explicit","source":"technical-observed [X41]","createdAtLsn":2,"updatedAtLsn":2},{"id":46,"specId":1,"plane":"intent","kind":"context","kindOrdinal":29,"title":"The onion-peel derivation structure (impasse discovery, rederivation, reconciliation, resolution) is specified in the spec-elicitation.md d…","body":"The onion-peel derivation structure (impasse discovery, rederivation, reconciliation, resolution) is specified in the spec-elicitation.md document.","basis":"explicit","source":"external-observed [X5]","createdAtLsn":2,"updatedAtLsn":2},{"id":47,"specId":1,"plane":"intent","kind":"context","kindOrdinal":30,"title":"Stakeholder preference: phantom nodes and faded nodes are informational only and carry no interactive actions.","body":"Stakeholder preference: phantom nodes and faded nodes are informational only and carry no interactive actions.","basis":"explicit","source":"stakeholder [X24]","createdAtLsn":2,"updatedAtLsn":2},{"id":49,"specId":1,"plane":"intent","kind":"context","kindOrdinal":31,"title":"Stakeholder preference: render the derivation narrative as a pannable, zoomable graph.","body":"Stakeholder preference: render the derivation narrative as a pannable, zoomable graph.","basis":"explicit","source":"stakeholder [X10]","createdAtLsn":2,"updatedAtLsn":2},{"id":51,"specId":1,"plane":"intent","kind":"context","kindOrdinal":32,"title":"Stakeholder preference: when a nested run is collapsed, sibling nodes shift to fill vacated space, triggering a layout reflow.","body":"Stakeholder preference: when a nested run is collapsed, sibling nodes shift to fill vacated space, triggering a layout reflow.","basis":"explicit","source":"stakeholder [X22]","createdAtLsn":2,"updatedAtLsn":2},{"id":52,"specId":1,"plane":"intent","kind":"context","kindOrdinal":33,"title":"Stakeholder preference: fan-in and fan-out steps are represented as nested nodes within any one phase node.","body":"Stakeholder preference: fan-in and fan-out steps are represented as nested nodes within any one phase node.","basis":"explicit","source":"stakeholder [X17]","createdAtLsn":2,"updatedAtLsn":2},{"id":54,"specId":1,"plane":"intent","kind":"context","kindOrdinal":34,"title":"Stakeholder preference: perspective nodes show which branch was taken; unchosen branches fade out to indicate they were not taken but could…","body":"Stakeholder preference: perspective nodes show which branch was taken; unchosen branches fade out to indicate they were not taken but could be materialized later.","basis":"explicit","source":"stakeholder [X23]","createdAtLsn":2,"updatedAtLsn":2},{"id":55,"specId":1,"plane":"intent","kind":"context","kindOrdinal":35,"title":"Stakeholder preference: there is no separate 'trunk' node type; the trunk is simply the outermost shell of the onion-layered layout.","body":"Stakeholder preference: there is no separate 'trunk' node type; the trunk is simply the outermost shell of the onion-layered layout.","basis":"explicit","source":"stakeholder [X19]","createdAtLsn":2,"updatedAtLsn":2},{"id":59,"specId":1,"plane":"intent","kind":"context","kindOrdinal":36,"title":"Stakeholder preference: the nudging state is indicated by a treatment inside the node body (compact labelled chip or badge such as 'NUDGING…","body":"Stakeholder preference: the nudging state is indicated by a treatment inside the node body (compact labelled chip or badge such as 'NUDGING' or '⚡ NUDGE' in phosphor amber) rather than as an external indicator.","basis":"explicit","source":"stakeholder [X25]","createdAtLsn":2,"updatedAtLsn":2},{"id":61,"specId":1,"plane":"intent","kind":"context","kindOrdinal":37,"title":"A DetailPanel.tsx component exists at src/components/DetailPanel.tsx as a sibling to MacroView.tsx, confirming reuse is structurally feasib…","body":"A DetailPanel.tsx component exists at src/components/DetailPanel.tsx as a sibling to MacroView.tsx, confirming reuse is structurally feasible.","basis":"explicit","source":"technical-observed [X40]","createdAtLsn":2,"updatedAtLsn":2},{"id":65,"specId":1,"plane":"intent","kind":"context","kindOrdinal":38,"title":"Stakeholder preference: all three frame modes (initial, rederive, grounding_enrichment) render as the same phase-group node component, with…","body":"Stakeholder preference: all three frame modes (initial, rederive, grounding_enrichment) render as the same phase-group node component, with mode differences expressed via color, label, or border style only.","basis":"explicit","source":"stakeholder [X20]","createdAtLsn":2,"updatedAtLsn":2},{"id":67,"specId":1,"plane":"intent","kind":"context","kindOrdinal":39,"title":"A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.","body":"A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.","basis":"explicit","source":"stakeholder [X4]","createdAtLsn":2,"updatedAtLsn":2},{"id":68,"specId":1,"plane":"intent","kind":"context","kindOrdinal":40,"title":"Stakeholder preference: when a nested derivation run is collapsed it shrinks to a small pill or badge node showing key stats (run count, ou…","body":"Stakeholder preference: when a nested derivation run is collapsed it shrinks to a small pill or badge node showing key stats (run count, outcome).","basis":"explicit","source":"stakeholder [X21]","createdAtLsn":2,"updatedAtLsn":2},{"id":69,"specId":1,"plane":"intent","kind":"context","kindOrdinal":41,"title":"Stakeholder preference: custom React Flow node types exist for each semantic role: trunk phase, impasse, phase group, fan-out, run, fan-in,…","body":"Stakeholder preference: custom React Flow node types exist for each semantic role: trunk phase, impasse, phase group, fan-out, run, fan-in, reconciliation, perspective, phantom, and dead-end impasse.","basis":"explicit","source":"stakeholder [X13]","createdAtLsn":2,"updatedAtLsn":2},{"id":43,"specId":1,"plane":"intent","kind":"goal","kindOrdinal":1,"title":"Each node's form and function must visually communicate what happened at that specific point in the derivation tree.","body":"Each node's form and function must visually communicate what happened at that specific point in the derivation tree.","basis":"explicit","source":"stakeholder [G4]","createdAtLsn":2,"updatedAtLsn":2},{"id":57,"specId":1,"plane":"intent","kind":"goal","kindOrdinal":2,"title":"The macro view's visual design must be beautiful, novel, and information-dense, avoiding generic dashboard or AI-generated aesthetics.","body":"The macro view's visual design must be beautiful, novel, and information-dense, avoiding generic dashboard or AI-generated aesthetics.","basis":"explicit","source":"stakeholder [G2]","createdAtLsn":2,"updatedAtLsn":2},{"id":60,"specId":1,"plane":"intent","kind":"goal","kindOrdinal":3,"title":"A user should be able to understand what happened at each derivation step from the node visuals alone (numbers, IDs, outcomes) without need…","body":"A user should be able to understand what happened at each derivation step from the node visuals alone (numbers, IDs, outcomes) without needing to open a detail panel.","basis":"explicit","source":"stakeholder [G3]","createdAtLsn":2,"updatedAtLsn":2},{"id":66,"specId":1,"plane":"intent","kind":"goal","kindOrdinal":4,"title":"The macro view answers the question 'how did the spec get here?' by visualizing the full onion-peel cycle of impasse discovery, rederivatio…","body":"The macro view answers the question 'how did the spec get here?' by visualizing the full onion-peel cycle of impasse discovery, rederivation, reconciliation, and resolution.","basis":"explicit","source":"stakeholder [G1]","createdAtLsn":2,"updatedAtLsn":2},{"id":3,"specId":1,"plane":"intent","kind":"term","kindOrdinal":1,"title":"The spec-elicitation system's derivation process consists of four phases in str…","basis":"explicit","source":"external-observed [T2]","detail":{"definition":"The spec-elicitation system's derivation process consists of four phases in strict dependency order: grounding, shaping, pinning, and defining_done."},"createdAtLsn":2,"updatedAtLsn":2},{"id":7,"specId":1,"plane":"intent","kind":"term","kindOrdinal":2,"title":"The HubNode type has hubType of justification | decision | impasse | perspectiv…","basis":"explicit","source":"technical-observed [T7]","detail":{"definition":"The HubNode type has hubType of justification | decision | impasse | perspective, and carries impasseStatus (open | resolved | superseded) and perspectiveStatus (open | selected | rejected)."},"createdAtLsn":2,"updatedAtLsn":2},{"id":9,"specId":1,"plane":"intent","kind":"term","kindOrdinal":3,"title":"The onion-peel structure refers to the iterative cycle of impasse discovery, re…","basis":"explicit","source":"external-observed [T13]","detail":{"definition":"The onion-peel structure refers to the iterative cycle of impasse discovery, rederivation, fan-out/fan-in synthesis, reconciliation, and resolution that builds the derivation history."},"createdAtLsn":2,"updatedAtLsn":2},{"id":12,"specId":1,"plane":"intent","kind":"term","kindOrdinal":4,"title":"The ReconciliationRecord type captures reconciliation outcomes (accepted | retr…","basis":"explicit","source":"technical-observed [T6]","detail":{"definition":"The ReconciliationRecord type captures reconciliation outcomes (accepted | retry | recurse | bail) along with candidateNodeIds, baselineNodeIds, activatedNodeIds, archivedNodeIds, triggerImpasseIds, resolvedImpasseIds, unresolvedImpasseIds, and a materialProgress flag."},"createdAtLsn":2,"updatedAtLsn":2},{"id":22,"specId":1,"plane":"intent","kind":"term","kindOrdinal":5,"title":"The FrameRecord type includes: id, parentFrameId, baselineFrameId, entryPhase,…","basis":"explicit","source":"technical-observed [T3]","detail":{"definition":"The FrameRecord type includes: id, parentFrameId, baselineFrameId, entryPhase, triggerImpasseIds, mode (initial | rederive | grounding_enrichment), attemptNumber, nudgingActive, createdAt, and summary."},"createdAtLsn":2,"updatedAtLsn":2},{"id":23,"specId":1,"plane":"intent","kind":"term","kindOrdinal":6,"title":"The ArtifactFile type bundles all spec data into a single file: manifest, sourc…","basis":"explicit","source":"technical-observed [T8]","detail":{"definition":"The ArtifactFile type bundles all spec data into a single file: manifest, sources, extractedClaims, interventions, and a graph object containing nodes, edges, frames, derivationRuns, fanInRecords, reconciliationRecords, and snapshots."},"createdAtLsn":2,"updatedAtLsn":2},{"id":24,"specId":1,"plane":"intent","kind":"term","kindOrdinal":7,"title":"The DerivationRunRecord type includes: id, frameId, phase, runIndex, inputNodeI…","basis":"explicit","source":"technical-observed [T4]","detail":{"definition":"The DerivationRunRecord type includes: id, frameId, phase, runIndex, inputNodeIds, outputCandidateIds, impassesFound, status (running | completed | failed), and createdAt."},"createdAtLsn":2,"updatedAtLsn":2},{"id":25,"specId":1,"plane":"intent","kind":"term","kindOrdinal":8,"title":"A dead-end impasse is one whose reconciliation chose the 'bail' outcome — the s…","basis":"explicit","source":"stakeholder [T11]","detail":{"definition":"A dead-end impasse is one whose reconciliation chose the 'bail' outcome — the system gave up on that branch entirely."},"createdAtLsn":2,"updatedAtLsn":2},{"id":48,"specId":1,"plane":"intent","kind":"term","kindOrdinal":9,"title":"The FanInRecord type captures the fan-in step: id, frameId, phase, inputRunIds,…","basis":"explicit","source":"technical-observed [T5]","detail":{"definition":"The FanInRecord type captures the fan-in step: id, frameId, phase, inputRunIds, groupings (with resolution: merged | best_selected | impasse_surfaced), outputCandidateIds, droppedCandidateIds, and createdAt."},"createdAtLsn":2,"updatedAtLsn":2},{"id":53,"specId":1,"plane":"intent","kind":"term","kindOrdinal":10,"title":"materialProgress=true on a ReconciliationRecord means at least some nodes were…","basis":"explicit","source":"stakeholder [T12]","detail":{"definition":"materialProgress=true on a ReconciliationRecord means at least some nodes were activated or archived during that reconciliation — real forward progress occurred."},"createdAtLsn":2,"updatedAtLsn":2},{"id":58,"specId":1,"plane":"intent","kind":"term","kindOrdinal":11,"title":"The Phase type has four ordered values: grounding, shaping, pinning, and defini…","basis":"explicit","source":"technical-observed [T1]","detail":{"definition":"The Phase type has four ordered values: grounding, shaping, pinning, and defining_done."},"createdAtLsn":2,"updatedAtLsn":2},{"id":62,"specId":1,"plane":"intent","kind":"term","kindOrdinal":12,"title":"The macro view is the component within the Spec Explorer UI that narrates the f…","basis":"explicit","source":"stakeholder [T14]","detail":{"definition":"The macro view is the component within the Spec Explorer UI that narrates the full derivation history of a spec via a pannable, zoomable React Flow graph of ~20–40 semantically typed nodes."},"createdAtLsn":2,"updatedAtLsn":2},{"id":63,"specId":1,"plane":"intent","kind":"term","kindOrdinal":13,"title":"A phantom node represents the case where no perspective is selected — it appear…","basis":"explicit","source":"stakeholder [T10]","detail":{"definition":"A phantom node represents the case where no perspective is selected — it appears when an alternative perspective is used."},"createdAtLsn":2,"updatedAtLsn":2}],"edges":[{"id":1,"specId":1,"category":"dependency","sourceId":16,"targetId":28,"basis":"explicit","createdAtLsn":2,"updatedAtLsn":2},{"id":2,"specId":1,"category":"dependency","sourceId":16,"targetId":50,"basis":"explicit","createdAtLsn":2,"updatedAtLsn":2},{"id":3,"specId":1,"category":"dependency","sourceId":45,"targetId":32,"basis":"explicit","createdAtLsn":2,"updatedAtLsn":2},{"id":4,"specId":1,"category":"dependency","sourceId":61,"targetId":67,"basis":"explicit","createdAtLsn":2,"updatedAtLsn":2},{"id":5,"specId":1,"category":"dependency","sourceId":16,"targetId":32,"basis":"explicit","createdAtLsn":2,"updatedAtLsn":2},{"id":6,"specId":1,"category":"dependency","sourceId":45,"targetId":16,"basis":"explicit","createdAtLsn":2,"updatedAtLsn":2}],"nodeCount":70,"edgeCount":6,"lsn":3},"isError":false,"timestamp":1780754584784}} +{"type":"message","id":"67ab98c6","parentId":"9f4a7573","timestamp":"2026-06-06T14:03:24.598Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"**Evaluating realization source design**\n\nI'm thinking about how the realization source might be designed to meet specific requirements. The category of \"realization\" could mean that a new requirement helps realize a goal. I wonder if edges can be connected with existing code and batch references in the review set. It seems like using existing code strings could be important here, and I definitely need an exact dry run to make sure everything aligns properly.","thinkingSignature":"{\"id\":\"rs_0fdf1a6645f3ebbb016a242899bb8481918bd1a3bf655cdfa8\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqJCigXeqtLp--B0dzWZjhWkFDEhbBERtKiYeLNQJ1_nRdgk0II-Y2m1Qu3eLf3tgonOmep90j4zqb2-hKI8djr6ZxEsungaZZTTa3tOWeSgK3Yacg3owShwL9XKUnKCxvVLED6cpuIx7qvG9xFIclsTP84Nqs1NsuWV5C_GYFi3R-o3C_Yaq0Z8SmYE9fWul592LmVsE0gR5ECikESVxRerjWzyZvOx_y5wEnOrduvn44TfCiCxNO-i0iGiQKj8FNwLcYIJZr-s057qZ4IDXp8ZU4xgp76ZuSHoK2Lz80oNfGI7nsoyUXulB5dfMqLm9ZV3K881OhuDIvWrGfZyxwi8BTKiLE9VW1jLyO27jkWW-faxAywGdjPhKEnIDJV7V2I9XkArwFu3_yNSQfDSHfQgyRa6hpAfeE2otKlYkPtYMJx7gnpWveQWKsLgaoKNYLfRMqSRrA_9U-jsypSPbrEv4xEa3BaJOYWBMaHq9DwY-VmqtOsqWLAjwdHkTganah3zSmY3IiBZ0zIFWlHGrt1CDMBay_EAoPBZpBArVb9pSoYRMtL6986GAeab5A7wH4f_kLEP6cLauIgr1BRc1A1sUSK3MCwi-pJntzwRNhOle1fMWjqxjJmRRvXBVZtDt3XwsXzmHQHMN5gyqEXw0zOrffefwNJSekBcv3z98Idrv5zT21Yqnr0C_vVem3Rjq0e0MvXH9w-ihSF6EBYK8fq7KE8moMEvlfAHbdAv8We-F4pnDkm_TmTdIz1PLK_3UfbbGBL4lVkQ7GDc5QDls-klgx2h643exKzQ28aOBQ7syLzro9iw0kgucleI1EKeNBg-fBFbL_d6YIjOGdSYIarN7Cc4XqoAT5AUtz3Xc5ORTAhAg3wwpJbgKLVjmX-_m9ZGh3bzpsZljXcvwtmbDm27FBgBSLbClBzinRBT20gNQLbLRuF2nOBYcaiENOA8n4nybm4Tdgax0n7lVWUTqeAST2uC2nNdZ1_DSWmdOmn_VUPXWGXA1YO5lYrWXCV--V06rQm0YKPSq-tZqrJmh9KuTqV02a3sPF4_ah_RbM4yQUwW1kfyKii4ANtcS0RdOrVocT4DYDsHgHcwfzJp0ziE1SJhsoAgaFRRK2KCFHYTwZgTO3sjhhrjHf6qYbrLQ43D41EA3Afe3eGt77T5QaeHvG-RGVJQ_fOzLbwyoF2BBoQz4zaP-j-oCxiee-x1j-BIk_dLqSzQi_4q9AKwUjxM24lm6FWhry1gRLLCNgoF6duULz1dLKbgu82CnRIkZYqt6MMrfD4v6MswgG5_4fql3dEoyacOuaDcEOOkQjtQpTpPk3t9PqkEwzmHISTwNPWVDtjX9jPSc27F2ymeqBa4LiNdNCX4VLZ-5_GzbTajWm2Ad_VHFrXQqGTj6F-PGIrBLv-Zps5LMaxSwhGh_LnwNMRDXn0ThO_Ja2QBXUb0GehxTWqKuuFu8wAcQYnQEwyN9jgzSi11ocGXhSyW85znurJpNsG834ZjvUQAdP4EA00ayj8tAbVyX7eVKNZYS_xhaEVYQAvdLNCkb0_APPrJzAaLJHDqSoj-yuQmWd7i513bq1LbdWVewVsiT3fpTYHk6q4TV8VqskSQYGM4yfWc3yn2k5LhvPv41BsypE3sHigabaLIn5VqFdz34fd9bctw6emTC5XQXgHlCPJcAY80grnYS4dk4H7lfunwI7DCjXCFnDUOG2gRzPMEMrOtW5rF7EEhdmtfRiE6Gem3jbhjbuCbDE5Jml0apM7TCoPht8ZSjbNP52z1Pv-E756BlXbzArgZ9-LTVUt9XkUQtk-sq8UVKnGMfRChBIAW4gSLYTr9hIEjKkikyBDDHNEbmj6guddD-ENq6GRvFg8WfgpZfu5YZpb9zaah3Rl9g8davVZFmTa6mwNl5Z3B4w9mX3ILNSv3HLiiRzO6LlOa1Z8MfoDEpAbVXBmzsDuOOcJU9FmSZqseH1N1zp7Mv0DQVpdIBRKJ5dUET8MVgHEztTS3U39sAX7W95Cn0ORhMBJTPPqX4XAqHV64mR7FMzNrYJ3XwpEO-3VAyprpfVpRLwMynxiCjZyamUgHn3Gu-7tXZ0OJjTIFYL8tCyjiuzAJtQtmeUTHmoet_5q4iM-miJu4tkjklWzdtUf0ylMPI6czTTSSuQZCYsS9sRYXGpWsbAML4-Z5MxHegdftATYYEnb9djX7ksoZAbz0owPS-rFXC2sdpOuCv9QAECKqNAoA1PBS658ThORIh4HApKX1CJX9wwlNnEiqGgjwHYpbKvWrCiZt4YNznR7ePkS7pXYQDZMYBT_iTloEKrvkoIjhNsUNOWdRh7pMz1Z7wnYpnSgR0jVkOB6FAMvvd-s-51X1gez4Yf2uj4uvfv1brXBZ9L_6I0_Eyc7xc0OUqUX_mE7oXNdH4v0G2I7VdUh1LaFr8gTAfib3Xh1573LfT-mCALO0rjpY-k5tV17Fi4ib2Tax3yRF4bn67aLCSmXL9xo4FfMejnuyuyqDzJyNBHZ1uxd5yG65rZCQETv_I_n8WcHGn-cqGS1OgL9cmoeOSBO4bJF-yhSuwQavBhYnHHtclAu5mafqGjuz4y8s6bkpHFIZNB2ncj4jsv5mMCyN9xGAmZnEVuf92j6-iBci00K315pkDjRg==\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"**Evaluating realization source design**\\n\\nI'm thinking about how the realization source might be designed to meet specific requirements. The category of \\\"realization\\\" could mean that a new requirement helps realize a goal. I wonder if edges can be connected with existing code and batch references in the review set. It seems like using existing code strings could be important here, and I definitely need an exact dry run to make sure everything aligns properly.\"}]}"},{"type":"toolCall","id":"call_9d6ZouW5DvBPQILU5Mpal3TA|fc_0fdf1a6645f3ebbb016a2428a013c081918f606feee09e34b3","name":"present_review_set","arguments":{"exchangeId":"fe-809-macro-view-intent-proof-1","payload":{"schemaVersion":1,"lens":"intent","epistemicStatus":"inferred","grounding":{"summary":"The existing macro-view intent graph already states that node visuals should explain derivation events, that the macro view is read-only and React Flow-based, and that a right-side detail panel should be reused. This proposal adds two small intent commitments that make those expectations reviewable.","support":["G1: Each node's form and function must visually communicate what happened at that specific point in the derivation tree.","G3: A user should be able to understand what happened at each derivation step from the node visuals alone.","CTX9: Clicking a node opens a detail panel on the right side of the screen.","CTX39: A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.","CON3: The macro view is strictly read-only."]},"pitch":{"title":"Macro-view node legibility and read-only detail inspection","narrative":"Capture two inferred commitments from the seeded macro-view intent graph: macro nodes need a concrete visual-legibility acceptance criterion, and node selection should provide detail inspection by reusing the existing right-side panel without adding mutation affordances."},"nodes":[{"ref":"n1","plane":"intent","kind":"criterion","title":"Macro node visuals are independently legible","body":"A macro-view node is acceptable when its visible treatment communicates the derivation role and current state well enough for a user to identify the relevant phase, outcome/status, and key counts or IDs without first opening the detail panel.","source":"inferred"},{"ref":"n2","plane":"intent","kind":"requirement","title":"Macro node selection opens the reused read-only detail panel","body":"When a user clicks a macro-view node, the macro view opens the existing right-side detail panel for inspection while preserving the macro view's read-only constraint and avoiding mutation actions.","source":"inferred"}],"edges":[{"category":"support","source":{"existingCode":"G3"},"target":"n1","stance":"for","rationale":"The criterion makes the existing goal of understanding derivation steps from node visuals alone reviewable."},{"category":"support","source":{"existingCode":"G1"},"target":"n1","stance":"for","rationale":"The criterion specifies how form and function should communicate what happened at a derivation point."},{"category":"support","source":{"existingCode":"CTX39"},"target":"n2","stance":"for","rationale":"The existing Micro View detail panel is available and should be reused by the macro view."},{"category":"support","source":{"existingCode":"CON3"},"target":"n2","stance":"for","rationale":"The requirement constrains detail inspection to remain read-only."}]}}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.5","usage":{"input":2687,"output":1052,"cacheRead":6656,"cacheWrite":0,"totalTokens":10395,"cost":{"input":0.013435,"output":0.03156,"cacheRead":0.003328,"cacheWrite":0,"total":0.048323}},"stopReason":"toolUse","timestamp":1780754584785,"responseId":"resp_0fdf1a6645f3ebbb016a2428992e888191bc6e699c915515ef"}} +{"type":"message","id":"7829dde9","parentId":"67ab98c6","timestamp":"2026-06-06T14:03:24.601Z","message":{"role":"toolResult","toolCallId":"call_9d6ZouW5DvBPQILU5Mpal3TA|fc_0fdf1a6645f3ebbb016a2428a013c081918f606feee09e34b3","toolName":"present_review_set","content":[{"type":"text","text":"STRUCTURAL_ILLEGAL\n\n- entityDrafts: entityDrafts must be non-empty\n- edgeDrafts: edgeDrafts must be non-empty"}],"details":{"status":"structural_illegal","diagnostics":[{"field":"entityDrafts","message":"entityDrafts must be non-empty"},{"field":"edgeDrafts","message":"edgeDrafts must be non-empty"}]},"isError":false,"timestamp":1780754604601}} +{"type":"message","id":"ba00edc5","parentId":"7829dde9","timestamp":"2026-06-06T14:03:39.443Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"{\"id\":\"rs_0fdf1a6645f3ebbb016a2428aed9b081919c60f7850a9dc9e8\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqJCixp1wqZcazTDKO5mKTOlyqZAeKSFWKa34MKv-8MuPUZ9STrdqm5Zp60-0lrmm58cDvBoF6ZSsJHishWjBfeGknpnUsnzBcwWxioO65IpqZRN-AMxj27HL0Gg_igUfkI4amHTycKqcYdm0qDdd48kWMRzwxyYQgkFXEe3oPvcWPcR6HCcaVS-kDczrlyRuxXKjgQLp-bsDaVEDTtN0E1oWl0JsTYjDbBD7qeTiWSAvPFCJPBZXD1O6m7rSUOmheL9cDws23hi6uezR2m58mOIwfjj-p1na7fs31ogf7H7j2ViKOYGA7sL7HWlVZsplWpKqidmO-cwbK5jqqCzc7jDvXNHDPZ1vF7mp1GMFV91HAMw4Z1BjoDCIw-UBcFzffmNfEe9uEAybV357IuVaCDYFO8qTk_uY_VwlBX6t8zjASxVd6NkX5txJOgLHmeeyDfIJx6B6mgQ9h47TUbC5s84YqmZAKFE_2q9JjYtFNZAQm1771rNWz42KL3FgfP8vmcsk2_vN5fDspsgZtZwFhxxNOKq0M_mFGBJ7gRQYW1Nlz1v_gcLS4F1ZK5V5Te0isKgza1PxAjx8x3JfQ4vW-wgpaO19Yy1OxmLvfMi2H4Z6L8cD3tZ41VwwutJkYjoTLRW-ow2axRNDcCY_Sx0a1SVq93jix6ZptFe3CZeK-BXDy0tFhEw9rGzB2u83jAZtft1y1BeMUHh5YEyVmjjevPWfBUW1uStajEX46t8N9i4LYGiCvsrBNzKPBO9hwP83gIMzrKPLUAQ8AKRw3rJvV6Kp2FBwMr1OqhJxQ2V9EFcZIOKghjmu842Uca4MMGBVurpDQqqInReg65M4uq6_x0mvc00QSnjyywhG3QAXkFWsyZeMNYC0gOa6CnPvMD5gAAofuslsf19cO7VLlzlk1Z-bEXZ-05DzpGlA8TVesknBA9vnPvGaTQRApVJGju1yxBH3-h9kPra5nrhYhdn4yccMh-TYcIME6jqYoQRr-uHzY51tUpACaSF-YFXmNc99ipRYQEBcl6GBIbbUnrWV49f0oWN8lJPf4TiVobgyMiAZeLrf-PYOqLduG1KST5535NfpU7s1U2qTylpLuRPD22gwquUUrV8fYPvHSOT6veVtiFwb_1QNui4mgjScHyk5AIdA52N1nKgAxbmatwMYnevxVeFoh6oXnHiZUkw22UYyTopT5-ftS4bloSYYThTFT1Kk_XjESuUgK9OuaI76KDGLsJg_OguJztlgFr-pWq7meDUTNgs9Zm6eX1J_7gC-HpY4k8WEEd1BZDwoXqB9hL5DN6z-U_Wvi2oQlsxokTZKIzCTGzgf5l747fdQ0e1E2I-_gJR3ABcTEKd1Vd4-6bK_xQB6UYAiw_yA0rXqwqx21QPMw4-TUUkFs3s-TuzrYxkhdNTu8TXp_Dcr6i-Pj4Gi50KRgl1KTeuVZgV1i4SpHJeqMjOQzBtoDrjJnWDwiirU4Dbuzk1lnrrKTR4xnyqK1-jgrx48Oz2GRKFESoZWVtUK86dR0FgyYeQHxmpRx8GBWYiqpjxjhrJHjndVYrTRCzum1cGI-RkM4eTJVVObvTTM=\",\"summary\":[]}"},{"type":"toolCall","id":"call_6SUdydSIFDKpCldkm6LRzQEl|fc_0fdf1a6645f3ebbb016a2428b1238c8191895644c6d768f915","name":"present_review_set","arguments":{"exchangeId":"fe-809-macro-view-intent-proof-1","payload":{"schemaVersion":1,"lens":"intent","epistemicStatus":"inferred","grounding":{"summary":"The existing macro-view intent graph already states that node visuals should explain derivation events, that the macro view is read-only and React Flow-based, and that a right-side detail panel should be reused. This proposal adds two small intent commitments that make those expectations reviewable.","support":["G1: Each node's form and function must visually communicate what happened at that specific point in the derivation tree.","G3: A user should be able to understand what happened at each derivation step from the node visuals alone.","CTX9: Clicking a node opens a detail panel on the right side of the screen.","CTX39: A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.","CON3: The macro view is strictly read-only."]},"pitch":{"title":"Macro-view node legibility and read-only detail inspection","narrative":"Capture two inferred commitments from the seeded macro-view intent graph: macro nodes need a concrete visual-legibility acceptance criterion, and node selection should provide detail inspection by reusing the existing right-side panel without adding mutation affordances."},"entityDrafts":[{"ref":"n1","plane":"intent","kind":"criterion","title":"Macro node visuals are independently legible","body":"A macro-view node is acceptable when its visible treatment communicates the derivation role and current state well enough for a user to identify the relevant phase, outcome/status, and key counts or IDs without first opening the detail panel.","source":"inferred"},{"ref":"n2","plane":"intent","kind":"requirement","title":"Macro node selection opens the reused read-only detail panel","body":"When a user clicks a macro-view node, the macro view opens the existing right-side detail panel for inspection while preserving the macro view's read-only constraint and avoiding mutation actions.","source":"inferred"}],"edgeDrafts":[{"category":"support","source":{"existingCode":"G3"},"target":"n1","stance":"for","rationale":"The criterion makes the existing goal of understanding derivation steps from node visuals alone reviewable."},{"category":"support","source":{"existingCode":"G1"},"target":"n1","stance":"for","rationale":"The criterion specifies how form and function should communicate what happened at a derivation point."},{"category":"support","source":{"existingCode":"CTX39"},"target":"n2","stance":"for","rationale":"The existing Micro View detail panel is available and should be reused by the macro view."},{"category":"support","source":{"existingCode":"CON3"},"target":"n2","stance":"for","rationale":"The requirement constrains detail inspection to remain read-only."}]}}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.5","usage":{"input":1034,"output":706,"cacheRead":9216,"cacheWrite":0,"totalTokens":10956,"cost":{"input":0.00517,"output":0.02118,"cacheRead":0.004608,"cacheWrite":0,"total":0.030958000000000003}},"stopReason":"toolUse","timestamp":1780754604601,"responseId":"resp_0fdf1a6645f3ebbb016a2428acf0c48191b473a31588e26b95"}} +{"type":"message","id":"a5dae8aa","parentId":"ba00edc5","timestamp":"2026-06-06T14:03:39.445Z","message":{"role":"toolResult","toolCallId":"call_6SUdydSIFDKpCldkm6LRzQEl|fc_0fdf1a6645f3ebbb016a2428b1238c8191895644c6d768f915","toolName":"present_review_set","content":[{"type":"text","text":"STRUCTURAL_ILLEGAL\n\n- entityDrafts[0].draftId: draftId must be non-empty\n- entityDrafts[1].draftId: draftId must be non-empty\n- edgeDrafts[0].target: endpoint must be an object\n- edgeDrafts[1].target: endpoint must be an object\n- edgeDrafts[2].target: endpoint must be an object\n- edgeDrafts[3].target: endpoint must be an object"}],"details":{"status":"structural_illegal","diagnostics":[{"field":"entityDrafts[0].draftId","message":"draftId must be non-empty"},{"field":"entityDrafts[1].draftId","message":"draftId must be non-empty"},{"field":"edgeDrafts[0].target","message":"endpoint must be an object"},{"field":"edgeDrafts[1].target","message":"endpoint must be an object"},{"field":"edgeDrafts[2].target","message":"endpoint must be an object"},{"field":"edgeDrafts[3].target","message":"endpoint must be an object"}]},"isError":false,"timestamp":1780754619445}} +{"type":"message","id":"b1c44d61","parentId":"a5dae8aa","timestamp":"2026-06-06T14:03:51.949Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"{\"id\":\"rs_0fdf1a6645f3ebbb016a2428bc5e9081918a4e7b92060ce4c6\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqJCi93dWdtflcfaHYfXv9b8ceJxX2QMDCYBFrqYmuFiJD7ryfhO0II8pZ0133hfzBCJLMeRbrVwaVZGchwgqUTiFL3KlfcVoO4dF8t0ISdJF74--0hsfz4tjTvyGfb9B80A6GaMd3zSbWHUxYVWMm8SAyLcQU0MaEkfW7S2gIfXTQqb2COwRjIr_IsOWZEGOgAMuj13mVWa3Mhm5N7Q14FiRRnI1YV-02l2IMsqGOxFcbTwBGluB7JKixtsD97gWrGOs7KSMYqoM1Tzua5-TPtYiaZzMFt1hJtb_KXXXGa1S1tXA8bjBxq3YfmA-GHt0PM0xfzOzQqjLkEwRBsOdRO-Ug7F17UFPh5rHwCpf8y9jnTM2-soFBvrW39vTrtHgXcdvdQDJjyc3TBP_c8v4ZceQxyKS9ezjxYFBgubRqWcc7t-48I9lcnNKmK4_-bQRsB-8JNQJnk55Ugq2C7_2Xxn256GLSezTFj1-qlwoGva51tgq3GO7FZpzVOLIoLLmeXHCKwvgOBPcYhDb9T3ZrYmPd3phTsIifiuJ4_HUcgEUVy_wG7pr_OiSabjDBPHrYWIwyheqEy1Rpj_qb3yWDlY_gonJeu-HxJJ7zzYhpaljE8Gog2S4gwlhgThMCdHy-N11nHsMVA5pPwzVjGdZGVULnsFSd8uNsLJ7KcMGC_sI8du6yzEqcJbY4x5bcYfNji6umidxrdDYGFlHcj7mKYevLu56DcTqxhn_heW7Runroeiox_c2MGNGpjINTXgHpOUOkOBpgL6sJkRKrhRXuUDLoaB-pTGjBfiJXtH5j5i5xtE2HuIDasVXCno2cbqS8h0Lux8rzm_eczdOsr4Db0p_U3QR5cAorpAoqt5zA3Al4FJ48Zb74RMITfBsKks1FguKK2vUxKdL5EbaStsErFNik9LipteVqkWd4KgcEqTosHs50vSv_WXeAX0So7e8RGPzdrxIPOdr9t7qkG30uMlqjOBxVfg6DVQCHh57VO0Sav4HC5Bcpm7Xsh0einy-2T7MwtbXNqcgrPBPvDi2jWP_cmQ==\",\"summary\":[]}"},{"type":"toolCall","id":"call_oQxhWoFm95PjpJNI5pVuHjKK|fc_0fdf1a6645f3ebbb016a2428bd3ac4819181a75a0ed7aa01b8","name":"present_review_set","arguments":{"exchangeId":"fe-809-macro-view-intent-proof-1","payload":{"schemaVersion":1,"lens":"intent","epistemicStatus":"inferred","grounding":{"summary":"The existing macro-view intent graph already states that node visuals should explain derivation events, that the macro view is read-only and React Flow-based, and that a right-side detail panel should be reused. This proposal adds two small intent commitments that make those expectations reviewable.","support":["G1: Each node's form and function must visually communicate what happened at that specific point in the derivation tree.","G3: A user should be able to understand what happened at each derivation step from the node visuals alone.","CTX9: Clicking a node opens a detail panel on the right side of the screen.","CTX39: A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.","CON3: The macro view is strictly read-only."]},"pitch":{"title":"Macro-view node legibility and read-only detail inspection","narrative":"Capture two inferred commitments from the seeded macro-view intent graph: macro nodes need a concrete visual-legibility acceptance criterion, and node selection should provide detail inspection by reusing the existing right-side panel without adding mutation affordances."},"entityDrafts":[{"draftId":"n1","plane":"intent","kind":"criterion","title":"Macro node visuals are independently legible","body":"A macro-view node is acceptable when its visible treatment communicates the derivation role and current state well enough for a user to identify the relevant phase, outcome/status, and key counts or IDs without first opening the detail panel.","source":"inferred"},{"draftId":"n2","plane":"intent","kind":"requirement","title":"Macro node selection opens the reused read-only detail panel","body":"When a user clicks a macro-view node, the macro view opens the existing right-side detail panel for inspection while preserving the macro view's read-only constraint and avoiding mutation actions.","source":"inferred"}],"edgeDrafts":[{"category":"support","source":{"existingCode":"G3"},"target":{"draftId":"n1"},"stance":"for","rationale":"The criterion makes the existing goal of understanding derivation steps from node visuals alone reviewable."},{"category":"support","source":{"existingCode":"G1"},"target":{"draftId":"n1"},"stance":"for","rationale":"The criterion specifies how form and function should communicate what happened at a derivation point."},{"category":"support","source":{"existingCode":"CTX39"},"target":{"draftId":"n2"},"stance":"for","rationale":"The existing Micro View detail panel is available and should be reused by the macro view."},{"category":"support","source":{"existingCode":"CON3"},"target":{"draftId":"n2"},"stance":"for","rationale":"The requirement constrains detail inspection to remain read-only."}]}}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.5","usage":{"input":1338,"output":647,"cacheRead":9728,"cacheWrite":0,"totalTokens":11713,"cost":{"input":0.006690000000000001,"output":0.01941,"cacheRead":0.004863999999999999,"cacheWrite":0,"total":0.030964000000000002}},"stopReason":"toolUse","timestamp":1780754619446,"responseId":"resp_0fdf1a6645f3ebbb016a2428bbc87c81918a809a04b17ad8b1"}} +{"type":"message","id":"3000dfc4","parentId":"b1c44d61","timestamp":"2026-06-06T14:03:51.957Z","message":{"role":"toolResult","toolCallId":"call_oQxhWoFm95PjpJNI5pVuHjKK|fc_0fdf1a6645f3ebbb016a2428bd3ac4819181a75a0ed7aa01b8","toolName":"present_review_set","content":[{"type":"text","text":"## Macro-view node legibility and read-only detail inspection\n\nCapture two inferred commitments from the seeded macro-view intent graph: macro nodes need a concrete visual-legibility acceptance criterion, and node selection should provide detail inspection by reusing the existing right-side panel without adding mutation affordances.\n\nLens: intent\n\nEpistemic status: inferred\n\n### Grounding\n\nThe existing macro-view intent graph already states that node visuals should explain derivation events, that the macro view is read-only and React Flow-based, and that a right-side detail panel should be reused. This proposal adds two small intent commitments that make those expectations reviewable.\n\n- G1: Each node's form and function must visually communicate what happened at that specific point in the derivation tree.\n- G3: A user should be able to understand what happened at each derivation step from the node visuals alone.\n- CTX9: Clicking a node opens a detail panel on the right side of the screen.\n- CTX39: A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.\n- CON3: The macro view is strictly read-only.\n\n### Entity drafts\n\n- **n1** (intent/criterion): Macro node visuals are independently legible\n A macro-view node is acceptable when its visible treatment communicates the derivation role and current state well enough for a user to identify the relevant phase, outcome/status, and key counts or IDs without first opening the detail panel.\n\n- **n2** (intent/requirement): Macro node selection opens the reused read-only detail panel\n When a user clicks a macro-view node, the macro view opens the existing right-side detail panel for inspection while preserving the macro view's read-only constraint and avoiding mutation actions.\n\n### Edge drafts\n\n- G3 —support [for]→ n1\n The criterion makes the existing goal of understanding derivation steps from node visuals alone reviewable.\n\n- G1 —support [for]→ n1\n The criterion specifies how form and function should communicate what happened at a derivation point.\n\n- CTX39 —support [for]→ n2\n The existing Micro View detail panel is available and should be reused by the macro view.\n\n- CON3 —support [for]→ n2\n The requirement constrains detail inspection to remain read-only."}],"details":{"schema":"brunch.structured_exchange.present","v":1,"exchange_id":"fe-809-macro-view-intent-proof-1","tool_meta":{"curr":"present_review_set","next":"request_review"},"display":{"heading":"Macro-view node legibility and read-only detail inspection","body":"Capture two inferred commitments from the seeded macro-view intent graph: macro nodes need a concrete visual-legibility acceptance criterion, and node selection should provide detail inspection by reusing the existing right-side panel without adding mutation affordances."},"review_set":{"nodes":[{"draft_id":"n1","plane":"intent","kind":"criterion","title":"Macro node visuals are independently legible","body":"A macro-view node is acceptable when its visible treatment communicates the derivation role and current state well enough for a user to identify the relevant phase, outcome/status, and key counts or IDs without first opening the detail panel."},{"draft_id":"n2","plane":"intent","kind":"requirement","title":"Macro node selection opens the reused read-only detail panel","body":"When a user clicks a macro-view node, the macro view opens the existing right-side detail panel for inspection while preserving the macro view's read-only constraint and avoiding mutation actions."}],"edges":[{"category":"support","source":{"existing_code":"G3"},"target":{"draft_id":"n1"},"stance":"for","rationale":"The criterion makes the existing goal of understanding derivation steps from node visuals alone reviewable."},{"category":"support","source":{"existing_code":"G1"},"target":{"draft_id":"n1"},"stance":"for","rationale":"The criterion specifies how form and function should communicate what happened at a derivation point."},{"category":"support","source":{"existing_code":"CTX39"},"target":{"draft_id":"n2"},"stance":"for","rationale":"The existing Micro View detail panel is available and should be reused by the macro view."},{"category":"support","source":{"existing_code":"CON3"},"target":{"draft_id":"n2"},"stance":"for","rationale":"The requirement constrains detail inspection to remain read-only."}]}},"isError":false,"timestamp":1780754631956}} +{"type":"message","id":"23edd109","parentId":"3000dfc4","timestamp":"2026-06-06T14:03:53.152Z","message":{"role":"assistant","content":[{"type":"text","text":"Presented a valid review set for external Brunch RPC review.","textSignature":"{\"v\":1,\"id\":\"msg_0fdf1a6645f3ebbb016a2428c8dc308191851c65c861d94a8a\",\"phase\":\"final_answer\"}"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.5","usage":{"input":1451,"output":16,"cacheRead":10752,"cacheWrite":0,"totalTokens":12219,"cost":{"input":0.007255,"output":0.00048,"cacheRead":0.005376,"cacheWrite":0,"total":0.013111000000000001}},"stopReason":"stop","timestamp":1780754631958,"responseId":"resp_0fdf1a6645f3ebbb016a2428c844788191bb85ff1015a58a44"}} +{"type":"message","id":"65058712","parentId":"23edd109","timestamp":"2026-06-06T14:03:53.178Z","message":{"role":"toolResult","toolCallId":"fe-809-macro-view-intent-proof-1:request_review","toolName":"request_review","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Review decision\n\nApproved\n\nComment:\n\n> Probe approval."}],"details":{"schema":"brunch.structured_exchange.request","v":1,"exchange_id":"fe-809-macro-view-intent-proof-1","tool_meta":{"prev":"present_review_set","curr":"request_review","next":"capture_review"},"answered":{"decision":"approve","comment":"Probe approval."}}}} + diff --git a/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/transcript.md b/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/transcript.md new file mode 100644 index 000000000..a4076333d --- /dev/null +++ b/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/transcript.md @@ -0,0 +1,195 @@ +# Transcript — session.jsonl + +## 1. User + +Brunch FE-809 project-graph proof. The selected spec is seeded from "macro-view-grounded-intent" and already has explicit intent-plane graph truth. + +Use read_graph in overview mode to inspect existing node codes. Then use present_review_set exactly once to propose a small exact review set derived from the existing macro-view intent graph. + +Proposal constraints: +- Create one or two new intent-plane requirement or criterion nodes. +- Include at least one edge using category "support" with stance "for" or category "realization". +- When referencing existing graph truth, use existingCode strings from read_graph output, never raw ids. +- Use schemaVersion 1, lens "intent", epistemicStatus "inferred", non-empty grounding.summary, grounding.support, pitch.title, and pitch.narrative. +- Do not call commit_graph. +- Do not call request_review; stop after a successful present_review_set so the external Brunch RPC reviewer can approve it. + +## 2. Tool result: read_graph + +Graph overview (LSN 3): 70 node(s), 6 edge(s). + +- [CON1] intent/constraint: "The design language explicitly prohibits generic UI patterns such as Material Design, Tailwind defaults, SaaS dashboard aesthetics, rounded…" +- [CON2] intent/constraint: "This brief is scoped exclusively to the macro view component and its rendering of the derivation story; other parts of the UI are explicitl…" +- [CON3] intent/constraint: "The macro view is strictly read-only; users can pan, zoom, and collapse/expand nodes, but cannot trigger any data mutations." +- [CON4] intent/constraint: "Users must manually refresh to see new derivation steps in the macro view." +- [CON5] intent/constraint: "The macro view is built inside a Vite + React + Tailwind SPA." +- [CON6] intent/constraint: "Every page load of the macro view starts fully expanded, with no memory of previously collapsed nodes." +- [CON7] intent/constraint: "Phase color values must be expressed as oklch values within the phosphor palette." +- [CON8] intent/constraint: "The macro view must use a manual layout algorithm rather than dagre, because the spatial grammar requires precise control over depth lanes…" +- [CON9] intent/constraint: "The CRT design language for the macro view mandates: var(--font-mono), oklch phase colors, warm-tinted dark surfaces, phosphor glow, and no…" +- [CON10] intent/constraint: "Collapsed/expanded state in the macro view is purely ephemeral in-memory React state and is never persisted." +- [CON11] intent/constraint: "The macro view must use React Flow (@xyflow/react) version ^12." +- [CON12] intent/constraint: "The macro view is a snapshot-only view: it reads artifact data once on mount and does not update reactively." +- [CTX1] intent/context: "In the spec-elicitation system, fan-out runs 2–3 independent clean-room derivation sessions from the same upstream context, and fan-in is a…" +- [CTX2] intent/context: "The CRT/phosphor design language is a project-wide standard defined in .impeccable.md, covering amber primary color, dark-only theme, phosp…" +- [CTX3] intent/context: "Stakeholder preference: the macro view shares the CRT phosphor design language (amber primary, JetBrains Mono, dark warm surfaces, phosphor…" +- [CTX4] intent/context: "Stakeholder preference: bail reconciliation outcome visual treatment intentionally matches the failed run treatment for consistency." +- [CTX5] intent/context: "Stakeholder preference: a ReconciliationRecord with outcome='bail' is the mechanism by which a branch becomes a dead-end impasse." +- [CTX6] intent/context: "Stakeholder preference: lane widths in the macro layout scale proportionally to the number of nodes at that depth rather than being fixed." +- [CTX7] intent/context: "The existing micro view is built with Sigma.js, handles 700+ nodes, and answers 'what does the graph look like now?'." +- [CTX8] intent/context: "src/styles/theme.css already defines the phosphor palette as oklch tokens including --color-phase-grounding (green), --color-phase-shaping…" +- [CTX9] intent/context: "Stakeholder preference: clicking a node opens a detail panel on the right side of the screen." +- [CTX10] intent/context: "Stakeholder preference: React Flow group/parent nodes are used for phase containers in the macro view." +- [CTX11] intent/context: "Stakeholder preference: the materialProgress flag is visualized as a subtle chip or checkmark inside the reconciliation node, alongside the…" +- [CTX12] intent/context: "Stakeholder preference: a failed run is shown with a red color and a dimmed interior." +- [CTX13] intent/context: "Stakeholder preference: the visual design must feel like CRT/terminal aesthetic while preserving modern design principles; information dens…" +- [CTX14] intent/context: "A MacroView.tsx component already exists at src/components/MacroView.tsx with a header docblock describing the intended architecture: Story…" +- [CTX15] intent/context: "The macro view is one specific view within the broader Spec Explorer UI." +- [CTX16] intent/context: "Stakeholder preference: reconciliation outcome affects the whole node's visual weight: accepted=normal, retry=amber border, recurse=blue bo…" +- [CTX17] intent/context: "Stakeholder preference: phase color assignments are grounding=blue (foundational), shaping=green (growth/emergence), pinning=amber (fixity/…" +- [CTX18] intent/context: "Stakeholder preference: a 'running' status is unlikely to appear but should be highlighted somehow if it does." +- [CTX19] intent/context: "Stakeholder preference: derivation phases (such as reconciliation) are encoded as nodes within the graph rather than as separate UI constru…" +- [CTX20] intent/context: "The macro view is structurally different from the micro view: it shows ~20-40 rich nodes representing the derivation process rather than gr…" +- [CTX21] intent/context: "Reconciliation compares candidate nodes from re-derivation against the previous iteration's nodes for that phase and classifies each relati…" +- [CTX22] intent/context: "Stakeholder preference: derivation depth in the macro layout is computed from the parentFrameId chain length." +- [CTX23] intent/context: "Stakeholder preference: impasse nodes connect sub-trees and increase the breadth of the layout to represent the opening of a new derivation…" +- [CTX24] intent/context: "Stakeholder preference: users can fold (collapse) any nested derivation run to hide detail." +- [CTX25] intent/context: "Stakeholder preference: fan-in groupings render as color-coded rows inside the fan-in node — green for 'merged', amber for 'best_selected',…" +- [CTX26] intent/context: "Because the macro view renders inside a React Flow canvas (not directly in the DOM), large node counts are not a DOM performance concern." +- [CTX27] intent/context: "Stakeholder preference: layout grammar encodes timeline through verticality (t+1 / more recent appears higher) and breadth encodes onion-pe…" +- [CTX28] intent/context: "The theme.css token assignment differs from grounding X34: theme.css maps shaping=amber and pinning=cyan, while X34 specifies shaping=green…" +- [CTX29] intent/context: "The onion-peel derivation structure (impasse discovery, rederivation, reconciliation, resolution) is specified in the spec-elicitation.md d…" +- [CTX30] intent/context: "Stakeholder preference: phantom nodes and faded nodes are informational only and carry no interactive actions." +- [CTX31] intent/context: "Stakeholder preference: render the derivation narrative as a pannable, zoomable graph." +- [CTX32] intent/context: "Stakeholder preference: when a nested run is collapsed, sibling nodes shift to fill vacated space, triggering a layout reflow." +- [CTX33] intent/context: "Stakeholder preference: fan-in and fan-out steps are represented as nested nodes within any one phase node." +- [CTX34] intent/context: "Stakeholder preference: perspective nodes show which branch was taken; unchosen branches fade out to indicate they were not taken but could…" +- [CTX35] intent/context: "Stakeholder preference: there is no separate 'trunk' node type; the trunk is simply the outermost shell of the onion-layered layout." +- [CTX36] intent/context: "Stakeholder preference: the nudging state is indicated by a treatment inside the node body (compact labelled chip or badge such as 'NUDGING…" +- [CTX37] intent/context: "A DetailPanel.tsx component exists at src/components/DetailPanel.tsx as a sibling to MacroView.tsx, confirming reuse is structurally feasib…" +- [CTX38] intent/context: "Stakeholder preference: all three frame modes (initial, rederive, grounding_enrichment) render as the same phase-group node component, with…" +- [CTX39] intent/context: "A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it." +- [CTX40] intent/context: "Stakeholder preference: when a nested derivation run is collapsed it shrinks to a small pill or badge node showing key stats (run count, ou…" +- [CTX41] intent/context: "Stakeholder preference: custom React Flow node types exist for each semantic role: trunk phase, impasse, phase group, fan-out, run, fan-in,…" +- [G1] intent/goal: "Each node's form and function must visually communicate what happened at that specific point in the derivation tree." +- [G2] intent/goal: "The macro view's visual design must be beautiful, novel, and information-dense, avoiding generic dashboard or AI-generated aesthetics." +- [G3] intent/goal: "A user should be able to understand what happened at each derivation step from the node visuals alone (numbers, IDs, outcomes) without need…" +- [G4] intent/goal: "The macro view answers the question 'how did the spec get here?' by visualizing the full onion-peel cycle of impasse discovery, rederivatio…" +- [T1] intent/term: "The spec-elicitation system's derivation process consists of four phases in str…" [has detail] +- [T2] intent/term: "The HubNode type has hubType of justification | decision | impasse | perspectiv…" [has detail] +- [T3] intent/term: "The onion-peel structure refers to the iterative cycle of impasse discovery, re…" [has detail] +- [T4] intent/term: "The ReconciliationRecord type captures reconciliation outcomes (accepted | retr…" [has detail] +- [T5] intent/term: "The FrameRecord type includes: id, parentFrameId, baselineFrameId, entryPhase,…" [has detail] +- [T6] intent/term: "The ArtifactFile type bundles all spec data into a single file: manifest, sourc…" [has detail] +- [T7] intent/term: "The DerivationRunRecord type includes: id, frameId, phase, runIndex, inputNodeI…" [has detail] +- [T8] intent/term: "A dead-end impasse is one whose reconciliation chose the 'bail' outcome — the s…" [has detail] +- [T9] intent/term: "The FanInRecord type captures the fan-in step: id, frameId, phase, inputRunIds,…" [has detail] +- [T10] intent/term: "materialProgress=true on a ReconciliationRecord means at least some nodes were…" [has detail] +- [T11] intent/term: "The Phase type has four ordered values: grounding, shaping, pinning, and defini…" [has detail] +- [T12] intent/term: "The macro view is the component within the Spec Explorer UI that narrates the f…" [has detail] +- [T13] intent/term: "A phantom node represents the case where no perspective is selected — it appear…" [has detail] + +- Edge #1: CTX8 —[dependency]→ CON7 +- Edge #2: CTX8 —[dependency]→ CON9 +- Edge #3: CTX28 —[dependency]→ CTX17 +- Edge #4: CTX37 —[dependency]→ CTX39 +- Edge #5: CTX8 —[dependency]→ CTX17 +- Edge #6: CTX28 —[dependency]→ CTX8 + +## 3. Tool result: present_review_set + +STRUCTURAL_ILLEGAL + +- entityDrafts: entityDrafts must be non-empty +- edgeDrafts: edgeDrafts must be non-empty + +## 4. Tool result: present_review_set + +STRUCTURAL_ILLEGAL + +- entityDrafts[0].draftId: draftId must be non-empty +- entityDrafts[1].draftId: draftId must be non-empty +- edgeDrafts[0].target: endpoint must be an object +- edgeDrafts[1].target: endpoint must be an object +- edgeDrafts[2].target: endpoint must be an object +- edgeDrafts[3].target: endpoint must be an object + +## 5. Tool result: present_review_set + +## Macro-view node legibility and read-only detail inspection + +Capture two inferred commitments from the seeded macro-view intent graph: macro nodes need a concrete visual-legibility acceptance criterion, and node selection should provide detail inspection by reusing the existing right-side panel without adding mutation affordances. + +Lens: intent + +Epistemic status: inferred + +### Grounding + +The existing macro-view intent graph already states that node visuals should explain derivation events, that the macro view is read-only and React Flow-based, and that a right-side detail panel should be reused. This proposal adds two small intent commitments that make those expectations reviewable. + +- G1: Each node's form and function must visually communicate what happened at that specific point in the derivation tree. +- G3: A user should be able to understand what happened at each derivation step from the node visuals alone. +- CTX9: Clicking a node opens a detail panel on the right side of the screen. +- CTX39: A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it. +- CON3: The macro view is strictly read-only. + +### Entity drafts + +- **n1** (intent/criterion): Macro node visuals are independently legible + A macro-view node is acceptable when its visible treatment communicates the derivation role and current state well enough for a user to identify the relevant phase, outcome/status, and key counts or IDs without first opening the detail panel. + +- **n2** (intent/requirement): Macro node selection opens the reused read-only detail panel + When a user clicks a macro-view node, the macro view opens the existing right-side detail panel for inspection while preserving the macro view's read-only constraint and avoiding mutation actions. + +### Edge drafts + +- G3 —support [for]→ n1 + The criterion makes the existing goal of understanding derivation steps from node visuals alone reviewable. + +- G1 —support [for]→ n1 + The criterion specifies how form and function should communicate what happened at a derivation point. + +- CTX39 —support [for]→ n2 + The existing Micro View detail panel is available and should be reused by the macro view. + +- CON3 —support [for]→ n2 + The requirement constrains detail inspection to remain read-only. + +## 6. Assistant + +Presented a valid review set for external Brunch RPC review. + +## 7. Tool result: request_review + +### Review decision + +Approved + +Comment: + +> Probe approval. + + +## Raw session JSONL + +```jsonl +{"type":"session","version":3,"id":"019e9d3e-7f21-76ae-bbc2-2955f779cdac","timestamp":"2026-06-06T14:02:59.233Z","cwd":"/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-project-graph-review-XEjX3W"} +{"type":"custom","customType":"brunch.session_binding","data":{"schemaVersion":1,"specId":1},"id":"1d2dd2e5","parentId":null,"timestamp":"2026-06-06T14:02:59.233Z"} +{"type":"session_info","id":"41b7b8b4","parentId":"1d2dd2e5","timestamp":"2026-06-06T14:02:59.233Z","name":"Macro View — grounded intent base — session 1"} +{"type":"custom","customType":"brunch.agent_runtime_state","data":{"schemaVersion":1,"reason":"switch","state":{"schemaVersion":1,"operationalMode":"elicit","agentStrategy":"project-graph","agentLens":"intent","agentGoal":"commit-converge"},"previous":{"schemaVersion":1,"operationalMode":"elicit","agentStrategy":"auto","agentLens":"auto","agentGoal":"grounding-advance"},"source":"extension"},"id":"6bb12643","parentId":"41b7b8b4","timestamp":"2026-06-06T14:02:59.234Z"} +{"type":"model_change","id":"b477f39f","parentId":"6bb12643","timestamp":"2026-06-06T14:02:59.245Z","provider":"openai-codex","modelId":"gpt-5.5"} +{"type":"thinking_level_change","id":"a605cfbe","parentId":"b477f39f","timestamp":"2026-06-06T14:02:59.245Z","thinkingLevel":"medium"} +{"type":"message","id":"ad8035b1","parentId":"a605cfbe","timestamp":"2026-06-06T14:02:59.248Z","message":{"role":"user","content":[{"type":"text","text":"Brunch FE-809 project-graph proof. The selected spec is seeded from \"macro-view-grounded-intent\" and already has explicit intent-plane graph truth.\n\nUse read_graph in overview mode to inspect existing node codes. Then use present_review_set exactly once to propose a small exact review set derived from the existing macro-view intent graph.\n\nProposal constraints:\n- Create one or two new intent-plane requirement or criterion nodes.\n- Include at least one edge using category \"support\" with stance \"for\" or category \"realization\".\n- When referencing existing graph truth, use existingCode strings from read_graph output, never raw ids.\n- Use schemaVersion 1, lens \"intent\", epistemicStatus \"inferred\", non-empty grounding.summary, grounding.support, pitch.title, and pitch.narrative.\n- Do not call commit_graph.\n- Do not call request_review; stop after a successful present_review_set so the external Brunch RPC reviewer can approve it."}],"timestamp":1780754579246}} +{"type":"message","id":"92a771d2","parentId":"ad8035b1","timestamp":"2026-06-06T14:03:04.778Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"**Figuring out resource requirements**\n\nI think I need to comply with my responsibilities here. It looks like I might need to read some resources, possibly about project graphs and manifests. The user's goals are clear, and understanding these elements is essential. I definitely want to ensure I’m thorough and getting it right. So, I'll check out the overview of the read_graph aspect to assure I grasp everything properly.","thinkingSignature":"{\"id\":\"rs_0fdf1a6645f3ebbb016a242895604c8191860b2373b604ba56\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqJCiYxOv2x4fVEv9Fz6wE3L7rYBpC-5ffak0dce1_rAKnx-RIPloigFTfvhEB5OCDYQnRgJbdtlZucJLd1dxM8N5RT1KCUf2ERyor97AMRSV1RtHWLOtMhR3lOvehFaDBk4NyCfIIXbvaKUGm7RQR6KmZbGayoHAXcSpM0iQnp7xwsg_Wv62c5D8xNuA7mUGrs5vFx2WVWakCD_fI4goZGlPcfI-Uq6TPayODoT8NFz0JDd-KBS9COGysBRmMKInFfnVXaE-KDMDCBllLkk6gJXWHGQW8H_yhCNHR8IHaf4Ez9pu4DxXpGLSXqG1ShYL8-JJdk40Qlxi1lmY_18nlGLih9s4-TZHMjJpxkSY0gZewuiDu4bZNKwVqZ_KQDMuHuQzWfB7ETEawC9a9lXqxcL60H_vSqDwY5PT4f30P7FqUhMnXdqkQ6ANJI4VEbPhdnKNUudxQhinv7DC4hvC4YtGP5jswANL0MlVpXDFUBSin2eNC_Xrcz0uonwqq3UG_IxsEah5YgfkJ5Qrn77r_v-rOlj4MZMr4ODfLc9IOODPg6mIT8Iafu10ImVQqNEXrNijfPWml530GNnhXYosGwQy4WJhxvlG0-1ppzwlmmNQq9GEBxcFmK-k4yMD7NjSYr_jpMeI-XnEO4MvAxSNx51aZvpM1SQFyg7-XeFCGW9GLjrioWST0jCVG9E_2SP7xjcROGrOQkTKW4oLUAapONIqIqWgMeap3vjWpTzBiEmBcCw0wkbJOXrP-dInwsvi_YxoBRkoNxlbHiAQ1xdaJVbyg59y_NThFh8mOEXU8JK3SvWld2-dfxgO1XzG4KTEfMVXUHJz8jU-tWBwIb8-_5vs22Ql2FBhzPPh90MCQ8dBollOU2s31WA5wXUmglCgUmHhXx8ak05D-kr_t0eMlEfWjTKA3KQbFKWAUQNSNZyOcsAFfehe0psAVjuStCnibcggUIfiYqYprVIAotequJgKQB8EgcBuXU6AZPDofHsDG9ujaPxFTCltcOjjx8svYJ8M5aTUfEkdffbcPdMPk4DU3GkRJ4mCr7l_zrQE3F2Nmq4JL4c2dVItumJy8IJHXTcpK5GYGHwwe0oswi15YC3_FZA==\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"**Figuring out resource requirements**\\n\\nI think I need to comply with my responsibilities here. It looks like I might need to read some resources, possibly about project graphs and manifests. The user's goals are clear, and understanding these elements is essential. I definitely want to ensure I’m thorough and getting it right. So, I'll check out the overview of the read_graph aspect to assure I grasp everything properly.\"}]}"},{"type":"toolCall","id":"call_bxjFEXbHWBJe2ozHZlzyi7yT|fc_0fdf1a6645f3ebbb016a242898cc508191a687914dad7b712d","name":"read_graph","arguments":{"mode":"overview"}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.5","usage":{"input":6748,"output":50,"cacheRead":0,"cacheWrite":0,"totalTokens":6798,"cost":{"input":0.033740000000000006,"output":0.0015,"cacheRead":0,"cacheWrite":0,"total":0.03524000000000001}},"stopReason":"toolUse","timestamp":1780754579255,"responseId":"resp_0fdf1a6645f3ebbb016a242894a2c88191a6e2cfce29f13cc6"}} +{"type":"message","id":"9f4a7573","parentId":"92a771d2","timestamp":"2026-06-06T14:03:04.784Z","message":{"role":"toolResult","toolCallId":"call_bxjFEXbHWBJe2ozHZlzyi7yT|fc_0fdf1a6645f3ebbb016a242898cc508191a687914dad7b712d","toolName":"read_graph","content":[{"type":"text","text":"Graph overview (LSN 3): 70 node(s), 6 edge(s).\n\n- [CON1] intent/constraint: \"The design language explicitly prohibits generic UI patterns such as Material Design, Tailwind defaults, SaaS dashboard aesthetics, rounded…\"\n- [CON2] intent/constraint: \"This brief is scoped exclusively to the macro view component and its rendering of the derivation story; other parts of the UI are explicitl…\"\n- [CON3] intent/constraint: \"The macro view is strictly read-only; users can pan, zoom, and collapse/expand nodes, but cannot trigger any data mutations.\"\n- [CON4] intent/constraint: \"Users must manually refresh to see new derivation steps in the macro view.\"\n- [CON5] intent/constraint: \"The macro view is built inside a Vite + React + Tailwind SPA.\"\n- [CON6] intent/constraint: \"Every page load of the macro view starts fully expanded, with no memory of previously collapsed nodes.\"\n- [CON7] intent/constraint: \"Phase color values must be expressed as oklch values within the phosphor palette.\"\n- [CON8] intent/constraint: \"The macro view must use a manual layout algorithm rather than dagre, because the spatial grammar requires precise control over depth lanes…\"\n- [CON9] intent/constraint: \"The CRT design language for the macro view mandates: var(--font-mono), oklch phase colors, warm-tinted dark surfaces, phosphor glow, and no…\"\n- [CON10] intent/constraint: \"Collapsed/expanded state in the macro view is purely ephemeral in-memory React state and is never persisted.\"\n- [CON11] intent/constraint: \"The macro view must use React Flow (@xyflow/react) version ^12.\"\n- [CON12] intent/constraint: \"The macro view is a snapshot-only view: it reads artifact data once on mount and does not update reactively.\"\n- [CTX1] intent/context: \"In the spec-elicitation system, fan-out runs 2–3 independent clean-room derivation sessions from the same upstream context, and fan-in is a…\"\n- [CTX2] intent/context: \"The CRT/phosphor design language is a project-wide standard defined in .impeccable.md, covering amber primary color, dark-only theme, phosp…\"\n- [CTX3] intent/context: \"Stakeholder preference: the macro view shares the CRT phosphor design language (amber primary, JetBrains Mono, dark warm surfaces, phosphor…\"\n- [CTX4] intent/context: \"Stakeholder preference: bail reconciliation outcome visual treatment intentionally matches the failed run treatment for consistency.\"\n- [CTX5] intent/context: \"Stakeholder preference: a ReconciliationRecord with outcome='bail' is the mechanism by which a branch becomes a dead-end impasse.\"\n- [CTX6] intent/context: \"Stakeholder preference: lane widths in the macro layout scale proportionally to the number of nodes at that depth rather than being fixed.\"\n- [CTX7] intent/context: \"The existing micro view is built with Sigma.js, handles 700+ nodes, and answers 'what does the graph look like now?'.\"\n- [CTX8] intent/context: \"src/styles/theme.css already defines the phosphor palette as oklch tokens including --color-phase-grounding (green), --color-phase-shaping…\"\n- [CTX9] intent/context: \"Stakeholder preference: clicking a node opens a detail panel on the right side of the screen.\"\n- [CTX10] intent/context: \"Stakeholder preference: React Flow group/parent nodes are used for phase containers in the macro view.\"\n- [CTX11] intent/context: \"Stakeholder preference: the materialProgress flag is visualized as a subtle chip or checkmark inside the reconciliation node, alongside the…\"\n- [CTX12] intent/context: \"Stakeholder preference: a failed run is shown with a red color and a dimmed interior.\"\n- [CTX13] intent/context: \"Stakeholder preference: the visual design must feel like CRT/terminal aesthetic while preserving modern design principles; information dens…\"\n- [CTX14] intent/context: \"A MacroView.tsx component already exists at src/components/MacroView.tsx with a header docblock describing the intended architecture: Story…\"\n- [CTX15] intent/context: \"The macro view is one specific view within the broader Spec Explorer UI.\"\n- [CTX16] intent/context: \"Stakeholder preference: reconciliation outcome affects the whole node's visual weight: accepted=normal, retry=amber border, recurse=blue bo…\"\n- [CTX17] intent/context: \"Stakeholder preference: phase color assignments are grounding=blue (foundational), shaping=green (growth/emergence), pinning=amber (fixity/…\"\n- [CTX18] intent/context: \"Stakeholder preference: a 'running' status is unlikely to appear but should be highlighted somehow if it does.\"\n- [CTX19] intent/context: \"Stakeholder preference: derivation phases (such as reconciliation) are encoded as nodes within the graph rather than as separate UI constru…\"\n- [CTX20] intent/context: \"The macro view is structurally different from the micro view: it shows ~20-40 rich nodes representing the derivation process rather than gr…\"\n- [CTX21] intent/context: \"Reconciliation compares candidate nodes from re-derivation against the previous iteration's nodes for that phase and classifies each relati…\"\n- [CTX22] intent/context: \"Stakeholder preference: derivation depth in the macro layout is computed from the parentFrameId chain length.\"\n- [CTX23] intent/context: \"Stakeholder preference: impasse nodes connect sub-trees and increase the breadth of the layout to represent the opening of a new derivation…\"\n- [CTX24] intent/context: \"Stakeholder preference: users can fold (collapse) any nested derivation run to hide detail.\"\n- [CTX25] intent/context: \"Stakeholder preference: fan-in groupings render as color-coded rows inside the fan-in node — green for 'merged', amber for 'best_selected',…\"\n- [CTX26] intent/context: \"Because the macro view renders inside a React Flow canvas (not directly in the DOM), large node counts are not a DOM performance concern.\"\n- [CTX27] intent/context: \"Stakeholder preference: layout grammar encodes timeline through verticality (t+1 / more recent appears higher) and breadth encodes onion-pe…\"\n- [CTX28] intent/context: \"The theme.css token assignment differs from grounding X34: theme.css maps shaping=amber and pinning=cyan, while X34 specifies shaping=green…\"\n- [CTX29] intent/context: \"The onion-peel derivation structure (impasse discovery, rederivation, reconciliation, resolution) is specified in the spec-elicitation.md d…\"\n- [CTX30] intent/context: \"Stakeholder preference: phantom nodes and faded nodes are informational only and carry no interactive actions.\"\n- [CTX31] intent/context: \"Stakeholder preference: render the derivation narrative as a pannable, zoomable graph.\"\n- [CTX32] intent/context: \"Stakeholder preference: when a nested run is collapsed, sibling nodes shift to fill vacated space, triggering a layout reflow.\"\n- [CTX33] intent/context: \"Stakeholder preference: fan-in and fan-out steps are represented as nested nodes within any one phase node.\"\n- [CTX34] intent/context: \"Stakeholder preference: perspective nodes show which branch was taken; unchosen branches fade out to indicate they were not taken but could…\"\n- [CTX35] intent/context: \"Stakeholder preference: there is no separate 'trunk' node type; the trunk is simply the outermost shell of the onion-layered layout.\"\n- [CTX36] intent/context: \"Stakeholder preference: the nudging state is indicated by a treatment inside the node body (compact labelled chip or badge such as 'NUDGING…\"\n- [CTX37] intent/context: \"A DetailPanel.tsx component exists at src/components/DetailPanel.tsx as a sibling to MacroView.tsx, confirming reuse is structurally feasib…\"\n- [CTX38] intent/context: \"Stakeholder preference: all three frame modes (initial, rederive, grounding_enrichment) render as the same phase-group node component, with…\"\n- [CTX39] intent/context: \"A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.\"\n- [CTX40] intent/context: \"Stakeholder preference: when a nested derivation run is collapsed it shrinks to a small pill or badge node showing key stats (run count, ou…\"\n- [CTX41] intent/context: \"Stakeholder preference: custom React Flow node types exist for each semantic role: trunk phase, impasse, phase group, fan-out, run, fan-in,…\"\n- [G1] intent/goal: \"Each node's form and function must visually communicate what happened at that specific point in the derivation tree.\"\n- [G2] intent/goal: \"The macro view's visual design must be beautiful, novel, and information-dense, avoiding generic dashboard or AI-generated aesthetics.\"\n- [G3] intent/goal: \"A user should be able to understand what happened at each derivation step from the node visuals alone (numbers, IDs, outcomes) without need…\"\n- [G4] intent/goal: \"The macro view answers the question 'how did the spec get here?' by visualizing the full onion-peel cycle of impasse discovery, rederivatio…\"\n- [T1] intent/term: \"The spec-elicitation system's derivation process consists of four phases in str…\" [has detail]\n- [T2] intent/term: \"The HubNode type has hubType of justification | decision | impasse | perspectiv…\" [has detail]\n- [T3] intent/term: \"The onion-peel structure refers to the iterative cycle of impasse discovery, re…\" [has detail]\n- [T4] intent/term: \"The ReconciliationRecord type captures reconciliation outcomes (accepted | retr…\" [has detail]\n- [T5] intent/term: \"The FrameRecord type includes: id, parentFrameId, baselineFrameId, entryPhase,…\" [has detail]\n- [T6] intent/term: \"The ArtifactFile type bundles all spec data into a single file: manifest, sourc…\" [has detail]\n- [T7] intent/term: \"The DerivationRunRecord type includes: id, frameId, phase, runIndex, inputNodeI…\" [has detail]\n- [T8] intent/term: \"A dead-end impasse is one whose reconciliation chose the 'bail' outcome — the s…\" [has detail]\n- [T9] intent/term: \"The FanInRecord type captures the fan-in step: id, frameId, phase, inputRunIds,…\" [has detail]\n- [T10] intent/term: \"materialProgress=true on a ReconciliationRecord means at least some nodes were…\" [has detail]\n- [T11] intent/term: \"The Phase type has four ordered values: grounding, shaping, pinning, and defini…\" [has detail]\n- [T12] intent/term: \"The macro view is the component within the Spec Explorer UI that narrates the f…\" [has detail]\n- [T13] intent/term: \"A phantom node represents the case where no perspective is selected — it appear…\" [has detail]\n\n- Edge #1: CTX8 —[dependency]→ CON7\n- Edge #2: CTX8 —[dependency]→ CON9\n- Edge #3: CTX28 —[dependency]→ CTX17\n- Edge #4: CTX37 —[dependency]→ CTX39\n- Edge #5: CTX8 —[dependency]→ CTX17\n- Edge #6: CTX28 —[dependency]→ CTX8"}],"details":{"nodes":[{"id":6,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":1,"title":"The design language explicitly prohibits generic UI patterns such as Material Design, Tailwind defaults, SaaS dashboard aesthetics, rounded…","body":"The design language explicitly prohibits generic UI patterns such as Material Design, Tailwind defaults, SaaS dashboard aesthetics, rounded corners, or blue primary buttons.","basis":"explicit","source":"external-observed [C6]","createdAtLsn":2,"updatedAtLsn":2},{"id":8,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":2,"title":"This brief is scoped exclusively to the macro view component and its rendering of the derivation story; other parts of the UI are explicitl…","body":"This brief is scoped exclusively to the macro view component and its rendering of the derivation story; other parts of the UI are explicitly out of scope.","basis":"explicit","source":"stakeholder [C5]","createdAtLsn":2,"updatedAtLsn":2},{"id":11,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":3,"title":"The macro view is strictly read-only; users can pan, zoom, and collapse/expand nodes, but cannot trigger any data mutations.","body":"The macro view is strictly read-only; users can pan, zoom, and collapse/expand nodes, but cannot trigger any data mutations.","basis":"explicit","source":"stakeholder [C10]","createdAtLsn":2,"updatedAtLsn":2},{"id":13,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":4,"title":"Users must manually refresh to see new derivation steps in the macro view.","body":"Users must manually refresh to see new derivation steps in the macro view.","basis":"explicit","source":"stakeholder [C12]","createdAtLsn":2,"updatedAtLsn":2},{"id":19,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":5,"title":"The macro view is built inside a Vite + React + Tailwind SPA.","body":"The macro view is built inside a Vite + React + Tailwind SPA.","basis":"explicit","source":"stakeholder [C2]","createdAtLsn":2,"updatedAtLsn":2},{"id":21,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":6,"title":"Every page load of the macro view starts fully expanded, with no memory of previously collapsed nodes.","body":"Every page load of the macro view starts fully expanded, with no memory of previously collapsed nodes.","basis":"explicit","source":"stakeholder [C9]","createdAtLsn":2,"updatedAtLsn":2},{"id":28,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":7,"title":"Phase color values must be expressed as oklch values within the phosphor palette.","body":"Phase color values must be expressed as oklch values within the phosphor palette.","basis":"explicit","source":"stakeholder [C13]","createdAtLsn":2,"updatedAtLsn":2},{"id":38,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":8,"title":"The macro view must use a manual layout algorithm rather than dagre, because the spatial grammar requires precise control over depth lanes…","body":"The macro view must use a manual layout algorithm rather than dagre, because the spatial grammar requires precise control over depth lanes and vertical flow.","basis":"explicit","source":"stakeholder [C3]","createdAtLsn":2,"updatedAtLsn":2},{"id":50,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":9,"title":"The CRT design language for the macro view mandates: var(--font-mono), oklch phase colors, warm-tinted dark surfaces, phosphor glow, and no…","body":"The CRT design language for the macro view mandates: var(--font-mono), oklch phase colors, warm-tinted dark surfaces, phosphor glow, and no generic UI patterns.","basis":"explicit","source":"stakeholder [C4]","createdAtLsn":2,"updatedAtLsn":2},{"id":56,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":10,"title":"Collapsed/expanded state in the macro view is purely ephemeral in-memory React state and is never persisted.","body":"Collapsed/expanded state in the macro view is purely ephemeral in-memory React state and is never persisted.","basis":"explicit","source":"stakeholder [C8]","createdAtLsn":2,"updatedAtLsn":2},{"id":64,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":11,"title":"The macro view must use React Flow (@xyflow/react) version ^12.","body":"The macro view must use React Flow (@xyflow/react) version ^12.","basis":"explicit","source":"stakeholder [C1]","createdAtLsn":2,"updatedAtLsn":2},{"id":70,"specId":1,"plane":"intent","kind":"constraint","kindOrdinal":12,"title":"The macro view is a snapshot-only view: it reads artifact data once on mount and does not update reactively.","body":"The macro view is a snapshot-only view: it reads artifact data once on mount and does not update reactively.","basis":"explicit","source":"stakeholder [C11]","createdAtLsn":2,"updatedAtLsn":2},{"id":1,"specId":1,"plane":"intent","kind":"context","kindOrdinal":1,"title":"In the spec-elicitation system, fan-out runs 2–3 independent clean-room derivation sessions from the same upstream context, and fan-in is a…","body":"In the spec-elicitation system, fan-out runs 2–3 independent clean-room derivation sessions from the same upstream context, and fan-in is a separate LLM agent that synthesizes the minimal viable set before reconciliation.","basis":"explicit","source":"external-observed [X7]","createdAtLsn":2,"updatedAtLsn":2},{"id":2,"specId":1,"plane":"intent","kind":"context","kindOrdinal":2,"title":"The CRT/phosphor design language is a project-wide standard defined in .impeccable.md, covering amber primary color, dark-only theme, phosp…","body":"The CRT/phosphor design language is a project-wide standard defined in .impeccable.md, covering amber primary color, dark-only theme, phosphor glow behavior, scanline texture, and JetBrains Mono font.","basis":"explicit","source":"external-observed [X6]","createdAtLsn":2,"updatedAtLsn":2},{"id":4,"specId":1,"plane":"intent","kind":"context","kindOrdinal":3,"title":"Stakeholder preference: the macro view shares the CRT phosphor design language (amber primary, JetBrains Mono, dark warm surfaces, phosphor…","body":"Stakeholder preference: the macro view shares the CRT phosphor design language (amber primary, JetBrains Mono, dark warm surfaces, phosphor glow, scanline textures).","basis":"explicit","source":"stakeholder [X11]","createdAtLsn":2,"updatedAtLsn":2},{"id":5,"specId":1,"plane":"intent","kind":"context","kindOrdinal":4,"title":"Stakeholder preference: bail reconciliation outcome visual treatment intentionally matches the failed run treatment for consistency.","body":"Stakeholder preference: bail reconciliation outcome visual treatment intentionally matches the failed run treatment for consistency.","basis":"explicit","source":"stakeholder [X29]","createdAtLsn":2,"updatedAtLsn":2},{"id":10,"specId":1,"plane":"intent","kind":"context","kindOrdinal":5,"title":"Stakeholder preference: a ReconciliationRecord with outcome='bail' is the mechanism by which a branch becomes a dead-end impasse.","body":"Stakeholder preference: a ReconciliationRecord with outcome='bail' is the mechanism by which a branch becomes a dead-end impasse.","basis":"explicit","source":"stakeholder [X36]","createdAtLsn":2,"updatedAtLsn":2},{"id":14,"specId":1,"plane":"intent","kind":"context","kindOrdinal":6,"title":"Stakeholder preference: lane widths in the macro layout scale proportionally to the number of nodes at that depth rather than being fixed.","body":"Stakeholder preference: lane widths in the macro layout scale proportionally to the number of nodes at that depth rather than being fixed.","basis":"explicit","source":"stakeholder [X31]","createdAtLsn":2,"updatedAtLsn":2},{"id":15,"specId":1,"plane":"intent","kind":"context","kindOrdinal":7,"title":"The existing micro view is built with Sigma.js, handles 700+ nodes, and answers 'what does the graph look like now?'.","body":"The existing micro view is built with Sigma.js, handles 700+ nodes, and answers 'what does the graph look like now?'.","basis":"explicit","source":"stakeholder [X2]","createdAtLsn":2,"updatedAtLsn":2},{"id":16,"specId":1,"plane":"intent","kind":"context","kindOrdinal":8,"title":"src/styles/theme.css already defines the phosphor palette as oklch tokens including --color-phase-grounding (green), --color-phase-shaping…","body":"src/styles/theme.css already defines the phosphor palette as oklch tokens including --color-phase-grounding (green), --color-phase-shaping (amber), --color-phase-pinning (cyan), --color-phase-defining-done (purple), plus --color-phosphor-red, surface-0..3 warm darks, and --font-mono JetBrains Mono.","basis":"explicit","source":"technical-observed [X39]","createdAtLsn":2,"updatedAtLsn":2},{"id":17,"specId":1,"plane":"intent","kind":"context","kindOrdinal":9,"title":"Stakeholder preference: clicking a node opens a detail panel on the right side of the screen.","body":"Stakeholder preference: clicking a node opens a detail panel on the right side of the screen.","basis":"explicit","source":"stakeholder [X33]","createdAtLsn":2,"updatedAtLsn":2},{"id":18,"specId":1,"plane":"intent","kind":"context","kindOrdinal":10,"title":"Stakeholder preference: React Flow group/parent nodes are used for phase containers in the macro view.","body":"Stakeholder preference: React Flow group/parent nodes are used for phase containers in the macro view.","basis":"explicit","source":"stakeholder [X18]","createdAtLsn":2,"updatedAtLsn":2},{"id":20,"specId":1,"plane":"intent","kind":"context","kindOrdinal":11,"title":"Stakeholder preference: the materialProgress flag is visualized as a subtle chip or checkmark inside the reconciliation node, alongside the…","body":"Stakeholder preference: the materialProgress flag is visualized as a subtle chip or checkmark inside the reconciliation node, alongside the outcome indicator.","basis":"explicit","source":"stakeholder [X35]","createdAtLsn":2,"updatedAtLsn":2},{"id":26,"specId":1,"plane":"intent","kind":"context","kindOrdinal":12,"title":"Stakeholder preference: a failed run is shown with a red color and a dimmed interior.","body":"Stakeholder preference: a failed run is shown with a red color and a dimmed interior.","basis":"explicit","source":"stakeholder [X27]","createdAtLsn":2,"updatedAtLsn":2},{"id":27,"specId":1,"plane":"intent","kind":"context","kindOrdinal":13,"title":"Stakeholder preference: the visual design must feel like CRT/terminal aesthetic while preserving modern design principles; information dens…","body":"Stakeholder preference: the visual design must feel like CRT/terminal aesthetic while preserving modern design principles; information density is high but not overwhelming.","basis":"explicit","source":"stakeholder [X37]","createdAtLsn":2,"updatedAtLsn":2},{"id":29,"specId":1,"plane":"intent","kind":"context","kindOrdinal":14,"title":"A MacroView.tsx component already exists at src/components/MacroView.tsx with a header docblock describing the intended architecture: Story…","body":"A MacroView.tsx component already exists at src/components/MacroView.tsx with a header docblock describing the intended architecture: Story IR → Layout → Render with React Flow custom node types; the file is otherwise a stub.","basis":"explicit","source":"technical-observed [X38]","createdAtLsn":2,"updatedAtLsn":2},{"id":30,"specId":1,"plane":"intent","kind":"context","kindOrdinal":15,"title":"The macro view is one specific view within the broader Spec Explorer UI.","body":"The macro view is one specific view within the broader Spec Explorer UI.","basis":"explicit","source":"stakeholder [X1]","createdAtLsn":2,"updatedAtLsn":2},{"id":31,"specId":1,"plane":"intent","kind":"context","kindOrdinal":16,"title":"Stakeholder preference: reconciliation outcome affects the whole node's visual weight: accepted=normal, retry=amber border, recurse=blue bo…","body":"Stakeholder preference: reconciliation outcome affects the whole node's visual weight: accepted=normal, retry=amber border, recurse=blue border, bail=red border + dimmed interior.","basis":"explicit","source":"stakeholder [X28]","createdAtLsn":2,"updatedAtLsn":2},{"id":32,"specId":1,"plane":"intent","kind":"context","kindOrdinal":17,"title":"Stakeholder preference: phase color assignments are grounding=blue (foundational), shaping=green (growth/emergence), pinning=amber (fixity/…","body":"Stakeholder preference: phase color assignments are grounding=blue (foundational), shaping=green (growth/emergence), pinning=amber (fixity/commitment), defining_done=violet (completion/closure).","basis":"explicit","source":"stakeholder [X34]","createdAtLsn":2,"updatedAtLsn":2},{"id":33,"specId":1,"plane":"intent","kind":"context","kindOrdinal":18,"title":"Stakeholder preference: a 'running' status is unlikely to appear but should be highlighted somehow if it does.","body":"Stakeholder preference: a 'running' status is unlikely to appear but should be highlighted somehow if it does.","basis":"explicit","source":"stakeholder [X26]","createdAtLsn":2,"updatedAtLsn":2},{"id":34,"specId":1,"plane":"intent","kind":"context","kindOrdinal":19,"title":"Stakeholder preference: derivation phases (such as reconciliation) are encoded as nodes within the graph rather than as separate UI constru…","body":"Stakeholder preference: derivation phases (such as reconciliation) are encoded as nodes within the graph rather than as separate UI constructs.","basis":"explicit","source":"stakeholder [X15]","createdAtLsn":2,"updatedAtLsn":2},{"id":35,"specId":1,"plane":"intent","kind":"context","kindOrdinal":20,"title":"The macro view is structurally different from the micro view: it shows ~20-40 rich nodes representing the derivation process rather than gr…","body":"The macro view is structurally different from the micro view: it shows ~20-40 rich nodes representing the derivation process rather than graph content.","basis":"explicit","source":"stakeholder [X3]","createdAtLsn":2,"updatedAtLsn":2},{"id":36,"specId":1,"plane":"intent","kind":"context","kindOrdinal":21,"title":"Reconciliation compares candidate nodes from re-derivation against the previous iteration's nodes for that phase and classifies each relati…","body":"Reconciliation compares candidate nodes from re-derivation against the previous iteration's nodes for that phase and classifies each relationship using lineage edge types.","basis":"explicit","source":"external-observed [X8]","createdAtLsn":2,"updatedAtLsn":2},{"id":37,"specId":1,"plane":"intent","kind":"context","kindOrdinal":22,"title":"Stakeholder preference: derivation depth in the macro layout is computed from the parentFrameId chain length.","body":"Stakeholder preference: derivation depth in the macro layout is computed from the parentFrameId chain length.","basis":"explicit","source":"stakeholder [X30]","createdAtLsn":2,"updatedAtLsn":2},{"id":39,"specId":1,"plane":"intent","kind":"context","kindOrdinal":23,"title":"Stakeholder preference: impasse nodes connect sub-trees and increase the breadth of the layout to represent the opening of a new derivation…","body":"Stakeholder preference: impasse nodes connect sub-trees and increase the breadth of the layout to represent the opening of a new derivation branch.","basis":"explicit","source":"stakeholder [X16]","createdAtLsn":2,"updatedAtLsn":2},{"id":40,"specId":1,"plane":"intent","kind":"context","kindOrdinal":24,"title":"Stakeholder preference: users can fold (collapse) any nested derivation run to hide detail.","body":"Stakeholder preference: users can fold (collapse) any nested derivation run to hide detail.","basis":"explicit","source":"stakeholder [X12]","createdAtLsn":2,"updatedAtLsn":2},{"id":41,"specId":1,"plane":"intent","kind":"context","kindOrdinal":25,"title":"Stakeholder preference: fan-in groupings render as color-coded rows inside the fan-in node — green for 'merged', amber for 'best_selected',…","body":"Stakeholder preference: fan-in groupings render as color-coded rows inside the fan-in node — green for 'merged', amber for 'best_selected', red for 'impasse_surfaced' — distinguished by a colored left border or chip.","basis":"explicit","source":"stakeholder [X32]","createdAtLsn":2,"updatedAtLsn":2},{"id":42,"specId":1,"plane":"intent","kind":"context","kindOrdinal":26,"title":"Because the macro view renders inside a React Flow canvas (not directly in the DOM), large node counts are not a DOM performance concern.","body":"Because the macro view renders inside a React Flow canvas (not directly in the DOM), large node counts are not a DOM performance concern.","basis":"explicit","source":"stakeholder [X9]","createdAtLsn":2,"updatedAtLsn":2},{"id":44,"specId":1,"plane":"intent","kind":"context","kindOrdinal":27,"title":"Stakeholder preference: layout grammar encodes timeline through verticality (t+1 / more recent appears higher) and breadth encodes onion-pe…","body":"Stakeholder preference: layout grammar encodes timeline through verticality (t+1 / more recent appears higher) and breadth encodes onion-peel derivation depth.","basis":"explicit","source":"stakeholder [X14]","createdAtLsn":2,"updatedAtLsn":2},{"id":45,"specId":1,"plane":"intent","kind":"context","kindOrdinal":28,"title":"The theme.css token assignment differs from grounding X34: theme.css maps shaping=amber and pinning=cyan, while X34 specifies shaping=green…","body":"The theme.css token assignment differs from grounding X34: theme.css maps shaping=amber and pinning=cyan, while X34 specifies shaping=green and pinning=amber. Reconciling this is a minor concern but theme.css is the source of truth for the existing design system.","basis":"explicit","source":"technical-observed [X41]","createdAtLsn":2,"updatedAtLsn":2},{"id":46,"specId":1,"plane":"intent","kind":"context","kindOrdinal":29,"title":"The onion-peel derivation structure (impasse discovery, rederivation, reconciliation, resolution) is specified in the spec-elicitation.md d…","body":"The onion-peel derivation structure (impasse discovery, rederivation, reconciliation, resolution) is specified in the spec-elicitation.md document.","basis":"explicit","source":"external-observed [X5]","createdAtLsn":2,"updatedAtLsn":2},{"id":47,"specId":1,"plane":"intent","kind":"context","kindOrdinal":30,"title":"Stakeholder preference: phantom nodes and faded nodes are informational only and carry no interactive actions.","body":"Stakeholder preference: phantom nodes and faded nodes are informational only and carry no interactive actions.","basis":"explicit","source":"stakeholder [X24]","createdAtLsn":2,"updatedAtLsn":2},{"id":49,"specId":1,"plane":"intent","kind":"context","kindOrdinal":31,"title":"Stakeholder preference: render the derivation narrative as a pannable, zoomable graph.","body":"Stakeholder preference: render the derivation narrative as a pannable, zoomable graph.","basis":"explicit","source":"stakeholder [X10]","createdAtLsn":2,"updatedAtLsn":2},{"id":51,"specId":1,"plane":"intent","kind":"context","kindOrdinal":32,"title":"Stakeholder preference: when a nested run is collapsed, sibling nodes shift to fill vacated space, triggering a layout reflow.","body":"Stakeholder preference: when a nested run is collapsed, sibling nodes shift to fill vacated space, triggering a layout reflow.","basis":"explicit","source":"stakeholder [X22]","createdAtLsn":2,"updatedAtLsn":2},{"id":52,"specId":1,"plane":"intent","kind":"context","kindOrdinal":33,"title":"Stakeholder preference: fan-in and fan-out steps are represented as nested nodes within any one phase node.","body":"Stakeholder preference: fan-in and fan-out steps are represented as nested nodes within any one phase node.","basis":"explicit","source":"stakeholder [X17]","createdAtLsn":2,"updatedAtLsn":2},{"id":54,"specId":1,"plane":"intent","kind":"context","kindOrdinal":34,"title":"Stakeholder preference: perspective nodes show which branch was taken; unchosen branches fade out to indicate they were not taken but could…","body":"Stakeholder preference: perspective nodes show which branch was taken; unchosen branches fade out to indicate they were not taken but could be materialized later.","basis":"explicit","source":"stakeholder [X23]","createdAtLsn":2,"updatedAtLsn":2},{"id":55,"specId":1,"plane":"intent","kind":"context","kindOrdinal":35,"title":"Stakeholder preference: there is no separate 'trunk' node type; the trunk is simply the outermost shell of the onion-layered layout.","body":"Stakeholder preference: there is no separate 'trunk' node type; the trunk is simply the outermost shell of the onion-layered layout.","basis":"explicit","source":"stakeholder [X19]","createdAtLsn":2,"updatedAtLsn":2},{"id":59,"specId":1,"plane":"intent","kind":"context","kindOrdinal":36,"title":"Stakeholder preference: the nudging state is indicated by a treatment inside the node body (compact labelled chip or badge such as 'NUDGING…","body":"Stakeholder preference: the nudging state is indicated by a treatment inside the node body (compact labelled chip or badge such as 'NUDGING' or '⚡ NUDGE' in phosphor amber) rather than as an external indicator.","basis":"explicit","source":"stakeholder [X25]","createdAtLsn":2,"updatedAtLsn":2},{"id":61,"specId":1,"plane":"intent","kind":"context","kindOrdinal":37,"title":"A DetailPanel.tsx component exists at src/components/DetailPanel.tsx as a sibling to MacroView.tsx, confirming reuse is structurally feasib…","body":"A DetailPanel.tsx component exists at src/components/DetailPanel.tsx as a sibling to MacroView.tsx, confirming reuse is structurally feasible.","basis":"explicit","source":"technical-observed [X40]","createdAtLsn":2,"updatedAtLsn":2},{"id":65,"specId":1,"plane":"intent","kind":"context","kindOrdinal":38,"title":"Stakeholder preference: all three frame modes (initial, rederive, grounding_enrichment) render as the same phase-group node component, with…","body":"Stakeholder preference: all three frame modes (initial, rederive, grounding_enrichment) render as the same phase-group node component, with mode differences expressed via color, label, or border style only.","basis":"explicit","source":"stakeholder [X20]","createdAtLsn":2,"updatedAtLsn":2},{"id":67,"specId":1,"plane":"intent","kind":"context","kindOrdinal":39,"title":"A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.","body":"A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.","basis":"explicit","source":"stakeholder [X4]","createdAtLsn":2,"updatedAtLsn":2},{"id":68,"specId":1,"plane":"intent","kind":"context","kindOrdinal":40,"title":"Stakeholder preference: when a nested derivation run is collapsed it shrinks to a small pill or badge node showing key stats (run count, ou…","body":"Stakeholder preference: when a nested derivation run is collapsed it shrinks to a small pill or badge node showing key stats (run count, outcome).","basis":"explicit","source":"stakeholder [X21]","createdAtLsn":2,"updatedAtLsn":2},{"id":69,"specId":1,"plane":"intent","kind":"context","kindOrdinal":41,"title":"Stakeholder preference: custom React Flow node types exist for each semantic role: trunk phase, impasse, phase group, fan-out, run, fan-in,…","body":"Stakeholder preference: custom React Flow node types exist for each semantic role: trunk phase, impasse, phase group, fan-out, run, fan-in, reconciliation, perspective, phantom, and dead-end impasse.","basis":"explicit","source":"stakeholder [X13]","createdAtLsn":2,"updatedAtLsn":2},{"id":43,"specId":1,"plane":"intent","kind":"goal","kindOrdinal":1,"title":"Each node's form and function must visually communicate what happened at that specific point in the derivation tree.","body":"Each node's form and function must visually communicate what happened at that specific point in the derivation tree.","basis":"explicit","source":"stakeholder [G4]","createdAtLsn":2,"updatedAtLsn":2},{"id":57,"specId":1,"plane":"intent","kind":"goal","kindOrdinal":2,"title":"The macro view's visual design must be beautiful, novel, and information-dense, avoiding generic dashboard or AI-generated aesthetics.","body":"The macro view's visual design must be beautiful, novel, and information-dense, avoiding generic dashboard or AI-generated aesthetics.","basis":"explicit","source":"stakeholder [G2]","createdAtLsn":2,"updatedAtLsn":2},{"id":60,"specId":1,"plane":"intent","kind":"goal","kindOrdinal":3,"title":"A user should be able to understand what happened at each derivation step from the node visuals alone (numbers, IDs, outcomes) without need…","body":"A user should be able to understand what happened at each derivation step from the node visuals alone (numbers, IDs, outcomes) without needing to open a detail panel.","basis":"explicit","source":"stakeholder [G3]","createdAtLsn":2,"updatedAtLsn":2},{"id":66,"specId":1,"plane":"intent","kind":"goal","kindOrdinal":4,"title":"The macro view answers the question 'how did the spec get here?' by visualizing the full onion-peel cycle of impasse discovery, rederivatio…","body":"The macro view answers the question 'how did the spec get here?' by visualizing the full onion-peel cycle of impasse discovery, rederivation, reconciliation, and resolution.","basis":"explicit","source":"stakeholder [G1]","createdAtLsn":2,"updatedAtLsn":2},{"id":3,"specId":1,"plane":"intent","kind":"term","kindOrdinal":1,"title":"The spec-elicitation system's derivation process consists of four phases in str…","basis":"explicit","source":"external-observed [T2]","detail":{"definition":"The spec-elicitation system's derivation process consists of four phases in strict dependency order: grounding, shaping, pinning, and defining_done."},"createdAtLsn":2,"updatedAtLsn":2},{"id":7,"specId":1,"plane":"intent","kind":"term","kindOrdinal":2,"title":"The HubNode type has hubType of justification | decision | impasse | perspectiv…","basis":"explicit","source":"technical-observed [T7]","detail":{"definition":"The HubNode type has hubType of justification | decision | impasse | perspective, and carries impasseStatus (open | resolved | superseded) and perspectiveStatus (open | selected | rejected)."},"createdAtLsn":2,"updatedAtLsn":2},{"id":9,"specId":1,"plane":"intent","kind":"term","kindOrdinal":3,"title":"The onion-peel structure refers to the iterative cycle of impasse discovery, re…","basis":"explicit","source":"external-observed [T13]","detail":{"definition":"The onion-peel structure refers to the iterative cycle of impasse discovery, rederivation, fan-out/fan-in synthesis, reconciliation, and resolution that builds the derivation history."},"createdAtLsn":2,"updatedAtLsn":2},{"id":12,"specId":1,"plane":"intent","kind":"term","kindOrdinal":4,"title":"The ReconciliationRecord type captures reconciliation outcomes (accepted | retr…","basis":"explicit","source":"technical-observed [T6]","detail":{"definition":"The ReconciliationRecord type captures reconciliation outcomes (accepted | retry | recurse | bail) along with candidateNodeIds, baselineNodeIds, activatedNodeIds, archivedNodeIds, triggerImpasseIds, resolvedImpasseIds, unresolvedImpasseIds, and a materialProgress flag."},"createdAtLsn":2,"updatedAtLsn":2},{"id":22,"specId":1,"plane":"intent","kind":"term","kindOrdinal":5,"title":"The FrameRecord type includes: id, parentFrameId, baselineFrameId, entryPhase,…","basis":"explicit","source":"technical-observed [T3]","detail":{"definition":"The FrameRecord type includes: id, parentFrameId, baselineFrameId, entryPhase, triggerImpasseIds, mode (initial | rederive | grounding_enrichment), attemptNumber, nudgingActive, createdAt, and summary."},"createdAtLsn":2,"updatedAtLsn":2},{"id":23,"specId":1,"plane":"intent","kind":"term","kindOrdinal":6,"title":"The ArtifactFile type bundles all spec data into a single file: manifest, sourc…","basis":"explicit","source":"technical-observed [T8]","detail":{"definition":"The ArtifactFile type bundles all spec data into a single file: manifest, sources, extractedClaims, interventions, and a graph object containing nodes, edges, frames, derivationRuns, fanInRecords, reconciliationRecords, and snapshots."},"createdAtLsn":2,"updatedAtLsn":2},{"id":24,"specId":1,"plane":"intent","kind":"term","kindOrdinal":7,"title":"The DerivationRunRecord type includes: id, frameId, phase, runIndex, inputNodeI…","basis":"explicit","source":"technical-observed [T4]","detail":{"definition":"The DerivationRunRecord type includes: id, frameId, phase, runIndex, inputNodeIds, outputCandidateIds, impassesFound, status (running | completed | failed), and createdAt."},"createdAtLsn":2,"updatedAtLsn":2},{"id":25,"specId":1,"plane":"intent","kind":"term","kindOrdinal":8,"title":"A dead-end impasse is one whose reconciliation chose the 'bail' outcome — the s…","basis":"explicit","source":"stakeholder [T11]","detail":{"definition":"A dead-end impasse is one whose reconciliation chose the 'bail' outcome — the system gave up on that branch entirely."},"createdAtLsn":2,"updatedAtLsn":2},{"id":48,"specId":1,"plane":"intent","kind":"term","kindOrdinal":9,"title":"The FanInRecord type captures the fan-in step: id, frameId, phase, inputRunIds,…","basis":"explicit","source":"technical-observed [T5]","detail":{"definition":"The FanInRecord type captures the fan-in step: id, frameId, phase, inputRunIds, groupings (with resolution: merged | best_selected | impasse_surfaced), outputCandidateIds, droppedCandidateIds, and createdAt."},"createdAtLsn":2,"updatedAtLsn":2},{"id":53,"specId":1,"plane":"intent","kind":"term","kindOrdinal":10,"title":"materialProgress=true on a ReconciliationRecord means at least some nodes were…","basis":"explicit","source":"stakeholder [T12]","detail":{"definition":"materialProgress=true on a ReconciliationRecord means at least some nodes were activated or archived during that reconciliation — real forward progress occurred."},"createdAtLsn":2,"updatedAtLsn":2},{"id":58,"specId":1,"plane":"intent","kind":"term","kindOrdinal":11,"title":"The Phase type has four ordered values: grounding, shaping, pinning, and defini…","basis":"explicit","source":"technical-observed [T1]","detail":{"definition":"The Phase type has four ordered values: grounding, shaping, pinning, and defining_done."},"createdAtLsn":2,"updatedAtLsn":2},{"id":62,"specId":1,"plane":"intent","kind":"term","kindOrdinal":12,"title":"The macro view is the component within the Spec Explorer UI that narrates the f…","basis":"explicit","source":"stakeholder [T14]","detail":{"definition":"The macro view is the component within the Spec Explorer UI that narrates the full derivation history of a spec via a pannable, zoomable React Flow graph of ~20–40 semantically typed nodes."},"createdAtLsn":2,"updatedAtLsn":2},{"id":63,"specId":1,"plane":"intent","kind":"term","kindOrdinal":13,"title":"A phantom node represents the case where no perspective is selected — it appear…","basis":"explicit","source":"stakeholder [T10]","detail":{"definition":"A phantom node represents the case where no perspective is selected — it appears when an alternative perspective is used."},"createdAtLsn":2,"updatedAtLsn":2}],"edges":[{"id":1,"specId":1,"category":"dependency","sourceId":16,"targetId":28,"basis":"explicit","createdAtLsn":2,"updatedAtLsn":2},{"id":2,"specId":1,"category":"dependency","sourceId":16,"targetId":50,"basis":"explicit","createdAtLsn":2,"updatedAtLsn":2},{"id":3,"specId":1,"category":"dependency","sourceId":45,"targetId":32,"basis":"explicit","createdAtLsn":2,"updatedAtLsn":2},{"id":4,"specId":1,"category":"dependency","sourceId":61,"targetId":67,"basis":"explicit","createdAtLsn":2,"updatedAtLsn":2},{"id":5,"specId":1,"category":"dependency","sourceId":16,"targetId":32,"basis":"explicit","createdAtLsn":2,"updatedAtLsn":2},{"id":6,"specId":1,"category":"dependency","sourceId":45,"targetId":16,"basis":"explicit","createdAtLsn":2,"updatedAtLsn":2}],"nodeCount":70,"edgeCount":6,"lsn":3},"isError":false,"timestamp":1780754584784}} +{"type":"message","id":"67ab98c6","parentId":"9f4a7573","timestamp":"2026-06-06T14:03:24.598Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"**Evaluating realization source design**\n\nI'm thinking about how the realization source might be designed to meet specific requirements. The category of \"realization\" could mean that a new requirement helps realize a goal. I wonder if edges can be connected with existing code and batch references in the review set. It seems like using existing code strings could be important here, and I definitely need an exact dry run to make sure everything aligns properly.","thinkingSignature":"{\"id\":\"rs_0fdf1a6645f3ebbb016a242899bb8481918bd1a3bf655cdfa8\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqJCigXeqtLp--B0dzWZjhWkFDEhbBERtKiYeLNQJ1_nRdgk0II-Y2m1Qu3eLf3tgonOmep90j4zqb2-hKI8djr6ZxEsungaZZTTa3tOWeSgK3Yacg3owShwL9XKUnKCxvVLED6cpuIx7qvG9xFIclsTP84Nqs1NsuWV5C_GYFi3R-o3C_Yaq0Z8SmYE9fWul592LmVsE0gR5ECikESVxRerjWzyZvOx_y5wEnOrduvn44TfCiCxNO-i0iGiQKj8FNwLcYIJZr-s057qZ4IDXp8ZU4xgp76ZuSHoK2Lz80oNfGI7nsoyUXulB5dfMqLm9ZV3K881OhuDIvWrGfZyxwi8BTKiLE9VW1jLyO27jkWW-faxAywGdjPhKEnIDJV7V2I9XkArwFu3_yNSQfDSHfQgyRa6hpAfeE2otKlYkPtYMJx7gnpWveQWKsLgaoKNYLfRMqSRrA_9U-jsypSPbrEv4xEa3BaJOYWBMaHq9DwY-VmqtOsqWLAjwdHkTganah3zSmY3IiBZ0zIFWlHGrt1CDMBay_EAoPBZpBArVb9pSoYRMtL6986GAeab5A7wH4f_kLEP6cLauIgr1BRc1A1sUSK3MCwi-pJntzwRNhOle1fMWjqxjJmRRvXBVZtDt3XwsXzmHQHMN5gyqEXw0zOrffefwNJSekBcv3z98Idrv5zT21Yqnr0C_vVem3Rjq0e0MvXH9w-ihSF6EBYK8fq7KE8moMEvlfAHbdAv8We-F4pnDkm_TmTdIz1PLK_3UfbbGBL4lVkQ7GDc5QDls-klgx2h643exKzQ28aOBQ7syLzro9iw0kgucleI1EKeNBg-fBFbL_d6YIjOGdSYIarN7Cc4XqoAT5AUtz3Xc5ORTAhAg3wwpJbgKLVjmX-_m9ZGh3bzpsZljXcvwtmbDm27FBgBSLbClBzinRBT20gNQLbLRuF2nOBYcaiENOA8n4nybm4Tdgax0n7lVWUTqeAST2uC2nNdZ1_DSWmdOmn_VUPXWGXA1YO5lYrWXCV--V06rQm0YKPSq-tZqrJmh9KuTqV02a3sPF4_ah_RbM4yQUwW1kfyKii4ANtcS0RdOrVocT4DYDsHgHcwfzJp0ziE1SJhsoAgaFRRK2KCFHYTwZgTO3sjhhrjHf6qYbrLQ43D41EA3Afe3eGt77T5QaeHvG-RGVJQ_fOzLbwyoF2BBoQz4zaP-j-oCxiee-x1j-BIk_dLqSzQi_4q9AKwUjxM24lm6FWhry1gRLLCNgoF6duULz1dLKbgu82CnRIkZYqt6MMrfD4v6MswgG5_4fql3dEoyacOuaDcEOOkQjtQpTpPk3t9PqkEwzmHISTwNPWVDtjX9jPSc27F2ymeqBa4LiNdNCX4VLZ-5_GzbTajWm2Ad_VHFrXQqGTj6F-PGIrBLv-Zps5LMaxSwhGh_LnwNMRDXn0ThO_Ja2QBXUb0GehxTWqKuuFu8wAcQYnQEwyN9jgzSi11ocGXhSyW85znurJpNsG834ZjvUQAdP4EA00ayj8tAbVyX7eVKNZYS_xhaEVYQAvdLNCkb0_APPrJzAaLJHDqSoj-yuQmWd7i513bq1LbdWVewVsiT3fpTYHk6q4TV8VqskSQYGM4yfWc3yn2k5LhvPv41BsypE3sHigabaLIn5VqFdz34fd9bctw6emTC5XQXgHlCPJcAY80grnYS4dk4H7lfunwI7DCjXCFnDUOG2gRzPMEMrOtW5rF7EEhdmtfRiE6Gem3jbhjbuCbDE5Jml0apM7TCoPht8ZSjbNP52z1Pv-E756BlXbzArgZ9-LTVUt9XkUQtk-sq8UVKnGMfRChBIAW4gSLYTr9hIEjKkikyBDDHNEbmj6guddD-ENq6GRvFg8WfgpZfu5YZpb9zaah3Rl9g8davVZFmTa6mwNl5Z3B4w9mX3ILNSv3HLiiRzO6LlOa1Z8MfoDEpAbVXBmzsDuOOcJU9FmSZqseH1N1zp7Mv0DQVpdIBRKJ5dUET8MVgHEztTS3U39sAX7W95Cn0ORhMBJTPPqX4XAqHV64mR7FMzNrYJ3XwpEO-3VAyprpfVpRLwMynxiCjZyamUgHn3Gu-7tXZ0OJjTIFYL8tCyjiuzAJtQtmeUTHmoet_5q4iM-miJu4tkjklWzdtUf0ylMPI6czTTSSuQZCYsS9sRYXGpWsbAML4-Z5MxHegdftATYYEnb9djX7ksoZAbz0owPS-rFXC2sdpOuCv9QAECKqNAoA1PBS658ThORIh4HApKX1CJX9wwlNnEiqGgjwHYpbKvWrCiZt4YNznR7ePkS7pXYQDZMYBT_iTloEKrvkoIjhNsUNOWdRh7pMz1Z7wnYpnSgR0jVkOB6FAMvvd-s-51X1gez4Yf2uj4uvfv1brXBZ9L_6I0_Eyc7xc0OUqUX_mE7oXNdH4v0G2I7VdUh1LaFr8gTAfib3Xh1573LfT-mCALO0rjpY-k5tV17Fi4ib2Tax3yRF4bn67aLCSmXL9xo4FfMejnuyuyqDzJyNBHZ1uxd5yG65rZCQETv_I_n8WcHGn-cqGS1OgL9cmoeOSBO4bJF-yhSuwQavBhYnHHtclAu5mafqGjuz4y8s6bkpHFIZNB2ncj4jsv5mMCyN9xGAmZnEVuf92j6-iBci00K315pkDjRg==\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"**Evaluating realization source design**\\n\\nI'm thinking about how the realization source might be designed to meet specific requirements. The category of \\\"realization\\\" could mean that a new requirement helps realize a goal. I wonder if edges can be connected with existing code and batch references in the review set. It seems like using existing code strings could be important here, and I definitely need an exact dry run to make sure everything aligns properly.\"}]}"},{"type":"toolCall","id":"call_9d6ZouW5DvBPQILU5Mpal3TA|fc_0fdf1a6645f3ebbb016a2428a013c081918f606feee09e34b3","name":"present_review_set","arguments":{"exchangeId":"fe-809-macro-view-intent-proof-1","payload":{"schemaVersion":1,"lens":"intent","epistemicStatus":"inferred","grounding":{"summary":"The existing macro-view intent graph already states that node visuals should explain derivation events, that the macro view is read-only and React Flow-based, and that a right-side detail panel should be reused. This proposal adds two small intent commitments that make those expectations reviewable.","support":["G1: Each node's form and function must visually communicate what happened at that specific point in the derivation tree.","G3: A user should be able to understand what happened at each derivation step from the node visuals alone.","CTX9: Clicking a node opens a detail panel on the right side of the screen.","CTX39: A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.","CON3: The macro view is strictly read-only."]},"pitch":{"title":"Macro-view node legibility and read-only detail inspection","narrative":"Capture two inferred commitments from the seeded macro-view intent graph: macro nodes need a concrete visual-legibility acceptance criterion, and node selection should provide detail inspection by reusing the existing right-side panel without adding mutation affordances."},"nodes":[{"ref":"n1","plane":"intent","kind":"criterion","title":"Macro node visuals are independently legible","body":"A macro-view node is acceptable when its visible treatment communicates the derivation role and current state well enough for a user to identify the relevant phase, outcome/status, and key counts or IDs without first opening the detail panel.","source":"inferred"},{"ref":"n2","plane":"intent","kind":"requirement","title":"Macro node selection opens the reused read-only detail panel","body":"When a user clicks a macro-view node, the macro view opens the existing right-side detail panel for inspection while preserving the macro view's read-only constraint and avoiding mutation actions.","source":"inferred"}],"edges":[{"category":"support","source":{"existingCode":"G3"},"target":"n1","stance":"for","rationale":"The criterion makes the existing goal of understanding derivation steps from node visuals alone reviewable."},{"category":"support","source":{"existingCode":"G1"},"target":"n1","stance":"for","rationale":"The criterion specifies how form and function should communicate what happened at a derivation point."},{"category":"support","source":{"existingCode":"CTX39"},"target":"n2","stance":"for","rationale":"The existing Micro View detail panel is available and should be reused by the macro view."},{"category":"support","source":{"existingCode":"CON3"},"target":"n2","stance":"for","rationale":"The requirement constrains detail inspection to remain read-only."}]}}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.5","usage":{"input":2687,"output":1052,"cacheRead":6656,"cacheWrite":0,"totalTokens":10395,"cost":{"input":0.013435,"output":0.03156,"cacheRead":0.003328,"cacheWrite":0,"total":0.048323}},"stopReason":"toolUse","timestamp":1780754584785,"responseId":"resp_0fdf1a6645f3ebbb016a2428992e888191bc6e699c915515ef"}} +{"type":"message","id":"7829dde9","parentId":"67ab98c6","timestamp":"2026-06-06T14:03:24.601Z","message":{"role":"toolResult","toolCallId":"call_9d6ZouW5DvBPQILU5Mpal3TA|fc_0fdf1a6645f3ebbb016a2428a013c081918f606feee09e34b3","toolName":"present_review_set","content":[{"type":"text","text":"STRUCTURAL_ILLEGAL\n\n- entityDrafts: entityDrafts must be non-empty\n- edgeDrafts: edgeDrafts must be non-empty"}],"details":{"status":"structural_illegal","diagnostics":[{"field":"entityDrafts","message":"entityDrafts must be non-empty"},{"field":"edgeDrafts","message":"edgeDrafts must be non-empty"}]},"isError":false,"timestamp":1780754604601}} +{"type":"message","id":"ba00edc5","parentId":"7829dde9","timestamp":"2026-06-06T14:03:39.443Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"{\"id\":\"rs_0fdf1a6645f3ebbb016a2428aed9b081919c60f7850a9dc9e8\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqJCixp1wqZcazTDKO5mKTOlyqZAeKSFWKa34MKv-8MuPUZ9STrdqm5Zp60-0lrmm58cDvBoF6ZSsJHishWjBfeGknpnUsnzBcwWxioO65IpqZRN-AMxj27HL0Gg_igUfkI4amHTycKqcYdm0qDdd48kWMRzwxyYQgkFXEe3oPvcWPcR6HCcaVS-kDczrlyRuxXKjgQLp-bsDaVEDTtN0E1oWl0JsTYjDbBD7qeTiWSAvPFCJPBZXD1O6m7rSUOmheL9cDws23hi6uezR2m58mOIwfjj-p1na7fs31ogf7H7j2ViKOYGA7sL7HWlVZsplWpKqidmO-cwbK5jqqCzc7jDvXNHDPZ1vF7mp1GMFV91HAMw4Z1BjoDCIw-UBcFzffmNfEe9uEAybV357IuVaCDYFO8qTk_uY_VwlBX6t8zjASxVd6NkX5txJOgLHmeeyDfIJx6B6mgQ9h47TUbC5s84YqmZAKFE_2q9JjYtFNZAQm1771rNWz42KL3FgfP8vmcsk2_vN5fDspsgZtZwFhxxNOKq0M_mFGBJ7gRQYW1Nlz1v_gcLS4F1ZK5V5Te0isKgza1PxAjx8x3JfQ4vW-wgpaO19Yy1OxmLvfMi2H4Z6L8cD3tZ41VwwutJkYjoTLRW-ow2axRNDcCY_Sx0a1SVq93jix6ZptFe3CZeK-BXDy0tFhEw9rGzB2u83jAZtft1y1BeMUHh5YEyVmjjevPWfBUW1uStajEX46t8N9i4LYGiCvsrBNzKPBO9hwP83gIMzrKPLUAQ8AKRw3rJvV6Kp2FBwMr1OqhJxQ2V9EFcZIOKghjmu842Uca4MMGBVurpDQqqInReg65M4uq6_x0mvc00QSnjyywhG3QAXkFWsyZeMNYC0gOa6CnPvMD5gAAofuslsf19cO7VLlzlk1Z-bEXZ-05DzpGlA8TVesknBA9vnPvGaTQRApVJGju1yxBH3-h9kPra5nrhYhdn4yccMh-TYcIME6jqYoQRr-uHzY51tUpACaSF-YFXmNc99ipRYQEBcl6GBIbbUnrWV49f0oWN8lJPf4TiVobgyMiAZeLrf-PYOqLduG1KST5535NfpU7s1U2qTylpLuRPD22gwquUUrV8fYPvHSOT6veVtiFwb_1QNui4mgjScHyk5AIdA52N1nKgAxbmatwMYnevxVeFoh6oXnHiZUkw22UYyTopT5-ftS4bloSYYThTFT1Kk_XjESuUgK9OuaI76KDGLsJg_OguJztlgFr-pWq7meDUTNgs9Zm6eX1J_7gC-HpY4k8WEEd1BZDwoXqB9hL5DN6z-U_Wvi2oQlsxokTZKIzCTGzgf5l747fdQ0e1E2I-_gJR3ABcTEKd1Vd4-6bK_xQB6UYAiw_yA0rXqwqx21QPMw4-TUUkFs3s-TuzrYxkhdNTu8TXp_Dcr6i-Pj4Gi50KRgl1KTeuVZgV1i4SpHJeqMjOQzBtoDrjJnWDwiirU4Dbuzk1lnrrKTR4xnyqK1-jgrx48Oz2GRKFESoZWVtUK86dR0FgyYeQHxmpRx8GBWYiqpjxjhrJHjndVYrTRCzum1cGI-RkM4eTJVVObvTTM=\",\"summary\":[]}"},{"type":"toolCall","id":"call_6SUdydSIFDKpCldkm6LRzQEl|fc_0fdf1a6645f3ebbb016a2428b1238c8191895644c6d768f915","name":"present_review_set","arguments":{"exchangeId":"fe-809-macro-view-intent-proof-1","payload":{"schemaVersion":1,"lens":"intent","epistemicStatus":"inferred","grounding":{"summary":"The existing macro-view intent graph already states that node visuals should explain derivation events, that the macro view is read-only and React Flow-based, and that a right-side detail panel should be reused. This proposal adds two small intent commitments that make those expectations reviewable.","support":["G1: Each node's form and function must visually communicate what happened at that specific point in the derivation tree.","G3: A user should be able to understand what happened at each derivation step from the node visuals alone.","CTX9: Clicking a node opens a detail panel on the right side of the screen.","CTX39: A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.","CON3: The macro view is strictly read-only."]},"pitch":{"title":"Macro-view node legibility and read-only detail inspection","narrative":"Capture two inferred commitments from the seeded macro-view intent graph: macro nodes need a concrete visual-legibility acceptance criterion, and node selection should provide detail inspection by reusing the existing right-side panel without adding mutation affordances."},"entityDrafts":[{"ref":"n1","plane":"intent","kind":"criterion","title":"Macro node visuals are independently legible","body":"A macro-view node is acceptable when its visible treatment communicates the derivation role and current state well enough for a user to identify the relevant phase, outcome/status, and key counts or IDs without first opening the detail panel.","source":"inferred"},{"ref":"n2","plane":"intent","kind":"requirement","title":"Macro node selection opens the reused read-only detail panel","body":"When a user clicks a macro-view node, the macro view opens the existing right-side detail panel for inspection while preserving the macro view's read-only constraint and avoiding mutation actions.","source":"inferred"}],"edgeDrafts":[{"category":"support","source":{"existingCode":"G3"},"target":"n1","stance":"for","rationale":"The criterion makes the existing goal of understanding derivation steps from node visuals alone reviewable."},{"category":"support","source":{"existingCode":"G1"},"target":"n1","stance":"for","rationale":"The criterion specifies how form and function should communicate what happened at a derivation point."},{"category":"support","source":{"existingCode":"CTX39"},"target":"n2","stance":"for","rationale":"The existing Micro View detail panel is available and should be reused by the macro view."},{"category":"support","source":{"existingCode":"CON3"},"target":"n2","stance":"for","rationale":"The requirement constrains detail inspection to remain read-only."}]}}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.5","usage":{"input":1034,"output":706,"cacheRead":9216,"cacheWrite":0,"totalTokens":10956,"cost":{"input":0.00517,"output":0.02118,"cacheRead":0.004608,"cacheWrite":0,"total":0.030958000000000003}},"stopReason":"toolUse","timestamp":1780754604601,"responseId":"resp_0fdf1a6645f3ebbb016a2428acf0c48191b473a31588e26b95"}} +{"type":"message","id":"a5dae8aa","parentId":"ba00edc5","timestamp":"2026-06-06T14:03:39.445Z","message":{"role":"toolResult","toolCallId":"call_6SUdydSIFDKpCldkm6LRzQEl|fc_0fdf1a6645f3ebbb016a2428b1238c8191895644c6d768f915","toolName":"present_review_set","content":[{"type":"text","text":"STRUCTURAL_ILLEGAL\n\n- entityDrafts[0].draftId: draftId must be non-empty\n- entityDrafts[1].draftId: draftId must be non-empty\n- edgeDrafts[0].target: endpoint must be an object\n- edgeDrafts[1].target: endpoint must be an object\n- edgeDrafts[2].target: endpoint must be an object\n- edgeDrafts[3].target: endpoint must be an object"}],"details":{"status":"structural_illegal","diagnostics":[{"field":"entityDrafts[0].draftId","message":"draftId must be non-empty"},{"field":"entityDrafts[1].draftId","message":"draftId must be non-empty"},{"field":"edgeDrafts[0].target","message":"endpoint must be an object"},{"field":"edgeDrafts[1].target","message":"endpoint must be an object"},{"field":"edgeDrafts[2].target","message":"endpoint must be an object"},{"field":"edgeDrafts[3].target","message":"endpoint must be an object"}]},"isError":false,"timestamp":1780754619445}} +{"type":"message","id":"b1c44d61","parentId":"a5dae8aa","timestamp":"2026-06-06T14:03:51.949Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"{\"id\":\"rs_0fdf1a6645f3ebbb016a2428bc5e9081918a4e7b92060ce4c6\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqJCi93dWdtflcfaHYfXv9b8ceJxX2QMDCYBFrqYmuFiJD7ryfhO0II8pZ0133hfzBCJLMeRbrVwaVZGchwgqUTiFL3KlfcVoO4dF8t0ISdJF74--0hsfz4tjTvyGfb9B80A6GaMd3zSbWHUxYVWMm8SAyLcQU0MaEkfW7S2gIfXTQqb2COwRjIr_IsOWZEGOgAMuj13mVWa3Mhm5N7Q14FiRRnI1YV-02l2IMsqGOxFcbTwBGluB7JKixtsD97gWrGOs7KSMYqoM1Tzua5-TPtYiaZzMFt1hJtb_KXXXGa1S1tXA8bjBxq3YfmA-GHt0PM0xfzOzQqjLkEwRBsOdRO-Ug7F17UFPh5rHwCpf8y9jnTM2-soFBvrW39vTrtHgXcdvdQDJjyc3TBP_c8v4ZceQxyKS9ezjxYFBgubRqWcc7t-48I9lcnNKmK4_-bQRsB-8JNQJnk55Ugq2C7_2Xxn256GLSezTFj1-qlwoGva51tgq3GO7FZpzVOLIoLLmeXHCKwvgOBPcYhDb9T3ZrYmPd3phTsIifiuJ4_HUcgEUVy_wG7pr_OiSabjDBPHrYWIwyheqEy1Rpj_qb3yWDlY_gonJeu-HxJJ7zzYhpaljE8Gog2S4gwlhgThMCdHy-N11nHsMVA5pPwzVjGdZGVULnsFSd8uNsLJ7KcMGC_sI8du6yzEqcJbY4x5bcYfNji6umidxrdDYGFlHcj7mKYevLu56DcTqxhn_heW7Runroeiox_c2MGNGpjINTXgHpOUOkOBpgL6sJkRKrhRXuUDLoaB-pTGjBfiJXtH5j5i5xtE2HuIDasVXCno2cbqS8h0Lux8rzm_eczdOsr4Db0p_U3QR5cAorpAoqt5zA3Al4FJ48Zb74RMITfBsKks1FguKK2vUxKdL5EbaStsErFNik9LipteVqkWd4KgcEqTosHs50vSv_WXeAX0So7e8RGPzdrxIPOdr9t7qkG30uMlqjOBxVfg6DVQCHh57VO0Sav4HC5Bcpm7Xsh0einy-2T7MwtbXNqcgrPBPvDi2jWP_cmQ==\",\"summary\":[]}"},{"type":"toolCall","id":"call_oQxhWoFm95PjpJNI5pVuHjKK|fc_0fdf1a6645f3ebbb016a2428bd3ac4819181a75a0ed7aa01b8","name":"present_review_set","arguments":{"exchangeId":"fe-809-macro-view-intent-proof-1","payload":{"schemaVersion":1,"lens":"intent","epistemicStatus":"inferred","grounding":{"summary":"The existing macro-view intent graph already states that node visuals should explain derivation events, that the macro view is read-only and React Flow-based, and that a right-side detail panel should be reused. This proposal adds two small intent commitments that make those expectations reviewable.","support":["G1: Each node's form and function must visually communicate what happened at that specific point in the derivation tree.","G3: A user should be able to understand what happened at each derivation step from the node visuals alone.","CTX9: Clicking a node opens a detail panel on the right side of the screen.","CTX39: A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.","CON3: The macro view is strictly read-only."]},"pitch":{"title":"Macro-view node legibility and read-only detail inspection","narrative":"Capture two inferred commitments from the seeded macro-view intent graph: macro nodes need a concrete visual-legibility acceptance criterion, and node selection should provide detail inspection by reusing the existing right-side panel without adding mutation affordances."},"entityDrafts":[{"draftId":"n1","plane":"intent","kind":"criterion","title":"Macro node visuals are independently legible","body":"A macro-view node is acceptable when its visible treatment communicates the derivation role and current state well enough for a user to identify the relevant phase, outcome/status, and key counts or IDs without first opening the detail panel.","source":"inferred"},{"draftId":"n2","plane":"intent","kind":"requirement","title":"Macro node selection opens the reused read-only detail panel","body":"When a user clicks a macro-view node, the macro view opens the existing right-side detail panel for inspection while preserving the macro view's read-only constraint and avoiding mutation actions.","source":"inferred"}],"edgeDrafts":[{"category":"support","source":{"existingCode":"G3"},"target":{"draftId":"n1"},"stance":"for","rationale":"The criterion makes the existing goal of understanding derivation steps from node visuals alone reviewable."},{"category":"support","source":{"existingCode":"G1"},"target":{"draftId":"n1"},"stance":"for","rationale":"The criterion specifies how form and function should communicate what happened at a derivation point."},{"category":"support","source":{"existingCode":"CTX39"},"target":{"draftId":"n2"},"stance":"for","rationale":"The existing Micro View detail panel is available and should be reused by the macro view."},{"category":"support","source":{"existingCode":"CON3"},"target":{"draftId":"n2"},"stance":"for","rationale":"The requirement constrains detail inspection to remain read-only."}]}}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.5","usage":{"input":1338,"output":647,"cacheRead":9728,"cacheWrite":0,"totalTokens":11713,"cost":{"input":0.006690000000000001,"output":0.01941,"cacheRead":0.004863999999999999,"cacheWrite":0,"total":0.030964000000000002}},"stopReason":"toolUse","timestamp":1780754619446,"responseId":"resp_0fdf1a6645f3ebbb016a2428bbc87c81918a809a04b17ad8b1"}} +{"type":"message","id":"3000dfc4","parentId":"b1c44d61","timestamp":"2026-06-06T14:03:51.957Z","message":{"role":"toolResult","toolCallId":"call_oQxhWoFm95PjpJNI5pVuHjKK|fc_0fdf1a6645f3ebbb016a2428bd3ac4819181a75a0ed7aa01b8","toolName":"present_review_set","content":[{"type":"text","text":"## Macro-view node legibility and read-only detail inspection\n\nCapture two inferred commitments from the seeded macro-view intent graph: macro nodes need a concrete visual-legibility acceptance criterion, and node selection should provide detail inspection by reusing the existing right-side panel without adding mutation affordances.\n\nLens: intent\n\nEpistemic status: inferred\n\n### Grounding\n\nThe existing macro-view intent graph already states that node visuals should explain derivation events, that the macro view is read-only and React Flow-based, and that a right-side detail panel should be reused. This proposal adds two small intent commitments that make those expectations reviewable.\n\n- G1: Each node's form and function must visually communicate what happened at that specific point in the derivation tree.\n- G3: A user should be able to understand what happened at each derivation step from the node visuals alone.\n- CTX9: Clicking a node opens a detail panel on the right side of the screen.\n- CTX39: A right-side detail panel is already implemented and wired in the Micro View; the macro view should reuse it.\n- CON3: The macro view is strictly read-only.\n\n### Entity drafts\n\n- **n1** (intent/criterion): Macro node visuals are independently legible\n A macro-view node is acceptable when its visible treatment communicates the derivation role and current state well enough for a user to identify the relevant phase, outcome/status, and key counts or IDs without first opening the detail panel.\n\n- **n2** (intent/requirement): Macro node selection opens the reused read-only detail panel\n When a user clicks a macro-view node, the macro view opens the existing right-side detail panel for inspection while preserving the macro view's read-only constraint and avoiding mutation actions.\n\n### Edge drafts\n\n- G3 —support [for]→ n1\n The criterion makes the existing goal of understanding derivation steps from node visuals alone reviewable.\n\n- G1 —support [for]→ n1\n The criterion specifies how form and function should communicate what happened at a derivation point.\n\n- CTX39 —support [for]→ n2\n The existing Micro View detail panel is available and should be reused by the macro view.\n\n- CON3 —support [for]→ n2\n The requirement constrains detail inspection to remain read-only."}],"details":{"schema":"brunch.structured_exchange.present","v":1,"exchange_id":"fe-809-macro-view-intent-proof-1","tool_meta":{"curr":"present_review_set","next":"request_review"},"display":{"heading":"Macro-view node legibility and read-only detail inspection","body":"Capture two inferred commitments from the seeded macro-view intent graph: macro nodes need a concrete visual-legibility acceptance criterion, and node selection should provide detail inspection by reusing the existing right-side panel without adding mutation affordances."},"review_set":{"nodes":[{"draft_id":"n1","plane":"intent","kind":"criterion","title":"Macro node visuals are independently legible","body":"A macro-view node is acceptable when its visible treatment communicates the derivation role and current state well enough for a user to identify the relevant phase, outcome/status, and key counts or IDs without first opening the detail panel."},{"draft_id":"n2","plane":"intent","kind":"requirement","title":"Macro node selection opens the reused read-only detail panel","body":"When a user clicks a macro-view node, the macro view opens the existing right-side detail panel for inspection while preserving the macro view's read-only constraint and avoiding mutation actions."}],"edges":[{"category":"support","source":{"existing_code":"G3"},"target":{"draft_id":"n1"},"stance":"for","rationale":"The criterion makes the existing goal of understanding derivation steps from node visuals alone reviewable."},{"category":"support","source":{"existing_code":"G1"},"target":{"draft_id":"n1"},"stance":"for","rationale":"The criterion specifies how form and function should communicate what happened at a derivation point."},{"category":"support","source":{"existing_code":"CTX39"},"target":{"draft_id":"n2"},"stance":"for","rationale":"The existing Micro View detail panel is available and should be reused by the macro view."},{"category":"support","source":{"existing_code":"CON3"},"target":{"draft_id":"n2"},"stance":"for","rationale":"The requirement constrains detail inspection to remain read-only."}]}},"isError":false,"timestamp":1780754631956}} +{"type":"message","id":"23edd109","parentId":"3000dfc4","timestamp":"2026-06-06T14:03:53.152Z","message":{"role":"assistant","content":[{"type":"text","text":"Presented a valid review set for external Brunch RPC review.","textSignature":"{\"v\":1,\"id\":\"msg_0fdf1a6645f3ebbb016a2428c8dc308191851c65c861d94a8a\",\"phase\":\"final_answer\"}"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.5","usage":{"input":1451,"output":16,"cacheRead":10752,"cacheWrite":0,"totalTokens":12219,"cost":{"input":0.007255,"output":0.00048,"cacheRead":0.005376,"cacheWrite":0,"total":0.013111000000000001}},"stopReason":"stop","timestamp":1780754631958,"responseId":"resp_0fdf1a6645f3ebbb016a2428c844788191bb85ff1015a58a44"}} +{"type":"message","id":"65058712","parentId":"23edd109","timestamp":"2026-06-06T14:03:53.178Z","message":{"role":"toolResult","toolCallId":"fe-809-macro-view-intent-proof-1:request_review","toolName":"request_review","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Review decision\n\nApproved\n\nComment:\n\n> Probe approval."}],"details":{"schema":"brunch.structured_exchange.request","v":1,"exchange_id":"fe-809-macro-view-intent-proof-1","tool_meta":{"prev":"present_review_set","curr":"request_review","next":"capture_review"},"answered":{"decision":"approve","comment":"Probe approval."}}}} +``` + diff --git a/.pi/extensions/worktree/index.ts b/.pi/extensions/worktree/index.ts deleted file mode 100644 index b385ddf1b..000000000 --- a/.pi/extensions/worktree/index.ts +++ /dev/null @@ -1,541 +0,0 @@ -import { execFile as execFileCallback } from 'node:child_process'; -import type { Stats } from 'node:fs'; -import { readFile, stat, writeFile } from 'node:fs/promises'; -import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; -import { promisify } from 'node:util'; - -import { - SessionManager, - type ExtensionAPI, - type ExtensionCommandContext, -} from '@earendil-works/pi-coding-agent'; -import { Type } from 'typebox'; - -const execFile = promisify(execFileCallback); - -export const WORKTREE_SWITCH_COMMAND = 'worktree:switch'; -export const WORKTREE_SWITCH_TOOL = 'switch_worktree'; -export const WORKTREE_CREATE_COMMAND = 'worktree:create'; -export const WORKTREE_CREATE_TOOL = 'create_worktree'; - -const DIRTY_WORKTREE_WARNING = - 'Caller worktree has uncommitted changes; the new worktree was created from committed HEAD only.'; - -const DEFAULT_GREEK_WORDS = [ - 'alpha', - 'beta', - 'gamma', - 'delta', - 'epsilon', - 'zeta', - 'eta', - 'theta', - 'iota', - 'kappa', - 'lambda', - 'mu', - 'nu', - 'xi', - 'omicron', - 'pi', - 'rho', - 'sigma', - 'tau', - 'upsilon', - 'phi', - 'chi', - 'psi', - 'omega', -] as const; - -export type WorktreeValidationResult = - | { readonly ok: true; readonly cwd: string } - | { - readonly ok: false; - readonly path: string; - readonly reason: 'missing' | 'not-directory' | 'bare-repository' | 'not-git-worktree'; - }; - -export interface SwitchWorktreeResultDetails { - readonly status: 'staged' | 'switched' | 'cancelled' | 'failed'; - readonly targetPath: string; - readonly sessionFile?: string; - readonly reason?: string; -} - -export interface SwitchWorktreeOptions { - readonly sessionDir?: string; -} - -export interface SiblingWorktreePlan { - readonly path: string; - readonly branch: string; - readonly attempted: readonly string[]; -} - -export interface SiblingWorktreePlanOptions { - readonly sourceRoot: string; - readonly greekWords?: readonly string[]; - readonly chooseStartIndex?: (candidateCount: number) => number; - readonly pathExists: (path: string) => Promise; - readonly branchExists: (branch: string) => Promise; -} - -export interface CreateSiblingWorktreeOptions { - readonly greekWords?: readonly string[]; - readonly chooseStartIndex?: (candidateCount: number) => number; -} - -export type CreateSiblingWorktreeResultDetails = - | { - readonly status: 'created'; - readonly sourceRoot: string; - readonly sourceCommit: string; - readonly path: string; - readonly branch: string; - readonly dirty: boolean; - readonly dirtyWarning?: string; - readonly stdout: string; - readonly stderr: string; - } - | { - readonly status: 'failed'; - readonly reason: string; - readonly sourceRoot?: string; - readonly sourceCommit?: string; - readonly path?: string; - readonly branch?: string; - readonly attempted?: readonly string[]; - readonly stdout?: string; - readonly stderr?: string; - }; - -interface GitProbeResult { - readonly ok: boolean; - readonly stdout: string; - readonly stderr: string; -} - -interface ReplacementMessageContext { - readonly sendUserMessage: (message: string) => Promise; - readonly ui: { - readonly notify: (message: string, type?: 'info' | 'warning' | 'error') => void; - }; -} - -interface WorktreeCreationContext { - readonly cwd: string; - readonly hasUI?: boolean; - readonly ui: { - readonly notify: (message: string, type?: 'info' | 'warning' | 'error') => void; - readonly setEditorText?: (text: string) => void; - }; -} - -export function resolveSwitchTarget(targetPath: string, cwd: string): string { - const trimmed = targetPath.trim(); - if (trimmed.length === 0) return ''; - return isAbsolute(trimmed) ? resolve(trimmed) : resolve(cwd, trimmed); -} - -export async function validateGitWorktree(targetPath: string): Promise { - let targetStat: Stats; - try { - targetStat = await stat(targetPath); - } catch { - return { ok: false, reason: 'missing', path: targetPath }; - } - - if (!targetStat.isDirectory()) { - return { ok: false, reason: 'not-directory', path: targetPath }; - } - - const bareProbe = await gitProbe(targetPath, 'rev-parse', '--is-bare-repository'); - if (bareProbe.ok && bareProbe.stdout.trim() === 'true') { - return { ok: false, reason: 'bare-repository', path: targetPath }; - } - - const worktreeProbe = await gitProbe(targetPath, 'rev-parse', '--is-inside-work-tree'); - if (!worktreeProbe.ok || worktreeProbe.stdout.trim() !== 'true') { - return { ok: false, reason: 'not-git-worktree', path: targetPath }; - } - - return { ok: true, cwd: targetPath }; -} - -export async function planSiblingWorktree(options: SiblingWorktreePlanOptions): Promise { - const words = options.greekWords ?? DEFAULT_GREEK_WORDS; - if (words.length === 0) throw new Error('No Greek suffix words configured.'); - - const startIndex = normalizeStartIndex( - options.chooseStartIndex?.(words.length) ?? randomStartIndex(words.length), - words.length, - ); - const parentDir = dirname(options.sourceRoot); - const sourceBasename = basename(options.sourceRoot); - const attempted: string[] = []; - - for (let offset = 0; offset < words.length; offset += 1) { - const word = words[(startIndex + offset) % words.length]; - if (!word) continue; - const name = `${sourceBasename}-${word}`; - attempted.push(name); - - const path = join(parentDir, name); - if (await options.pathExists(path)) continue; - if (await options.branchExists(name)) continue; - - return { path, branch: name, attempted }; - } - - throw new Error(`No available sibling worktree name. Attempted: ${attempted.join(', ')}`); -} - -export async function createSiblingWorktree( - ctx: WorktreeCreationContext, - options: CreateSiblingWorktreeOptions = {}, -): Promise { - const rootProbe = await gitProbe(ctx.cwd, 'rev-parse', '--show-toplevel'); - if (!rootProbe.ok) { - const reason = gitFailureReason('Could not resolve caller git worktree root.', rootProbe); - ctx.ui.notify(reason, 'error'); - return { status: 'failed', reason, stdout: rootProbe.stdout, stderr: rootProbe.stderr }; - } - const sourceRoot = rootProbe.stdout.trim(); - - const headProbe = await gitProbe(ctx.cwd, 'rev-parse', 'HEAD'); - if (!headProbe.ok) { - const reason = gitFailureReason('Could not resolve caller HEAD.', headProbe); - ctx.ui.notify(reason, 'error'); - return { status: 'failed', reason, sourceRoot, stdout: headProbe.stdout, stderr: headProbe.stderr }; - } - const sourceCommit = headProbe.stdout.trim(); - - const dirtyProbe = await gitProbe(ctx.cwd, 'status', '--porcelain'); - if (!dirtyProbe.ok) { - const reason = gitFailureReason('Could not inspect caller worktree status.', dirtyProbe); - ctx.ui.notify(reason, 'error'); - return { - status: 'failed', - reason, - sourceRoot, - sourceCommit, - stdout: dirtyProbe.stdout, - stderr: dirtyProbe.stderr, - }; - } - const dirty = dirtyProbe.stdout.trim().length > 0; - - let plan: SiblingWorktreePlan; - try { - plan = await planSiblingWorktree({ - sourceRoot, - ...(options.greekWords === undefined ? {} : { greekWords: options.greekWords }), - ...(options.chooseStartIndex === undefined ? {} : { chooseStartIndex: options.chooseStartIndex }), - pathExists, - branchExists: (branch) => branchExists(sourceRoot, branch), - }); - } catch (error) { - const reason = error instanceof Error ? error.message : 'Could not plan sibling worktree.'; - ctx.ui.notify(reason, 'error'); - return { status: 'failed', reason, sourceRoot, sourceCommit }; - } - - const addProbe = await gitProbe(sourceRoot, 'worktree', 'add', '-b', plan.branch, plan.path, sourceCommit); - if (!addProbe.ok) { - const reason = gitFailureReason('Could not create sibling git worktree.', addProbe); - ctx.ui.notify(reason, 'error'); - return { - status: 'failed', - reason, - sourceRoot, - sourceCommit, - path: plan.path, - branch: plan.branch, - attempted: plan.attempted, - stdout: addProbe.stdout, - stderr: addProbe.stderr, - }; - } - - const validation = await validateGitWorktree(plan.path); - if (!validation.ok) { - const reason = validationReason(validation); - ctx.ui.notify(reason, 'error'); - return { - status: 'failed', - reason, - sourceRoot, - sourceCommit, - path: plan.path, - branch: plan.branch, - attempted: plan.attempted, - }; - } - - const switchCommand = `/${WORKTREE_SWITCH_COMMAND} ${validation.cwd}`; - if (ctx.hasUI) ctx.ui.setEditorText?.(switchCommand); - if (dirty) ctx.ui.notify(DIRTY_WORKTREE_WARNING, 'warning'); - ctx.ui.notify(`Created git worktree ${validation.cwd} at ${sourceCommit}.`, 'info'); - - const created = { - status: 'created' as const, - sourceRoot, - sourceCommit, - path: validation.cwd, - branch: plan.branch, - dirty, - stdout: addProbe.stdout, - stderr: addProbe.stderr, - }; - return dirty ? { ...created, dirtyWarning: DIRTY_WORKTREE_WARNING } : created; -} - -export async function createRelocatedSession( - sourceSessionFile: string, - targetCwd: string, - sessionDir?: string, -): Promise { - const manager = SessionManager.forkFrom(sourceSessionFile, targetCwd, sessionDir); - const sessionFile = manager.getSessionFile(); - if (!sessionFile) throw new Error('Pi did not persist the relocated session file.'); - await cleanForkedSessionHeader(sessionFile); - return sessionFile; -} - -export async function cleanForkedSessionHeader(sessionFile: string): Promise { - const content = await readFile(sessionFile, 'utf8'); - const lineEnd = content.indexOf('\n'); - const firstLine = lineEnd === -1 ? content : content.slice(0, lineEnd); - if (firstLine.trim().length === 0) return; - - const header = JSON.parse(firstLine) as Record; - if (header.type !== 'session' || !Object.hasOwn(header, 'parentSession')) return; - - delete header.parentSession; - const rest = lineEnd === -1 ? '' : content.slice(lineEnd); - await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`); -} - -export async function runSwitchWorktree( - targetPath: string, - ctx: ExtensionCommandContext, - options: SwitchWorktreeOptions = {}, -): Promise { - const resolvedTarget = resolveSwitchTarget(targetPath, ctx.cwd); - if (resolvedTarget.length === 0) { - ctx.ui.notify('Usage: /worktree:switch ', 'error'); - return { status: 'failed', targetPath: resolvedTarget, reason: 'missing target path' }; - } - - const validation = await validateGitWorktree(resolvedTarget); - if (!validation.ok) { - const reason = validationReason(validation); - ctx.ui.notify(reason, 'error'); - return { status: 'failed', targetPath: resolvedTarget, reason }; - } - - if (ctx.hasUI) { - const confirmed = await ctx.ui.confirm( - 'Switch Pi worktree?', - `Relocate this Pi session to ${validation.cwd}?\n\nThe current session file will be preserved.`, - ); - if (!confirmed) { - ctx.ui.notify('Worktree switch cancelled.', 'info'); - return { status: 'cancelled', targetPath: validation.cwd }; - } - } - - const sourceSessionFile = ctx.sessionManager.getSessionFile(); - if (!sourceSessionFile) { - const reason = 'Current Pi session is not persisted, so it cannot be relocated.'; - ctx.ui.notify(reason, 'error'); - return { status: 'failed', targetPath: validation.cwd, reason }; - } - - const relocatedSessionFile = await createRelocatedSession( - sourceSessionFile, - validation.cwd, - options.sessionDir, - ); - const continuation = continuationPrompt(validation.cwd); - const result = await ctx.switchSession(relocatedSessionFile, { - withSession: async (replacementCtx: ReplacementMessageContext) => { - await replacementCtx.sendUserMessage(continuation); - replacementCtx.ui.notify(`Relocated Pi session to ${validation.cwd}`, 'info'); - }, - }); - - if (result.cancelled) { - return { - status: 'cancelled', - targetPath: validation.cwd, - sessionFile: relocatedSessionFile, - reason: 'session switch cancelled by a Pi hook', - }; - } - - return { status: 'switched', targetPath: validation.cwd, sessionFile: relocatedSessionFile }; -} - -export default function registerWorktreeExtension(pi: ExtensionAPI): void { - pi.registerCommand(WORKTREE_SWITCH_COMMAND, { - description: 'Relocate this Pi session to another git worktree', - handler: async (args, ctx) => { - await runSwitchWorktree(args, ctx); - }, - }); - - pi.registerCommand(WORKTREE_CREATE_COMMAND, { - description: 'Create a sibling git worktree from this cwd HEAD and stage a worktree switch', - handler: async (_args, ctx) => { - await createSiblingWorktree(ctx); - }, - }); - - pi.registerTool({ - name: WORKTREE_SWITCH_TOOL, - label: 'Switch worktree', - description: - 'Validate a target git worktree and stage /worktree:switch in the editor so the user can explicitly relocate this Pi session.', - promptSnippet: - 'switch_worktree validates a target git worktree and stages a /worktree:switch command for user-confirmed Pi session relocation.', - promptGuidelines: [ - 'Call switch_worktree only after the user explicitly asks to move this Pi session to another git worktree.', - 'Do not use switch_worktree to create, delete, prune, or clean up worktrees.', - 'After switch_worktree stages /worktree:switch , tell the user to press Enter if they want to relocate the session.', - ], - parameters: Type.Object({ - path: Type.String({ description: 'Absolute or relative path to the target git worktree.' }), - }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const resolvedTarget = resolveSwitchTarget(params.path, ctx.cwd); - const validation = await validateGitWorktree(resolvedTarget); - if (!validation.ok) { - const reason = validationReason(validation); - return { - content: [{ type: 'text' as const, text: reason }], - details: { - status: 'failed', - targetPath: resolvedTarget, - reason, - } satisfies SwitchWorktreeResultDetails, - }; - } - - const command = `/${WORKTREE_SWITCH_COMMAND} ${validation.cwd}`; - if (ctx.hasUI) ctx.ui.setEditorText(command); - return { - content: [ - { - type: 'text' as const, - text: ctx.hasUI - ? `Staged ${command}. Press Enter to relocate this Pi session.` - : `Validated ${validation.cwd}. Run ${command} in interactive Pi to relocate this session.`, - }, - ], - details: { status: 'staged', targetPath: validation.cwd } satisfies SwitchWorktreeResultDetails, - }; - }, - }); - - pi.registerTool({ - name: WORKTREE_CREATE_TOOL, - label: 'Create sibling worktree', - description: - 'Create a sibling git worktree from the caller cwd HEAD, then stage /worktree:switch for explicit relocation.', - promptSnippet: - 'create_worktree creates a sibling git worktree from the current cwd committed HEAD and stages /worktree:switch ; it never deletes or prunes worktrees.', - promptGuidelines: [ - 'Call create_worktree only when the user explicitly asks to create a sibling git worktree.', - 'The created worktree is based on the caller cwd HEAD; warn that uncommitted changes are excluded when the caller worktree is dirty.', - 'Do not delete, prune, clean up, or manage existing worktrees after creation.', - 'After create_worktree stages /worktree:switch , tell the user to press Enter if they want to relocate the Pi session.', - ], - parameters: Type.Object({}), - async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { - const details = await createSiblingWorktree(ctx); - if (details.status === 'failed') { - return { - content: [{ type: 'text' as const, text: details.reason }], - details, - }; - } - - const warning = details.dirtyWarning ? `\n\nWarning: ${details.dirtyWarning}` : ''; - return { - content: [ - { - type: 'text' as const, - text: `Created ${details.path} on branch ${details.branch} from ${details.sourceCommit}. Staged /worktree:switch ${details.path}.${warning}`, - }, - ], - details, - }; - }, - }); -} - -function continuationPrompt(targetCwd: string): string { - return `Continue in the relocated Pi session from cwd: ${targetCwd}`; -} - -function validationReason(validation: Extract): string { - switch (validation.reason) { - case 'missing': - return `Target path does not exist: ${validation.path}`; - case 'not-directory': - return `Target path is not a directory: ${validation.path}`; - case 'bare-repository': - return `Target path is a bare git repository, not a working tree: ${validation.path}`; - case 'not-git-worktree': - return `Target path is not a git working tree: ${validation.path}`; - } -} - -function normalizeStartIndex(candidate: number, candidateCount: number): number { - if (!Number.isFinite(candidate) || candidateCount <= 0) return 0; - const whole = Math.trunc(candidate); - return ((whole % candidateCount) + candidateCount) % candidateCount; -} - -function randomStartIndex(candidateCount: number): number { - return Math.floor(Math.random() * candidateCount); -} - -async function pathExists(path: string): Promise { - try { - await stat(path); - return true; - } catch (error) { - return !isNodeError(error) || error.code !== 'ENOENT'; - } -} - -async function branchExists(cwd: string, branch: string): Promise { - const result = await gitProbe(cwd, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`); - return result.ok; -} - -function gitFailureReason(prefix: string, result: GitProbeResult): string { - const output = [result.stderr.trim(), result.stdout.trim()].filter((part) => part.length > 0).join('\n'); - return output.length > 0 ? `${prefix}\n${output}` : prefix; -} - -async function gitProbe(cwd: string, ...args: string[]): Promise { - try { - const { stdout, stderr } = await execFile('git', args, { cwd }); - return { ok: true, stdout, stderr }; - } catch (error) { - const result = error as { readonly stdout?: unknown; readonly stderr?: unknown }; - return { - ok: false, - stdout: typeof result.stdout === 'string' ? result.stdout : '', - stderr: typeof result.stderr === 'string' ? result.stderr : '', - }; - } -} - -function isNodeError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && 'code' in error; -} diff --git a/bin/brunch-cli.js b/bin/brunch-cli.js index 740c69df9..b1f81467a 100755 --- a/bin/brunch-cli.js +++ b/bin/brunch-cli.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { runBrunchCli } from "../dist/brunch.js" +import { runBrunchCli } from "../dist/app/brunch.js" runBrunchCli() .then((code) => { diff --git a/docs/architecture/pi-seam-extensions.md b/docs/architecture/pi-seam-extensions.md index 5613572a3..3afbc776c 100644 --- a/docs/architecture/pi-seam-extensions.md +++ b/docs/architecture/pi-seam-extensions.md @@ -208,18 +208,18 @@ The user (and the agent, on the user's behalf) should be able to refer to graph - `before_agent_start` system-prompt injection for teaching the active agent how to interpret Brunch `#` handles and when to call a lookup/re-read tool. The inserted handle is just transcript text unless Brunch adds a later parser/indexer. - Brunch custom transcript entries (`pi.appendEntry`, `pi.registerMessageRenderer`) for future mention ledger/staleness records and resolved entity snapshots; these are separate from the autocomplete insertion itself. - `prepareNextTurn` for injecting mention-staleness hints into the agent's next-turn context, alongside the existing `worldUpdate` flow. -- The reconciliation-need substrate and global LSN (see §Reconciliation-need substrate and §Graph clock) for comparing the LSN at which a mention was last *snapshotted into the model's working context* against the entity's current LSN. +- The reconciliation-need substrate and spec-local LSN (see §Reconciliation-need substrate and §Graph clock) for comparing the `{specId, lsn}` at which a mention was last *snapshotted into the model's working context* against the entity's current LSN. ### Brunch-owned work - A `#` autocomplete provider sourced from `SpecRegistry` + current spec's graph index. It may search current titles and descriptions, but the inserted `value` must be a stable handle such as `#A12` or `#`; popup `label`/`description` are UI-only and are not session metadata. -- A Brunch mention indexer that parses user/assistant text for stable `#` handles after input and resolves them to `{ id: NodeId, title_at_mention: string, lsn_at_mention: number }` for the session mention ledger. This parsing/indexing step, not Pi autocomplete, is what creates structured mention state. +- A Brunch mention indexer that parses user/assistant text for stable `#` handles after input and resolves them to `{ specId, id: NodeId, title_at_mention: string, lsn_at_mention: number }` for the session mention ledger. This parsing/indexing step, not Pi autocomplete, is what creates structured mention state. - A graph lookup/re-read tool (for example `brunch.entity_reread`) whose prompt guidance tells the agent to resolve `#A12` by passing the handle without the `#` when deeper entity detail matters. -- A `SessionMentionLedger` in the session-scoped state: for each `id` ever mentioned in this session, the highest `snapshotted_lsn` — i.e. the LSN at which the agent most recently received the full entity payload (either via initial context, a `worldUpdate` cascade, or an explicit re-read tool call). The ledger persists with the session and survives compaction. +- A `SessionMentionLedger` in the session-scoped state: for each `{specId, id}` ever mentioned in this session, the highest `snapshotted_lsn` — i.e. the spec-local LSN at which the agent most recently received the full entity payload (either via initial context, a `worldUpdate` cascade, or an explicit re-read tool call). The ledger persists with the session and survives compaction. - A staleness check executed during `prepareNextTurn`: 1. Walk the session's `SessionMentionLedger`. - 2. For every entry where the entity's current LSN > `snapshotted_lsn`, the entity is **stale-in-context** for this session. - 3. Brunch synthesizes a `brunch.mention_staleness_hint` entry (custom message, `deliverAs: "nextTurn"`) summarising the stale set. The hint is **discretionary advice to the agent**, not a forced re-read: it tells the agent "if you intend to reason over `#foo` again, re-read it; the snapshot you have is from LSN 412, current is LSN 487." + 2. For every entry where the entity's current `{specId, lsn}` is newer than `snapshotted_lsn` for that same spec, the entity is **stale-in-context** for this session. + 3. Brunch synthesizes a `brunch.mention_staleness_hint` entry (custom message, `deliverAs: "nextTurn"`) summarising the stale set. The hint is **discretionary advice to the agent**, not a forced re-read: it tells the agent "if you intend to reason over `#foo` again, re-read it; the snapshot you have is from spec-local LSN 412, current is LSN 487." 4. The agent decides whether to invoke a re-read tool (which then updates `snapshotted_lsn`) or to proceed with the existing snapshot, accepting the staleness. - A `brunch.entity_reread` command/tool (through the shared command layer) that re-snapshots a named entity and updates `snapshotted_lsn` to the LSN observed at re-read. @@ -228,7 +228,7 @@ The user (and the agent, on the user's behalf) should be able to refer to graph - Mentions are anchored to stable handles/IDs, never to titles. Title-based autocomplete is a UX affordance only; the transcript persists the inserted textual handle, not the popup label/description. - The mention ledger is **session-scoped**, not transcript-scoped: the question "what has this agent seen at what LSN" is a per-session model-context question, and crossing sessions (via `switchSession`) legitimately resets it. - Staleness hints are **discretionary**. The agent's autonomy over its own context is preserved; Brunch merely surfaces the gap. The product stance is that re-read is cheap and worth doing when in doubt, but the framework does not mandate it. -- Staleness hints reuse the same `worldUpdate` machinery and the same global LSN as the rest of the change-log / reconciliation substrate; this is not a parallel staleness mechanism. +- Staleness hints reuse the same `worldUpdate` machinery and the same spec-local `{specId, lsn}` watermark as the rest of the change-log / reconciliation substrate; this is not a parallel staleness mechanism. ### Residual risks @@ -266,61 +266,56 @@ The PRD asserts that every durable graph mutation advances a monotonic graph rev ### The non-negotiable invariant -**The graph clock and change log must remain absolutely consistent.** Every durable mutation to spec-workspace graph state must: +**Each spec's graph clock and change log must remain absolutely consistent.** Every durable mutation to selected-spec graph state must: -1. Advance the graph clock by exactly one LSN per commit. -2. Append change-log entries tagged with that LSN inside the same SQLite transaction as the data writes. +1. Advance that spec's graph clock by exactly one LSN per commit. +2. Append change-log entries tagged with `{spec_id, lsn}` inside the same SQLite transaction as the data writes. 3. Carry per-entity optimistic concurrency information so concurrent writers see explicit conflicts rather than lost updates. -Any code path that mutates graph state without participating in this protocol is a defect, not a feature. There is no escape hatch, no "internal-only" write path, no maintenance script that bypasses the command layer. Schema migrations that move data must themselves allocate LSNs and emit change-log entries. +Any code path that mutates graph state without participating in this protocol is a defect, not a feature. There is no escape hatch, no "internal-only" write path, no maintenance script that bypasses the command layer. Pre-release schema migrations may reshape scratch data directly, but live graph/spec mutations still route through the command layer. ### ORM: Drizzle Brunch will use Drizzle on top of `better-sqlite3` for graph persistence. The reasoning: -- Drizzle keeps SQL explicit; the LSN-bump and change-log insert remain visible in the command-layer code rather than hidden in middleware. -- Drizzle supports `RETURNING` clauses, which makes the single-statement LSN bump (`UPDATE graph_clock SET lsn = lsn + 1 WHERE id = 1 RETURNING lsn`) idiomatic. -- Drizzle's transaction API gives the command layer one explicit boundary inside which all of (precondition check, entity writes, version bumps, LSN allocation, change-log insert) must happen. +- Drizzle keeps SQL explicit; the spec-local LSN bump and change-log insert remain visible in the command-layer code rather than hidden in middleware. +- Drizzle supports `RETURNING` clauses, which makes the target-spec LSN bump (`UPDATE graph_clock SET lsn = lsn + 1 WHERE spec_id = ? RETURNING lsn`) idiomatic. +- Drizzle's transaction API gives the command layer one explicit boundary inside which all of (precondition check, entity writes, version bumps, spec-local LSN allocation, change-log insert) must happen. - Drizzle has no built-in change-tracking middleware competing with this scheme, unlike ORMs that try to provide "automatic audit trails." ORMs that promise automatic change tracking (Prisma middleware, TypeORM subscribers, sequelize hooks) are explicitly rejected for this layer. Their hooks run at the wrong time relative to LSN allocation and would create a second, weaker mutation path the command layer cannot enforce. -### Single LSN per commit +### Single selected-spec LSN per commit -A commit is the unit of advance, not a row. The shape: +A commit is the unit of advance, not a row. LSNs are local to the spec being +mutated: -- A `graph_clock` table with a single row carrying the current `lsn` value. -- Each transaction allocates exactly one LSN via `UPDATE graph_clock SET lsn = lsn + 1 RETURNING lsn`. -- A `change_log` table keyed by `(lsn, seq)` where `seq` orders multiple ops within the same commit. -- Every entity row carries `version INTEGER NOT NULL` for optimistic concurrency, separate from the LSN. +- A `graph_clock` table keyed by `spec_id`, carrying that spec's current `lsn`. +- `createSpec` inserts the spec's initial clock row at LSN 1 alongside the `create_spec` audit entry; every later selected-spec mutation allocates exactly one LSN via `UPDATE graph_clock SET lsn = lsn + 1 WHERE spec_id = ? RETURNING lsn`. +- A missing clock row for an existing spec is storage corruption, not a first-mutation case; runtime code fails loud instead of recreating it. +- A `change_log` table keyed by `(spec_id, lsn)`. +- Every entity row keeps its existing `spec_id` plus local `created_at_lsn` / `updated_at_lsn`; a bare LSN is comparable only inside that spec. Schema sketch: ```sql CREATE TABLE graph_clock ( - id INTEGER PRIMARY KEY CHECK (id = 1), - lsn INTEGER NOT NULL + spec_id INTEGER PRIMARY KEY REFERENCES specs(id), + lsn INTEGER NOT NULL DEFAULT 0 ); -INSERT INTO graph_clock (id, lsn) VALUES (1, 0); CREATE TABLE change_log ( - lsn INTEGER NOT NULL, - seq INTEGER NOT NULL, - ts INTEGER NOT NULL, - actor TEXT NOT NULL, -- 'user' | 'agent:' | 'side_task:' - turn_id TEXT, -- nullable; present for agent-attributed writes - target_kind TEXT NOT NULL, -- 'node' | 'edge' | 'coherence' | ... - target_id TEXT NOT NULL, - op TEXT NOT NULL, -- 'create' | 'update' | 'delete' | ... - before_json TEXT, -- optional; see "before-images" below - after_json TEXT, - PRIMARY KEY (lsn, seq) + spec_id INTEGER NOT NULL REFERENCES specs(id), + lsn INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + operation TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (spec_id, lsn) ); -CREATE INDEX change_log_target_idx ON change_log (target_id, lsn); -CREATE INDEX change_log_lsn_idx ON change_log (lsn); +CREATE INDEX change_log_lsn_idx ON change_log (spec_id, lsn); ``` -`change_log(lsn, seq)` as a composite key gives Brunch the right shape on day one and avoids a painful migration later from a per-row-LSN model. +`change_log(spec_id, lsn)` makes selected-spec freshness explicit and prevents sibling-spec mutations from making an unchanged spec appear stale. ### LSN-per-commit correctness @@ -330,29 +325,29 @@ In the POC, the Brunch host is a single-process single-writer over the SQLite da db.transaction((tx) => { // 1. precondition checks (ifVersion guards, structural legality) // 2. entity writes (UPDATE ... WHERE id = ? AND version = ? ; bump version) - // 3. allocate the LSN: + // 3. allocate the target spec's LSN: const [{ lsn }] = tx .update(graphClock) .set({ lsn: sql`${graphClock.lsn} + 1` }) - .where(eq(graphClock.id, 1)) + .where(eq(graphClock.spec_id, specId)) .returning({ lsn: graphClock.lsn }); - // 4. insert change_log rows tagged with `lsn` and ordered by `seq` + // 4. insert change_log rows tagged with `spec_id` + `lsn` // 5. update coherence_state if dirty-set changed }); // 6. post-commit fanout to subscribers (TUI redraw, WS broadcast) ``` -This shape stays correct as long as the invariant holds: **every mutation goes through this helper, inside one transaction, with the LSN bump and the change-log insert as siblings of the data write.** The risk is not the mechanism; it is socialization of the rule. +This shape stays correct as long as the invariant holds: **`createSpec` creates the target spec's clock row, every later mutation uses the update-only LSN bump inside one transaction, and the spec-scoped change-log insert is a sibling of the data write.** The risk is not the mechanism; it is socialization of the rule. ### Enforcing the invariant -To make "command layer is the only entry point" enforceable rather than aspirational: +To make "command layer is the only live entry point" enforceable rather than aspirational: - The graph Drizzle client is not exported as a public symbol from the graph subsystem. Only a `GraphCommands` facade is exported. - Tests assert that no source file outside `graph/commands/*` imports the raw Drizzle client for graph tables. A simple grep-based check in CI is sufficient for the POC and can be promoted later. - Pi tools that need to mutate graph state register thin shims that call `GraphCommands.*`. They never receive a database handle. - Side tasks, lenses, RPC clients, web mutations, and TUI slash commands all call the same `GraphCommands` facade. There is no per-caller specialization of write paths. -- Schema migrations themselves use `GraphCommands` for any data movement; they may add or alter tables outside the command layer, but they may not write graph data without participating in the LSN/change-log protocol. +- Pre-release schema migrations may reshape scratch data directly when schema truth moves, including graph-clock/change-log backfills. Live graph/spec mutations still route through the command layer and must not repair missing clock rows as a compatibility fallback. - The post-commit fanout is the only legal way to learn about changes. Subscribers must not poll the change log without going through the subsystem's subscription API. This rule is the social load-bearing piece. The mechanism is small; the discipline is the architecture. @@ -404,9 +399,9 @@ The mechanism itself is small: schema is ~30 lines, the `applyMutation` helper i ### Milestone implications for the change log -- **M4 (graph data plane)** introduces the `graph_clock`, `change_log`, and `coherence_state` schema, the `GraphCommands` facade, single-LSN-per-commit allocation, per-entity `ifVersion`, and post-commit fanout. Before-images may be deferred. +- **M4 (graph data plane)** introduces the `graph_clock`, `change_log`, and `coherence_state` schema, the `GraphCommands` facade, selected-spec LSN-per-commit allocation, per-entity `ifVersion`, and post-commit fanout. Before-images may be deferred. - **M5 (agent ↔ graph integration)** requires that every agent graph tool route through `GraphCommands` rather than touching Drizzle directly. -- **M7 (detection, relevance, turn-boundary reconciliation)** consumes the change log via `prepareNextTurn` and depends on the `lsn` and `target_id` indexes existing. +- **M7 (detection, relevance, turn-boundary reconciliation)** consumes the change log via `prepareNextTurn` and depends on `{spec_id, lsn}` watermarks and target indexes existing. - **M8 (coherence)** likely turns on before-images and adds semantic-coherence validation that itself allocates LSNs through the command layer. - **M9 (compaction-aware continuity)** must preserve session-scoped `lastSeenLsn` across compaction so interest filtering against the change log remains correct after long sessions. @@ -456,10 +451,10 @@ CREATE INDEX recon_need_target_idx ON reconciliation_need (spec_id, plane); ### Mutation invariant -Needs are mutated through a `ReconciliationCommands` facade alongside `GraphCommands` and under the same global LSN + change-log discipline: +Needs are mutated through a `ReconciliationCommands` facade alongside `GraphCommands` and under the same spec-local LSN + change-log discipline: -- Every need create/update/resolve/supersede allocates an LSN via the same `graph_clock` table. -- Every need mutation appends a `change_log` entry with `target_kind = 'reconciliation_need'`. +- Every need create/update/resolve/supersede allocates an LSN via the target spec's `graph_clock` row. +- Every need mutation appends a `change_log` entry keyed by `{spec_id, lsn}` with `target_kind = 'reconciliation_need'`. - Needs and graph nodes may be mutated in the **same transaction** when the interviewer resolves a need inline as part of an ordinary turn's commit. This is desirable: a question whose answer both writes a new invariant and closes a `possible_observation` need should commit atomically. - Side tasks raise needs through the same facade; attribution remains clean (`raised_by_actor = 'side_task:'`). diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index a310b0867..234fde900 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -147,9 +147,9 @@ Observed behavior: | Scenario | Result | Evidence | | --- | --- | --- | -| Idle TUI mount | Header, footer, status, diagnostic widget, and title are called from one snapshot; tests assert the same formatter output used by the wrapper. | `src/brunch-tui.test.ts` | +| Idle TUI mount | Header, footer, status, diagnostic widget, and title are called from one snapshot; tests assert the same formatter output used by the wrapper. | `src/app/brunch-tui.test.ts` | | `/reload` / extension reload | Chrome is not durable inside Pi UI; reload must rerun extension setup and call `renderBrunchChrome` with a fresh Brunch snapshot. | source/API behavior; wrapper is stateless by design | -| Session replacement / selected-session reopen | Existing Brunch extension calls the session-lifecycle binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace snapshot. The `/brunch` settings-switcher action activates decisions through the coordinator, calls `ctx.switchSession()`, and renders fresh chrome/notification only through `withSession` replacement context. | `src/brunch-tui.test.ts` | +| Session replacement / selected-session reopen | Existing Brunch extension calls the session-lifecycle binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace snapshot. The `/brunch` settings-switcher action activates decisions through the coordinator, calls `ctx.switchSession()`, and renders fresh chrome/notification only through `withSession` replacement context. | `src/app/brunch-tui.test.ts` | | RPC degradation | `setStatus`, string-array `setWidget`, `setTitle`, and `notify` emit RPC `extension_ui_request` events; `setHeader`, `setFooter`, and `setWorkingIndicator` are RPC no-ops. Brunch chrome currently uses TUI-only header/footer plus diagnostic widget/title; fixture drivers should not assert TUI-only header/footer or a chrome-owned status key. | Pi RPC source + temp RPC JSONL probe | ## Startup/splash logo asset decision diff --git a/docs/archive/PLAN_HISTORY.md b/docs/archive/PLAN_HISTORY.md index 8f4ad27fa..1da2cd99a 100644 --- a/docs/archive/PLAN_HISTORY.md +++ b/docs/archive/PLAN_HISTORY.md @@ -3,6 +3,13 @@ This file is the active POC-line plan archive for `memory/PLAN.md`. Legacy pre-`next` history was moved out of the live docs tree with the old archived implementation. +## 2026-06-06 Sync archive + +Archived from `memory/PLAN.md` during topology-chain sync so the live plan keeps only active/next/parallel definitions plus the last few completion summaries. + +- 2026-06-05 `dev-seed-fixtures` — Done: spec-scoped graph LSN and clock-row hardening. Replaced workspace-global graph-clock/change-log semantics with `(spec_id, lsn)` storage and selected-spec LSN allocation through `CommandExecutor`; `createSpec` creates exactly one clock row; later mutations use update-only clock bumps that fail loud on missing rows; and legacy migrations backfill one clock row per spec from graph/change-log/reconciliation history. Verified: command-executor, DB migration, graph snapshot, seed-fixture, RPC, prompt-context, and seeded-dev-rpc smoke tests. +- 2026-06-04 `graph-tool-resilience` (FE-808) — Done: graph nodes persist per-kind ordinals and expose projected codes; `commitGraph` applies one explicit/implicit batch basis, returns one created-node identity shape, plans once inside the transaction before LSN allocation/writes, and shares dry-run/commit structural validation; adapters resolve selected-spec existing-node codes into structured diagnostics; single-node `createNode` rejects retired basis values; same-spec supersession cycles are rejected atomically; active-context graph reads omit hidden superseded nodes and dangling edges; product-path probes landed existing-code, retry-diagnostics, and ambiguity/no-overcommit evidence. + ## 2026-06-05 Rolling completion archive Archived from `memory/PLAN.md` when FE-807 closed and the live frontier advanced to `project-graph-review-cycle`. diff --git a/docs/design/GRAPH_MODEL.md b/docs/design/GRAPH_MODEL.md index fab8adbb7..354b5d9e2 100644 --- a/docs/design/GRAPH_MODEL.md +++ b/docs/design/GRAPH_MODEL.md @@ -920,12 +920,12 @@ because they evolve faster than the schema: - **Observer classification / translation tables** — phrase-pattern → kind mappings for post-exchange capture. Seeded in - [`src/agents/strategies/README.md`](../../src/agents/strategies/README.md); + [`src/.pi/skills/strategies/README.md`](../../src/.pi/skills/strategies/README.md); lands as prompt-pack content with M5 `agent-graph-integration`. - **Topology-driven question ranking** — graph-shape heuristics for what to ask next (e.g. "requirement with no incoming proof edge → suggest a criterion"). Seeded in - [`src/agents/lenses/README.md`](../../src/agents/lenses/README.md); + [`src/.pi/skills/lenses/README.md`](../../src/.pi/skills/lenses/README.md); lands as lens prompt-pack content with M5. Both draw on the archived diff --git a/docs/testing/seeded-dev-rpc.md b/docs/testing/seeded-dev-rpc.md index fa1a0b760..75bf6b814 100644 --- a/docs/testing/seeded-dev-rpc.md +++ b/docs/testing/seeded-dev-rpc.md @@ -14,25 +14,25 @@ Prefer a workbench directory so seeded `.brunch/` state does not mix with whatev ```bash REPO="$(git rev-parse --show-toplevel)" -WORKSPACE="$REPO/.fixtures/workbenches/seeded-dev-rpc" -mkdir -p "$WORKSPACE" +DEV_WORKSPACE="$REPO/.fixtures/workbenches/seeded-dev-rpc" +mkdir -p "$DEV_WORKSPACE" ``` To reset this scratch workspace only: ```bash -rm -rf "$WORKSPACE/.brunch" +rm -rf "$DEV_WORKSPACE/.brunch" ``` Do not run that cleanup command against a workspace whose Brunch sessions or graph data you care about. ## 1. Seed all current fixtures -Run the seed loader from the target workspace. It loads every `.fixtures/seeds//.json` through `CommandExecutor` into `$WORKSPACE/.brunch/data.db`. +Run the seed loader from the target workspace. It loads every `.fixtures/seeds//.json` through `CommandExecutor` into `$DEV_WORKSPACE/.brunch/data.db`. ```bash ( - cd "$WORKSPACE" + cd "$DEV_WORKSPACE" "$REPO/node_modules/.bin/tsx" "$REPO/src/graph/seed-fixtures.ts" ) ``` @@ -52,9 +52,9 @@ The loader currently seeds all sets. Inspect the actual spec ids before issuing brunch_rpc() { local payload="$1" ( - cd "$WORKSPACE" + cd "$DEV_WORKSPACE" printf '%s\n' "$payload" | \ - BRUNCH_DEV_RPC=1 "$REPO/node_modules/.bin/tsx" "$REPO/src/brunch.ts" --mode=rpc + BRUNCH_DEV_RPC=1 "$REPO/node_modules/.bin/tsx" "$REPO/src/app/brunch.ts" --mode=rpc ) } ``` @@ -68,6 +68,14 @@ brunch_rpc '{"jsonrpc":"2.0","id":1,"method":"rpc.discover"}' \ | jq 'select(.id == 1).result.methods[].method' ``` +For one-shot command-line work, prefer the dev helper. It sets `BRUNCH_DEV_RPC=1`, sends one request, filters notifications, and prints only the response result: + +```bash +"$REPO/node_modules/.bin/tsx" "$REPO/src/dev/workspace-rpc.ts" \ + --workspace "$DEV_WORKSPACE" \ + graph.overview '{"specId":4}' +``` + ## 3. Inspect seeded specs ```bash @@ -90,6 +98,10 @@ brunch_rpc "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"graph.overview\",\"params Projected node codes are not stored in the DB. They are rendered from `kind` + `kindOrdinal` using the graph labels (`G1`, `TH1`, `T1`, `CTX1`, `R1`, `CR1`, etc.). Use `graph.overview` to find the current `kindOrdinal` before referencing existing nodes by code. +`lsn` is the selected spec's local graph-clock value. Compare freshness as +`{specId, lsn}`; seeded spec ids and bare LSN values do not imply workspace-wide +ordering. + ## 4. Activate a session when session methods matter Graph reads and `dev.graph.commitGraph` take explicit `specId` and do not require a selected session. Session methods do. @@ -115,8 +127,8 @@ cat > /tmp/brunch-dev-commit.json </.json" +``` + +For inspection without writing: + +```bash +"$REPO/node_modules/.bin/tsx" "$REPO/src/graph/export-fixtures.ts" \ + --workspace "$DEV_WORKSPACE" \ + --spec-id "$SPEC_ID" \ + | jq '{spec, nodeCount:(.nodes|length), edgeCount:(.edges|length)}' +``` + ### Basis rule of thumb - `explicit` — exact human-authored/manual curation or exact reviewed items. @@ -167,5 +207,5 @@ For agent-addressable dev mutations, run a separate `BRUNCH_DEV_RPC=1 --mode=rpc - `Method not found` for `dev.graph.commitGraph`: check `BRUNCH_DEV_RPC=1` and ensure you are using `--mode=rpc`, not the TUI-started web sidecar. - `graph node code "G1" does not resolve`: inspect `graph.overview` for the selected `specId`; codes are spec-scoped. -- Empty `workspace.selectionState`: check that you seeded from the same `$WORKSPACE` directory you are using for RPC. -- Stale or surprising graph state: reset only the scratch workspace with `rm -rf "$WORKSPACE/.brunch"`, then reseed. +- Empty `workspace.selectionState`: check that you seeded from the same `$DEV_WORKSPACE` directory you are using for RPC. +- Stale or surprising graph state: reset only the scratch workspace with `rm -rf "$DEV_WORKSPACE/.brunch"`, then reseed. diff --git a/drizzle/0002_spec_scoped_graph_clock.sql b/drizzle/0002_spec_scoped_graph_clock.sql new file mode 100644 index 000000000..15d871cf4 --- /dev/null +++ b/drizzle/0002_spec_scoped_graph_clock.sql @@ -0,0 +1,60 @@ +CREATE TABLE `graph_clock_new` ( + `spec_id` integer PRIMARY KEY NOT NULL, + `lsn` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`spec_id`) REFERENCES `specs`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `graph_clock_new` (`spec_id`, `lsn`) +SELECT + `specs`.`id`, + COALESCE(`max_lsn_by_spec`.`lsn`, 0) +FROM `specs` +LEFT JOIN ( + SELECT `spec_id`, max(`lsn`) AS `lsn` + FROM ( + SELECT CAST(json_extract(`payload`, '$.specId') AS integer) AS `spec_id`, `lsn` + FROM `change_log` + WHERE json_extract(`payload`, '$.specId') IS NOT NULL + UNION ALL + SELECT `spec_id`, `created_at_lsn` AS `lsn` FROM `nodes` + UNION ALL + SELECT `spec_id`, `updated_at_lsn` AS `lsn` FROM `nodes` + UNION ALL + SELECT `spec_id`, `created_at_lsn` AS `lsn` FROM `edges` + UNION ALL + SELECT `spec_id`, `updated_at_lsn` AS `lsn` FROM `edges` + UNION ALL + SELECT `spec_id`, `created_at_lsn` AS `lsn` FROM `reconciliation_need` + UNION ALL + SELECT `spec_id`, `resolved_at_lsn` AS `lsn` FROM `reconciliation_need` WHERE `resolved_at_lsn` IS NOT NULL + ) + GROUP BY `spec_id` +) `max_lsn_by_spec` ON `max_lsn_by_spec`.`spec_id` = `specs`.`id`; +--> statement-breakpoint +DROP TABLE `graph_clock`; +--> statement-breakpoint +ALTER TABLE `graph_clock_new` RENAME TO `graph_clock`; +--> statement-breakpoint +CREATE TABLE `change_log_new` ( + `spec_id` integer NOT NULL, + `lsn` integer NOT NULL, + `operation` text NOT NULL, + `payload` text NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + PRIMARY KEY(`spec_id`, `lsn`), + FOREIGN KEY (`spec_id`) REFERENCES `specs`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `change_log_new` (`spec_id`, `lsn`, `operation`, `payload`, `created_at`) +SELECT + CAST(json_extract(`payload`, '$.specId') AS integer) AS `spec_id`, + `lsn`, + `operation`, + `payload`, + `created_at` +FROM `change_log` +WHERE json_extract(`payload`, '$.specId') IS NOT NULL; +--> statement-breakpoint +DROP TABLE `change_log`; +--> statement-breakpoint +ALTER TABLE `change_log_new` RENAME TO `change_log`; diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 000000000..57c38e485 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,616 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d52c1722-788f-4bc4-9b5d-4bb832520ac4", + "prevId": "8fbe0765-0bb3-4d00-856f-fd09968b1c6b", + "tables": { + "change_log": { + "name": "change_log", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lsn": { + "name": "lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "change_log_spec_id_specs_id_fk": { + "name": "change_log_spec_id_specs_id_fk", + "tableFrom": "change_log", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "change_log_spec_lsn_pk": { + "name": "change_log_spec_lsn_pk", + "columns": [ + "spec_id", + "lsn" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "edges": { + "name": "edges", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stance": { + "name": "stance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basis": { + "name": "basis", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'explicit'" + }, + "rationale": { + "name": "rationale", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_lsn": { + "name": "created_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at_lsn": { + "name": "updated_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "edges_spec_id_specs_id_fk": { + "name": "edges_spec_id_specs_id_fk", + "tableFrom": "edges", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "edges_source_id_nodes_id_fk": { + "name": "edges_source_id_nodes_id_fk", + "tableFrom": "edges", + "tableTo": "nodes", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "edges_target_id_nodes_id_fk": { + "name": "edges_target_id_nodes_id_fk", + "tableFrom": "edges", + "tableTo": "nodes", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "graph_clock": { + "name": "graph_clock", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "lsn": { + "name": "lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "graph_clock_spec_id_specs_id_fk": { + "name": "graph_clock_spec_id_specs_id_fk", + "tableFrom": "graph_clock", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "node_kind_counters": { + "name": "node_kind_counters", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plane": { + "name": "plane", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_ordinal": { + "name": "next_ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "node_kind_counters_spec_plane_kind_unique": { + "name": "node_kind_counters_spec_plane_kind_unique", + "columns": [ + "spec_id", + "plane", + "kind" + ], + "isUnique": true + } + }, + "foreignKeys": { + "node_kind_counters_spec_id_specs_id_fk": { + "name": "node_kind_counters_spec_id_specs_id_fk", + "tableFrom": "node_kind_counters", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "nodes": { + "name": "nodes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plane": { + "name": "plane", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind_ordinal": { + "name": "kind_ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basis": { + "name": "basis", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'explicit'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_lsn": { + "name": "created_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at_lsn": { + "name": "updated_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "nodes_spec_plane_kind_ordinal_unique": { + "name": "nodes_spec_plane_kind_ordinal_unique", + "columns": [ + "spec_id", + "plane", + "kind", + "kind_ordinal" + ], + "isUnique": true + } + }, + "foreignKeys": { + "nodes_spec_id_specs_id_fk": { + "name": "nodes_spec_id_specs_id_fk", + "tableFrom": "nodes", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reconciliation_need": { + "name": "reconciliation_need", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_edge_id": { + "name": "target_edge_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_a_id": { + "name": "target_a_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_b_id": { + "name": "target_b_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_lsn": { + "name": "created_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at_lsn": { + "name": "resolved_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "reconciliation_need_spec_id_specs_id_fk": { + "name": "reconciliation_need_spec_id_specs_id_fk", + "tableFrom": "reconciliation_need", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reconciliation_need_target_edge_id_edges_id_fk": { + "name": "reconciliation_need_target_edge_id_edges_id_fk", + "tableFrom": "reconciliation_need", + "tableTo": "edges", + "columnsFrom": [ + "target_edge_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reconciliation_need_target_a_id_nodes_id_fk": { + "name": "reconciliation_need_target_a_id_nodes_id_fk", + "tableFrom": "reconciliation_need", + "tableTo": "nodes", + "columnsFrom": [ + "target_a_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reconciliation_need_target_b_id_nodes_id_fk": { + "name": "reconciliation_need_target_b_id_nodes_id_fk", + "tableFrom": "reconciliation_need", + "tableTo": "nodes", + "columnsFrom": [ + "target_b_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "specs": { + "name": "specs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "readiness_grade": { + "name": "readiness_grade", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'grounding_onboarding'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 431874df8..13c7dcac5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1780577981107, "tag": "0001_aspiring_orphan", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1780668000000, + "tag": "0002_spec_scoped_graph_clock", + "breakpoints": true } ] } \ No newline at end of file diff --git a/memory/PLAN.md b/memory/PLAN.md index dd7510333..b8ccf7a39 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -23,7 +23,7 @@ The delivery cut's black triangles are (live graph observability is now landed; 4. **Graph tool resilience:** the direct agent graph path survives more than the one A14 happy path: existing-node refs, structural-illegal diagnostics/retry, and ambiguity/no-overcommit cases. 5. **Review cycle, if included in the POC story:** `project-graph` proposal generation surfaces a dry-run-valid review set, and approval commits atomically. -All delivery frontiers must also continue materializing the locked source topology (D52-L): `src/{.pi, agents, db, graph, session, rpc, web}` with directed dependencies. Treat topology completion as a product-delivery dimension, not cleanup. Each frontier definition names the files/directories it should move toward their final home. +All delivery frontiers must also continue materializing the locked source topology (D52-L): target `src/{app, workspace, scripts, .pi, db, graph, session, projections, renderers, rpc, web}` with directed dependencies and explicit migration notes where current files have not moved yet. Treat topology completion as a product-delivery dimension, not cleanup. Each frontier definition names the files/directories it should move toward their final home. 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. @@ -31,12 +31,11 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple ### Active -1. `project-graph-review-cycle` — P1 unless demo narrative promotes it: real `project-graph` review-set proposal/approval loop. +1. `poc-live-ship-gate` — P1 final gate; current active slice is live selected-spec mention autocomplete before the fresh-cwd runbook. ### Next 1. `minimal-authority-shell` — P1 safety: thin POC authority posture over already-existing command-result seams and `elicit` tool policy. -2. `poc-live-ship-gate` — P1 final gate: fresh-cwd runbook exercising the composed product path end to end. ### Parallel / Low-conflict @@ -57,93 +56,15 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple ## Frontier Definitions -### agents-composition-layer - -- **Name:** Agent prompt-resource composition, runtime manifests, and snapshot contexts -- **Linear:** [FE-806](https://linear.app/hash/issue/FE-806/agent-prompt-resource-composition-runtime-manifests-and-snapshot) -- **Branch:** `ln/fe-806-agents-composition-layer` -- **Kind:** structural -- **Status:** done -- **Objective:** Build the D58-L/D59-L/D60-L `agents/` layer so runtime state changes behavior: `agents/state.ts` legal tuples and resource manifest metadata; `agents/compose.ts` runtime header + gated manifests; Brunch-owned markdown resources for definitions/goals/strategies/lenses/methods; agent-context snapshot renderers; and migration/deletion of the old `src/.pi/context` composer. -- **Why now / unlocks:** Runtime vocabulary has landed, but stored axes are not enough. The POC needs switchable strategies/lenses/goals to change prompt posture and available resources before capture and review-cycle behavior can be judged plausibly. -- **Acceptance:** - - `compose(agentId, sessionState, spec, workspace, snapshots)` emits the agent-control header, runtime-state header, compact context handles, and gated ``, ``, ``, and `` manifests. - - AUTO axes list exactly the legal set for the current agent/op-mode/grade/allow-list; pinned axes point to the pinned resource; illegal tuples are rejected in code. - - At least the P0 behavior resources exist and are readable: `step-wise-disambiguate`, `propose-graph`, `intent` lens, `design` lens, grounding/capture objectives, and structured-exchange/capture/graph-commit methods as needed for following frontiers. - - Snapshot rendering is split correctly: PULL in `graph/`/`session/`, RENDER in `agents/contexts/`, SURFACE through composition or snapshot tools. - - `src/.pi/context/` is removed or reduced to a compatibility-free tombstone; prompt composition lives in `src/agents/`. -- **Verification:** Inner — manifest filtering/gating tests, legal/illegal tuple tests, resource location tests, snapshot render tests. Middle — compose legality across projected runtime states and spec grades; probe/manual prompt review showing two strategies/lenses produce materially different manifests/posture. Behavioral quality remains a fitness signal, not a merge gate. -- **Topology materialization:** Complete `src/agents/{definitions,goals,strategies,lenses,methods,contexts}` as the prompt/control subtree; `.pi/extensions/` only adapts Pi seams; `agents/` may import from `graph/` and `session/`, never the reverse; context string rendering does not leak into `graph/` pull functions. -- **Cross-cutting obligations:** Preserve D39-L sealed resource policy: manifest metadata is code-owned, not filesystem-discovered. Workspace posture is workspace-scoped header input, not spec/session/graph truth. Multi-spec discipline: composition reads the selected spec's grade/graph snapshots only. -- **Traceability:** D25-L, D39-L, D40-L, D52-L, D58-L, D59-L, D60-L / I18-L, I33-L, I35-L, I38-L / A14-L, A22-L. -- **Design docs:** `memory/SPEC.md` §Prompt/runtime profile architecture; `src/agents/README.md`; `src/.pi/README.md`. -- **Current execution pointer:** Complete. Prompt manifests, selected-spec context renderers, product prompt-path snapshot wiring, legacy `.pi/context` deletion, and deterministic runtime-posture proof are landed. - -### capture-response-to-graph - -- **Name:** Structured response capture into selected-spec graph truth -- **Linear:** [FE-807](https://linear.app/hash/issue/FE-807/structured-response-capture-into-selected-spec-graph-truth) -- **Branch:** `ln/fe-807-capture-response-to-graph` -- **Kind:** structural / tracer bullet -- **Status:** done -- **Certainty:** proving -- **Stabilizes:** I30-L, I31-L, I39-L, I40-L — capture must aim at the selected-spec graph through stable projected node-code/basis semantics rather than raw ids or path-shaped basis values. -- **Lights up:** structured exchange response → explicit-basis graph truth → selected-spec web observer update. -- **Objective:** Prove the single-exchange path: a typed structured-exchange response is captured synchronously into high-confidence graph mutations through `CommandExecutor`, and the resulting graph change is visible through web/TUI projections. -- **Why now / unlocks:** Structured exchanges and graph commits work separately. This frontier makes elicitation actually graph-native for the POC. It directly attacks A22-L while preserving the single mutation authority. -- **Acceptance:** - - A narrow capture path exists for 2–4 high-confidence intent facts, starting with basic/grounding kinds such as `goal`, `context`, `constraint`, `criterion`, or `assumption`; low-confidence implications remain out of graph truth and can be rendered as preface/disambiguation material. - - Capture targets the spec bound to the session's `brunch.session_binding`; it never writes to a workspace-global graph or an unbound/default spec. - - Captured graph mutations route only through `CommandExecutor`, write directly stated/exactly captured items with `basis: explicit`, allocate stable kind ordinals, and produce normal LSN/change-log entries. - - The transcript retains the source structured exchange; graph readers expose the committed nodes/edges; the live web observer updates after capture. - - Capture failures are loud and diagnosable (`structural_illegal`, policy/authority result, or explicit no-capture), not silent partial writes. -- **Verification:** Inner — capture classification fixtures; command-input shape tests; no-bypass tests. Middle — replay a structured-exchange response fixture through capture and assert graph/change-log/projection results; negative fixtures for low-confidence material and malformed responses. Outer — manual/probe run: user answers a structured prompt, capture commits a small graph slice, web observer updates. -- **Topology materialization:** `session/` owns transcript/exchange extraction; `graph/capture/` owns capture-to-command translation and structural/domain policy; `.pi/extensions/structured-exchange` remains an adapter; `.pi/extensions/graph` remains a tool adapter; `rpc/` and `web/` observe through projection handlers only. -- **Cross-cutting obligations:** Preserve D4-L/D20-L single-authority mutation; keep capture synchronous and bounded for POC; do not introduce deferred observer/auditor queues or canonical chat/turn tables here. Capture must respect D61-L: claims are node-level truth inside the selected spec. Preserve D62-L/D63-L/D64-L: projected codes are presentation handles, basis is approval strength, and readiness bands guide capture objectives without becoming kind whitelists. -- **Traceability:** R10, R16, R17, R21, R22 / D4-L, D17-L, D18-L, D20-L, D21-L, D45-L, D52-L, D54-L, D56-L, D57-L, D61-L, D62-L, D63-L, D64-L / I30-L, I31-L, I39-L, I40-L / A22-L, A3-L. -- **Design docs:** `docs/design/GRAPH_MODEL.md`; `docs/design/ELICITATION_LENSES.md`; `memory/SPEC.md` D17-L/D18-L/D61-L. -- **Current execution pointer:** Complete. `session.submitExchangeResponse` now appends the terminal structured-exchange response, synchronously captures directly labeled text facts via `graph/capture/structured-response.ts`, commits selected-spec `basis: explicit` graph nodes through `CommandExecutor`, returns `captured | no_capture | structural_illegal`, and publishes graph invalidations. Broader LLM extraction quality, reconciliation-need capture, and readiness-grade capture remain future fitness/work. - - -### graph-tool-resilience - -- **Name:** Materialize graph write contract and broaden direct graph-tool proof -- **Linear:** [FE-808](https://linear.app/hash/issue/FE-808/broaden-direct-graph-tool-proof-beyond-the-a14-happy-path) -- **Branch:** `ln/fe-808-graph-tool-resilience` -- **Kind:** structural hardening / tracer bullet -- **Status:** done -- **Certainty:** proving -- **Stabilizes:** I34-L, I39-L, I40-L, I41-L — graph writes need stable node handles, correct approval basis, and supersession acyclicity before capture/review frontiers build on them. -- **Lights up:** real `read_graph` / `commit_graph` path with projected existing-node references, diagnostics/retry, and no-overcommit behavior through the default Brunch runtime factory. -- **Objective:** Materialize the locked graph write contract in schema, domain types, CommandExecutor validation, tool adapters, and snapshots, then extend the real `read_graph`/`commit_graph` product-path proof to representative failure and complexity cases. -- **Why now / unlocks:** The A14 commitGraph subclaim is partially validated by one successful run, but the canonical graph contract has moved: projected node codes, `basis: explicit | implicit`, per-kind ordinal allocation, and supersession acyclicity are now structural invariants. Capture and review-cycle work should not land against the old raw-id / `accepted_review_set` model. -- **Acceptance:** - - DB/domain schema stores `kind_ordinal`, allocates it monotonically per `(spec_id, plane, kind)` through `CommandExecutor` counter rows or equivalent, and rejects duplicate `(spec_id, plane, kind, kind_ordinal)` tuples. - - Graph node metadata owns globally unique 1–3 letter presentation labels plus non-exclusive readiness-band membership; snapshots/prompts/tools render projected codes without storing code strings. - - Accepted nodes/edges use only `basis: explicit | implicit`; `propose-graph` direct commits are `implicit`, exact user/reviewed writes are `explicit`, and retired `accepted_review_set` values are rejected. - - `commitGraph` accepts one approval basis for the batch, returns created ids/kind ordinals, resolves existing-node references from projected codes through adapters, and no longer requires agents to use raw DB ids. - - Supersession edge creation validates acyclicity against existing same-spec supersession edges plus proposed batch edges, including intra-batch and mixed cycles. - - Graph-truth vs active-context reads are explicit enough that active-context snapshots do not return dangling edges to hidden superseded nodes. - - Additional probe scenarios landed under `.fixtures/runs/`: existing-node reference (`2026-06-04-existing-code-ref`), illegal proof-edge retry (`2026-06-04-retry-diagnostics`), and ambiguity/no-overcommit (`2026-06-04-ambiguity-no-overcommit`). - - Probe reports record scenario id, attempts, retry count, diagnostics seen, final graph counts/LSN, committed code/title summaries, projected-code/ambiguity outcome evidence, and friction. - - Tool guidance and `structural_illegal` diagnostics are sufficient for at least one corrected retry path (`2026-06-04-retry-diagnostics`); ambiguity probe records no unsupported graph writes. - - Existing-node refs target the selected spec's graph only. -- **Verification:** Inner — schema/domain/CommandExecutor tests for ordinal allocation, basis enum rejection, existing-code resolution, supersession acyclicity, active-context filtering, and tool adapter schema/results. Middle/Outer — real model probe runs with transcript/report artifacts; no artificial injection of the module under test that bypasses the default Brunch runtime factory. -- **Topology materialization:** Keep probes in `src/probes/` and `.fixtures/runs/`; keep tool adapter code in `src/.pi/extensions/graph/`; keep validators/diagnostics in `src/graph/`; no probe-only graph runtime wiring that product launch does not use. -- **Cross-cutting obligations:** Avoid harness-as-false-proof: the probe must exercise the same default Brunch runtime factory and registered tools that the product uses. Record fitness, not just pass/fail. Preserve D62-L/D63-L/D64-L as graph-wide contracts rather than adapter-local conveniences. -- **Traceability:** D4-L, D20-L, D51-L, D53-L, D60-L, D62-L, D63-L, D64-L / I34-L, I35-L, I39-L, I40-L, I41-L / A14-L, A5-L. -- **Design docs:** `docs/architecture/probes-and-transcripts.md`; `docs/design/GRAPH_MODEL.md`. -- **Current execution pointer:** FE-808 closure chain completed; no active scope file remains. - ### project-graph-review-cycle - **Name:** Project-graph review-set proposal and atomic acceptance - **Linear:** [FE-809](https://linear.app/hash/issue/FE-809/project-graph-review-set-proposal-and-atomic-acceptance) - **Branch:** `ln/fe-809-project-graph-review-cycle` - **Kind:** structural / bounded feature -- **Status:** active +- **Status:** done - **Certainty:** proving -- **Stabilizes:** I34-L, I40-L — exact review approval must become one explicit-basis atomic graph batch, not a path-shaped basis value or partial commit. +- **Stabilizes:** I15-L, I20-L, I34-L, I40-L — exact review approval must become one explicit-basis atomic graph batch, not a path-shaped basis value or partial commit; only structurally valid review payloads may become user-reviewable. - **Lights up:** `project-graph` proposal → dry-run-valid `present_review_set` → approval → `acceptReviewSet` graph commit. - **Objective:** Wire the `project-graph` strategy from real agent proposal generation through `present_review_set` / `request_review`, dry-run gating, approve/request-changes/reject response handling, and atomic `acceptReviewSet` commit. - **Why now / unlocks:** This is the P1 proposal/review story. It is only P0 if the POC demo requires user-reviewed batch graph commitments rather than direct `propose-graph` and capture paths. @@ -154,11 +75,11 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple - Request-changes and reject are transcript-visible outcomes; request-changes can trigger a successor proposal or an explicit deferred path. - Web/TUI can observe the proposal/decision state enough for the POC; full review UX polish may remain thin. - **Verification:** Inner — review-set schema tests, dry-run/real-run differential tests, accept atomicity tests. Middle — structured-exchange review-cycle fixture; no-bypass checks. Outer — targeted probe: `project-graph` proposes, user approves, graph updates and web observer sees it. -- **Topology materialization:** Review payload schemas/renderers live under `.pi/extensions/structured-exchange` or `.pi/extensions/graph` only as adapter surfaces; proposal validation/translation lives in `graph/` review modules; agent strategy resource lives in `agents/strategies/project-graph.md`; web observes via RPC projections. +- **Topology materialization:** Review payload schemas live under `.pi/extensions/exchanges` as the current structured-exchange schema seam; reusable review payload construction/rendering lives under `projections/structured-exchange/` and `renderers/structured-exchange/`; proposal validation/translation lives in `graph/` review modules; agent strategy resource lives in `.pi/skills/strategies/project-graph.md`; web observes via RPC projections. - **Cross-cutting obligations:** Preserve D27-L: review-set proposal is a structured-exchange payload, not a standalone public review-set entity. Reviewer advisory writes remain deferred unless explicitly scoped. Existing-node references and review payloads use projected graph codes at adapter/UI boundaries, not raw DB ids. -- **Traceability:** R21, R23 / D4-L, D20-L, D26-L, D27-L, D51-L, D53-L, D62-L, D63-L / I11-L, I34-L, I40-L / A14-L, A16-L. +- **Traceability:** R21, R23 / D4-L, D20-L, D26-L, D27-L, D51-L, D53-L, D62-L, D63-L / I11-L, I15-L, I20-L, I34-L, I40-L / A14-L, A16-L. - **Design docs:** `docs/design/REVIEW_SETS.md`; `docs/design/GRAPH_MODEL.md`; `memory/SPEC.md` D27-L. -- **Current execution pointer:** Active; first FE-809 scope card pending. +- **Current execution pointer:** Done 2026-06-06. Structured-exchange schema/emission lock and approval wiring are complete, and `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/` proves the real `project-graph` agent path: selected-spec graph read, dry-run-gated `present_review_set`, public-RPC approval through `session.submitExchangeResponse`, one explicit-basis `acceptReviewSet` graph commit, and graph invalidations with `{specId, lsn}`. The probe also fixed a real policy gap: commitment-grade `generate-proposal` now activates `present_review_set` / `request_review` for the Brunch runtime tool posture. ### minimal-authority-shell @@ -177,7 +98,7 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple - Any human-only action encountered by current POC paths returns structured `needs_human` in headless/RPC rather than throwing a TUI-only dialog assumption. - No new standalone authority service is introduced. - **Verification:** Inner — policy/result-shape tests for touched actions. Middle — small authority matrix over current POC paths (agent graph tool, capture write, review approve if present, RPC/headless selection). Outer — manual smoke only if a TUI-visible policy path changes. -- **Topology materialization:** Policy lives in `graph/policy` and `.pi/extensions/operational-mode.ts` / command-policy adapters as appropriate; no caller-side policy snippets in `web/`, `rpc/`, or agent resources. +- **Topology materialization:** Policy lives in `graph/policy` and `.pi/extensions/runtime/` / command-policy adapters as appropriate; no caller-side policy snippets in `web/`, `rpc/`, or agent resources. - **Cross-cutting obligations:** This is a minimal shell, not full M6. Do not widen into comprehensive RBAC/permissions unless a current POC path needs it. - **Traceability:** R5, R6, R10 / D20-L, D34-L, D40-L / A18-L, A3-L. - **Design docs:** `memory/SPEC.md` D20-L/D34-L/D40-L; `docs/reference/pi-extensions.md`. @@ -206,6 +127,7 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple - **Cross-cutting obligations:** Keep the gate small and real. Do not turn it into a generic e2e framework or use it to backfill unrelated polish. - **Traceability:** R4, R7, R10, R11, R12, R16, R19, R24, R28 / D5-L, D11-L, D19-L, D21-L, D33-L, D36-L, D52-L, D61-L, D62-L, D63-L, D64-L / I22-L, I32-L, I35-L, I38-L, I39-L, I40-L / A5-L. - **Design docs:** `docs/architecture/probes-and-transcripts.md`; `docs/architecture/pi-ui-extension-patterns.md`; `memory/SPEC.md` verification stance. +- **Current execution pointer:** A prepared live-mention autocomplete scope exists at `memory/cards/poc-live-ship-gate--live-mention-autocomplete.md`; it is a narrow product-path defect slice inside the ship-gate frontier, not M7 mention-ledger work. ### probes-and-transcripts-evolution @@ -227,14 +149,14 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple - **Linear:** unassigned - **Kind:** hardening - **Status:** parallel / attach-to-frontier -- **Objective:** Keep the D52-L source topology legible as delivery work moves files: update local READMEs, add no-bypass/import-boundary checks where a new seam appears, and remove retired compatibility paths. -- **Why now / unlocks:** The topology is itself a delivery asset: future agents and humans need to know where product behavior lives without rediscovering old `src/.pi/context` or root-level scattering. -- **Acceptance:** When a frontier materially changes `src/{.pi, agents, db, graph, session, rpc, web}`, its README/boundary tests reflect the new responsibility split; stale paths are deleted rather than aliased unless the current slice truly needs a transition. +- **Objective:** Keep the D52-L source topology legible as delivery work moves files: update local READMEs, add no-bypass/import-boundary checks where a new seam appears, and remove retired compatibility paths. The adapter/domain-local `project` / `format` helper migration has landed under top-level `projections/` and `renderers/`; future hardening should preserve those as narrow boundary layers rather than vague utility buckets. +- **Why now / unlocks:** The topology is itself a delivery asset: future agents and humans need to know where product behavior lives without rediscovering old `src/.pi/context`, root-level entrypoint scattering, or Pi-extension-owned projection/formatting helpers. +- **Acceptance:** When a frontier materially changes `src/{app, workspace, scripts, .pi, db, graph, session, projections, renderers, rpc, web}`, its README/boundary tests reflect the responsibility split; stale paths are deleted rather than aliased unless the current slice truly needs a transition. - **Verification:** File-scoped documentation review and existing no-bypass/import-boundary tests; add grep/architecture tests only where they protect a real seam. -- **Topology materialization:** This frontier should usually be implemented as part of the frontier that caused the topology change; keep it separate only for doc/test-only hardening with low conflict. +- **Topology materialization:** This frontier should usually be implemented as part of the frontier that caused the topology change; keep it separate only for doc/test-only hardening with low conflict. Completed 2026-06-06: root entrypoints moved to `app/`/`workspace/`/`scripts/`, reusable projection/rendering helpers moved to `projections/`/`renderers/`, and D40-L runtime-state policy now uses shared projected policy while `.pi` remains the adapter. - **Cross-cutting obligations:** Do not create speculative folders. A directory earns existence by carrying present code/resources or by making an already-used seam legible. - **Traceability:** D52-L, D39-L, D4-L. -- **Design docs:** `src/README.md`; `src/.pi/README.md`; `src/agents/README.md`; `src/db/README.md`; `src/graph/README.md`; `src/rpc/README.md`; `src/session/README.md`; `src/web/README.md`. +- **Design docs:** `src/README.md`; `src/.pi/README.md`; `src/.pi/agents/README.md`; `src/.pi/skills/README.md`; `src/.pi/extensions/README.md`; `src/db/README.md`; `src/graph/README.md`; `src/projections/README.md`; `src/renderers/README.md`; `src/rpc/README.md`; `src/session/README.md`; `src/web/README.md`. ### dev-seed-fixtures @@ -246,27 +168,29 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple - **Why now / unlocks:** Delivery frontiers (`capture-response-to-graph`, the live-graph observer follow-on, `poc-live-ship-gate`) need real multi-spec graph data to exercise UI/agent/observer behavior without hand-authoring. The Bilal port already provides three loadable specs; enhancing them surfaces under-represented planes/kinds (notably `thesis`/`goal`) for richer capture and observer demos. - **Acceptance:** - Seed contract stays loadable: each set's port script self-validates every `.json` through the real loader (same structural checks `commitGraph` enforces) before writing. - - `npm run seed` loads every `.fixtures/seeds//.json` into the workspace DB through `CommandExecutor` (never direct row inserts), preserving graph clock / change log / lsn coherence. + - `npm run seed` loads every `.fixtures/seeds//.json` into the workspace DB through `CommandExecutor` (never direct row inserts), preserving spec-local graph clock / change log / LSN coherence. - New seed sets follow the established shape: vendored `_originals/`, throwaway `_port-script.ts`, consolidated `.json`, generated `README.md`; derived variant sets may instead document the deterministic filter over an existing seed set and keep mixed-basis product-run output under `.fixtures/runs/`. - Product curation runs over seeds leave transcript-backed artifacts (`session.jsonl`, `transcript.md`, `report.json`, and graph readback when graph truth is the proof target) and prove real `commit_graph` transcript evidence plus implicit graph rows; mixed-basis snapshots are not registered as reusable seeds. - **Enhancement backlog (captured, not yet scoped):** 1. Enhance Bilal-port fixtures *through Brunch itself* by feeding the original briefs Bilal authored, to recover `thesis`/`goal` structure the current ported graphs under-express. 2. Port and enhance the earlier product version's fixtures (the legacy walkthrough scenarios in `docs/praxis/manual-testing.md`), raising quality through better semantic definition (kinds, detail) and internal connection (edges). -- **Verification:** Inner — `src/graph/seed-fixtures.test.ts` seeds a real fixture into an in-memory DB and asserts spec/node/edge counts plus change-log/clock coherence, 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/`. +- **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:** No active scope file. Product-driven fixture-curation tracer landed: `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. Next `dev-seed-fixtures` scope may review curation fitness and decide whether variants need smaller prompts, richer base profiles, or a reusable mixed-basis export step. -- **Traceability:** D4-L, D19-L, D20-L, D52-L, D61-L, D62-L, D63-L / A14-L. +- **Current execution pointer:** Active semantic-mutation curation scope exists at `memory/cards/dev-seed-fixtures--semantic-graph-mutations.md`; it is not parallel-safe with FE-809 graph/review work on the same worktree because it touches `CommandExecutor` and review-set graph code. Product-driven fixture-curation tracer evidence remains the quality-review input: `macro-view-grounded-intent` is a deterministic explicit-basis Bilal variant, and `.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/` proves one real `propose-graph`/`commit_graph` run created implicit intent nodes from that base. +- **Traceability:** D4-L, D16-L, D19-L, D20-L, D52-L, D61-L, D62-L, D63-L / I1-L / A4-L, A14-L. - **Design docs:** `.fixtures/seeds/bilal-port/README.md`; `docs/design/GRAPH_MODEL.md`; `docs/praxis/manual-testing.md`. ## Recently Completed +- 2026-06-06 `project-graph-review-cycle` (FE-809) — Done: `project-graph` now has active review tools at commitment readiness, real agent proposal generation reaches `present_review_set`, approval goes through public `session.submitExchangeResponse`, `CommandExecutor.acceptReviewSet` commits the exact reviewed batch with `basis: explicit`, and graph/session invalidations publish with `{specId, lsn}`. Verified: `src/.pi/agents/state.test.ts`, `src/.pi/__tests__/prompting.test.ts`, `src/probes/project-graph-review-cycle-proof.test.ts`, and real run `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/`. + +- 2026-06-06 `topology-readmes-and-boundaries` — Done: root product entrypoints moved to `app/`/`workspace/`/`scripts`; reusable graph/session/structured-exchange/workspace projection helpers moved to `projections/`; reusable markdown/text renderers moved to `renderers/`; `src/projections/topology-boundaries.test.ts` now guards the projection/renderer adapter boundary; and D40-L runtime-state policy now shares `elicit-read-only` tool-policy definitions from `projections/session/runtime-policy.ts` while `.pi/extensions/runtime` remains the Pi tool adapter. Verified: targeted topology/runtime tests and `npm run verify`. + - 2026-06-05 `capture-response-to-graph` (FE-807) — Done: synchronous response-capture tracer. Added a narrow labeled-text translator for `Goal:`, `Context:`, `Constraint:`, and `Criterion:` facts; wired public `session.submitExchangeResponse` to capture through the transcript binding's spec and `CommandExecutor.commitGraph({basis: explicit})`; returned loud capture outcomes; published graph invalidations; and added a public-RPC proof that activation/trigger/submit/overview exposes captured projected codes. Verified: `src/graph/capture/structured-response.test.ts`, `src/rpc/handlers.test.ts`, `src/probes/capture-response-to-graph-proof.test.ts`. - 2026-06-05 `dev-seed-fixtures` — Done: first product-driven fixture curation tracer. Added deterministic `bilal-port-variants/macro-view-grounded-intent` explicit-only intent base, a `fixture-curation` probe runner/report summarizer, and run artifacts proving `gpt-5.5` used real `read_graph`/`commit_graph` product tools to persist two implicit requirement nodes plus six implicit edges through `CommandExecutor`. Verified: `src/probes/fixture-curation-loop.test.ts`, `src/graph/seed-fixtures.test.ts`, real run `.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/`. -- 2026-06-04 `graph-tool-resilience` (FE-808) — Done: graph nodes persist per-kind ordinals and expose projected codes; `commitGraph` applies one explicit/implicit batch basis, returns one created-node identity shape, plans once inside the transaction before LSN allocation/writes, and shares dry-run/commit structural validation; adapters resolve selected-spec existing-node codes into structured diagnostics without sentinel endpoint refs or thrown errors; single-node `createNode` rejects retired basis values before LSN/counter/node/change-log allocation; same-spec supersession cycles are rejected atomically; active-context graph reads omit hidden superseded nodes and dangling edges while graph-truth reads remain available; product-path probes landed existing-code, retry-diagnostics, and ambiguity/no-overcommit evidence under `.fixtures/runs/propose-graph-commit/`. - -Older history (including `agents-composition-layer`, `live-graph-observer`, `agent-graph-integration`, `spec-persistence-and-startup`, `sealed-pi-profile-runtime-state`, `pi-ui-extension-patterns`, `web-shell`, `jsonl-session-viability`, `mode-shell-and-fixture-driver`, `walking-skeleton`): `docs/archive/PLAN_HISTORY.md` +Older history (including `graph-tool-resilience`, spec-scoped graph-clock hardening, `agents-composition-layer`, `live-graph-observer`, `agent-graph-integration`, `spec-persistence-and-startup`, `sealed-pi-profile-runtime-state`, `pi-ui-extension-patterns`, `web-shell`, `jsonl-session-viability`, `mode-shell-and-fixture-driver`, `walking-skeleton`): `docs/archive/PLAN_HISTORY.md` ## Dependencies @@ -274,9 +198,9 @@ Older history (including `agents-composition-layer`, `live-graph-observer`, `age nodes: graph-tool-resilience [done · P0] materialized graph write contract and broadened A14 proof capture-response-to-graph [done · P0] structured answer -> graph truth -> observer update - project-graph-review-cycle [active · P1] real project-graph review-set approval loop + project-graph-review-cycle [done · P1] real project-graph review-set approval loop minimal-authority-shell [next · P1] thin safety posture for current POC paths - poc-live-ship-gate [next · P1] final fresh-cwd composed product runbook + poc-live-ship-gate [active · P1] final fresh-cwd composed product runbook 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 @@ -306,7 +230,7 @@ horizon: notes: - 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`. - - `project-graph-review-cycle` is P1 unless the POC demo narrative requires batch proposal/review as a central story; promote it to P0 if so. + - `project-graph-review-cycle` is complete evidence for the optional batch proposal/review story; keep future review-quality work as follow-up, not FE-809 completion debt. - `topology-readmes-and-boundaries` is not a license for abstract cleanup; it rides with concrete delivery seams. - Multi-spec workspace discipline applies throughout: target the selected/current spec explicitly; no workspace-global graph truth in the POC. ``` diff --git a/memory/SPEC.md b/memory/SPEC.md index 51af90f99..4c97ac750 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -101,11 +101,11 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | --- | --- | --- | --- | --- | --- | | A1-L | `pi-coding-agent` exposes enough seams (services, custom message roles, `prepareNextTurn`, `transformContext`, RPC mode, JSONL sessions, extension UI surface) to host all M0–M9 capabilities without forking pi. | high | open | D1-L | M0–M2: walking skeleton + mode shell + JSONL viability prove the substrate. | | A3-L | A single Brunch-owned command layer (with optimistic concurrency, validation, audit, and coherence triggers) is sufficient for both agent and human writers across all four modes for the POC's graph scale. | medium | open | D4-L | M4 + M5 + M6: graph plane, agent-↔-graph wiring, and authority tiers all routed through the same surface. | -| A4-L | A monotonic global LSN per commit (one-LSN-per-transaction) is adequate for change-log replay, reconciliation-need ordering, and mention staleness without per-row vector clocks. | high | open | I1-L, I4-L | M4 + M7: replay fidelity and `worldUpdate` ordering tests. | +| A4-L | A monotonic **spec-local** LSN per selected-spec commit is adequate for selected-spec change-log replay, reconciliation-need ordering, and mention/world-update staleness without a workspace-global audit clock or per-row vector clocks. | high | partially validated | I1-L, I4-L | 2026-06-05 spec-scoped graph-clock slices proved `createSpec`, `createNode`, readiness-grade, `commitGraph`, reconciliation-need, graph overview, RPC invalidation, prompt context, seed-fixture, and legacy-migration paths use `{specId, lsn}` watermarks with exactly one clock row per persisted spec. M7 still needs generated `worldUpdate` traces. | | A5-L | Agent-as-user probes over the public Brunch RPC surface can produce regression-quality transcript artifacts without depending on a parallel brief-library subsystem. | medium | partially validated | D5-L, D48-L, D49-L | FE-744 public-RPC parity proves the deterministic transport/projection substrate for current structured-exchange permutations; future brief-based or generative golden-fixture work must enter through the probe/transcript artifact path. | | A6-L | The graph-native vocabulary can be deferred from explicit per-plane namespacing (`intent.*`, `oracle.*`, etc.) and start unified under `graph.*` without painful rework later. | medium | open | D3-L | M4–M5: if intent-plane plus oracle-plane stubs both fit under one namespace cleanly, the assumption holds. | | A7-L | ~~`framing_as` as an orthogonal modality on existing node kinds is sufficient for product-intent ontology.~~ | — | **retired** | D7-L, D54-L, D56-L | Validated and retired by Phase 2 node lock: `framing_as` is absorbed by first-class `thesis`, `term`, and `constraint` kinds plus `goal`. The modality, allowed matrix (I7-L), and "promote on relation-policy pressure" escape hatch are all retired. See [`docs/design/GRAPH_MODEL.md` §framing_as — retired](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#framing_as--retired). | -| A8-L | One reconciliation-need substrate, sharing the same global LSN as the change log, can absorb impasses, conflicts, gaps, and process debt without needing finer kind subtypes in the POC. | medium | open | D8-L | M8 + adversarial fixtures ("contradictory requirements") exercise the substrate; subtype split deferred per Open Question #10. | +| A8-L | One reconciliation-need substrate, sharing the same **spec-local** LSN as that spec's change log, can absorb impasses, conflicts, gaps, and process debt without needing finer kind subtypes in the POC. | medium | open | D8-L | M8 + adversarial fixtures ("contradictory requirements") exercise the substrate; subtype split deferred per Open Question #10. | | A9-L | A session-scoped mention ledger of (`entity_id`, `snapshotted_lsn`) is the right granularity for staleness hints; transcript-scoped or graph-scoped ledgers are not needed for the POC. | low | open | I7-L | M7 — turn-boundary reconciliation slice; observed via fixture runs that stress re-read decisions. | | A11-L | Pi's `prepareNextTurn` plus custom-message delivery are sufficient to express side-task result delivery without inventing a second event plane or forking pi. | medium | open | D15-L | M5 + M7: side-task registry wiring and next-turn delivery proof. | | A13-L | If Brunch later adds deferred observer/auditor jobs, a durable queue keyed by session id and session-exchange entry range can recover async audit/backfill after process interruption without reintroducing canonical chat/turn tables; whether this shares storage with a generalized work-item/reconciliation table can be deferred. | medium | open | D18-L, I14-L | Deferred until async audit/backfill lands: restart/idempotence tests exercise exchange-keyed jobs once graph writes exist. | @@ -125,18 +125,18 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. -- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture routes TUI launch policy through `src/brunch-pi-profile.ts`, creates an in-memory Brunch-owned `SettingsManager` policy instead of reading ambient global/project `.pi/settings.json`, disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell, and defaults Brunch-launched Pi to offline mode; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/.pi/pi-extension-shell.ts`) rather than ambient discovery; *explicit* means the shell statically imports its product extensions and registers them from a fixed ordered list — it must not filesystem-discover or dynamically `import()` extension modules at runtime, because a Brunch-internal discovery layer is itself the discovery this decision rejects. Each product extension exposes one registrar taking explicit dependencies, and the shell wires those dependencies at the call site; the `default` exports under `src/.pi/extensions/*` exist only for dev `/reload` iteration, not as a product load path. Product extension modules live under `src/.pi/extensions/*`, and reusable Pi TUI components live under `src/.pi/components/*`, so they can also be iterated by launching Pi from `src/` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under auto-discovered `.pi/extensions` or `.pi/components` resource directories; extension/component tests live under `src/.pi/__tests__/`. The profile boundary now owns the audited behavior-shaping settings list in code (`BRUNCH_SETTINGS_POLICY` / `BRUNCH_SETTINGS_AUDITED_GETTERS`), with hostile ambient settings and reload-resilience tests covering shell path/prefix, npm command, ambient resources, skill commands, double-escape behavior, compaction/retry, image/terminal/UI, transport/theme/changelog, and telemetry settings. Remaining profile work is runtime-state/prompt/tool posture, not ambient settings file leakage. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, nesting Brunch's product extension modules under `src/.pi/extensions/brunch/`, or replacing the explicit static shell list with a Brunch-internal filesystem-discovery / `brunchExtensionMeta` / `loadOrder` mechanism as the product runtime load path. - - Tooling exception: root `.pi/extensions/worktree/index.ts` is a project-local developer convenience for direct Pi sessions only. It is not a Brunch product extension, is not imported by `src/.pi/pi-extension-shell.ts`, and does not weaken the sealed Brunch Pi Profile; Brunch-launched product sessions continue to disable ambient `.pi/` discovery unless deliberately imported. The extension may register direct-Pi `/worktree:switch` / `switch_worktree` and `/worktree:create` / `create_worktree` affordances: switching preserves the old session file by default, creation uses the caller cwd's committed `HEAD` and a sibling `-` branch/path, dirty worktrees warn that uncommitted changes are excluded, and generated worktrees are never auto-deleted or pruned. -- **D40-L — Runtime state is transcript-backed Brunch session-agent state, not hidden extension memory.** `src/session/runtime-state.ts` owns the pure projection. It reconstructs agent posture from linear `brunch.agent_runtime_state` entries (`reason: "init" | "switch"`), last-writer-wins at turn preparation and over `session.runtimeState`; default/empty slots are explicit when no entry family exists. Its axes are `op_mode` (`elicit`, future `execute`) plus optional, AUTO-able objective axes `strategy`, `lens`, and `goal` (D25-L, D59-L). `session.runtimeState` also exposes shaped mention slots, world-update watermarks (latest graph LSN and optional git head, without raw transcript detail bags), and lifecycle facts when transcript-backed entries make them computable; this is a projection contract, not a mutable state table. The **foreground session agent** (`elicitor` now, future `executor`) is *derived* from `op_mode`, not stored; the other agent roles (`reviewer`, `reconciler`, future `scout`/`researcher`) are async sub-agent/side-chain workers (D29-L, D44-L) invoked out-of-band, never part of the session state machine. `op_mode` gates tool authority, applied by `src/.pi/extensions/operational-mode.ts` (current `elicit` policy denies side-effecting `bash`/`edit`/`write` plus user-shell interception) while `.pi` reuses session-owned state definitions. Prompt composition is a separate concern (D58-L). Depends on: D17-L, D23-L, D25-L, D39-L, D58-L, D59-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority, storing the foreground role as independent session state, the "runtime bundle / role preset" as one knob deriving model/thinking/resources, and binding prompt-resource location to `src/.pi/context/`. -- **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/.pi/extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. -- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. -- **D52-L — Source topology is `src/{.pi, agents, db, graph, session, rpc, web}` with directed layer dependencies.** `graph/` is the domain layer: CommandExecutor, readers, policy, validators, snapshot bucketing, change-log replay, reconciliation-need substrate; it imports from `db/` (Drizzle schema, migrations, connection lifecycle) and no other layer imports `db/` directly. `session/` owns transcript projection, exchange extraction, workspace coordination, session binding, runtime-state projection, and LSN staleness tracking over Pi JSONL. `agents/` is organized by registry/resource family (`definitions/`, `goals/`, `strategies/`, `lenses/`, `methods/`, `contexts/`) and imports snapshot/state projection functions from `graph/` and `session/`; it owns prompt composition, context building, prompt resources, and future agent registry definitions that drive `op_mode`/goal/strategy/lens selection. `.pi/extensions/` houses Pi adapter registrars (agent tools, TUI commands, TUI enhancements) and may re-export/use session-owned runtime-state helpers; `.pi/components/` houses reusable TUI components. `rpc/` owns Brunch JSON-RPC handlers. `web/` owns the React client. Dependency direction: `.pi/extensions/` and `rpc/` may import from `graph/`, `session/`, and `agents/`; `agents/` imports from `graph/` and `session/`; `graph/` imports from `db/`; `web/` is a standalone build target. Depends on: D2-L, D4-L, D39-L, D40-L. Supersedes: scattering session domain files at `src/` root; nesting prompt composition exclusively under `src/.pi/context/`. +- **D39-L — Brunch owns sealed Pi settings plus an explicit Brunch extension bundle around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. `src/.pi/brunch-pi-settings.ts` owns settings policy, resource-loader policy, and offline defaults: it creates an in-memory Brunch-owned `SettingsManager` policy instead of reading ambient global/project `.pi/settings.json`, disables ambient context files, extensions, prompt templates, skills, and themes, and defaults Brunch-launched Pi to offline mode; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. `src/.pi/brunch-pi-extensions.ts` owns the explicit Brunch extension factory: it statically imports product extension registrars and registers them from a fixed ordered list rather than ambient discovery. That explicit list must not filesystem-discover or dynamically `import()` extension modules at runtime, because a Brunch-internal discovery layer is itself the discovery this decision rejects. Each product extension exposes one registrar taking explicit dependencies, and the extension bundle wires those dependencies at the call site; the `default` exports under `src/.pi/extensions/*` exist only for dev `/reload` iteration, not as a product load path. Product extension modules live under `src/.pi/extensions/*`, and reusable Pi TUI components live under `src/.pi/components/*`, so they can also be iterated by launching Pi from `src/` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under auto-discovered `.pi/extensions` or `.pi/components` resource directories; extension/component tests live under `src/.pi/__tests__/`. The settings boundary owns the audited behavior-shaping settings list in code (`BRUNCH_SETTINGS_POLICY` / `BRUNCH_SETTINGS_AUDITED_GETTERS`), with hostile ambient settings and reload-resilience tests covering shell path/prefix, npm command, ambient resources, skill commands, double-escape behavior, compaction/retry, image/terminal/UI, transport/theme/changelog, and telemetry settings. Remaining sealed-Pi work is runtime-state/prompt/tool posture, not ambient settings file leakage. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, nesting Brunch's product extension modules under `src/.pi/extensions/brunch/`, or replacing the explicit static extension list with a Brunch-internal filesystem-discovery / `brunchExtensionMeta` / `loadOrder` mechanism as the product runtime load path. + - Tooling exception: the worktree helper extension now lives outside this repository under the user Pi agent tree (`~/.pi/agent/extensions/worktree/index.ts`) for direct Pi sessions only. It is not a Brunch product extension, is not imported by `src/.pi/brunch-pi-extensions.ts`, and does not weaken the sealed Brunch Pi settings/extensions boundary; Brunch-launched product sessions continue to disable ambient `.pi/` discovery unless deliberately imported. The extension may register direct-Pi `/worktree:switch` / `switch_worktree` and `/worktree:create` / `create_worktree` affordances, but Brunch does not test, package, or document it as a product extension. +- **D40-L — Runtime state is transcript-backed Brunch session-agent state, not hidden extension memory.** `src/session/runtime-state.ts` owns the transcript entry facts (`brunch.agent_runtime_state` schema, parser, and init/switch append helpers); `src/projections/session/runtime-state.ts` owns the pure reusable projection and `src/projections/session/runtime-policy.ts` owns operational-mode/role policy definitions. The projection reconstructs agent posture from linear `brunch.agent_runtime_state` entries (`reason: "init" | "switch"`), last-writer-wins at turn preparation and over `session.runtimeState`; default/empty slots are explicit when no entry family exists. Runtime-state entries are Pi JSONL state-change facts, not assistant/user chat content: init and switch entries should render, when visible, as dim non-chat state rows analogous to Pi thinking/model-change rows, and must not enter LLM context as ordinary conversation. Its axes are `op_mode` (`elicit`, future `execute`) plus optional, AUTO-able objective axes `strategy`, `lens`, and `goal` (D25-L, D59-L). `session.runtimeState` also exposes shaped mention slots, world-update watermarks (latest graph LSN and optional git head, without raw transcript detail bags), and lifecycle facts when transcript-backed entries make them computable; this is a projection contract, not a mutable state table. The **foreground session agent** (`elicitor` now, future `executor`) is *derived* from `op_mode`, not stored; the other agent roles (`reviewer`, `reconciler`, future `scout`/`researcher`) are async sub-agent/side-chain workers (D29-L, D44-L) invoked out-of-band, never part of the session state machine. `op_mode` gates tool authority, applied by `src/.pi/extensions/runtime/index.ts` (current `elicit` policy denies side-effecting `bash`/`edit`/`write` plus user-shell interception) while `.pi` reuses session-owned entry definitions and projected policy. Prompt composition is a separate concern (D58-L). Depends on: D17-L, D23-L, D25-L, D39-L, D58-L, D59-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority, storing the foreground role as independent session state, the "runtime bundle / role preset" as one knob deriving model/thinking/resources, and binding prompt-resource location to `src/.pi/context/`. +- **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/.pi/extensions/commands/policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. +- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the discovered project name, selected spec, and real activated session id/label, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome and startup dialog are project-first shell surfaces with selected-spec context: the project name labels the cwd container, the spec title labels the selected graph, and the session label distinguishes transcript instances. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are product projections over Pi session metadata: every Brunch-created session should immediately receive a neutral workspace-global `Untitled Session N` `session_info` label, and later user/generated names may characterize the transcript without replacing spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, consuming the status-key namespace for chrome's own static summary, using spec title as the default session label, or allowing two unchanged Brunch-created default names to collide in one cwd. +- **D52-L — Source topology targets `src/{app, workspace, scripts, .pi, db, graph, session, projections, renderers, rpc, web}` with directed layer dependencies.** Product entrypoints live under `src/app/`, local executable utility ownership is reserved under `src/scripts/`, package/workspace identity tests live under `src/workspace/`, and reusable projection/rendering modules live under top-level `src/projections/` and `src/renderers/` rather than whichever domain or adapter first needed them. `app/` owns product host entrypoints and wiring. `workspace/` owns cwd/package/workspace identity helpers. `scripts/` owns local executable utilities. `.pi/` is the sealed Pi-harness runtime surface: `agents/` owns runtime prompt assembly, role definitions, legal resource manifests, and agent-context orchestration; `skills/` owns goal/strategy/lens/method markdown resources read on demand; `components/` owns reusable Pi TUI/message components; `extensions/` owns Pi registrars for tools, hooks, commands, chrome, context tools, system-prompt append, exchanges, graph tools, workspace dialogs, runtime policy, and session lifecycle. `graph/` is the domain layer: CommandExecutor, readers, policy, validators, snapshot bucketing, change-log replay, reconciliation-need substrate; it imports from `db/` (Drizzle schema, migrations, connection lifecycle) and no other layer imports `db/` directly. `session/` owns transcript projection, exchange extraction, workspace coordination, session binding, runtime-state transcript entries, and LSN staleness tracking over Pi JSONL. `projections/` owns structured DTOs derived from graph/session/workspace/tool facts; it must not render lossy text and must not import adapters, transports, app entrypoints, or web code. `renderers/` owns lossy text/markdown/toon/tool-content rendering over domain or projection inputs; it may import input types from `graph/`, `session/`, or `projections/` as needed, but must not import adapters, transports, app entrypoints, or web code. `rpc/` owns Brunch JSON-RPC handlers. `web/` owns the React client. Dependency direction: `.pi/`, `rpc/`, and `app/` may import from `graph/`, `session/`, `projections/`, and `renderers/`; `.pi/agents/` may import from `graph/`, `session/`, `projections/`, and `renderers/` to build agent context; `.pi/extensions/` may import from `.pi/agents/` and `.pi/components/`; `projections/` may import from `graph/`, `session/`, and `workspace/`; `renderers/` may import from `projections/`, `graph/`, and `session/`; `graph/` imports from `db/`; `web/` is a standalone build target. Depends on: D2-L, D4-L, D39-L, D40-L. Supersedes: scattering session domain files at `src/` root; treating Pi-only agents as a host-independent top-level `src/.pi/` layer; nesting prompt composition under `src/.pi/context/`; treating reusable `project` / `format` helpers as owned by whichever adapter first needed them. #### Data model & vocabulary - **D3-L — Graph-native, session-native vocabulary; no generic `records.*` surface.** Commands converge on `graph.*` / `session.*` (with per-plane families `intent.*`, `oracle.*`, `design.*`, `plan.*` available when sharper semantics are useful). Depends on: A6-L. Supersedes: —. - **D7-L — ~~`framing_as` modality, not first-class kinds.~~ Retired.** `framing_as` is absorbed by first-class `thesis`, `term`, `constraint`, and `goal` kinds per the Phase 2 node lock. No node carries a `framing_as` field. See [`docs/design/GRAPH_MODEL.md` §framing_as — retired](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#framing_as--retired). Depends on: A7-L (retired). Superseded by: D54-L, D56-L. -- **D8-L — Reconciliation needs are a first-class substrate alongside graph truth, change log, and a bounded coherence verdict.** Needs (impasses, gaps, contradictions, process debt) share the same global LSN as the change log and follow the same mutation invariant. Per [`docs/design/GRAPH_MODEL.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#reconciliationneed--separate-substrate-not-a-graph-edge), each need targets exactly one of `{kind: 'edge', edgeId}` or `{kind: 'node_pair', aId, bId}` and is not itself a graph edge. For the POC, coherence is not an unbounded aesthetic or philosophical judgment; it is the product-visible verdict produced from structural legality plus surfaced contradictions/gaps/unresolved needs, with the exact rubric still open under A21-L until M8. Depends on: A8-L, A21-L. Refined by: D51-L. Supersedes: any `concerns`-edge wiring from reconciliation needs to graph nodes. +- **D8-L — Reconciliation needs are a first-class substrate alongside graph truth, change log, and a bounded coherence verdict.** Needs (impasses, gaps, contradictions, process debt) share the same spec-local LSN as their owning spec's change log and follow the same mutation invariant. Per [`docs/design/GRAPH_MODEL.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#reconciliationneed--separate-substrate-not-a-graph-edge), each need targets exactly one of `{kind: 'edge', edgeId}` or `{kind: 'node_pair', aId, bId}` and is not itself a graph edge. For the POC, coherence is not an unbounded aesthetic or philosophical judgment; it is the product-visible verdict produced from structural legality plus surfaced contradictions/gaps/unresolved needs, with the exact rubric still open under A21-L until M8 and the subtype split deferred per A8-L. Depends on: A8-L, A21-L. Refined by: D51-L. Supersedes: any `concerns`-edge wiring from reconciliation needs to graph nodes. - **D9-L — Reasoning records split by shape.** `decision` is graph-native; `impasse` is a reconciliation need, not a graph node; `justification` stays compact (rendered text on the decision) until forced otherwise. Phase 2 (per `docs/design/GRAPH_MODEL.md`) keeps `decision` as a plain node rather than a hyper-edge / hub-node for the POC. Depends on: D8-L. Supersedes: —. - **D54-L — Graph node shape is a common flat interface with `kind_ordinal`, `title`, `body`, `basis`, `source`, and a per-kind `detail` JSON column; canonical contract is [`docs/design/GRAPH_MODEL.md` §GraphNode](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#graphnode--the-single-shape).** All planes and kinds share one `nodes` table. `id` is the internal SQLite integer/FK identity; `kind_ordinal` is the monotonic per-`(spec, plane, kind)` ordinal used with `kind` to project a stable human reference code (D62-L). The rendered code string is not stored in the database. `plane` determines which closed `kind` enum applies; `kind` is structurally validated. `basis ∈ explicit | implicit` records item-level approval strength per D63-L. `source` is a free-form string for epistemic attribution (e.g. "stakeholder", "regulatory", "derived", "agent synthesis") — convention by prompt, not structural validation; it exists for context-snapshot enrichment and will be rendered back into sparse text, not used for policy or filtering. `detail` is an optional JSON column with per-kind validated sub-structures: `decision` requires `{ chosen_option, rejected, rationale }`, `term` requires `{ definition, aliases? }`; all other kinds must omit `detail`. `provenance` is retired from the node shape — `change_log` at `createdAtLsn` owns the audit trail, while `basis` and `source` carry only local interpretation fields. The intent kind rubric (modality of claim + source question per kind) is agent-facing prompting guidance in GRAPH_MODEL.md §"Prompting guidance for kind discrimination", not structural enforcement. Depends on: D4-L, D16-L, D52-L, D56-L, D62-L, D63-L. Supersedes: D7-L (`framing_as` modality), the deferred Phase 2 node placeholder in prior GRAPH_MODEL.md. - **D55-L — `provenance` retired from both edges and nodes; `change_log` owns audit trail and mutation path.** Transcript entry pointers (`sessionId`, `entryId`, `proposalEntryId`) are fragile under compaction and redundant with `change_log` keyed by `createdAtLsn` / `updatedAtLsn`. `basis` does **not** encode the transport or strategy path; per D63-L it records whether the exact graph item was user-approved (`explicit`) or agent-materialized after concept-level approval (`implicit`). `change_log.operation` and payload record the durable mutation context (`create_node`, `commit_graph`, `accept_review_set`, etc.). Edges retain `basis` and `rationale`; nodes retain `basis` and `source` (epistemic attribution). Depends on: D16-L, D51-L, D54-L, D63-L. Supersedes: `EdgeProvenance` from Phase 1 edge lock, the planned node-side `provenance` symmetry with edges, and the former `accepted_review_set` basis-as-path enum. @@ -210,16 +210,16 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D6-L — JSONL-first transcript persistence in `.brunch/sessions/`; SQLite-backed graph persistence in `.brunch/`.** Two durability surfaces with distinct responsibilities. Transcript starts on pi `SessionManager` redirected to the project-local directory; graph plane is SQLite from M4. Brunch does not recreate canonical `chat` or `turn` tables while Pi JSONL remains viable for Brunch-supported linear sessions. Validated by M2. Supersedes: —. - **D15-L — Side tasks are a first-class Brunch subsystem delivered through the same transcript/event substrate.** Side tasks are main-agent-invoked, non-blocking work items: the main agent fires them and continues without awaiting a return value. A Brunch-owned `SideTaskRegistry` tracks status; the only path a side task influences the main agent is by appending a custom-message status update to the session log that arrives at the next-turn boundary through the existing `prepareNextTurn` path — never mid-turn. Side-task writes remain subject to the same command-layer authority as primary-agent writes. This is distinct from D44-L Subagent (main-agent-invoked **blocking** tool call whose result is returned directly as tool content). Depends on: A11-L, D4-L. Supersedes: —. -- **D16-L — Graph persistence uses Drizzle over `better-sqlite3`, with one-LSN-per-commit and no bypass paths.** The command layer owns precondition checks, structural validation, entity writes, LSN allocation, change-log append, and any coherence updates inside one transaction. This rule applies equally to migrations and maintenance code; there is no privileged write path outside the command-executor protocol. Runtime row/insert/update schemas are derived from Drizzle table definitions through `drizzle-typebox` (`createInsertSchema`, `createSelectSchema`) rather than hand-authored alongside the table. **Settled by A20-L spike (2026-06-01):** `drizzle-orm@0.45.2` + `drizzle-kit@0.31.10` + `better-sqlite3@12.8.0` + `drizzle-typebox@0.3.3` + `@sinclair/typebox@0.34.14`. Pi tool parameter schemas use `typebox` v1.x (Pi's package) separately; Drizzle-derived row schemas stay internal to `db/`→`graph/`; shared enum `const` arrays bridge both. Depends on: A3-L, A4-L, A20-L (validated). Refined by: D41-L. Supersedes: —. +- **D16-L — Graph persistence uses Drizzle over `better-sqlite3`, with one selected-spec LSN per commit and no bypass paths.** The command layer owns precondition checks, structural validation, entity writes, spec-local LSN allocation, change-log append, and any coherence updates inside one transaction. `graph_clock` is keyed by `spec_id`; `createSpec` creates exactly one initial clock row at LSN 1, and every later live selected-spec mutation uses an update-only bump that fails loud if the row is absent. `change_log` carries `spec_id` and is keyed by `(spec_id, lsn)`, so a bare LSN is comparable only inside one spec. Live graph/spec mutations have no privileged write path outside the command-executor protocol; pre-release migrations may reshape scratch data directly when schema truth moves, including backfilling spec-owned clock/change-log rows for legacy scratch databases. Runtime row/insert/update schemas are derived from Drizzle table definitions through `drizzle-typebox` (`createInsertSchema`, `createSelectSchema`) rather than hand-authored alongside the table. **Settled by A20-L spike (2026-06-01):** `drizzle-orm@0.45.2` + `drizzle-kit@0.31.10` + `better-sqlite3@12.8.0` + `drizzle-typebox@0.3.3` + `@sinclair/typebox@0.34.14`. Pi tool parameter schemas use `typebox` v1.x (Pi's package) separately; Drizzle-derived row schemas stay internal to `db/`→`graph/`; shared enum `const` arrays bridge both. Depends on: A3-L, A4-L, A20-L (validated). Refined by: D41-L. Supersedes: —. - **D18-L — Post-exchange capture is synchronous elicitor work for the POC; observer/auditor queues are deferred backstops, not primary extraction authority.** After a user response closes a session exchange, the elicitor may run a post-exchange capture step in the same turn-boundary flow: commit high-confidence extractive facts, concrete reconciliation needs, and justified spec-readiness updates through the `CommandExecutor`; fold low-confidence implications into later questions rather than graph truth. Brunch may still introduce durable observer/auditor jobs keyed by session id plus exchange entry ids for restartable audit, quality checks, or later backfill, but those jobs are not the load-bearing path for keeping the next turn's world fresh. Any async job writes still route through the command layer and remain operational queue state unless they surface semantic work as reconciliation needs. Depends on: A13-L, A22-L, D4-L, D13-L, D16-L. Supersedes: the old DB-backed `chat` / `turn` mental model and the earlier observer-owned primary extraction path. - **D28-L — Regenerated review-set proposals are appended as successor `present_review_set` toolResult payloads in the linear Pi JSONL session; projection helpers filter to the accepted set for context economy.** When the user requests changes, the agent appends a successor structured-exchange proposal payload that references its predecessor via `supersedes`; prior proposal payloads are *not* deleted from JSONL but remain visible as raw transcript history. This stays within Brunch's linear transcript policy — no Pi branching is created. Pi JSONL is treated as a "capture everything" store for replay and audit. Projection helpers used to drive the agent (context injection, summarization) walk the `supersedes` chain and surface only the latest (or ultimately accepted) proposal — the agent does not re-process every superseded proposal as live context. The reviewer likewise sees only the accepted set, not the regeneration history. Depends on: D6-L, D12-L, D17-L, D24-L, D27-L. Supersedes: any "in-place edit" or "fork-on-regenerate" mental model, and the retired standalone `brunch.review_set_proposal` entry family. - **D29-L — Reviewer is an async advisory role with narrow write authority.** After a batch acceptance closes, Brunch may enqueue a reviewer job keyed by session id plus the batch-acceptance entry id; the job survives process restart and analyzes the accepted batch plus its graph neighborhood for coherence, completeness, and gaps. **Reviewer writes only `reconciliation_need` records via the `CommandExecutor`**; it never writes graph entities, edges, change-log entries directly, or any other record class. Findings reach the user through next-turn delivery as advisory items on the reconciliation-need surface — the batch acceptance remains the user's atomic commitment and the reviewer cannot amend it. (Suggestion-shaped findings may later route to candidate-artefacts when that substrate exists; the POC routes everything to reconciliation needs.) Depends on: A16-L, D4-L, D8-L, D15-L, D17-L, D18-L, D20-L, D27-L. Supersedes: any "reviewer may quietly amend the graph" mental model. - **D24-L — Brunch POC enforces a linear transcript policy over Pi JSONL.** Pi's session tree is a substrate capability, not a Brunch product surface. Until branch-aware continuity/coherence is explicitly designed, Brunch-controlled interactive/runtime flows block `/tree`, `/fork`, and `/clone` through the thinnest available Pi hooks; transcript readers reject non-linear session files instead of flattening, adapting, migrating, or selecting a branch. This is intentional fail-fast pre-release posture: avoid compatibility debt with Pi internals or earlier Brunch revisions, and keep wrapper/adapter layers minimal. Depends on: D6-L, D11-L, D13-L. Supersedes: treating active-branch projection as Brunch product semantics. -- **D43-L — Auto-compaction is a Brunch-owned `session_before_compact` extension whose anchor preservation contract is an externalized JSON config.** Brunch always owns this hook because Pi's default summary cannot know about Brunch's transcript-native continuity entries. The extension composes a deterministic preserved-anchor header (rendered byte-stable from the configured anchor set against the pre-compaction branch) with an LLM-generated narrative summary, then returns Pi's standard `{ compaction: { summary, firstKeptEntryId, tokensBefore } }` shape. The summarization model is resolved through the active runtime bundle (D40-L) — typically a cheap/fast "compaction" preset (e.g. Gemini Flash, Haiku) — with fallback to Pi's default compaction on missing auth, empty output, or unexpected error so compaction is never gated on extension success. The anchor contract lives in [src/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/auto-compaction-anchors.json) as `{ kind, select, rationale }` rules (`select ∈ first | latest | active-leaves | all-unresolved`) so it can be reviewed and updated without SPEC churn; the file is validated through a D41-L-compatible runtime schema when the module lands. Brunch-initiated proactive compaction (post-`acceptReviewSet`, on shutdown) and reactor-side compaction triggers are deferred. Session-scoped continuity metadata (`lastSeenLsn`, interest sets) is *projected* from the change log plus the preserved anchor entries — it is not itself an anchor and never appears in the JSON. Depends on: D6-L, D15-L, D17-L, D40-L, D41-L. Supersedes: relying on Pi's default `session_before_compact` summary to keep Brunch-specific continuity intelligible. +- **D43-L — Auto-compaction is a Brunch-owned `session_before_compact` extension whose anchor preservation contract is an externalized TypeScript contract.** Brunch always owns this hook because Pi's default summary cannot know about Brunch's transcript-native continuity entries. The extension composes a deterministic preserved-anchor header (rendered byte-stable from the configured anchor set against the pre-compaction branch) with an LLM-generated narrative summary, then returns Pi's standard `{ compaction: { summary, firstKeptEntryId, tokensBefore } }` shape. The summarization model is resolved through the active runtime bundle (D40-L) — typically a cheap/fast "compaction" preset (e.g. Gemini Flash, Haiku) — with fallback to Pi's default compaction on missing auth, empty output, or unexpected error so compaction is never gated on extension success. The anchor contract lives in [src/.pi/extensions/compaction/index.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/compaction/index.ts) as `{ kind, select, rationale }` rules (`select ∈ first | latest | active-leaves | all-unresolved`) so it can be reviewed and updated without SPEC churn; the file is validated through a D41-L-compatible runtime schema when the module lands. Brunch-initiated proactive compaction (post-`acceptReviewSet`, on shutdown) and reactor-side compaction triggers are deferred. Session-scoped continuity metadata (`lastSeenLsn`, interest sets) is *projected* from the change log plus the preserved anchor entries — it is not itself an anchor and never appears in the JSON. Depends on: D6-L, D15-L, D17-L, D40-L, D41-L. Supersedes: relying on Pi's default `session_before_compact` summary to keep Brunch-specific continuity intelligible. #### Schema & validation -- **D41-L — Boundary schemas are runtime-validated and JSON-Schema-exportable; Zod v4 may be the product/protocol schema source.** Brunch boundary shapes must have one runtime schema source of truth, derived static TypeScript types, and JSON Schema output wherever a public protocol or Pi tool boundary needs discoverability. Zod v4 is permitted — and preferred for structured-exchange product/protocol schemas — when the schema stays inside Zod's JSON-representable subset and tests prove `z.toJSONSchema(..., { unrepresentable: "throw" })` succeeds for the exported boundary. TypeBox remains valid for Pi tool parameter objects, small config/frontmatter contracts, and any seam where the direct JSON-Schema-shaped authoring style is cheaper. Do not hand-author parallel Zod and TypeBox definitions for the same boundary; if a Pi API requires a JSON-Schema-shaped object, generate or adapt it from the chosen source schema and test the adapter. Drizzle table definitions remain canonical for persisted shapes; row/insert/update validation must be derived from Drizzle through a single adapter path (`drizzle-zod`, `drizzle-orm/typebox`, or equivalent selected during the A20-L prep-envelope spike) rather than hand-authored alongside the table. Boundary Zod schemas must avoid transforms, `Date`, `Map`, `Set`, `bigint`, and other unrepresentable constructs unless an explicit adapter owns the input/output split and JSON Schema export tests cover it; refinements are allowed only for runtime constraints that stay inside JSON-representable input/output shapes and are covered by parse tests plus export tests. Static TS types come from the schema source (`z.infer` for Zod, `Static` for TypeBox); runtime parsing uses the matching library (`zExample.parse`/`safeParse` for Zod, `Value.Parse`/`Value.Check` for TypeBox). Depends on: D4-L, D5-L, D16-L. Supersedes: TypeBox as Brunch's single runtime schema vocabulary, the ban on Zod outside downstream adapters, and an implicit "any runtime schema library is fine" posture. +- **D41-L — Boundary schemas are runtime-validated and JSON-Schema-exportable; Zod v4 may be the product/protocol schema source.** Brunch boundary shapes must have one runtime schema source of truth, derived static TypeScript types, and JSON Schema output wherever a public protocol or Pi tool boundary needs discoverability. Zod v4 is permitted — and preferred for structured-exchange product/protocol schemas — when the schema stays inside Zod's JSON-representable subset and tests prove `z.toJSONSchema(..., { unrepresentable: "throw" })` succeeds for the exported boundary. For the structured-exchange seam specifically, Zod is the canonical authoring layer for both transcript `toolResult.details` and active Pi tool params; Pi's `defineTool` typing is satisfied only through the single `pi-schema.ts` adapter, not per-tool TypeBox schemas. TypeBox remains valid for unrelated Pi tool parameter objects, small config/frontmatter contracts, and any seam where the direct JSON-Schema-shaped authoring style is cheaper. Do not hand-author parallel Zod and TypeBox definitions for the same boundary; if a Pi API requires a JSON-Schema-shaped object, generate or adapt it from the chosen source schema and test the adapter. Drizzle table definitions remain canonical for persisted shapes; row/insert/update validation must be derived from Drizzle through a single adapter path (`drizzle-zod`, `drizzle-orm/typebox`, or equivalent selected during the A20-L prep-envelope spike) rather than hand-authored alongside the table. Boundary Zod schemas must avoid transforms, `Date`, `Map`, `Set`, `bigint`, and other unrepresentable constructs unless an explicit adapter owns the input/output split and JSON Schema export tests cover it; refinements are allowed only for runtime constraints that stay inside JSON-representable input/output shapes and are covered by parse tests plus export tests. Static TS types come from the schema source (`z.infer` for Zod, `Static` for TypeBox); runtime parsing uses the matching library (`zExample.parse`/`safeParse` for Zod, `Value.Parse`/`Value.Check` for TypeBox). Depends on: D4-L, D5-L, D16-L, D37-L. Supersedes: TypeBox as Brunch's single runtime schema vocabulary, the ban on Zod outside downstream adapters, and an implicit "any runtime schema library is fine" posture. #### Interaction & UI shape @@ -227,7 +227,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, DB-backed spec lookup through `CommandExecutor`, internal session-start binding for pi-created replacement sessions, `.brunch/workspace.json` current spec/session acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, treating `.brunch/workspace.json` as an implicit instruction to resume without user-visible Brunch flow, or resolving spec names from JSONL. - **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces are preferably represented by registered structured-exchange `present_*` / `request_*` toolResult families when durable structure is needed; there is no DB-owned prompt/response entity. At idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L, D37-L. Supersedes: standalone custom-entry carriers as the default structured interaction shape. -- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges use durable toolResult families.** A system/assistant-originated structured interaction should use the thinnest Pi-supported transcript seam for its shape. The preferred Brunch seam is registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, `request_*` tools collect and persist the user response, and future `capture_*` tools persist assistant analysis of candidate semantic changes without mutating graph truth. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both transcript display content and model-readable context; `toolResult.details` is the structured projection/recovery payload. The landed Zod-authored target details model under `src/.pi/extensions/structured-exchange/schemas/` uses checked `schema` + `v` discriminants, `exchange_id`, compact `tool_meta` sequence/sibling metadata, exactly-one request outcome presence (`answered` | `cancelled` | `unavailable`), user-authored `comment` versus runtime-authored `message`, strict `present_candidates` rubrics/`graph_refs`, and intentionally minimal no-graph `capture_*` details. Runtime tools/projections still use the existing tuple details model until a deliberate migration slice rewires them to these exports. Implemented present/request tools use `executionMode: "sequential"`; FE-744's real Pi RPC ordering proof validates that same-assistant-message `present_options → request_choice` persists the present `toolResult` before the request `toolResult` and emits the present `tool_execution_end` before the request UI opens, and the public Brunch RPC parity proof drives the current deterministic tuple-shaped permutation set over product methods only. RPC event consumers should not assume `request_*` `tool_execution_start` precedes its extension UI request, because Pi may emit the UI request first. Standalone Brunch custom entries remain valid for genuinely non-exchange session facts such as `brunch.session_binding`, `brunch.agent_runtime_state`, lens switches, side-task results, and mention/world-update delivery; they are not the default carrier for establishment offers, review-set proposals, intent hints, or structured response surfaces. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L, D41-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`, or treating the retired scope-card contract as canonical after the schema README and tests have landed. +- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges use durable toolResult families.** A system/assistant-originated structured interaction should use the thinnest Pi-supported transcript seam for its shape. The preferred Brunch seam is registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, `request_*` tools collect and persist the user response, and future `capture_*` tools persist assistant analysis of candidate semantic changes without mutating graph truth. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both transcript display content and model-readable context; `toolResult.details` is the structured projection/recovery payload. The landed Zod-authored target details model under `src/.pi/extensions/exchanges/schemas/` uses checked `schema` + `v` discriminants, `exchange_id`, compact `tool_meta` sequence/sibling metadata, exactly-one request outcome presence (`answered` | `cancelled` | `unavailable`), user-authored `comment` versus runtime-authored `message`, strict `present_candidates` rubrics/`graph_refs`, and intentionally minimal no-graph `capture_*` details. Runtime tools, session-triggered present/request emissions, and the intentional RPC/editor relay now construct details through canonical `src/exchanges/project/*` projectors and render durable markdown through `src/exchanges/format/*` renderers where a formatter exists. Implemented present/request tools use `executionMode: "sequential"`; FE-744's real Pi RPC ordering proof validates that same-assistant-message `present_options → request_choice` persists the present `toolResult` before the request `toolResult` and emits the present `tool_execution_end` before the request UI opens, and the public Brunch RPC parity proof drives the current deterministic Zod-shaped permutation set over product methods only. RPC event consumers should not assume `request_*` `tool_execution_start` precedes its extension UI request, because Pi may emit the UI request first. Standalone Brunch custom entries remain valid for genuinely non-exchange session facts such as `brunch.session_binding`, `brunch.agent_runtime_state`, lens switches, side-task results, and mention/world-update delivery; they are not the default carrier for establishment offers, review-set proposals, intent hints, or structured response surfaces. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L, D41-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`, or treating the retired scope-card contract as canonical after the schema README and tests have landed. - **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-exchange tool needs a complex shape (multi-select, review-style response, or a deferred multi-question/questionnaire shape) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. - **D13-L — Capture-aware session exchange projection.** Post-exchange capture consumes derived session exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text and/or terminal structured-exchange `request_*` toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-exchange results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable graph-code text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable graph node code from D62-L (`#G1`, `#CON2`, `#R3`, `#CR4`, etc.) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret these handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. The ledger stores internal `(entity_id, snapshotted_lsn)` pairs, not titles or raw code strings alone, and drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, D62-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata or using raw DB ids as user-facing handles. @@ -244,22 +244,22 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **Data gatherers** — read-only context fetchers whose output grounds proposals: **scout** (codebase recon: `read`, `grep`, `find`, `ls`), **researcher** (web research: `web_search`, `web_fetch`), and **graph-reader** (read-only Brunch graph projection tools). - **Variant proposer** — **proposer** (no tools): given a grounding bundle plus a batch-proposal lens frame, emits exactly one well-formed variant of a candidate proposal. The main agent achieves diversity by issuing parallel `tasks: []` invocations of `proposer` with intentionally distinct framings — the subagent realization of the "design it twice" pattern from `ln-design` and the parallel fan-out anticipated by `ln-oracles`. Each `proposer` invocation runs in its own isolated context so variants don't cross-contaminate; the main agent collects N outputs and composes the comparison via the D31-L meta-rubric (and/or project-specific axes) before writing a review-set structured-exchange payload through the elicitor flow. `proposer` is system-prompt-only by design: it cannot read the graph, write files, or call `CommandExecutor`; the main agent supplies grounded inputs and owns any product write. Future per-lens proposers may exist only if the generic proposer proves too blunt. This division mirrors the batch-proposal flow in D26-L: `propose-graph` and `project-graph` strategies can delegate variant generation to fan-out `proposer` invocations while `intent` / `design` / `oracle` lenses frame the proposal subject; purely extractive single-exchange work may stay main-agent-only. Worker-style write-capable subagents are deferred until an execute operational mode lands. Cross-extension agent registration (Amos's `globalThis.__pi_subagents` bridge) is deferred because it conflicts with profile sealing; the POC registry is Brunch-owned only. NDJSON stream events from the subprocess drive TUI tool-progress UI; a `subagent.progress` RPC subscription for headless/web is deferred. Subagents are an optional enhancement to candidate-proposal diversity, not a load-bearing M0–M9 substrate: they enhance R20/D27-L proposal generation when bandwidth permits. Depends on: D2-L, D26-L, D27-L, D30-L, D31-L, D39-L, D41-L. Distinct from: D15-L Side task (non-blocking, status-via-custom-message), the deferred Side chat (user-invoked overlay; see Future Direction Register). Supersedes: —. -- **D36-L — Spec/session selection is a reusable hierarchical decision model with transport-specific presentations.** Brunch owns a pure spec/session selection model that renders cwd-scoped inventory without calling the user-created object a “workspace”. In TUI mode, the model may present a fast “continue last session” affordance when `.brunch/workspace.json` points to a valid spec+session; otherwise, or after “other spec/session”, the durable tree is: `create new spec → provide spec name → session created automatically`; `resume existing spec → choose existing spec → create a new session OR resume existing session → choose existing session`. The UI should not list every spec as a top-level action label; “resume existing spec” is the top-level intent, and the spec list is the next screen/scrollable selector. The model returns a product decision (`new spec`, `new session for spec`, `open session`, `continue selected session`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/workspace.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. TUI startup and in-session paths share branded `pi-tui` components and colocated logo assets under `src/.pi/components/workspace-dialog`; adapters differ only in terminal lifecycle and Pi session-replacement mechanics (`ProcessTerminal`/`TUI.showOverlay` before Pi starts, `ctx.ui.custom(..., { overlay: true })` inside Pi), not in product semantics. RPC/headless transports must not invoke the TUI picker; they expose the same initial-selection requirement and activation decisions as JSON-RPC/product results so CLI JSON-RPC clients can select or create spec/session correctly. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/workspace.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, one-off startup-only picker implementations, a flat action list that says “workspace” for specs, top-level `resume spec X` labels, and a separate intermediate action chooser for switching. -- **D42-L — Session naming is a lifecycle side task over Pi `session_info`, not spec identity.** Brunch should use Pi session lifecycle hooks to opportunistically generate a short human-readable session name that characterizes what happened in the transcript. The preferred trigger is `session_shutdown` for `quit`, `new`, and `resume` replacements because it sees the just-finished transcript and can name it before later picker lists need to distinguish sessions; `session_before_compact` or post-compaction (`session_compact`) may be used to refresh names after major summarization, and a manual command can force regeneration for debugging. The naming call should mirror the model-selection pattern in the local `summarize.ts` extension example: choose a cheap/fast authorized model, extract user/assistant text plus salient tool calls from the current branch, ask for a concise title, and append a Pi `session_info` entry through `SessionManager.appendSessionInfo`. Naming must be best-effort and non-blocking with a tight budget: failures, missing auth, empty transcripts, or shutdown aborts leave the session unnamed rather than blocking session replacement or exit. Generated names label sessions in pickers and chrome, but do not affect spec ids, session bindings, graph truth, or replay semantics. Depends on: D6-L, D17-L, D21-L, D35-L. Supersedes: using spec title or session UUID alone as the only durable display label once transcripts have meaningful content. -- **D58-L — Brunch prompt composition is a thin runtime header plus a gated prompt-resource manifest, not eager selection of every objective pack.** `agents/compose(agentId, sessionState, spec, workspace, snapshots)` runs before Pi provider requests through Brunch's prompt extension and emits: **(1) agent control header** — keyed agent identity, model/thinking expectation, foreground role derived from `op_mode`, and mode/tool-authority summary; **(2) runtime-state header** — current pinned/AUTO `goal`, `strategy`, and `lens`, `spec.readiness_grade`, and workspace posture; **(3) resource manifests** — XML-style ``, ``, ``, and `` entries filtered by `agents/state.ts` legal tuples, grade, `op_mode`, and the agent allow-list, each carrying `{name, description, location}` for a Brunch-owned markdown resource under `src/agents/`; the `{name, description, location}` triples are code-owned in `agents/state.ts`, not filesystem-discovered, honoring D39-L sealing; **(4) compact pushed context** — only the minimal snapshot summary/handles needed to orient the turn, with detailed snapshot content still governed by D60-L. Detailed goal/strategy/lens/method instructions live in Brunch prompt resources and are loaded by the agent with `read` when needed, following the same simple mechanism Pi uses for skills. Method resources are the prompt-level home for Brunch tool-routing and sequencing guidance; tool definitions remain boundary schemas/execution hooks, not the whole Brunch guide to when or how tools should be composed. `AUTO` means the axis is unpinned: the manifest lists legal choices and router instructions tell the agent to choose only from the current manifest, reading the selected resource before applying it when detail matters. Pinned axes point to the pinned resource; code enforces legality and tool gating but does not choose or concatenate large semantic packs on the agent's behalf. Pi-native skills may still carry startup-scoped capabilities, but runtime-state-gated availability is Brunch's manifest, not ambient Pi discovery. `agents/` is a keyed resource registry (`definitions/`, `goals/`, `strategies/`, `lenses/`, `methods/`, `contexts/`); `agents/contexts/` is the D60-L snapshot render layer (code), not a manifest resource family; composition is projection, not a behavioral state machine. Depends on: D23-L, D25-L, D39-L, D40-L, D52-L, D59-L, D60-L. Supersedes: the flat "base + mode + role + strategy + lens + grade + …" layering; the fixed all-packs concatenation in `compose-brunch-prompt.ts`; "role preset / runtime bundle" as the composition unit; direct Layer-2 eager prompt-pack injection as the default mechanism; and `capability` as a parallel name for `method` / ``. +- **D36-L — Spec/session selection is a reusable hierarchical decision model with transport-specific presentations.** Brunch owns a pure spec/session selection model that renders cwd-scoped inventory under the discovered project name without calling the user-created object a “workspace”. In TUI mode, the model may present a fast “continue last session” affordance when `.brunch/workspace.json` points to a valid spec+session; otherwise, or after “other spec/session”, the durable tree is: `create new spec → provide spec name → session created automatically`; `resume existing spec → choose existing spec → create a new session OR resume existing session → choose existing session`. The UI should not list every spec as a top-level action label; “resume existing spec” is the top-level intent, and the spec list is the next screen/scrollable selector. The model returns a product decision (`new spec`, `new session for spec`, `open session`, `continue selected session`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/workspace.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. TUI startup and in-session paths share branded `pi-tui` components and colocated logo assets under `src/.pi/components/workspace-dialog`; adapters differ only in terminal lifecycle and Pi session-replacement mechanics (`ProcessTerminal`/`TUI.showOverlay` before Pi starts, `ctx.ui.custom(..., { overlay: true })` inside Pi), not in product semantics. RPC/headless transports must not invoke the TUI picker; they expose the same initial-selection requirement and activation decisions as JSON-RPC/product results so CLI JSON-RPC clients can select or create spec/session correctly. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/workspace.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, one-off startup-only picker implementations, a flat action list that says “workspace” for specs, top-level `resume spec X` labels, and a separate intermediate action chooser for switching. +- **D42-L — Session naming is Pi `session_info` presentation metadata, not spec identity.** Brunch-created sessions should be named at creation with neutral workspace-global defaults (`Untitled Session 1`, `Untitled Session 2`, …) so pickers/chrome never show an unnamed Brunch session and unchanged defaults do not collide across specs in the same cwd. These defaults are immediate lifecycle metadata, not LLM-generated summaries and not derived from the selected spec title. Brunch may later use Pi session lifecycle hooks to opportunistically replace a default with a short human-readable name that characterizes what happened in the transcript. The preferred generation trigger is `session_shutdown` for `quit`, `new`, and `resume` replacements because it sees the just-finished transcript and can name it before later picker lists need to distinguish sessions; `session_before_compact` or post-compaction (`session_compact`) may be used to refresh names after major summarization, and a manual/user rename command can force or override naming. The generation call should mirror the model-selection pattern in the local `summarize.ts` extension example: choose a cheap/fast authorized model, extract user/assistant text plus salient tool calls from the current branch, ask for a concise title, and append a Pi `session_info` entry through `SessionManager.appendSessionInfo`. Naming must be best-effort and non-blocking with a tight budget: failures, missing auth, empty transcripts, or shutdown aborts preserve the existing default/user label rather than blocking session replacement or exit. Session display names label sessions in pickers and chrome, but do not affect spec ids, session bindings, graph truth, or replay semantics. Depends on: D6-L, D17-L, D21-L, D35-L. Supersedes: using spec title or session UUID alone as the only durable display label once transcripts have meaningful content, leaving Brunch-created sessions unnamed, spec-local default numbering, or treating generated session names as canonical spec identity. +- **D58-L — Brunch prompt composition is a thin runtime header plus a gated prompt-resource manifest, not eager selection of every objective pack.** `.pi/agents/compose(agentId, sessionState, spec, workspace, snapshots)` runs before Pi provider requests through Brunch's prompt extension and emits: **(1) agent control header** — keyed agent identity, model/thinking expectation, foreground role derived from `op_mode`, and mode/tool-authority summary; **(2) runtime-state header** — current pinned/AUTO `goal`, `strategy`, and `lens`, `spec.readiness_grade`, and workspace posture; **(3) resource manifests** — XML-style ``, ``, ``, and `` entries filtered by `.pi/agents/state.ts` legal tuples, grade, `op_mode`, and the agent allow-list, each carrying `{name, description, location}` for a Brunch-owned markdown resource under `src/.pi/{agents,skills}/`; the `{name, description, location}` triples are code-owned in `.pi/agents/state.ts`, not filesystem-discovered, honoring D39-L sealing; **(4) compact pushed context** — only the minimal snapshot summary/handles needed to orient the turn, with detailed snapshot content still governed by D60-L. Detailed goal/strategy/lens/method instructions live in Brunch prompt resources and are loaded by the agent with `read` when needed, following the same simple mechanism Pi uses for skills. Method resources are the prompt-level home for Brunch tool-routing and sequencing guidance; tool definitions remain boundary schemas/execution hooks, not the whole Brunch guide to when or how tools should be composed. `AUTO` means the axis is unpinned: the manifest lists legal choices and router instructions tell the agent to choose only from the current manifest, reading the selected resource before applying it when detail matters. Pinned axes point to the pinned resource; code enforces legality and tool gating but does not choose or concatenate large semantic packs on the agent's behalf. Pi-native skills may still carry startup-scoped capabilities, but runtime-state-gated availability is Brunch's manifest, not ambient Pi discovery. `.pi/agents/` is the keyed agent prompt assembly layer (`definitions/`, `contexts/`); `.pi/skills/` carries goal/strategy/lens/method resources; `.pi/agents/contexts/` is the D60-L agent-context orchestration layer (code), not a manifest resource family or general renderer bucket. Reusable text renderers may migrate to `renderers/` under D52-L. Composition is projection, not a behavioral state machine. Depends on: D23-L, D25-L, D39-L, D40-L, D52-L, D59-L, D60-L. Supersedes: the flat "base + mode + role + strategy + lens + grade + …" layering; the fixed all-packs concatenation in `compose-brunch-prompt.ts`; "role preset / runtime bundle" as the composition unit; direct Layer-2 eager prompt-pack injection as the default mechanism; top-level `src/agents/` for Pi-only agents; and `capability` as a parallel name for `method` / ``. - **D59-L — `goal` is a grade-derived, AUTO-able objective axis, distinct from strategy.** A *goal* is what the session agent currently pursues; a *strategy* is the reusable interaction shape used to pursue it — a goal is pursued *via* a strategy *through* a lens (three orthogonal axes). The goal set is derived/gated by `spec.readiness_grade`: `grounding-advance` (fill grounding and advance the grade), `elicit-expand` (expand the elicited specification graph while ambiguity remains productive), `commit-converge` (reduce / lock down reviewable commitments), plus an always-on `capture-posture` (capture or confirm dev `posture`, D45-L). `goal` defaults to the grade-derived objective, may be pinned, or left `AUTO`; in either case D58-L manifests advertise the legal resource(s) rather than injecting the whole goal body. `elicit-expand` and `commit-converge` intentionally form the diverge/converge pair for the elicitation diamond; `elicit-I` / `elicit-II` are retired because they were phase-like labels, not objectives. "Advance the grade" is a goal, not a strategy — though the `grounding-advance` goal may carry a dedicated default interaction pattern. Depends on: D45-L, D57-L, D58-L. Supersedes: conflating the elicit-lifecycle objective with strategy selection. -- **D60-L — "Snapshot" splits into pull / render / surface, distinguishes graph-truth from active-context projections, and names two distinct subjects.** **Agent-context snapshot** = content the agent reasons over: `cwd` (filesystem kickoff heuristic — `.brunch?`, session count/length, README/markdown sizes, file counts), `graph` (overview/list/query), or `node` (variable-hop neighborhood). **PULL** is typed, read-only data access owned by the data layer (`graph/snapshot.ts` for graph/node; `session/` for cwd) and bypasses `CommandExecutor` (reads only); the typed value *is* the JSON form. Graph pulls must make the projection explicit: `graph_truth` includes accepted truth records, while `active_context` hides superseded predecessors and must also omit edges whose endpoints are hidden so snapshots do not contain dangling references. The graph read family should support the observed query shapes without becoming a generic records API: list nodes by kind(s), list nodes by D64-L readiness band(s), and find nodes related to anchor node(s) by edge category/direction/hop depth. **RENDER** turns the typed value into either an LLM-friendly string (owned solely by `agents/contexts/`, scaled by lens-plane and grade-depth) or JSON (trivial serialization), rendering projected stable node codes (D62-L) as primary handles. **SURFACE** delivers it: *pushed* (compose injects at turn boundary), *pulled* (thin `snapshot-*` / graph-read Pi tools wrap the renderer — markdown in `toolResult.content`, typed JSON in `toolResult.details` per I33-L), or *rpc/ui*. The separate **workspace projection** (`workspace.snapshot` — workspace/session/spec/chrome product state) is a different subject and keeps that name; reserve "snapshot" for the agent-context family. Depends on: D35-L, D52-L, D53-L, D62-L, D64-L. Supersedes: pre-rendering snapshots to strings in the pull layer, scattering snapshot build logic across `graph/`, `agents/contexts/`, and the `snapshot-*` tool stubs, or silently mixing graph-truth and active-context reads. +- **D60-L — "Snapshot" splits into pull / render / surface, distinguishes graph-truth from active-context projections, and names two distinct subjects.** **Agent-context snapshot** = content the agent reasons over: `cwd` (filesystem kickoff heuristic — `.brunch?`, session count/length, README/markdown sizes, file counts), `graph` (overview/list/query), or `node` (variable-hop neighborhood). **PULL** is typed, read-only data access owned by the data layer (`graph/snapshot.ts` for graph/node; `session/` for cwd) and bypasses `CommandExecutor` (reads only); the typed value *is* the JSON form. Graph pulls must make the projection explicit: `graph_truth` includes accepted truth records, while `active_context` hides superseded predecessors and must also omit edges whose endpoints are hidden so snapshots do not contain dangling references. The graph read family should support the observed query shapes without becoming a generic records API: list nodes by kind(s), list nodes by D64-L readiness band(s), and find nodes related to anchor node(s) by edge category/direction/hop depth. **RENDER** turns the typed value into either an LLM-friendly string or JSON (trivial serialization). Reusable lossy text/markdown rendering belongs in `renderers/`; `.pi/agents/contexts/` owns the agent-context orchestration decision — which typed pull to expose, how much detail to include, and how lens-plane/grade-depth shape the prompt-facing string — and may call reusable renderers. Rendered projected stable node codes (D62-L) remain the primary handles. **SURFACE** delivers it: *pushed* (compose injects at turn boundary), *pulled* (thin `snapshot-*` / graph-read Pi tools wrap the renderer — markdown in `toolResult.content`, typed JSON in `toolResult.details` per I33-L), or *rpc/ui*. The separate **workspace projection** (`workspace.snapshot` — workspace/session/spec/chrome product state) is a different subject and keeps that name; reserve "snapshot" for the agent-context family. Depends on: D35-L, D52-L, D53-L, D62-L, D64-L. Supersedes: pre-rendering snapshots to strings in the pull layer, scattering snapshot build logic across `graph/`, `.pi/agents/contexts/`, and the `snapshot-*` tool stubs, or silently mixing graph-truth and active-context reads. ### Critical Invariants | # | Invariant | Protected by | Proves | | --- | --- | --- | --- | -| I1-L | One global LSN per commit; every change-log entry, graph-node version, and reconciliation-need carries an LSN strictly monotonic with the global clock. | planned (M4 invariant tests) | D4-L, D6-L, D8-L | +| I1-L | One spec-local LSN per selected-spec commit; every persisted spec has exactly one `graph_clock` row; every change-log entry, graph-node version, and reconciliation-need in that spec carries an LSN strictly monotonic with that spec's graph clock. Bare LSNs are not comparable across sibling specs. | partially covered (`CommandExecutor`, migration, `commitGraph`, snapshot, RPC, prompt-context, and seed-fixture tests prove local allocation, one-row clock ownership, sibling isolation, and missing-clock invariant failure) | D4-L, D6-L, D8-L | | I2-L | All durable graph mutations originate from the Brunch command layer; no caller bypasses validation, audit, or coherence triggering. | planned (M5 architectural test + lint rule) | D4-L | | I3-L | Transcript reload reproduces raw assistant/user payloads plus Brunch session binding, structured elicitation entries, and other custom transcript entries byte-equivalently (modulo timestamps). | covered (M2 JSONL viability round-trip tests) | D6-L | -| I4-L | For every `worldUpdate` entry, all named graph items have LSNs strictly greater than the session's pre-update `lastSeenLsn`. | planned (M7 property test) | D6-L, I1-L | +| I4-L | For every `worldUpdate` entry, all named graph items have LSNs strictly greater than that session/spec's pre-update `lastSeenLsn`; the comparable watermark is `{specId, lsn}`. | planned (M7 property test) | D6-L, I1-L | | I5-L | For every `brunch.lens_switch` entry and every session/spec binding transition, the session interest set is recomputed before the next agent turn. | planned (M7 property test) | D11-L | -| I6-L | Every reconciliation need has `created_at_lsn ≤` current global LSN; its target is exactly one of `{kind:'edge', edgeId}` or `{kind:'node_pair', aId, bId}` per [`docs/design/GRAPH_MODEL.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#reconciliationneed--separate-substrate-not-a-graph-edge); resolved needs carry a strictly later `resolved_at_lsn`. | planned (M8 property test) | D8-L, D51-L, I1-L | +| I6-L | Every reconciliation need has `created_at_lsn ≤` current LSN for its owning spec; its target is exactly one of `{kind:'edge', edgeId}` or `{kind:'node_pair', aId, bId}` per [`docs/design/GRAPH_MODEL.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#reconciliationneed--separate-substrate-not-a-graph-edge); resolved needs carry a strictly later spec-local `resolved_at_lsn`. | partially covered (`CommandExecutor` reconciliation-need tests prove target-spec allocation and resolve ordering) | D8-L, D51-L, I1-L | | I7-L | ~~Every `framing_as` value belongs to the allowed matrix for that node's base kind.~~ **Retired.** `framing_as` absorbed by D54-L/D56-L node kinds; no node carries a `framing_as` field. | — | D7-L (retired) | | I8-L | Spec selection persists across pi `switchSession` (i.e. `/new`); the selected session file is reopened consistently by headless projection/capture paths; each session has exactly one `brunch.session_binding`, and a session's bound spec never changes. | partially covered (M0 coordinator/TUI boot integration tests + store-only probe checker; M1 no-injected-coordinator capture regression; M2 coordinator-created JSONL reload tests; manual TUI smoke still planned) | D11-L, D21-L | | I9-L | Every `brunch.mention` payload resolves a transcript `#` handle to a stable graph entity id; the ledger never stores title-anchored references or relies on autocomplete popup metadata. | planned (M7 invariant) | D14-L | @@ -268,32 +268,32 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I12-L | Side-task results are delivered only at turn boundaries; no side-task result may steer or mutate the active turn outside the next-turn delivery path. | planned (M7 side-task delivery invariant) | D15-L | | I13-L | At any idle linear session leaf, the latest unresolved interaction state is system/assistant-originated: user input is a response to an elicitation prompt, not ambient chat. | partially covered (structured-exchange pending/respond projection tests and FE-744 public-RPC parity probe; richer idle-state probes still planned) | D12-L, D24-L | | I14-L | If Brunch introduces deferred observer/auditor jobs, they are keyed by session id plus session-exchange entry-range ids and have durable status; replay/restart cannot enqueue duplicate jobs for the same exchange, and job writes never become the primary freshness path for the next elicitor turn. | deferred/planned only if observer-audit queue lands (M5+ restart/idempotence tests) | D18-L, D4-L | -| I15-L | Every review-set acceptance routes through `CommandExecutor` as one atomic `acceptReviewSet` command producing one LSN, one change-log entry, and one transaction over the entire batch. Partial acceptance is not representable through any product API. | planned (M5+ batch-acceptance command tests; review-set fixture parity) | D20-L, D27-L; I1-L, I11-L | +| I15-L | Every review-set acceptance routes through `CommandExecutor` as one atomic `acceptReviewSet` command producing one LSN, one change-log entry, and one transaction over the entire batch. Partial acceptance is not representable through any product API. | covered (`src/graph/command-executor/accept-review-set.test.ts` proves explicit-basis atomic writes, one `accept_review_set` change-log row with proposal-entry audit metadata, and rollback of graph rows/clock/kind counters on structural failure; structured-exchange/RPC tests prove approve/request-changes/reject terminal `request_review` outcomes and approval-to-`acceptReviewSet` commit wiring; `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/` proves real `project-graph` proposal approval through public RPC into one explicit-basis review-set commit) | D20-L, D27-L; I1-L, I11-L | | I16-L | Reviewer-attributed writes target only the `reconciliation_need` substrate; no reviewer-attributed `CommandExecutor` call writes graph entities, edges, change-log entries directly, or any other record class. | planned (M5+ architectural test on reviewer command writers; reviewer-attributed command-result audit) | D29-L; I2-L, I11-L | -| I17-L | Every batch-proposal or review-set structured-exchange payload declares an `epistemic_status` (`inferred | assumed | asserted | observed`) and enough grounding/support coverage to justify that status at proposal time; UI renderings honor this status as a presentation contract. | partially covered (`review-set-proposal.test.ts` covers the current product proposal helper rejecting missing `epistemicStatus` and empty grounding/support before surfacing a reviewable payload; thin-vs-rich grounding fixture semantics and structured-exchange carrier migration remain future work) | D30-L, D46-L; A14-L | -| I18-L | Every elicitor-emitted prompt/proposal payload facet that needs downstream routing (establishment offer, intent hint, review/proposal material) carries a `lens` field inside the structured-exchange details; capture, reviewer, and future observer/auditor routing filters on this field. | partially covered (`review-set-proposal.test.ts` covers current proposal lens validation; establishment/intent-hint routing tests and structured-exchange carrier migration remain planned with capture/reviewer slices) | D25-L, D26-L, D29-L | +| I17-L | Every batch-proposal or review-set structured-exchange payload declares an `epistemic_status` (`inferred | assumed | asserted | observed`) and enough grounding/support coverage to justify that status at proposal time; UI renderings honor this status as a presentation contract. | partially covered (`src/graph/review-set.test.ts` covers graph-owned review-set payload validation rejecting missing `epistemicStatus` and empty grounding/support before producing a dry-run-valid command; structured-exchange carrier/rendering and thin-vs-rich grounding fixture semantics remain future work) | D30-L, D46-L; A14-L | +| I18-L | Every elicitor-emitted prompt/proposal payload facet that needs downstream routing (establishment offer, intent hint, review/proposal material) carries a `lens` field inside the structured-exchange details; capture, reviewer, and future observer/auditor routing filters on this field. | partially covered (`src/graph/review-set.test.ts` covers graph-owned review-set lens validation; establishment/intent-hint routing tests and structured-exchange carrier migration remain planned with capture/reviewer slices) | D25-L, D26-L, D29-L | | I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.exchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source tests prove no exposed Brunch command path creates branches) | D24-L, D34-L | -| I20-L | Every user-reviewable review-set proposal payload has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface through `present_review_set` as reviewable review sets. | partially covered (`CommandExecutor.dryRunCommitGraph` and `review-set-proposal.test.ts` cover product-helper dry-run validation, invalid proposal-payload rejection, no graph mutation during dry-run, and dry-run/commit validation parity; real agent-generated `project-graph` proposal fixtures remain planned) | D27-L; A14-L | +| I20-L | Every user-reviewable review-set proposal payload has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface through `present_review_set` as reviewable review sets. | covered for the current review-set path (`src/graph/review-set.test.ts` and `CommandExecutor.dryRunCommitGraph` cover graph-owned payload validation, selected-spec projected-code resolution, invalid proposal-payload rejection, no graph mutation during dry-run, and dry-run/commit validation parity; `present_review_set` calls `CommandExecutor.dryRunAcceptReviewSet` through injected selected-spec graph deps before emitting recoverable present details, and invalid proposals return non-reviewable `structural_illegal` diagnostics; the real FE-809 probe records two invalid non-reviewable dry-run attempts followed by one recoverable review set approved through public RPC) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; FE-744 web live-update tests prove WebSocket notifications only invalidate/refetch canonical projection handlers after RPC-originated structured-exchange mutations; FE-795 TUI observer-host tests prove the TUI launch path starts a same-process WebSocket observer attachment with the shared product-update publisher, and selected-spec `commit_graph` publishes graph invalidation topics on that same bus; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `src/probes/scripts/verify-startup-no-resume.sh` pty/ANSI-stripped TUI probe oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | -| I23-L | Every structured elicitation interaction that owns the response surface persists durable semantic display only through Pi `toolResult` rows rendered by `renderResult`; `renderCall` and live `ctx.ui.*` surfaces are transient. A structured-exchange tuple has a recoverable `present_*` result and, when required, exactly one matching terminal `request_*` result before the next agent turn consumes it. The target details model is checked by `schema` + `v`, `exchange_id`, and `tool_meta`; request outcomes are an exactly-one property-presence union; user-authored text is `comment` and runtime-authored text is `message`; present-side status/kind/expected-request aliases and capture graph payloads are invalid in the Zod-authored schema layer. `toolResult.content` is rich markdown suitable for both TUI transcript display and model context; `toolResult.details` carries structured projection/recovery data. | covered for current FE-744 structured-exchange tools (registered sequential `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices`; tests cover non-semantic `renderCall`, markdown `renderResult`, present/request details, unmatched-present recovery, active-vs-stub registry, JSON-editor fallback for multi-choice, terminal `answered`/`cancelled`/`unavailable` projection closure, option content/rationale parity, and same-assistant-message `present_options → request_choice` ordering over a real Pi RPC run. The Zod-authored schema layer is covered by JSON Schema export and drift-rejection tests for present/request/capture details; runtime tools still need a deliberate migration to those exports. `present_review_set`, `present_candidates`, and `request_review` remain named stubs and intentionally unregistered.) | D12-L, D13-L, D17-L, D37-L, D38-L, D41-L | -| I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | covered for TUI-launch profile boundary by contract tests: ambient resource flags and explicit extension factories are preserved; hostile ambient global/project settings are ignored by the in-memory Brunch settings policy before and after reload; audited Pi settings getters are tracked in `src/brunch-pi-profile.ts`. Subagent subprocess inheritance remains future coverage under I29-L. | D2-L, D39-L | -| I25-L | The active `op_mode`, `strategy`, `lens`, and `goal` are reconstructable from linear `brunch.agent_runtime_state` entries at turn start and through `session.runtimeState`; concrete axis ids stay separate from the `auto` selection sentinel; the foreground session-agent role is derived from `op_mode`, not separately stored; tool gating follows the reconstructed `op_mode` so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted. Runtime-state projection remains transcript-backed and exposes empty/default mention, world-watermark, and lifecycle slots without inventing hidden extension memory. | covered (`src/session/runtime-state.test.ts` covers default state, cumulative last-writer-wins posture, mention/world/lifecycle slot projection, and non-linear rejection; `src/rpc/handlers.test.ts` covers explicit-target `session.runtimeState` discovery/params/spec validation; `src/.pi/__tests__/operational-mode.test.ts` covers append/project/switch helpers over the reconciled axis vocabulary, AUTO selection for every objective axis, init idempotence, previous-state snapshots, malformed/illegal tuple rejection, role derivation from `op_mode`, and Pi JSONL reload projection; `prompting.test.ts` covers prompt/tool-policy projection from the same transcript-backed runtime state). | D17-L, D23-L, D40-L, D58-L, D59-L | -| I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | -| I26-L | Runtime schema-library imports stay deliberately scoped: Zod may appear only in D41-L-acknowledged product/protocol schema seams such as `src/.pi/extensions/structured-exchange/schemas/`; TypeBox remains valid for Pi tool parameters, small config/frontmatter contracts, and future Drizzle-derived row schemas; no boundary may hand-author parallel Zod and TypeBox sources for the same shape. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | covered (structured-exchange schema tests prove Zod parse/export; grep-based architectural boundary test in `architecture.test.ts` enforces no direct `db/` imports outside `graph/`; Drizzle derivation via `drizzle-typebox` in `row-schemas.ts`) | D41-L | -| I28-L | Auto-compaction output preserves the configured anchor set byte-stable: every entry kind listed in [src/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/auto-compaction-anchors.json) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor config) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | +| I23-L | Every structured elicitation interaction that owns the response surface persists durable semantic display only through Pi `toolResult` rows rendered by `renderResult`; `renderCall` and live `ctx.ui.*` surfaces are transient. A structured-exchange tuple has a recoverable `present_*` result and, when required, exactly one matching terminal `request_*` result before the next agent turn consumes it. The target details model is checked by `schema` + `v`, `exchange_id`, and `tool_meta`; request outcomes are an exactly-one property-presence union; user-authored text is `comment` and runtime-authored text is `message`; present-side status/kind/expected-request aliases and capture graph payloads are invalid in the Zod-authored schema layer. `toolResult.content` is rich markdown suitable for both TUI transcript display and model context; `toolResult.details` carries structured projection/recovery data. | covered for current structured-exchange tools (registered sequential `present_question`, `present_options`, `present_review_set`, `request_answer`, `request_choice`, `request_choices`, and `request_review`; runtime details are emitted from canonical `schema`/`v`/snake_case Zod shapes; tests cover non-semantic `renderCall`, markdown `renderResult`, present/request details, unmatched-present recovery, active-vs-stub registry, JSON-editor fallback for multi-choice, terminal `answered`/`cancelled`/`unavailable` projection closure, option content/rationale parity, review-set `nodes`/`edges` details parity, invalid review proposal non-recovery, review pending-exchange recovery, public-RPC deterministic permutations, capture response-to-graph proof, and same-assistant-message `present_options → request_choice` ordering over a real Pi RPC run. The Zod-authored schema layer is covered by JSON Schema export, drift-rejection, and source-boundary tests for present/request/capture details. `present_candidates` remains a named stub and intentionally unregistered.) | D12-L, D13-L, D17-L, D37-L, D38-L, D41-L | +| I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless Brunch's sealed Pi settings/extension boundary explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | covered for TUI-launch settings/extension boundary by contract tests: ambient resource flags and explicit extension factories are preserved; hostile ambient global/project settings are ignored by the in-memory Brunch settings policy before and after reload; audited Pi settings getters are tracked in `src/.pi/brunch-pi-settings.ts`. Subagent subprocess inheritance remains future coverage under I29-L. | D2-L, D39-L | +| I25-L | The active `op_mode`, `strategy`, `lens`, and `goal` are reconstructable from linear `brunch.agent_runtime_state` entries at turn start and through `session.runtimeState`; concrete axis ids stay separate from the `auto` selection sentinel; the foreground session-agent role is derived from `op_mode`, not separately stored; tool gating follows the reconstructed `op_mode` so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted. Runtime-state projection remains transcript-backed and exposes empty/default mention, world-watermark, and lifecycle slots without inventing hidden extension memory. | covered (`src/session/runtime-state.test.ts` covers default state, cumulative last-writer-wins posture, mention/world/lifecycle slot projection, and non-linear rejection; `src/rpc/handlers.test.ts` covers explicit-target `session.runtimeState` discovery/params/spec validation; `src/.pi/__tests__/operational-mode.test.ts` covers append/project/switch helpers over the reconciled axis vocabulary, AUTO selection for every objective axis, init idempotence, previous-state snapshots, malformed/illegal tuple rejection, role derivation from `op_mode`, and Pi JSONL reload projection; `prompting.test.ts` covers prompt/tool-policy projection from the same transcript-backed runtime state, including selected-spec grade activation for commitment-grade `present_review_set` / `request_review` proposal tools). | D17-L, D23-L, D40-L, D58-L, D59-L | +| I27-L | Session display names are presentation metadata only: every Brunch-created session gets a neutral workspace-global default `session_info` label (`Untitled Session N`) at creation, unchanged defaults do not collide across specs in one cwd, later user/generated names may replace the default, and no naming path mutates spec identity, session binding, or graph truth. | planned (creation/boundary tests for workspace-global default allocation across specs and replacement sessions; session-lifecycle naming tests with empty transcript/auth failure/success paths; picker/chrome projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | +| I26-L | Runtime schema-library imports stay deliberately scoped: Zod may appear only in D41-L-acknowledged product/protocol schema seams such as `src/.pi/extensions/exchanges/schemas/`; TypeBox remains valid for unrelated Pi tool parameters, small config/frontmatter contracts, and future Drizzle-derived row schemas; no boundary may hand-author parallel Zod and TypeBox sources for the same shape. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | covered (structured-exchange schema tests prove Zod parse/export and assert semantic details contracts stay in `src/.pi/extensions/exchanges/schemas/`; the legacy `shared/model.ts` details interface is retired; structured-exchange TypeBox usage is quarantined to the single Pi `TSchema` cast adapter in `src/.pi/extensions/exchanges/pi-schema.ts`; grep-based architectural boundary test in `architecture.test.ts` enforces no direct `db/` imports outside `graph/`; Drizzle derivation via `drizzle-typebox` in `row-schemas.ts`) | D41-L | +| I28-L | Auto-compaction output preserves the configured anchor set byte-stable: every entry kind listed in [src/.pi/extensions/compaction/index.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/compaction/index.ts) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor contract) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | | I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec readiness-grade updates; low-confidence implications remain in structured-exchange preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | partially covered (`src/graph/capture/structured-response.test.ts` accepts only directly labeled text facts for the current tracer, rejects implication-only prose as `no_capture`, preserves structural diagnostics, and `src/probes/capture-response-to-graph-proof.test.ts` proves public RPC response capture into selected-spec graph truth; reconciliation-needs and readiness-grade capture remain planned) | D18-L, D47-L; A22-L | -| I31-L | `readiness_grade` is a forward gate, not a workflow location or kind whitelist: higher grades unlock later strategies/commitments/export paths but do not make earlier gathering/refinement invalid or unavailable, and the `CommandExecutor` must not reject a graph node solely because its kind belongs to a later readiness band. All grade mutations route through `CommandExecutor` and carry audit through the change log. | partially covered (`createSpec` / `getSpec` / `updateReadinessGrade` command tests cover storage and mutation audit; `src/agents/compose.test.ts` covers prompt-resource grade gates rejecting illegal pinned commitment selections and filtering AUTO availability; kind-vs-grade write permissiveness remains planned with graph-code/readiness-band work) | D20-L, D45-L, D64-L | +| I31-L | `readiness_grade` is a forward gate, not a workflow location or kind whitelist: higher grades unlock later strategies/commitments/export paths but do not make earlier gathering/refinement invalid or unavailable, and the `CommandExecutor` must not reject a graph node solely because its kind belongs to a later readiness band. All grade mutations route through `CommandExecutor` and carry audit through the change log. | partially covered (`createSpec` / `getSpec` / `updateReadinessGrade` command tests cover storage and mutation audit; `src/.pi/agents/compose.test.ts` covers prompt-resource grade gates rejecting illegal pinned commitment selections and filtering AUTO availability; kind-vs-grade write permissiveness remains planned with graph-code/readiness-band work) | D20-L, D45-L, D64-L | | I32-L | Public RPC structured-exchange driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `session.submitExchangeResponse`, and the deterministic permutation run produces linear Pi JSONL whose structured exchange projection preserves the same prompt/answer/status/comment artifacts as the equivalent TUI structured-exchange path. | covered for deterministic FE-744 parity under canonical session method names (`session.triggerExchange`, `session.pendingExchange`, `session.submitExchangeResponse`, `session.exchanges`): `rpc.discover` contract tests, pending/respond lifecycle tests, current public-RPC structured-exchange permutations, terminal non-answered status handling, option content/rationale parity, no repeated deterministic prompts, and transcript/exchange parity assertions. | D5-L, D48-L, D49-L; I10-L, I13-L, I21-L, I23-L | | I33-L | `capture_*` analysis entries are transcript evidence only: they persist as Brunch structured-exchange `toolResult` rows, are included by Brunch-semantic transcript renderers, are hidden or collapsed in TUI display, and never mutate graph truth or bypass `CommandExecutor`. | partially covered (minimum capture details schemas parse/export and reject graph payload fields; future runtime capture-analysis schema/rendering tests plus transcript renderer fixtures still need to prove persisted result rendering and TUI hide/collapse behavior; later graph-capture fixtures compare analysis candidates against committed graph mutations) | D17-L, D18-L, D37-L, D47-L, D50-L; I2-L, I11-L, I23-L, I30-L | | I34-L | `commitGraph` batch validation is all-or-nothing: if any node or edge in the batch is structurally illegal, the entire batch is rejected and no partial state is persisted; the agent receives diagnostics sufficient for bounded self-correction retry. | covered (`command-executor/commit-graph-batch.test.ts` and graph-tool adapter tests cover dry-run/commit diagnostic parity for invalid basis, missing refs/codes, invalid category/stance, self-loop, invalid node kind/detail shape, rollback of nodes/edges/change_log/counters, transaction-local planning before LSN allocation/writes, and structured adapter diagnostics without thrown projected-code errors or fake endpoint refs) | D53-L; I1-L, I11-L | -| I35-L | Graph context snapshots support multiple detail levels: a cursory/compact full-graph overview for orientation, and detailed node-neighborhood snapshots with configurable hop depth for focused work. Context builders in `agents/contexts/` orchestrate which level to inject or advertise based on mode/goal/strategy/lens/grade. | covered for current POC push path (`getGraphOverview` + `getNodeNeighborhood` in `snapshot.ts` with 10 tests; `src/agents/contexts/{graph,node,cwd}.test.ts` covers lens-shaped overview rendering, bounded node-neighborhood rendering, and selected-spec cwd/session/posture context; `src/.pi/__tests__/prompting.test.ts` proves the explicit shell/product prompt path supplies selected-spec-bound graph snapshots to `composeAgentPrompt()`). Pulled `snapshot-*` tools remain optional future surface. | D52-L, D53-L, D58-L | +| I35-L | Graph context snapshots support multiple detail levels: a cursory/compact full-graph overview for orientation, and detailed node-neighborhood snapshots with configurable hop depth for focused work. Context builders in `.pi/agents/contexts/` orchestrate which level to inject or advertise based on mode/goal/strategy/lens/grade. | covered for current POC push path (`getGraphOverview` + `getNodeNeighborhood` in `snapshot.ts` with 10 tests; `src/.pi/agents/contexts/{graph,node,cwd}.test.ts` covers lens-shaped overview rendering, bounded node-neighborhood rendering, and selected-spec cwd/session/posture context; `src/.pi/__tests__/prompting.test.ts` proves the explicit shell/product prompt path supplies selected-spec-bound graph snapshots to `composeAgentPrompt()`). Pulled `snapshot-*` tools remain optional future surface. | D52-L, D53-L, D58-L | | I36-L | Node `kind` is drawn from a per-plane closed enum structurally validated by the `CommandExecutor`; the intent kind category (basic / structural / reasoning) is a pure function of `kind` and is never stored on the node. | covered (CommandExecutor rejects invalid kind-for-plane; `intentKindCategory` is pure derivation with exhaustive switch; tests in `command-executor.test.ts`) | D54-L, D56-L | | I37-L | `detail` is per-kind validated by the `CommandExecutor`: `decision` and `term` nodes REQUIRE `detail` with their respective sub-schemas; all other kinds must omit `detail`; unknown fields in `detail` are rejected. | covered (detail-required/prohibited/shape tests in `command-executor.test.ts`) | D54-L | -| I38-L | Every Brunch prompt-resource manifest injected for an agent turn is generated from projected runtime state and spec/workspace gates: listed resources are Brunch-owned, readable under the active tool policy, legal for the current `(op_mode × goal × strategy × lens)` / grade / agent allow-list, and off-list resources are not advertised as available. AUTO axes never list illegal choices; pinned axes point to the pinned resource. | covered for current P0 manifest families (`src/agents/compose.test.ts` covers default header/context/manifest output, AUTO grade/allow-list filtering, pinned singleton resources, illegal pinned grade rejection, and readable `src/agents/` locations; `src/.pi/__tests__/prompting.test.ts` covers the explicit shell `before_agent_start` product path appending `agents/compose()` output from transcript-projected runtime state and no legacy composer import/resource discovery. Probe fitness may still track whether the agent reads selected resources before use.) | D39-L, D40-L, D58-L, D59-L | +| I38-L | Every Brunch prompt-resource manifest injected for an agent turn is generated from projected runtime state and spec/workspace gates: listed resources are Brunch-owned, readable under the active tool policy, legal for the current `(op_mode × goal × strategy × lens)` / grade / agent allow-list, and off-list resources are not advertised as available. AUTO axes never list illegal choices; pinned axes point to the pinned resource. | covered for current P0 manifest families (`src/.pi/agents/compose.test.ts` covers default header/context/manifest output, AUTO grade/allow-list filtering, pinned singleton resources, illegal pinned grade rejection, and readable `src/.pi/` locations; `src/.pi/__tests__/prompting.test.ts` covers the explicit shell `before_agent_start` product path appending `agents/compose()` output from transcript-projected runtime state and no legacy composer import/resource discovery. Probe fitness may still track whether the agent reads selected resources before use.) | D39-L, D40-L, D58-L, D59-L | | I39-L | Every graph node in a spec has exactly one stable projected human reference code derived from `kind` + `kind_ordinal`; `(spec_id, plane, kind, kind_ordinal)` is unique; ordinals are monotonic per `(spec_id, plane, kind)` and are not reused after deletion or supersession. | partially covered (`graph-tool-resilience` added `nodes.kind_ordinal`, `node_kind_counters`, DB uniqueness, CommandExecutor allocation for single-node/batch writes, rollback protection, `GraphNode.kindOrdinal` row mapping, globally unique 1–3 letter labels with readiness-band metadata, projected-code parsing, selected-spec adapter resolution before `CommandExecutor`, code-only `commit_graph` / `read_graph` schemas, and code-primary prompt/tool rendering; remaining slice still needs deletion/supersession no-reuse coverage) | D54-L, D62-L; I1-L, I11-L | -| I40-L | Accepted graph nodes and edges use only `basis ∈ explicit | implicit`; review-set approval and direct user statements produce `explicit`, `propose-graph` concept-level materialization produces `implicit`, and the mutation path is recoverable from `change_log` rather than from a persisted basis enum value such as `accepted_review_set`. | partially covered (`graph-tool-resilience` replaced the persisted basis enum with `explicit | implicit`, made `commitGraph` apply one batch approval basis to all created nodes/edges, made single-node `createNode` reject retired basis values before LSN/counter/node/change-log allocation, made `propose-graph` adapter commits implicit, made review-set translation explicit, rejected retired `accepted_review_set`, and records `change_log.operation` independently; `capture-response-to-graph` proves direct structured text responses commit explicit-basis graph nodes through `CommandExecutor`; remaining proof is full review-cycle acceptance) | D26-L, D27-L, D53-L, D63-L | +| I40-L | Accepted graph nodes and edges use only `basis ∈ explicit | implicit`; review-set approval and direct user statements produce `explicit`, `propose-graph` concept-level materialization produces `implicit`, and the mutation path is recoverable from `change_log` rather than from a persisted basis enum value such as `accepted_review_set`. | covered (`graph-tool-resilience` replaced the persisted basis enum with `explicit | implicit`, made `commitGraph` apply one batch approval basis to all created nodes/edges, made single-node `createNode` reject retired basis values before LSN/counter/node/change-log allocation, made `propose-graph` adapter commits implicit, made review-set translation explicit, rejected retired `accepted_review_set`, and records `change_log.operation` independently; `capture-response-to-graph` proves direct structured text responses commit explicit-basis graph nodes through `CommandExecutor`; `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/` proves full review-cycle approval creates explicit-basis graph truth) | D26-L, D27-L, D53-L, D63-L | | I41-L | Same-spec `supersession` edges form an acyclic directed graph; every edge-creation path validates proposed supersession edges together with existing supersession edges before committing. | covered (`command-executor/commit-graph-batch.test.ts` rejects existing-cycle closure, intra-batch cycles, and mixed existing+batch cycles through the shared dry-run/commit planner before batch writes; rejected cycles roll back or avoid batch nodes/edges/change_log; acyclic supersession commits remain covered by snapshot/CommandExecutor success paths) | D51-L, D53-L; I34-L | ## Future Direction Register @@ -318,27 +318,28 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c ### Prompt/runtime profile architecture -- Brunch prompt composition is a **runtime-header + gated prompt-resource manifest** composed per agent by `agents/compose(agentId, sessionState, spec, workspace, snapshots)` (D58-L). The direct injection is intentionally small: agent control summary, runtime state, legal resource manifests (``, ``, ``, `` with `name`, `description`, `location`), router rules for pinned/AUTO axes, and compact context handles/rendered snapshots. Detailed goal/strategy/lens/method bodies are Brunch-owned markdown resources the agent loads with `read` when needed, matching Pi's skill-loading pattern while letting Brunch filter availability per turn. The old `src/.pi/context/` prompt-pack layout is retired; product prompt assets live under `src/agents/`. -- Concrete `agents/` topology (D52-L). The markdown/code boundary falls exactly on the control-plane/behavior split: enforcement and projection are TypeScript; semantic prompting material is markdown. +- Brunch prompt composition is a **runtime-header + gated prompt-resource manifest** composed per agent by `.pi/agents/compose(agentId, sessionState, spec, workspace, snapshots)` (D58-L). The direct injection is intentionally small: agent control summary, runtime state, legal resource manifests (``, ``, ``, `` with `name`, `description`, `location`), router rules for pinned/AUTO axes, and compact context handles/rendered snapshots. Detailed goal/strategy/lens/method bodies are Brunch-owned markdown resources the agent loads with `read` when needed, matching Pi's skill-loading pattern while letting Brunch filter availability per turn. The old `src/.pi/context/` prompt-pack layout is retired; the old top-level `src/agents/` host-independent appearance is retired because these agents live only inside the Pi harness. +- Concrete `.pi/{agents,skills}` topology (D52-L). The markdown/code boundary falls on the control-plane/behavior split: enforcement and projection are TypeScript under `.pi/agents/`; semantic prompting material is markdown under `.pi/agents/definitions/` and `.pi/skills/`. ```text -src/agents/ - state.ts [ts] axis enums + legal (op_mode × goal × strategy × lens) tuple table; - also owns each resource's {name, description, location} manifest entry - compose.ts [ts] projection -> runtime header + gated manifest (not a state machine) - index.ts [ts] public entry / resource registry - definitions/*.md [md+] keyed agents (elicitor foreground; reviewer side). frontmatter = - model/thinking + tool authority + allow-lists; body = system prompt - goals/*.md [md] grounding-advance, elicit-expand, commit-converge, capture-posture - strategies/*.md [md] step-wise-decision-tree, step-wise-disambiguate, propose-graph, project-graph - lenses/*.md [md] intent, design, oracle (future execute: plan, sync, scope) - methods/*.md [md] run-structured-exchange, infer-and-capture, generate-proposal, - read-snapshot, commit-graph, review-for-gaps - contexts/*.ts [ts] D60-L snapshot RENDER layer (cwd, graph, node) -- NOT a manifest resource +src/.pi/ + agents/ + state.ts [ts] axis enums + legal (op_mode × goal × strategy × lens) tuple table; + also owns each resource's {name, description, location} manifest entry + compose.ts [ts] projection -> runtime header + gated manifest (not a state machine) + index.ts [ts] public entry / resource registry + definitions/*.md [md+] keyed agents (elicitor foreground; reviewer side). body = system prompt + contexts/*.ts [ts] D60-L agent-context orchestration (cwd, graph, node) -- NOT a manifest resource + skills/ + goals/*.md [md] grounding-advance, elicit-expand, commit-converge, capture-posture + strategies/*.md [md] step-wise-decision-tree, step-wise-disambiguate, propose-graph, project-graph + lenses/*.md [md] intent, design, oracle (future execute: plan, sync, scope) + methods/*.md [md] run-structured-exchange, infer-and-capture, generate-proposal, + read-snapshot, commit-graph, review-for-gaps ``` -- Manifest metadata is code-owned, not filesystem-discovered: `agents/state.ts` binds each legal axis value to its `{name, description, location}`, and `compose()` emits that binding; the agent `read`s the `.md` body at the listed `location` only when detail matters. This keeps the legal set and its labels in one tested place and honors D39-L sealing (no runtime resource discovery). Frontmatter-sourced manifest metadata is a deferred ergonomics option, not the POC mechanism. -- `agents/contexts/` is the D60-L snapshot render layer (TypeScript), surfaced as the header's compact pushed context or via the `snapshot-*` tools; it is not part of the `read`-on-demand resource manifest and carries no `` family. +- Manifest metadata is code-owned, not filesystem-discovered: `.pi/agents/state.ts` binds each legal axis value to its `{name, description, location}`, and `compose()` emits that binding; the agent `read`s the `.md` body at the listed `location` only when detail matters. This keeps the legal set and its labels in one tested place and honors D39-L sealing (no runtime resource discovery). Frontmatter-sourced manifest metadata is a deferred ergonomics option, not the POC mechanism. +- `.pi/agents/contexts/` is the D60-L agent-context orchestration layer (TypeScript), surfaced as the header's compact pushed context or via the `snapshot-*` tools; reusable text renderers may migrate to `renderers/`, and contexts are not part of the `read`-on-demand resource manifest and carry no `` family. - Workspace **posture** is workspace-scoped product state persisted in `.brunch/workspace.json`, not spec state, session state, or graph truth. D57-L keeps it off the spec row and graph; D58-L composition injects known posture values into the runtime header as an axis of agent influence, and the `capture-posture` goal (D59-L) can confirm or refine those values conversationally. - Readiness is an internal forward gate, not a user-facing workflow stepper, session-local phase, or graph-node-kind whitelist. `readiness_grade` lives on the spec row per D45-L; D64-L readiness bands describe non-exclusive evidence/rubric groupings for goal selection and snapshot filtering. Validators may warn when graph/transcript evidence and assigned grade diverge. Before readiness drives hard tool/agent authority beyond the POC, Brunch needs explicit rubrics for what evidence advances, blocks, or regresses grade. - Prompt resources and Pi skills are both progressive-disclosure mechanisms, but they are not authority. Brunch code owns runtime-state projection, legal tuple filtering, grade/allow-list gating, tool activation, and tool-call blocking. Pi-native skills may be used for startup-scoped capabilities; runtime-state-specific objective/method availability is advertised through Brunch's per-turn manifest so ambient user/project resources cannot leak into product behavior. @@ -377,7 +378,7 @@ src/agents/ ### Chrome surface evolution -- **Title and hidden-thinking-label as state-indicative chrome.** Pi exposes `ctx.ui.setTitle()` and `ctx.ui.setHiddenThinkingLabel()` as small dynamic chrome surfaces. Brunch now uses `setTitle()` narrowly as part of the D35-L chrome wrapper: the title is a stateless projection from the activated product snapshot, currently `brunch — `, and must not synthesize working-state it does not have. Richer title states tied to active role/lens/workflow remain deferred until stable producers exist. Hidden-thinking-label remains deferred: candidate labels vary by agent role or lens (e.g. "Eliciting…", "Reviewing batch…", "Reconciling…") and depend on the relevant subsystems (agent-role dispatcher, lens registry) landing first. +- **Title and hidden-thinking-label as state-indicative chrome.** Pi exposes `ctx.ui.setTitle()` and `ctx.ui.setHiddenThinkingLabel()` as small dynamic chrome surfaces. Brunch now uses `setTitle()` narrowly as part of the D35-L chrome wrapper: the title is a stateless project-first projection from the activated product snapshot (`brunch — ` with selected-spec context when space/surface allows), and must not synthesize working-state it does not have. Richer title states tied to active role/lens/workflow remain deferred until stable producers exist. Hidden-thinking-label remains deferred: candidate labels vary by agent role or lens (e.g. "Eliciting…", "Reviewing batch…", "Reconciling…") and depend on the relevant subsystems (agent-role dispatcher, lens registry) landing first. - **Status keys as the dynamic contribution channel.** `ctx.ui.setStatus(key, text)` remains the multi-extension-friendly seam for other Brunch extensions and future dynamic Brunch state to surface in the footer's status row. Brunch's chrome wrapper does not contribute its own status key by default; it merges all foreign status entries via `footerData.getExtensionStatuses()` into the footer's right column so contributions surface without anyone owning the whole footer. ### Planning persistence evolution @@ -403,10 +404,10 @@ src/agents/ | **Goal** | An optional, AUTO-able session-agent objective axis (D59-L): what the agent currently pursues, derived/gated by `spec.readiness_grade` — `grounding-advance`, `elicit-expand`, `commit-converge`, plus always-on `capture-posture`. Distinct from strategy (the *how*) and lens (the topical focus); `elicit-expand` / `commit-converge` are the diverge/converge pair in the elicitation diamond. | | **AUTO** | The unpinned state of an objective axis (`goal` / `strategy` / `lens`): composition advertises the legal choices in the current prompt-resource manifest and instructs the agent to self-select from that manifest only, reading the selected resource when detail matters (D58-L). | | **Brunch Pi Profile** | The sealed programmatic wrapper around embedded Pi: settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. It allows Brunch-owned resources while suppressing ambient `.pi/` behavior. | -| **Prompt resource** | A Brunch-owned markdown file under `src/agents/` containing detailed goal, strategy, lens, method, or agent-definition guidance. Prompt resources are loaded by the agent with `read` when needed; they are product control-plane assets, not ambient Pi prompt templates. | -| **Prompt-resource manifest** | The small per-turn D58-L manifest injected into the system prompt, listing only runtime-legal Brunch resources with `name`, `description`, and `location`. The `name`/`description`/`location` for each entry are code-owned in `agents/state.ts` (not filesystem-discovered), honoring D39-L sealing; `agents/contexts/` snapshot renderers are not manifest resources. It mirrors Pi's skill-list pattern but is filtered by Brunch runtime state, grade, and allow-lists. | -| **Method** | A tool-usage or workflow competence advertised as a Brunch prompt resource (`agents/methods/*.md`): run structured exchanges, infer-and-capture (D50-L), generate proposals/projections, read snapshots, mutate the graph, review for gaps. Method resources explain when to use a tool family and how to sequence it with other tools; executable tool definitions should stay focused on schemas, authority, and runtime behavior. A method may also be backed by a Pi-native skill, but actual tool authority remains code-owned through `op_mode` policy and active-tool gating. `capability` is retired as a synonym — use `method` and ``. | -| **Snapshot** | An *agent-context* content view the agent reasons over — `cwd`, `graph`, or `node` (D60-L): pulled (typed, read-only) from `graph/`/`session/`, rendered to LLM-string (in `agents/contexts/`) or JSON, surfaced pushed (compose) or pulled (`snapshot-*` / graph-read tools). Graph snapshots explicitly choose graph-truth vs active-context projection and may filter by node kind, readiness band, or edge category/direction. Distinct from the **workspace projection** (`workspace.snapshot`), which is product/UI state, not agent content. | +| **Prompt resource** | A Brunch-owned markdown file under `src/.pi/` containing detailed goal, strategy, lens, method, or agent-definition guidance. Prompt resources are loaded by the agent with `read` when needed; they are product control-plane assets, not ambient Pi prompt templates. | +| **Prompt-resource manifest** | The small per-turn D58-L manifest injected into the system prompt, listing only runtime-legal Brunch resources with `name`, `description`, and `location`. The `name`/`description`/`location` for each entry are code-owned in `.pi/agents/state.ts` (not filesystem-discovered), honoring D39-L sealing; `.pi/agents/contexts/` snapshot renderers are not manifest resources. It mirrors Pi's skill-list pattern but is filtered by Brunch runtime state, grade, and allow-lists. | +| **Method** | A tool-usage or workflow competence advertised as a Brunch prompt resource (`.pi/skills/methods/*.md`): run structured exchanges, infer-and-capture (D50-L), generate proposals/projections, read snapshots, mutate the graph, review for gaps. Method resources explain when to use a tool family and how to sequence it with other tools; executable tool definitions should stay focused on schemas, authority, and runtime behavior. A method may also be backed by a Pi-native skill, but actual tool authority remains code-owned through `op_mode` policy and active-tool gating. `capability` is retired as a synonym — use `method` and ``. | +| **Snapshot** | An *agent-context* content view the agent reasons over — `cwd`, `graph`, or `node` (D60-L): pulled (typed, read-only) from `graph/`/`session/`, rendered to LLM-string (in `.pi/agents/contexts/`) or JSON, surfaced pushed (compose) or pulled (`snapshot-*` / graph-read tools). Graph snapshots explicitly choose graph-truth vs active-context projection and may filter by node kind, readiness band, or edge category/direction. Distinct from the **workspace projection** (`workspace.snapshot`), which is product/UI state, not agent content. | | **Readiness grade** | Spec-owned forward gate stored on the `specs` row: `grounding_onboarding | elicitation_ready | commitments_ready | planning_ready`. It unlocks later strategies, review sets, and eventual export/plan/execute posture, but never forbids earlier gathering, refinement, or capture of clear later-band node kinds. | | **Elicitation posture** | Retired as persisted spec state. Use readiness grade plus active strategy/lens/review-set state to explain elicit behavior. | | **Commitment focus** | Retired as persisted spec state. Future commitment projection should derive from active review-set state and graph evidence if needed. | @@ -417,7 +418,7 @@ src/agents/ | **Spec / specification** | A user-created **initiative that exists to answer a problem** well enough to guide coordinated work, and that can reach a done-state even though the product, domains, and architecture keep evolving (D61-L). Concretely it is a container within a workspace, identified by its intent-graph root, holding sessions and the truth-bearing graph data (claims) gathered through them; the areas, seams, and domains it touches are not its identity. Multiple specs may coexist under one workspace; future plan-execution mode operates on a selected spec. | | **Claim** | Umbrella term for a truth-bearing graph node — the `structural` and `reasoning` intent kinds (requirement, assumption, constraint, invariant, decision, criterion, example) under D54-L/D56-L. Not a separate node kind: revision, conflict, supersession, and current-truth resolution happen at claim (node) level via supersession edges (D51-L), not at whole-spec level (D61-L). A claim is created within a spec; cross-spec claim survival/adoption is deferred (Future Direction §Spec initiative & claim model). | | **Session** | An elicitation transcript belonging to one spec. Backed by a linear pi JSONL session under `.brunch/sessions/`. A spec may have many sessions over time; a session never changes specs. Pi branch/tree mechanics are unsupported Brunch product behavior in the POC. | -| **Session display name** | Optional human-readable label for a session, stored as Pi `session_info` metadata and used by pickers/chrome to distinguish sessions. It may be user-set or Brunch-generated from transcript content; it is not canonical spec/session identity. | +| **Session display name** | Human-readable label for a session, stored as Pi `session_info` metadata and used by pickers/chrome to distinguish sessions. Brunch-created sessions start with neutral workspace-global defaults (`Untitled Session N`); users or best-effort generation may later replace that label with transcript-characterizing text. The label is not canonical spec/session identity. | | **Session binding** | The first Brunch custom entry in a session that binds the Pi session id to exactly one spec id and schema version. Makes JSONL self-describing; registry/index state is an acceleration, not the canonical binding. | | **Client attachment** | An ephemeral TUI instance, browser tab, stdio stream, or WebSocket connection attached to one or more Brunch product resources for viewing or driving. Client attachment state may guide subscriptions and UI routing, but it is not durable spec/session truth. | | **Workspace session coordinator** | The Brunch boot seam that returns `ready | select_spec | needs_human` workspace-session state for a cwd/mode, owns spec selection, selected-session reopening, and `/new`, creates/opens Pi sessions through `SessionManager`, writes `brunch.session_binding`, persists current spec/session acceleration in `.brunch/workspace.json`, and derives chrome state for callers. “Workspace” in this name refers to cwd scope, not a selectable product object. | @@ -429,13 +430,13 @@ src/agents/ | **Design graph** | Modules, interfaces, seams, and adapters accountable to intent. Stubbed in POC. | | **Plan graph** | Milestone/frontier/slice delivery claims accountable to intent, oracle, and design. Stubbed in POC. | | **Graph node code** | Stable spec-scoped human handle projected for a graph node from a hard-coded kind label plus stored monotonic per-kind ordinal (for example `G1`, `CON2`, `R3`, `CR4`). The code string is not stored in graph tables; internal lookup resolves it to `kind` + `kind_ordinal` and then to integer `NodeId`. Primary handle for `#`-mentions, snapshots, and agent prompts (D62-L). | -| **LSN** | Log Sequence Number. A single monotonic counter, one-LSN-per-commit, shared by the change log, graph-node versions, and reconciliation needs. | -| **Change log** | The audit trail of graph mutations. Authoritative for replay, `worldUpdate` synthesis, and reconciliation-need ordering. | -| **Reconciliation need** | First-class record of an open impasse, gap, contradiction, or process debt; carries `created_at_lsn`, optional `resolved_at_lsn`, and a target that is exactly one of `{kind:'edge', edgeId}` or `{kind:'node_pair', aId, bId}` per `docs/design/GRAPH_MODEL.md`. Recon-needs are a separate substrate, not graph edges (no `concerns`-edge wiring). Routine async jobs are not reconciliation needs unless they surface semantic work to resolve. | +| **LSN** | Log Sequence Number. A spec-local monotonic counter, one-LSN-per-selected-spec-commit, shared inside that spec by the change log, graph-node versions, and reconciliation needs. Compare as `{specId, lsn}`, never as a bare workspace-global number. | +| **Change log** | The audit trail of graph mutations, keyed by `(spec_id, lsn)`. Authoritative for selected-spec replay, `worldUpdate` synthesis, and reconciliation-need ordering. | +| **Reconciliation need** | First-class record of an open impasse, gap, contradiction, or process debt; carries `created_at_lsn`, optional `resolved_at_lsn`, and a target that is exactly one of `{kind:'edge', edgeId}` or `{kind:'node_pair', aId, bId}` per `docs/design/GRAPH_MODEL.md`. Recon-needs are spec-owned and use their owning spec's local graph clock. They are a separate substrate, not graph edges (no `concerns`-edge wiring). Routine async jobs are not reconciliation needs unless they surface semantic work to resolve. | | **Coherence verdict** | Per-spec product state (`coherent` / `incoherent`) emitted by validators and visible to both UI and agent. | | **Command layer** | The single Brunch-owned mutation surface. Validates, gates concurrency, audits, emits events, triggers coherence. Its public mutation entry point is the `CommandExecutor`, not direct ORM calls or caller-side authority gates. | -| **Command executor** | The deep module that accepts Brunch product commands plus execution context and returns structured command results (`ok`, `needs_human`, `policy_blocked`, `version_conflict`, `structural_illegal`). It hides attribution, minimal pre-M6 authority classification, validation, kind-ordinal allocation, transaction, LSN, change-log, and coherence-trigger mechanics from callers. | -| **commitGraph** | Single-tool atomic batch mutation accepting one approval basis plus `{ nodes, edges }` with intra-batch and existing-node references. One tool call, one LSN, stable kind-ordinal allocation, all-or-nothing (I34-L). The load-bearing tool for the `propose-graph` strategy's direct-commit path (D53-L), where concept-level materialization is `basis: implicit` (D63-L). | +| **Command executor** | The deep module that accepts Brunch product commands plus execution context and returns structured command results (`ok`, `needs_human`, `policy_blocked`, `version_conflict`, `structural_illegal`). It hides attribution, minimal pre-M6 authority classification, validation, kind-ordinal allocation, transaction, spec-local LSN, change-log, and coherence-trigger mechanics from callers. | +| **commitGraph** | Single-tool atomic batch mutation accepting one approval basis plus `{ nodes, edges }` with intra-batch and existing-node references. One tool call, one selected-spec LSN, stable kind-ordinal allocation, all-or-nothing (I34-L). The load-bearing tool for the `propose-graph` strategy's direct-commit path (D53-L), where concept-level materialization is `basis: implicit` (D63-L). | | **propose-graph** | Elicitor strategy for generative lenses where the agent proposes a novel coherent subgraph. The concept is presented to the user with rubric axes, choices, and recommendation via structured exchange; upon acceptance the agent generates and persists the full subgraph through `commitGraph` without intermediate entity-level review (D26-L, D53-L). The hardest thing to get structurally legal and the primary proof target for A14-L. | | **project-graph** | Elicitor strategy for deriving nodes and edges from existing graph truth (e.g. projecting requirements from upstream goals/constraints). Uses review-set commitment (D27-L). Extractive rather than inventive; lower structural-legality risk than propose-graph. | | **Brunch public RPC surface** | The one product-facing JSON-RPC surface exposed over stdio, WebSocket, and in-process handlers. Product clients use this surface for workspace, session, graph, and coherence projections plus session-native interaction methods; raw Pi RPC is hidden behind adapters when needed. | @@ -456,8 +457,8 @@ src/agents/ | **RPC structured-exchange parity proof** | The FE-744 product proof that a public Brunch RPC agent-as-user can complete the current deterministic structured-exchange permutations and leave Pi JSONL plus Brunch projections comparable in semantic kind and quality to a TUI-driven session. Contrasts with future generative elicitation-quality probes and with the raw Pi RPC structured-exchange editor fallback proof, which is supporting evidence only. | | **Structured-exchange preface** | Plain prose in a structured-exchange payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | | **Structured exchange tool** | A registered Pi tool in the `present_*` / `request_*` / future `capture_*` families. `present_*` tools persist assistant-originated offer/question/proposal markdown; `request_*` tools collect and persist the user's response; `capture_*` tools persist assistant analysis of likely semantic changes without mutating graph truth. Durable UI after reload/resume is rebuilt from toolResult `content`/`details` through `renderResult`, not from `renderCall` or live UI state. | -| **Present tool** | A `present_*` structured exchange tool (`present_question`, `present_options`, future `present_review_set`, `present_candidates`) whose toolResult markdown is the durable assistant-originated half of the exchange. The target details model identifies present rows with `schema: "brunch.structured_exchange.present"`, `v`, `exchange_id`, and `tool_meta.curr` / `tool_meta.next`; a present-side `status: presented` field is not needed because a persisted present result is already presented. | -| **Request tool** | A `request_*` structured exchange tool (`request_answer`, `request_choice`, `request_choices`, future `request_review`) whose live UI collects the user response and whose toolResult markdown/details are the durable response half. The target details model references sequence through `exchange_id` plus `tool_meta.prev`/`curr`/optional `next`, and encodes terminal outcome as exactly one of `answered`, `cancelled`, or `unavailable`. | +| **Present tool** | A `present_*` structured exchange tool (`present_question`, `present_options`, `present_review_set`, future `present_candidates`) whose toolResult markdown is the durable assistant-originated half of the exchange. The target details model identifies present rows with `schema: "brunch.structured_exchange.present"`, `v`, `exchange_id`, and `tool_meta.curr` / `tool_meta.next`; a present-side `status: presented` field is not needed because a persisted present result is already presented. | +| **Request tool** | A `request_*` structured exchange tool (`request_answer`, `request_choice`, `request_choices`, `request_review`) whose live UI collects the user response and whose toolResult markdown/details are the durable response half. The target details model references sequence through `exchange_id` plus `tool_meta.prev`/`curr`/optional `next`, and encodes terminal outcome as exactly one of `answered`, `cancelled`, or `unavailable`. | | **Capture tool** | A future `capture_*` structured-exchange tool (for example `capture_analysis`) whose normal persisted `toolResult` records ANALYSIS: high-confidence candidate graph mutations and low-confidence clarification candidates grounded in transcript evidence. It is transcript-visible but UI-hidden when possible, otherwise maximally collapsed; it is never a graph mutation. | | **ANALYSIS transcript section** | Human-reviewable transcript rendering of `capture_*` tool results. ANALYSIS explains candidate node/edge changes and uncertainties before graph persistence or before comparing later graph mutations to the transcript; it is evidence, not authority. | | **Structured exchange result details** | The structured payload in a structured-exchange toolResult. The target Zod-authored model uses checked `schema` + `v`, `exchange_id`, and `tool_meta`; request details use property presence (`answered`, `cancelled`, or `unavailable`) plus typed answer/choice/review `comment` data; `message` is reserved for runtime-authored cancellation/unavailable explanations; minimal capture details carry sequence identity only until a later design approves richer analysis payloads. Brunch projection should not need render lifecycle state to rebuild the exchange. | @@ -472,8 +473,8 @@ src/agents/ | **Subagent registry** | The set of registered subagent definitions loaded from `src/.pi/extensions/subagents/agents/*.md` at extension activation. Brunch-owned only for the POC; cross-extension agent registration is deferred. | | **Subagent agent definition** | A markdown file with TypeBox-validated frontmatter (`name`, `description`, `tools`, `model`) plus a system-prompt body. The frontmatter is the registry contract; the body is the subagent's standing instructions. | | **Auto-compaction extension** | The Brunch-owned `session_before_compact` extension (`src/.pi/extensions/auto-compaction.ts`) that renders the preserved anchor set as a deterministic markdown header and prepends it to an LLM-generated narrative summary. Resolves its summarization model through the active agent definition's model preference; falls through to Pi default compaction on auth/empty-output/unexpected errors. | -| **Preserved anchor set** | The configured list of transcript entry kinds and selection rules that must survive compaction byte-stable. Canonical source is [src/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/auto-compaction-anchors.json); each rule is `{ kind, select, rationale }` where `select ∈ first | latest | active-leaves | all-unresolved`. Externalized so it can be reviewed and updated for correctness without SPEC churn. | -| **Anchor contract** | The data inside the preserved-anchor JSON config — distinct from the rendering policy (which lives in code) and the LLM summarization (which is bundle-resolved). | +| **Preserved anchor set** | The configured list of transcript entry kinds and selection rules that must survive compaction byte-stable. Canonical source is [src/.pi/extensions/compaction/index.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/compaction/index.ts); each rule is `{ kind, select, rationale }` where `select ∈ first | latest | active-leaves | all-unresolved`. Externalized so it can be reviewed and updated for correctness without SPEC churn. | +| **Anchor contract** | The data inside the preserved-anchor TypeScript contract — distinct from the rendering policy (which lives in code) and the LLM summarization (which is bundle-resolved). | | **World update** | `worldUpdate` custom message synthesised in `prepareNextTurn` summarising relevant graph changes since the session's `lastSeenLsn`. | | **Mention ledger** | Per-session `(entity_id, snapshotted_lsn)` record driving discretionary staleness hints when an entity has changed since the agent last saw it. | | **Authority** | Source of a node's claim: `stakeholder | technical | external | derived`. | @@ -568,7 +569,7 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | Inner | Schema/shape validation at boundaries | JSON-RPC payloads, command results, structured elicitation entries, Zod-authored structured-exchange present/request/capture details with JSON Schema export, probe report metadata, graph exports, graph node-code/basis fields, runtime-gated prompt-resource manifests, and structured-exchange payload facets for review proposals, establishment offers, and elicitor intent hints (lens presence, `epistemic_status`, grounding coverage, entity-draft shape). | R8, R10, R11, R17, R20, R21, R23; I3-L, I10-L, I11-L, I17-L, I18-L, I23-L, I26-L, I38-L, I39-L, I40-L. | | Middle | **Probe oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to workspace-dialog startup behavior, in-flight reviewer-signal chrome behavior, and ambient-affordance rendering from latest establishment-offer structured-exchange facet. | D11-L, D21-L, D22-L, D25-L, D29-L, D36-L; I8-L, I13-L, I22-L. | | Middle | Round-trip tests | JSONL reload, linear transcript validation, session exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | -| Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, stable kind-ordinal allocation/no-reuse, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes` / `supersession` acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L, I39-L, I41-L. | +| Middle | Property-based / model-based tests | Spec-local LSN monotonicity, change-log replay, reconciliation-need invariants, stable kind-ordinal allocation/no-reuse, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one selected-spec LSN / one change-log entry, partial-batch impossible under mid-batch validation failure)**, **`supersedes` / `supersession` acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L, I39-L, I41-L. | | Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; `rpc.discover` describes public methods with usable schemas/examples; `session.triggerExchange` / `session.pendingExchange` / `session.submitExchangeResponse` / `session.exchanges` preserve transcript truth; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L, D48-L, D49-L; R11, R12, R27, R28. | | Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; spec readiness-grade mutations route through commands rather than session-local memory; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile; Brunch product extensions load through the explicit static shell list rather than filesystem discovery or a runtime extension-metadata protocol. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L, D45-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L, I31-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | @@ -593,12 +594,12 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | Invariant | Assigned oracle(s) | | --- | --- | -| I1-L | M4 property/model-based LSN and replay tests. | +| I1-L | `CommandExecutor`/migration/snapshot/RPC/seed-fixture tests now cover spec-local LSN allocation, exactly one `graph_clock` row per persisted spec, `(spec_id, lsn)` change-log shape, sibling isolation, missing-clock invariant failure, and rollback no-bump behavior; M4/M7 replay/property tests still extend this to generated traces. | | I2-L | M5 architectural boundary test plus `CommandExecutor` contract tests. | | I3-L | M2 JSONL round-trip tests and fixture replay parity. | -| I4-L | M7 generated LSN/change traces and paired-session fixture assertions. | +| I4-L | M7 generated `{specId, lsn}` change traces and paired-session fixture assertions. | | I5-L | M7 property tests over binding/lens transitions and interest-set recomputation. | -| I6-L | M4/M8 reconciliation-need property tests and contradictory-requirements fixture. | +| I6-L | `CommandExecutor` reconciliation-need create/resolve tests now cover spec-local LSN ordering; M4/M8 contradictory-requirements fixtures still cover semantic need invariants. | | I7-L | ~~M4+ framing matrix tests.~~ **Retired** with `framing_as` (D54-L, D56-L). | | I8-L | M0 probe oracle plus M2 coordinator-created JSONL reload tests. | | I9-L | M7 mention parser/ledger unit tests and staleness property tests. | @@ -607,19 +608,19 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | I12-L | M7 side-task delivery invariant tests and adversarial fixture when side tasks are active. | | I13-L | Structured-exchange pending/respond projection tests plus FE-744 public-RPC parity probe for idle linear-session leaf state; richer probe runs still planned. | | I14-L | Deferred unless observer/auditor queue lands: restart/idempotence tests over exchange-keyed jobs, plus proof that next-turn freshness does not depend on the async job completing. | -| I15-L | M5+ middle-loop property tests for batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible under mid-batch validation failure) paired with `acceptReviewSet` contract tests; review-set fixture parity in replay. | +| I15-L | `acceptReviewSet` contract tests plus FE-809 public-RPC review approval tests/probe prove one selected-spec LSN / one change-log entry / one explicit-basis batch, with partial acceptance unrepresentable. Future property tests can broaden batch-acceptance fuzzing but are no longer the first proof. | | I16-L | M5+ middle-loop architectural boundary test on reviewer-attributed `CommandExecutor` writers (rejects any non-`reconciliation_need` target); paired with reviewer-attributed command-result audit fixture. | | I17-L | M5+ inner-loop schema validation on review-set structured-exchange payloads (must declare `epistemic_status`); paired with outer-loop fixture assertion that status varies appropriately with grounding density (POC-phase fitness, not gate). | | I18-L | M5+ inner-loop schema validation on elicitor-emitted structured-exchange payload facets that need routing (must declare `lens`); paired with middle-loop property test that generated payloads route to the correct capture/reviewer/future-auditor consumer. | | I19-L | Brunch extension/runtime guard tests for `/tree`/`/fork`/`/clone` blocking plus transcript-reader non-linearity rejection tests. | -| I20-L | M5+ proposal-validation contract and differential tests proving only dry-run-valid proposals become reviewable review sets. | +| I20-L | Proposal-validation contract tests plus `present_review_set` dry-run gating prove invalid proposals emit non-reviewable `structural_illegal`; FE-809 real probe confirms invalid agent attempts did not become the pending review exchange. | | I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | | I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI probe assertions: no stale transcript before explicit resume, new-spec path creates an implicit first session, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id, and RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code. | | I23-L | FE-744 structured-exchange tests: `present_*` results persist rich markdown display through `toolResult.content`/`renderResult`; `request_*` tools mount an input-replacing TUI response surface when available; single-choice, multi-choice, freeform, and freeform-plus-choice answers persist as self-contained request result details; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; recovery helpers detect unmatched required presents; session exchange projection pairs the prompt-side present with the terminal request result. Structured-exchange schema tests cover the landed target details model: checked `schema`/`v`, `tool_meta`, candidate rubric/graph-ref shapes, review-set pointer shape, request answered/cancelled/unavailable unions, `comment` vs runtime `message`, and capture no-graph-payload minimum. | | I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | -| I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active `op_mode` / `strategy` / `lens` / `goal` (foreground role derived from `op_mode`), and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | +| I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active `op_mode` / `strategy` / `lens` / `goal` (foreground role derived from `op_mode`), and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit` while selected-spec grade gates activate commitment proposal tools. | | I26-L | Structured-exchange schema tests prove the acknowledged Zod seam parses and exports JSON Schema; future M4 architectural tests should grep/import-audit schema libraries and Drizzle row-schema derivation boundaries. | -| I28-L | Inner — TypeBox schema validation of [src/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/auto-compaction-anchors.json) shape; deterministic anchor-rendering unit tests (same branch + same config → same header bytes). Middle (M9) — compaction round-trip property tests across all configured anchors and selection rules; fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer (M9) — long-horizon adversarial fixture confirms session binding, latest runtime state, latest establishment offer, in-flight side-task results, and unresolved staleness hints remain agent-intelligible post-compaction. | +| I28-L | Inner — TypeBox schema validation of [src/.pi/extensions/compaction/index.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/compaction/index.ts) shape; deterministic anchor-rendering unit tests (same branch + same config → same header bytes). Middle (M9) — compaction round-trip property tests across all configured anchors and selection rules; fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer (M9) — long-horizon adversarial fixture confirms session binding, latest runtime state, latest establishment offer, in-flight side-task results, and unresolved staleness hints remain agent-intelligible post-compaction. | | I29-L | Inner — argv-shape tests for the `subagent` tool prove every spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent `--tools`/`--extension`/`--models`/`--append-system-prompt` set; TypeBox schema validation of `src/.pi/extensions/subagents/agents/*.md` frontmatter and `src/.pi/extensions/subagents/config.json`. Middle — isolation audit (no ambient `.pi/` resources reachable inside the subprocess; tool-allowlist conformance per starter agent; parent `CommandExecutor`/Brunch RPC handlers absent from subprocess environment). Outer — probe-driven proposal-generation runs invoking scout/researcher/graph-reader confirm grounding inputs flow through subagent outputs into review-set proposals without bypassing primary authority. | | I30-L | FE-807 covers the current labeled-text response tracer: committed graph facts are compared against transcript evidence and implication-only prose returns `no_capture`. Future capture fixtures still need reconciliation-need and readiness-grade cases plus broader LLM-quality comparisons against preface-only interpretations. | | I31-L | Spec-row command tests for grade updates plus prompt/tool-policy tests proving grade gates unlock later actions without disabling gathering/refinement; graph write tests proving later-band node kinds are not rejected solely because the current spec grade is lower. Card 1 covers the CommandExecutor grade-write path; prompt/tool-policy tests remain with M5. | @@ -629,7 +630,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | I37-L | M4 node-creation tests: decision/term rejected without detail; constraint accepted with or without detail; other kinds rejected with detail; unknown detail fields rejected. | | I38-L | `agents-composition-layer` inner tests: given projected runtime states and spec grades, compose emits manifests whose goal/strategy/lens/method resources are legal, Brunch-owned, readable, and filtered by the agent allow-list; AUTO axes list only legal choices and pinned axes point to their selected resource. Middle/outer probes may track whether the model actually reads the selected resource before applying it as fitness, not as an inner-loop gate. | | I39-L | `graph-tool-resilience` CommandExecutor/adapter/context tests: counter rows allocate monotonic per-kind ordinals in multi-node batches, rollback does not persist failed ordinals/counter rows, DB constraints reject duplicate `(spec_id, plane, kind, kind_ordinal)`, projected-code metadata is unique and parses by longest prefix, existing-code refs resolve inside the selected spec, and prompt/tool renderers use codes as primary handles. Remaining proof: deletion/supersession no-reuse. | -| I40-L | `graph-tool-resilience` CommandExecutor/adapter tests: `commitGraph` applies one batch basis to all created nodes/edges, single-node `createNode` rejects retired basis values before LSN/counter/node/change-log allocation, `propose-graph` adapter commits use `implicit`, review-set translation uses `explicit`, retired `accepted_review_set` is rejected, and `change_log.operation` remains independent of basis. FE-807 adds direct structured text response capture with `basis: explicit`. Remaining proof: full review-cycle acceptance path. | +| I40-L | `graph-tool-resilience` CommandExecutor/adapter tests: `commitGraph` applies one batch basis to all created nodes/edges, single-node `createNode` rejects retired basis values before LSN/counter/node/change-log allocation, `propose-graph` adapter commits use `implicit`, review-set translation uses `explicit`, retired `accepted_review_set` is rejected, and `change_log.operation` remains independent of basis. FE-807 adds direct structured text response capture with `basis: explicit`. FE-809 adds real project-graph review-cycle acceptance proof with explicit-basis readback under `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/`. | | I41-L | `graph-tool-resilience` CommandExecutor tests reject supersession cycles across existing edges, intra-batch edges, and mixed existing+batch edges, including rollback of batch nodes/edges/change_log; existing acyclic supersession paths still commit. | ### Design Notes diff --git a/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md b/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md new file mode 100644 index 000000000..a181f7545 --- /dev/null +++ b/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md @@ -0,0 +1,226 @@ +# Semantic graph mutations for fixture curation + +Frontier: dev-seed-fixtures +Status: active +Mode: chain +Created: 2026-06-05 + +## Orientation + +- Containing seam: `graph/CommandExecutor` as the single graph-truth mutation boundary. The current creation-only `commitGraph({nodes, edges})` shape is sufficient for `propose-graph` creation, but not for manual curation of persisted seed specs where humans must patch or remove existing graph items. +- Relevant frontier item: `dev-seed-fixtures` because the immediate product need is curated Bilal/reference seed data that can be edited in a local DB and exported back to `.fixtures/seeds/**`. This slice also touches the cross-frontier graph mutation contract (`D4-L`, `D20-L`, `D53-L`), so it must reconcile SPEC/GRAPH_MODEL when built. +- Volatile handoff state: a clean curation workspace exists at `.fixtures/workbenches/bilal-curation`; DB→fixture export and the one-shot RPC helper are already in place (`src/graph/export-fixtures.ts`, `src/dev/workspace-rpc.ts`). Active FE-809 review-cycle work currently touches `src/graph/command-executor.ts` and review-set graph code, so this scope is **not parallel-safe** on the same worktree until that work lands or the builder moves to an isolated worktree. +- Main open risk: edit/delete semantics can accidentally become a second mutation model. The implementation must preserve one transaction, one spec-local LSN, one change-log row, all-or-nothing structural validation, and no direct DB writes outside `CommandExecutor`. + +Posture: proving (inherited from `dev-seed-fixtures`). + +Frontier-level cross-cutting obligations this slice carries: + +- Preserve D4-L/D20-L: all semantic graph mutations route through the Brunch command layer and return structured command results. +- Preserve D16-L/A4-L: every graph mutation allocates exactly one `{specId, lsn}` through the target spec's existing `graph_clock` row; bare LSNs remain non-comparable across specs. +- Preserve D51-L/D54-L: accepted node `plane`/`kind` and edge `category`/endpoints/`stance` are immutable; changing those means delete+create or supersession, not an in-place patch. +- Preserve D62-L: `kind_ordinal` is monotonic and never reused after deletion or supersession; rendered codes stay projected, not stored. +- Preserve D63-L: `basis` remains approval strength (`explicit | implicit`), not mutation pathway. Editing or deleting an item does not rewrite its original basis. +- Preserve D19-L: curation-only RPC lives under `dev.*`, is enabled only by `BRUNCH_DEV_RPC=1`, and is absent from normal product discovery/read-only sidecars. +- Preserve D52-L: `graph/` owns mutation semantics; `rpc/` and `.pi/extensions/` adapt boundary refs and publish invalidation, never import `db/`. + +## Card 1 — Canonical semantic graph mutation command + +Status: next +Weight: full + +### Target Behavior + +`CommandExecutor` accepts one atomic selected-spec graph mutation batch containing create, patch, and delete operations over accepted graph nodes and edges. + +### Boundary Crossings + +```pseudo +→ graph command input type(s) +→ semantic mutation planner / structural validation +→ CommandExecutor transaction boundary +→ SQLite graph rows + spec-local graph_clock/change_log +→ graph snapshots / existing product callers +→ graph topology docs + SPEC/GRAPH_MODEL reconciliation +``` + +### Risks and Assumptions + +- RISK: `commitGraph` and the new semantic batch command drift into two validation engines. + → MITIGATION: one private planner/engine owns structural validation and write planning; any creation-only public surface is only an operation-constructor over that engine, or is removed by breakage-driven repair if no longer needed. +- RISK: delete semantics create dangling edges or surprising cascades. + → MITIGATION: node deletion rejects incident edges by default; destructive incident-edge deletion requires an explicit operation option and records deleted edge ids in the same change-log payload. +- RISK: in-place patches weaken immutable graph-shape decisions. + → MITIGATION: patch only mutable fields (`node.title`, `node.body`, `node.source`, `node.detail`; `edge.rationale`); reject `plane`, `kind`, `kindOrdinal`, `category`, endpoints, `stance`, `basis`, and LSN fields in patch payloads. +- RISK: adapters or tests silently depend on raw DB ids for human curation. + → MITIGATION: core may use internal ids after adapter resolution, but boundary tests must prove selected-spec projected-code resolution for the curation path in Card 2. +- ASSUMPTION: hard delete is acceptable for pre-release manual fixture curation. + → IMPACT IF FALSE: curation would need explicit supersession/retirement operations instead of deletion, changing exporter and UI expectations. + → VALIDATE: tests cover both hard delete and supersession preservation through graph-truth export; if the user wants historical curation lineage, scope a separate retention model before using deletes for reference fixtures. + → memory/SPEC.md: D51-L currently says accepted graph items are present-or-absent and category/kind changes are delete+recreate; no new assumption id expected unless this proves false. + +### Posture check + +This proving slice is a tracer bullet on two axes: + +- **Invariants:** it stabilizes the command-layer shape required to edit seed truth without bypassing `CommandExecutor`. +- **Proof of life:** a mixed create/update/delete batch must be visible through normal graph snapshots and later exportable as seed JSON. + +It deliberately does not attempt a full UI curation workflow or write leases. Those are adjacent surfaces, not required to prove the mutation seam. + +### Acceptance Criteria + +```pseudo tree +semantic graph mutation command +├── creation parity +│ ├── ✓ create-node/create-edge ops can express the existing `commitGraph` creation batch shape +│ ├── ✓ intra-batch refs and existing same-spec refs validate before any write +│ └── ✓ structural-illegal creation batch writes no rows and does not advance graph_clock +├── node patch +│ ├── ✓ patching title/body/source/detail advances only `updated_at_lsn` on the node +│ ├── ✓ invalid per-kind detail is rejected before LSN allocation +│ └── ✓ immutable fields (`plane`, `kind`, `kindOrdinal`, `basis`) are not patchable +├── edge patch +│ ├── ✓ patching rationale advances only `updated_at_lsn` on the edge +│ └── ✓ immutable fields (`category`, `source`, `target`, `stance`, `basis`) are not patchable +├── deletion +│ ├── ✓ deleting an edge removes that edge and records its id in the batch result/change-log payload +│ ├── ✓ deleting a node with incident edges rejects by default before LSN allocation +│ ├── ✓ deleting a node with explicit incident-edge deletion removes the node and its incident edges in one transaction +│ └── ✓ deleting a node does not decrement or reuse `(spec, plane, kind)` ordinals +├── atomicity and audit +│ ├── ✓ mixed create/patch/delete batch consumes one spec-local LSN and one change-log row +│ ├── ✓ any invalid op rejects the whole batch with diagnostics and no partial writes +│ ├── ✓ refs to nodes/edges from a sibling spec are rejected +│ └── ✓ result reports created, updated, and deleted node/edge identities sufficiently for adapters and tests +└── reconciliation + ├── ✓ existing creation callers either use the semantic engine or are updated directly; no second validation path remains + ├── ✓ `src/graph/README.md` describes the surviving command shape + └── ✓ `memory/SPEC.md` / `docs/design/GRAPH_MODEL.md` reconcile D53-L from creation-only `commitGraph` to semantic graph mutation, or explicitly preserve `commitGraph` as a creation-specific product tool over the same engine +``` + +### Verification Approach + +- Inner: `CommandExecutor` unit/regression tests — prove validation, all-or-nothing writes, spec scoping, LSN/change-log behavior, and immutable-field rules. +- Inner: snapshot/export cross-check — after a mixed mutation, `getGraphOverview(..., graph_truth)` and `exportSeedFixture` reflect the post-mutation graph. +- Middle: compile/import repair over existing graph callers — proves the old creation path did not keep an unmaintained validation fork. + +### Cross-cutting obligations + +- Do not introduce a generic records/data API; this remains graph-native command input. +- Do not add a permanent compatibility bridge. A creation-only `commitGraph` facade is acceptable only if it is still a present product tool name and delegates to the semantic engine without owning validation. +- Do not introduce workspace-global writes or compare bare LSNs. + +### Expected touched paths (tentative) + +```pseudo tree +src/graph/ +├── command-executor.ts ~ +├── command-executor.test.ts ~ +├── command-executor/ +│ ├── commit-graph-types.ts ~ +│ ├── commit-graph-batch.ts ~ +│ ├── semantic-mutation-types.ts +? +│ ├── semantic-mutation-planner.ts +? +│ └── semantic-mutation.test.ts +? +├── export-fixtures.test.ts ~ +├── index.ts ~ +└── README.md ~ +src/.pi/extensions/graph/ ? +src/graph/capture/ ? +src/rpc/ ? +docs/design/GRAPH_MODEL.md ~ +memory/SPEC.md ~ +memory/PLAN.md ? +``` + +## Card 2 — Dev curation RPC exposes semantic mutations by projected codes + +Status: next after Card 1 +Weight: full + +### Target Behavior + +A local curation agent can apply semantic graph mutations to a seeded workspace through one dev-only RPC method using projected graph codes instead of raw DB ids. + +### Boundary Crossings + +```pseudo +→ dev JSON-RPC params over stdio +→ selected-spec projected-code resolution +→ CommandExecutor semantic mutation command +→ product-update invalidation `{specId, lsn}` +→ seeded-dev workflow docs / one-shot RPC helper usage +``` + +### Risks and Assumptions + +- RISK: dev RPC becomes an accidental public product API. + → MITIGATION: method name stays under `dev.graph.*`, discovery requires `BRUNCH_DEV_RPC=1`, and read-only sidecars do not expose it. +- RISK: curation payloads require raw IDs and become unusable from UI/readback context. + → MITIGATION: node targets and edge endpoints at the RPC boundary accept projected existing codes (`G1`, `CTX4`, `R2`) and batch refs; raw edge ids may be allowed only where no stable projected edge code exists yet. +- RISK: creation-only `dev.graph.commitGraph` remains as stale docs/API after semantic mutation lands. + → MITIGATION: update `docs/testing/seeded-dev-rpc.md` to present the semantic method as the curation path; keep `dev.graph.commitGraph` only if it is intentionally retained as a tiny create-only convenience over the same command engine. +- ASSUMPTION: a one-shot JSON helper is enough ergonomics for agents before a richer `brunch-dev` CLI. + → IMPACT IF FALSE: curation sessions will stall on command ceremony, and a small command-specific CLI should be scoped next. + → VALIDATE: run manual smoke commands against a temporary seeded workspace and record the command shape in the docs. + +### Posture check + +This is a proving slice because it lights up the real local curation entrypoint without committing to a broad CLI or UI editor. It should be enough for an agent to patch/delete the Bilal specs safely; if not, the failed smoke identifies the next ergonomic slice. + +### Acceptance Criteria + +```pseudo tree +dev curation mutation RPC +├── discovery and access +│ ├── ✓ `rpc.discover` includes the method only when `BRUNCH_DEV_RPC=1` +│ └── ✓ the method is absent from normal/read-only sidecar discovery +├── refs and validation +│ ├── ✓ node targets accept selected-spec projected codes and reject malformed/unresolved codes with field diagnostics +│ ├── ✓ sibling-spec codes do not resolve accidentally +│ ├── ✓ batch create refs can be used by same-batch create-edge ops +│ └── ✓ invalid semantic operations return `structural_illegal` without writes +├── mutation behavior +│ ├── ✓ update-node, delete-edge, and create-node/create-edge work through the same RPC method +│ ├── ✓ success publishes `brunch.updated` with `{topic: "graph.overview", specId, lsn}` or the established graph mutation update payload +│ └── ✓ graph.overview readback shows the post-mutation graph and unchanged sibling-spec LSNs +└── workflow ergonomics + ├── ✓ `src/dev/workspace-rpc.ts` can call the semantic dev mutation method without JSON-RPC stdin ceremony + ├── ✓ `docs/testing/seeded-dev-rpc.md` shows one curation mutation example and one fixture export example + └── ✓ a fresh temporary seed workspace smoke mutates one spec, verifies sibling LSN stability, and exports the mutated spec JSON for inspection +``` + +### Verification Approach + +- Inner: RPC handler/discovery tests — prove dev-only exposure, schema validation, projected-code diagnostics, and product-update payloads. +- Middle: one-shot helper smoke against a temporary seeded workspace — prove the actual command an agent will use works end to end. +- Outer: optional manual curation rehearsal in `.fixtures/workbenches/bilal-curation` only after the user confirms the workspace may be mutated. + +### Cross-cutting obligations + +- Keep one-writer discipline: do not run dev RPC writes concurrently with TUI/agent writes against the same workspace unless deliberately testing concurrency. +- Do not add package scripts or bin aliases while `package.json` is dirty from unrelated work; the helper path is sufficient for this slice. +- Do not capture curated fixtures into reusable seed files until the user has reviewed the UI-curated content. + +### Expected touched paths (tentative) + +```pseudo tree +src/rpc/ +├── methods/dev-graph.ts ~ +├── handlers.test.ts ~ +└── README.md ? +src/dev/ +└── workspace-rpc.ts ~ +docs/testing/seeded-dev-rpc.md ~ +.fixtures/workbenches/ ? (scratch smoke only; do not commit DB state) +``` + +## Foreseeable follow-ons not scoped as build cards yet + +These are intentionally named but not pre-scoped because their exact shape depends on the manual curation discoveries made after Cards 1–2 land. + +1. **Manual Bilal spec curation pass.** Use `.fixtures/workbenches/bilal-curation` and the semantic dev mutation method to repair the current ported specs. Do not encode this as a code card until the user identifies the concrete curation edits or target quality rubric. +2. **Capture curated reference seed set.** Export reviewed DB state into a new seed set such as `.fixtures/seeds/bilal-curated/`; add a README documenting provenance (`bilal-port` + manual Brunch curation) and update seed tests only after the curated files exist. +3. **Richer curation CLI.** If `workspace-rpc.ts` plus JSON payloads remain too cumbersome, scope a tiny command-specific helper (`overview`, `mutate`, `capture`) without touching `package.json` until package-file dirtiness clears or the user asks for a bin/script. +4. **Product tool expansion.** Decide separately whether the agent-facing `commit_graph` tool should remain creation-only (likely) or gain patch/delete operations. Do not silently expose deletion to autonomous agents just because dev curation needs it. diff --git a/memory/cards/poc-live-ship-gate--live-mention-autocomplete.md b/memory/cards/poc-live-ship-gate--live-mention-autocomplete.md new file mode 100644 index 000000000..2279ff206 --- /dev/null +++ b/memory/cards/poc-live-ship-gate--live-mention-autocomplete.md @@ -0,0 +1,84 @@ +# Live selected-spec mention autocomplete + +Frontier: poc-live-ship-gate +Status: active +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. +- 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.snapshots.getGraphOverview()`. +- Keep projection trivial and local unless another surface needs the same structured candidate shape. + +### Assumption dependency + +None — this slice builds against already-landed selected-spec graph snapshots and Pi autocomplete provider seams. + +### 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/project-graph-review-cycle--approval-wiring.md b/memory/cards/project-graph-review-cycle--approval-wiring.md new file mode 100644 index 000000000..ebd4a03f5 --- /dev/null +++ b/memory/cards/project-graph-review-cycle--approval-wiring.md @@ -0,0 +1,152 @@ +# Review-set approval wiring + +Frontier: project-graph-review-cycle +Status: done +Mode: single +Created: 2026-06-06 + +## Orientation + +- Containing seam: FE-809 `project-graph-review-cycle`, specifically the product path from a transcript-backed `present_review_set` / `request_review` structured exchange to graph truth through `CommandExecutor.acceptReviewSet`. +- Relevant frontier item: `project-graph-review-cycle` ([FE-809](https://linear.app/hash/issue/FE-809/project-graph-review-set-proposal-and-atomic-acceptance)); the schema/emission lock is complete, and PLAN names approval-to-`acceptReviewSet` product wiring plus the real `project-graph` probe as remaining FE-809 work. +- Volatile handoff state: the topology cleanup moved reusable exchange helpers to `src/projections/structured-exchange/` and `src/renderers/structured-exchange/`, Pi exchange tools to `src/.pi/extensions/exchanges/`, and app entrypoints to `src/app/`. Two unrelated active cards remain: live mention autocomplete and dev semantic graph mutations. The semantic mutation card should wait because this slice may touch `CommandExecutor` / review-set graph code. +- Main open risk: approval could accidentally commit a stale or reconstructed payload that differs from the reviewed `present_review_set`. This slice must recover the exact pending review-set details from transcript truth, translate them once, and commit atomically only for `decision: "approve"`. + +Posture: proving (inherited from `project-graph-review-cycle`). + +Frontier-level cross-cutting obligations this slice carries: + +- Preserve D27-L/I15-L: review-set approval is one `acceptReviewSet` command, one spec-local LSN, one change-log row, and no partial acceptance. +- Preserve D28-L: request-changes and reject are transcript-visible terminal outcomes; they do not mutate graph truth in this slice. +- Preserve D4-L/D20-L: graph mutation routes only through `CommandExecutor`; RPC/session code must not write graph rows or call DB directly. +- Preserve D61-L/D62-L: existing graph references in review payloads use selected-spec projected codes at adapter/UI boundaries, then resolve inside graph translation. +- Preserve D63-L/I40-L: accepted review-set graph rows are `basis: explicit`; the review payload does not carry per-item basis. +- Preserve D37-L/D41-L: request-review details remain Zod-authored structured-exchange transcript payloads; TypeBox is only the RPC/Pi parameter adapter where needed. +- Preserve harness-as-false-proof guard: tests should exercise the public session/RPC path or the same projection helpers it uses, not private graph calls masquerading as product wiring. + +## Card 1 — Approve review-set exchange into graph truth + +Status: done +Weight: full + +Completed: 2026-06-06 — `session.submitExchangeResponse` now accepts review decisions, appends canonical `request_review` terminal results, routes approve through `CommandExecutor.acceptReviewSet`, publishes graph invalidations on approval, and leaves request-changes/reject non-mutating. Verified with `npm run verify`. + +### Target Behavior + +Submitting an approved pending review exchange commits the exact presented review set into the selected spec graph through `CommandExecutor.acceptReviewSet`. + +### Boundary Crossings + +```pseudo +→ transcript-backed pending review exchange +→ session.submitExchangeResponse public RPC params +→ request_review toolResult projection / append +→ reviewed present_review_set payload recovery +→ CommandExecutor.acceptReviewSet +→ graph/change_log/product update projections +→ docs/PLAN reconciliation for FE-809 remaining work +``` + +### Risks and Assumptions + +- RISK: Pending review response may be accepted without a matching open `present_review_set`. + → MITIGATION: recover the pending exchange via `pendingExchangeFromEnvelope`; require `pending.mode === "review"`, matching `exchangeId`, and a recoverable `reviewSet` payload before accepting review responses. +- RISK: The pending projection currently exposes `review_set` in snake_case details shape, while `CommandExecutor.acceptReviewSet` consumes the graph-domain camelCase `ReviewSetProposalPayload`. + → MITIGATION: add an explicit, tested adapter from canonical present-review-set details back to graph-domain payload, preserving exact node/edge semantics and selected-spec existing-code refs. +- RISK: Approval graph mutation may happen before the request-review toolResult is appended, making transcript audit look out of order. + → MITIGATION: define and test the order; prefer append+flush terminal `request_review` first, then commit with `proposalEntryId` pointing at the persisted request or reviewed present entry, unless implementation proves Pi session manager cannot expose the appended id cheaply. Whatever order is chosen must be documented in result/change-log tests. +- RISK: Reusing `session.submitExchangeResponse` for review decisions could break text/choice capture semantics. + → MITIGATION: extend the params schema as an additional answer branch (`{review:{decision, comment?}}`) and keep existing text/choice/multi-choice tests green. +- RISK: Request-changes could require immediate successor proposal generation. + → MITIGATION: this slice records the terminal request-changes outcome and returns a non-mutating result; successor generation remains a later FE-809/probe behavior unless already provided by the agent loop. +- ASSUMPTION: Approve/reject/request-changes can share `session.submitExchangeResponse` rather than adding a new public method. + → IMPACT IF FALSE: web/RPC clients would need a separate review response method, and PLAN/RPC docs would need a larger public-surface change. + → VALIDATE: handler tests prove `session.pendingExchange` returns `mode: "review"`, `session.submitExchangeResponse` accepts the review decision, and product updates/refetches match existing session mutation patterns. + → memory/SPEC.md: D49-L already lists `request_review` as a terminal response supported by `session.submitExchangeResponse`. + +### Posture check + +This proving slice lights up the FE-809 product path that is currently only graph-ready in isolation: transcript review approval → exact graph acceptance → observable graph update. It also stabilizes the invariant that review approval cannot be a caller-side patch/commit sequence. + +### Acceptance Criteria + +```pseudo tree +review approval product path +├── pending review projection +│ ├── ✓ session.pendingExchange returns `mode: "review"` with the canonical reviewed nodes/edges from a matching `present_review_set` +│ └── ✓ malformed or unsupported review-set details do not become an approvable pending review +├── response params and transcript append +│ ├── ✓ session.submitExchangeResponse accepts `{answer:{review:{decision:"approve", comment?}}}` only for pending review exchanges +│ ├── ✓ request_changes requires a non-empty comment and appends a canonical `request_review` toolResult without graph mutation +│ ├── ✓ reject appends a canonical `request_review` toolResult without graph mutation +│ └── ✓ text/choice/multi-choice response behavior and synchronous capture stay unchanged +├── approve-to-graph +│ ├── ✓ approve translates the exact reviewed `review_set` payload to `ReviewSetProposalPayload` and calls `CommandExecutor.acceptReviewSet` +│ ├── ✓ accepted nodes/edges are written with `basis: explicit`, one selected-spec LSN, and one `accept_review_set` change-log row +│ ├── ✓ existing-code endpoints resolve only inside the selected spec +│ ├── ✓ structural-illegal review payload returns a loud `structural_illegal` acceptance result and does not append misleading success state +│ └── ✓ success publishes selected-session updates and graph mutation updates with `{specId, lsn}` +├── public/result shape +│ ├── ✓ submit result distinguishes `review: {status:"approved", lsn,...}` from ordinary `capture` results or deliberately extends the existing result without overloading capture +│ └── ✓ rpc.discover schema/examples describe review submission without creating `reviewSet.*` methods +└── reconciliation + ├── ✓ src/rpc/README.md reflects `request_review` support if the public response schema changes + ├── ✓ memory/PLAN.md FE-809 execution pointer advances to the real `project-graph` proposal probe + └── ✓ memory/SPEC.md / docs/design/REVIEW_SETS.md are updated only if implementation changes D27-L/D28-L/D49-L semantics +``` + +### Verification Approach + +- Inner: session projection tests — prove review pending exchange recovery from Pi-like `present_review_set` toolResult details. +- Inner: RPC handler tests — drive `session.submitExchangeResponse` against a selected session containing a pending review exchange and assert transcript append, graph rows, change-log, and product updates. +- Inner: graph translation tests — prove details-to-review-payload adapter preserves selected-spec projected-code refs and rejects drifted fields. +- Middle: small product-path fixture/probe (deterministic, no LLM) — activate a workspace/spec/session, append/present a review set through the same structured-exchange projection helpers, submit approve over public RPC, read `graph.overview` back. +- Outer: real `project-graph` LLM proposal probe is not part of this card unless the implementation turns out already wired enough; scope/run it after this card lands. + +### Cross-cutting obligations + +- Do not add standalone `reviewSet.*` public RPC methods or DB-backed review-set entities. +- Do not expose partial acceptance or accept-with-edits. +- Do not add reviewer async jobs in this slice; D29-L reviewer remains deferred unless explicitly scoped. +- Do not give the web/browser direct graph mutation authority for review drafts; browser submits the review decision, not graph nodes/edges. +- Do not widen `commit_graph` tool semantics while wiring review approval. + +### Expected touched paths (tentative) + +```pseudo tree +src/session/ +├── structured-exchange-loop.ts ~ +├── structured-exchange-loop.test.ts ~ +├── exchange-projection.ts ? +└── exchange-projection.test.ts ? + +src/projections/structured-exchange/ +├── present-review-set.ts ~ +├── request-review.ts ~ +├── review-set-payload.ts +? +└── *.test.ts +? + +src/renderers/structured-exchange/ +└── request-review.ts ? + +src/rpc/ +├── methods/session.ts ~ +├── handlers.test.ts ~ +└── README.md ~? + +src/graph/ +├── review-set.ts ? +├── review-set.test.ts ? +└── command-executor/accept-review-set.test.ts ? + +src/probes/ +├── review-set-approval-proof.ts +? +└── review-set-approval-proof.test.ts +? + +memory/ +├── PLAN.md ~ +└── SPEC.md ? + +docs/design/ +└── REVIEW_SETS.md ? +``` diff --git a/memory/cards/project-graph-review-cycle--real-probe.md b/memory/cards/project-graph-review-cycle--real-probe.md new file mode 100644 index 000000000..291d5bb5a --- /dev/null +++ b/memory/cards/project-graph-review-cycle--real-probe.md @@ -0,0 +1,101 @@ +# Project-graph review-cycle real probe + +Frontier: project-graph-review-cycle +Status: done +Mode: single +Created: 2026-06-06 + +## Orientation + +- Containing seam: FE-809 `project-graph-review-cycle`; product approval wiring is landed, and the remaining risk is whether the real agent strategy emits a reviewable `present_review_set` that can be approved through public RPC. +- Relevant frontier item: `project-graph-review-cycle` on branch `ln/fe-809-project-graph-review-cycle`. +- Volatile state: reuse the clean explicit-basis fixture `.fixtures/seeds/bilal-port-variants/macro-view-grounded-intent.json`; do not mutate `.fixtures/workbenches/bilal-curation`. +- Main open risk: a synthetic probe could accidentally prove only the adapter/RPC path, not the real `project-graph` agent proposal path. + +Posture: proving (inherited from `project-graph-review-cycle`). + +## Card 1 — Real project-graph review-cycle probe + +Status: done +Weight: light + +Completed: 2026-06-06 — added `src/probes/project-graph-review-cycle-proof.ts`, fixed the commitment-grade active-tool policy so `project-graph` can call `present_review_set` / `request_review`, and persisted successful evidence at `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/`. The real run recorded two non-reviewable `structural_illegal` dry-run attempts before one successful reviewable `present_review_set`; public RPC approval then committed 2 explicit nodes and 4 explicit edges at selected-spec LSN 4. + +### Objective + +Add and run a reusable probe that proves a real `project-graph` agent turn can produce a dry-run-valid review set, expose it as a pending review exchange, approve it through `session.submitExchangeResponse`, and read back the explicit-basis graph commit. + +### Acceptance Criteria + +```pseudo tree +project-graph review-cycle proof +├── reusable probe script +│ ├── ✓ seeds a temp workspace from `.fixtures/seeds/bilal-port-variants/macro-view-grounded-intent.json` +│ ├── ✓ switches the selected session to `agentStrategy: "project-graph"` +│ ├── ✓ prompts the real agent to read graph context and produce one successful reviewable review set +│ ├── ✓ approves the pending review through public Brunch RPC, not by calling `acceptReviewSet` directly +│ └── ✓ writes `session.jsonl`, `transcript.md`, `report.json`, and `graph-snapshot.json` under `.fixtures/runs/project-graph-review-cycle//` +├── report oracle +│ ├── ✓ records whether `present_review_set` and `request_review` transcript results were observed +│ ├── ✓ records approve result status, LSN, created node/edge counts, and explicit-basis readback +│ ├── ✓ flags friction when no pending review exchange appears, approval fails, or graph truth is unchanged +│ └── ✓ fails success unless the graph LSN advances for the selected spec through review approval +└── frontier reconciliation + ├── ✓ `memory/PLAN.md` marks FE-809 complete if the real probe succeeds + └── ✓ residual agent/prompt drift becomes a narrow follow-up if the probe fails for non-harness reasons +``` + +### Verification Approach + +- Inner: probe unit tests for transcript parsing, report summarization, and artifact path/report writing. +- Middle: targeted probe command with the real agent runtime. +- Gate: `npm run verify` if implementation changes source/tests beyond generated run artifacts. + +### Cross-cutting obligations + +- No direct SQLite graph writes. +- No direct `CommandExecutor.acceptReviewSet` call from the probe; approval must go through public RPC so the proof covers transcript recovery and response wiring. +- Preserve projected-code graph references at the review-payload boundary. +- Keep generated evidence in `.fixtures/runs/project-graph-review-cycle/`; do not use or mutate `.fixtures/workbenches/bilal-curation`. + +### Assumption dependency + +None — the probe is the proof for the remaining FE-809 assumption. + +### Expected touched paths (tentative) + +```pseudo tree +src/probes/ +├── project-graph-review-cycle-proof.ts + +└── project-graph-review-cycle-proof.test.ts + + +src/.pi/ +├── agents/ +│ ├── state.ts ~ +│ └── state.test.ts ~ +└── __tests__/ + └── prompting.test.ts ~ + +.fixtures/runs/project-graph-review-cycle/ +└── / + + ├── session.jsonl + ├── transcript.md + ├── report.json + └── graph-snapshot.json + +memory/ +├── PLAN.md ~ +└── cards/project-graph-review-cycle--real-probe.md ~ +``` + +### Promotion checklist + +- [ ] Changes a requirement +- [ ] Creates, retires, or invalidates an assumption +- [ ] Depends on an unvalidated high-impact assumption +- [ ] Makes or reverses a non-trivial design decision +- [ ] Establishes a new seam-level invariant +- [ ] Changes a frontier-level cross-cutting obligation or verification architecture layer +- [ ] Crosses more than two major implementation seams rather than observing them through the probe +- [ ] First touch in an unfamiliar seam +- [ ] Cannot name the containing seam or rationale diff --git a/memory/cards/tooling--worktree-command-ux.md b/memory/cards/tooling--worktree-command-ux.md deleted file mode 100644 index 323fcbb9c..000000000 --- a/memory/cards/tooling--worktree-command-ux.md +++ /dev/null @@ -1,171 +0,0 @@ -# Worktree command UX hardening - -Frontier: n/a -Status: active -Mode: chain -Created: 2026-06-05 - -## Orientation - -- Containing seam: project-local direct-Pi developer tooling in `.pi/extensions/worktree/index.ts`, not Brunch product runtime code. -- Relevant frontier item: n/a. This is a tooling follow-up to commits `ab562d64` and `f6ee3104`; it should stay outside `memory/PLAN.md` unless the user promotes developer-workflow tooling to a frontier. -- Volatile handoff state: no `HANDOFF.md`; worktree is clean except the unrelated active `memory/cards/dev-seed-fixtures--curation-loop.md` scope file, which this slice must not touch. -- Main open risk: slash-command UX can drift from Brunch's established namespacing convention or accidentally preserve old aliases; this local tooling is still under free-rewrite posture, so make the new command names canonical. - -Posture: proving (inherited from project default; no containing PLAN frontier). - -Cross-cutting obligations this chain carries: - -- Preserve D39-L's tooling exception: root `.pi/extensions/worktree/index.ts` is direct-Pi developer convenience only and must not enter `src/.pi/pi-extension-shell.ts` or the sealed Brunch Pi Profile. -- Preserve worktree safety invariants from the landed extension: create from caller `HEAD`, warn on dirty caller state, preserve old session files, and never delete/prune worktrees. -- Use the existing Brunch command namespace pattern as the reference: `src/.pi/extensions/commands.ts` registers literal command names like `brunch:switch`; its file comment documents that Pi parses slash command names up to the first whitespace and passes colons through verbatim. - -## Card 1 — Namespace worktree slash commands - -Status: done -Weight: light - -### Objective - -Make `/worktree:create` and `/worktree:switch` the canonical slash commands for the project-local worktree extension. - -### Acceptance Criteria - -```pseudo -command registration -├── registers `worktree:create` for sibling worktree creation -├── registers `worktree:switch` for session relocation -├── does not register `/create-worktree` or `/switch-worktree` aliases -└── follows the `src/.pi/extensions/commands.ts` pattern: literal command constants containing `:` - -staged command text -├── `createSiblingWorktree` stages `/worktree:switch ` -├── `switch_worktree` stages `/worktree:switch ` -├── tool descriptions / prompt snippets name `/worktree:switch` -└── test expectations no longer mention old slash-command names except as negative assertions - -canonical docs -└── `memory/SPEC.md` D39-L tooling exception, if it names slash commands, names `/worktree:create` and `/worktree:switch` -``` - -### Verification Approach - -- Inner: `npm test -- src/.pi/__tests__/project-worktree-extension.test.ts` — proves registration, editor staging, and command-text changes. -- Inner: `npx oxlint .pi/extensions/worktree/index.ts src/.pi/__tests__/project-worktree-extension.test.ts` and `npx oxfmt --check ...` — proves touched files remain linted/formatted. - -### Cross-cutting obligations - -- Keep tool names `create_worktree` and `switch_worktree`; this card only renames slash commands. -- Do not add compatibility aliases for old slash commands unless the user explicitly asks. -- Do not modify Brunch product command registration under `src/.pi/extensions/commands.ts`; use it only as a pattern reference. - -### Assumption dependency - -None — Pi colon command parsing is already used by `src/.pi/extensions/commands.ts` and covered by existing Brunch command practice. - -### Expected touched paths (tentative) - -```pseudo -.pi/extensions/ -└── worktree/ - └── index.ts ~ - -src/.pi/__tests__/ -└── project-worktree-extension.test.ts ~ - -memory/ -└── SPEC.md ? -``` - -Done 2026-06-05: - -- Renamed canonical slash commands to `/worktree:switch` and `/worktree:create` while preserving tool names `switch_worktree` and `create_worktree`. -- Updated create/switch editor staging and tool descriptions/prompt snippets to name `/worktree:switch`. -- Reconciled D39-L tooling exception in `memory/SPEC.md` to name the namespaced slash commands. - -### Promotion checklist - -- [ ] Does this change a requirement? No — this is local tooling UX naming. -- [ ] Does this create, retire, or invalidate an assumption? No. -- [ ] Does this slice depend on an unvalidated high-impact assumption? No. -- [ ] Does this make or reverse a non-trivial design decision? No — follows existing colon namespace pattern. -- [ ] Does this establish a new seam-level invariant? No. -- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No. -- [ ] Does it cross more than two major seams? No. -- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? No. -- [ ] Can you not name the containing seam or current rationale from the live docs? No. - -## Card 2 — Offer existing worktrees from no-arg switch - -Status: next -Weight: light - -### Objective - -Make `/worktree:switch` without a path open an interactive selector over existing sibling/current-repo worktrees. - -### Acceptance Criteria - -```pseudo -no-arg switch discovery -├── `/worktree:switch ` keeps the existing direct validation + confirm + relocation behavior -├── `/worktree:switch` runs `git worktree list --porcelain` from the caller cwd -├── parses worktree entries into path plus branch/detached display metadata -├── excludes the caller worktree root from selectable targets -├── notifies when the caller cwd is not in a git repository -├── notifies when there are no other worktrees -└── cancels cleanly when the user dismisses the selector - -interactive selection -├── uses `ctx.ui.select` so the choice appears in Pi's overlay/dialog UI -├── labels options with enough context to distinguish path and branch/detached state -├── passes the selected path through the existing `runSwitchWorktree` validation path -└── keeps the existing confirmation before session relocation - -tests -├── covers porcelain parsing for branch and detached entries -├── covers exclusion of the current worktree -├── covers selector cancellation -└── covers selecting an existing worktree and reaching the switch path -``` - -### Verification Approach - -- Inner: helper tests for `git worktree list --porcelain` parsing and option filtering. -- Inner: command-handler/unit tests with fake `ctx.ui.select` — proves no-arg behavior, cancellation, and selected-path handoff. -- Middle: temp-git smoke in `src/.pi/__tests__/project-worktree-extension.test.ts` if practical — proves discovery sees linked worktrees created by real git. -- Gate: `npm run verify` before commit, scoped failures outside touched files reported rather than fixed. - -### Cross-cutting obligations - -- Keep relocation itself on the existing validated/confirmed `runSwitchWorktree` path; the selector is only target choice, not a bypass. -- Do not build worktree list/delete/prune management. -- Do not auto-switch when only one alternative exists; still show/select or otherwise require explicit user action. -- Do not use this command as a Brunch product spec/session switcher; it is direct-Pi cwd/session relocation only. - -### Assumption dependency - -None — this depends only on Git's stable porcelain worktree listing and existing Pi `ctx.ui.select` behavior. - -### Expected touched paths (tentative) - -```pseudo -.pi/extensions/ -└── worktree/ - └── index.ts ~ - -src/.pi/__tests__/ -└── project-worktree-extension.test.ts ~ -``` - -### Promotion checklist - -- [ ] Does this change a requirement? No — this hardens local tooling UX. -- [ ] Does this create, retire, or invalidate an assumption? No. -- [ ] Does this slice depend on an unvalidated high-impact assumption? No. -- [ ] Does this make or reverse a non-trivial design decision? No. -- [ ] Does this establish a new seam-level invariant? No. -- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No. -- [ ] Does it cross more than two major seams? No. -- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? No. -- [ ] Can you not name the containing seam or current rationale from the live docs? No. diff --git a/package-lock.json b/package-lock.json index 52edaa0a3..9f7aeeada 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@sinclair/typebox": "^0.34.49", + "@tailwindcss/vite": "^4.3.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "^7.6.13", @@ -40,6 +41,7 @@ "oxfmt": "latest", "oxlint": "^1.68.0", "oxlint-tsgolint": "^0.23.0", + "tailwindcss": "^4.3.0", "tsx": "^4.22.4", "typescript": "^5.7.0", "typescript-language-server": "^5.3.0", @@ -3545,6 +3547,38 @@ } } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -3552,6 +3586,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@mistralai/mistralai": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", @@ -4861,6 +4906,290 @@ "dev": true, "license": "MIT" }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@tanstack/history": { "version": "1.162.0", "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", @@ -6234,6 +6563,20 @@ "once": "^1.4.0" } }, + "node_modules/enhanced-resolve": { + "version": "5.22.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz", + "integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -6537,6 +6880,13 @@ "node": ">=14" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -6627,6 +6977,16 @@ "node": ">=18" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7864,6 +8224,27 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", diff --git a/package.json b/package.json index 582c3b2e3..d53afdddc 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,12 @@ "assets" ], "type": "module", - "main": "./dist/brunch.js", - "types": "./dist/brunch.d.ts", + "main": "./dist/app/brunch.js", + "types": "./dist/app/brunch.d.ts", "scripts": { - "dev": "tsx src/brunch.ts", + "dev": "tsx src/app/brunch.ts", "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/agents && cp -R src/.pi/components/workspace-dialog/assets dist/.pi/components/workspace-dialog/ && cp -R src/agents/definitions src/agents/goals src/agents/strategies src/agents/lenses src/agents/methods dist/agents/", + "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", "seed": "tsx src/graph/seed-fixtures.ts", "db:generate": "drizzle-kit generate", @@ -48,6 +48,7 @@ }, "devDependencies": { "@sinclair/typebox": "^0.34.49", + "@tailwindcss/vite": "^4.3.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "^7.6.13", @@ -64,6 +65,7 @@ "oxfmt": "latest", "oxlint": "^1.68.0", "oxlint-tsgolint": "^0.23.0", + "tailwindcss": "^4.3.0", "tsx": "^4.22.4", "typescript": "^5.7.0", "typescript-language-server": "^5.3.0", diff --git a/src/.pi/README.md b/src/.pi/README.md index 3779148b9..5b48f452d 100644 --- a/src/.pi/README.md +++ b/src/.pi/README.md @@ -1,14 +1,55 @@ -# Brunch Pi extension iteration +# .pi/ — Brunch Pi runtime surface -This directory is intentionally shaped like a project-local Pi resource tree so Brunch-owned extensions can be hot-reloaded while developing TUI affordances. +SPEC decisions: D25-L, D34-L, D35-L, D37-L, D39-L, D40-L, D52-L, D58-L, D59-L, D60-L -```bash -cd src -pi -# edit .pi/extensions/... or .pi/components/... -/reload +This directory is Brunch's sealed Pi-harness surface. It contains the agent personas/resources, Pi-native skill resources, product extension registrars, and reusable TUI components that run inside the embedded Pi coding-agent harness. + +## Owns + +- Pi-facing agent prompt assembly and runtime prompt resources. +- Pi extension registration: tools, lifecycle hooks, command handlers, autocomplete, TUI chrome, workspace dialogs. +- Pi-native skills/resources that the agent reads on demand. +- Reusable Pi TUI components used by those extensions. + +## Does NOT own + +- Graph truth, mutation policy, readers, or graph DTOs — `graph/` and target `projections/graph/`. +- Pi JSONL/session semantics and workspace/session coordination — `session/`. +- Product JSON-RPC handlers — `rpc/`. +- React client UI — `web/`. +- Reusable product projection/rendering once hoisted — target `projections/` and `renderers/` seams. + +## Layout + +```text +.pi/ +├── README.md +├── settings.json dev Pi settings for local `.pi` iteration +├── brunch-pi-settings.ts sealed Pi settings/resource-loader policy +├── brunch-pi-extensions.ts explicit Brunch extension factory; no ambient discovery +├── agents/ agent roles + stateful prompt assembly code +│ ├── definitions/ role prompt resources +│ └── contexts/ agent-context selection/render orchestration +├── skills/ goal/strategy/lens/method resources read by the agent +│ ├── goals/ +│ ├── strategies/ +│ ├── lenses/ +│ └── methods/ +├── components/ reusable Pi TUI/message components +└── extensions/ Pi registrars and runtime adapters +``` + +## Boundary rules + +```pseudo +rules: + .pi/agents/ -> session/, graph/ [state projection + snapshot pulls] + .pi/skills/ x> TypeScript imports [markdown resources only] + .pi/extensions/ -> .pi/agents/, .pi/components/, graph/, session/, rpc/ [adapter imports] + .pi/extensions/ x> db/ [no direct storage] + graph/, session/ x> .pi/ [domain layers never import Pi] ``` -Production Brunch does not rely on ambient discovery from the repository root. The product shell imports these modules explicitly; tests for extensions/components live in `.pi/__tests__/`, not inside auto-discovered resource directories. +Production Brunch does not rely on ambient discovery from the repository root. The product shell imports extension factories explicitly; tests for extensions/components live in `.pi/__tests__/`. -Prompting is adapter-only here: `extensions/prompting.ts` handles Pi `before_agent_start` and delegates composition to `src/agents/compose.ts` with explicit selected-spec/workspace context. Prompt resources and context renderers live under `src/agents/`; `.pi/` must not carry prompt-pack sources. +`SYSTEM.md` / `APPEND_SYSTEM.md` are Pi's static ambient prompt files. Brunch's dynamic selected-spec/runtime prompt contribution is per-turn and therefore uses `before_agent_start` in `extensions/system-prompts/`, appending to the already assembled Pi system prompt by returning `systemPrompt: event.systemPrompt + brunchPrompt`. diff --git a/src/.pi/__tests__/chrome.test.ts b/src/.pi/__tests__/chrome.test.ts index b958f96a7..b400dcbe7 100644 --- a/src/.pi/__tests__/chrome.test.ts +++ b/src/.pi/__tests__/chrome.test.ts @@ -4,17 +4,15 @@ import { describe, expect, it } from 'vitest'; import type { WorkspaceSessionReadyState } from '../../session/workspace-session-coordinator.js'; import { chromeStateForWorkspace, - formatBrunchChromeHeaderLines, - formatChromeWidgetLines, projectBrunchChromeFooterLines, renderBrunchChrome, -} from '../extensions/chrome.js'; +} from '../extensions/chrome/index.js'; describe('Brunch chrome projection', () => { it('uses activated session state instead of fabricating unbound', async () => { const state = chromeStateForWorkspace(readyWorkspace('/tmp/project', 'session-real')); - expect(formatBrunchChromeHeaderLines(state).join('\n')).toContain('session-real'); + expect(state.session.id).toBe('session-real'); }); it('populates session.label from workspace session name when available', () => { @@ -22,31 +20,16 @@ describe('Brunch chrome projection', () => { const state = chromeStateForWorkspace(workspace); expect(state.session.label).toBe('My spec — session 1'); - expect(formatBrunchChromeHeaderLines(state).join('\n')).toContain('My spec — session 1'); }); - it('formats chrome header as wordmark plus runtime-state summary', async () => { - const state = { - cwd: '/tmp/project', - spec: { id: 1, title: 'Spec One' }, - session: { id: 'session-1', label: 'Interview #1' }, - phase: 'elicitation' as const, - chatMode: 'responding-to-elicitation' as const, - runtime: { - bundle: 'elicit-default', - role: 'elicitor', - model: 'claude-sonnet', - thinking: 'medium', - lens: 'intent', - }, - }; + it('uses discovered workspace project identity when the coordinator supplies it', () => { + const workspace = readyWorkspace('/tmp/project', 'session-abc'); + workspace.chrome.project = { name: 'Package App', slug: 'package-app' }; + const state = chromeStateForWorkspace(workspace); - expect(formatBrunchChromeHeaderLines(state)).toEqual([ - '█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █', - '█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█', - 'runtime: elicit-default · role elicitor · claude-sonnet · thinking medium · lens intent', - 'spec: Spec One · session: Interview #1 · phase: elicitation', - ]); + expect(projectBrunchChromeFooterLines(state)[2]).toBe( + 'proj: Package App | spec: Spec One | mode: not reported | strategy: not reported | lens: not reported', + ); }); it('formats honest Brunch chrome from one product-state snapshot', async () => { @@ -58,28 +41,12 @@ describe('Brunch chrome projection', () => { chatMode: 'responding-to-elicitation' as const, }; - expect(formatBrunchChromeHeaderLines(state)).toEqual([ - '█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █', - '█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█', - 'runtime: not reported', - 'spec: Spec One · session: Interview #1 · phase: elicitation', - ]); expect(projectBrunchChromeFooterLines(state)).toEqual([ - 'brunch · runtime: not reported · build: not reported', - 'context: not reported', - 'state: responding-to-elicitation · coherence: unknown · worker: not reported', - 'spec: Spec One · session: Interview #1', + '/tmp/project no model', + 'no branch ctx ──────────── ?% ?/0', + 'proj: project | spec: Spec One | mode: not reported | strategy: not reported | lens: not reported', '', ]); - expect(formatChromeWidgetLines(state)).toEqual([ - 'brunch: █▄▄ █▀█ █ █ █▄ █ █▀▀ █ █ / █▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█', - 'cwd: /tmp/project', - 'spec: Spec One', - 'session: Interview #1', - 'runtime: not reported', - 'context: not reported', - 'chat mode: responding-to-elicitation', - ]); }); it('formats rich optional runtime and context metadata without fabricating missing fields', () => { @@ -94,7 +61,7 @@ describe('Brunch chrome projection', () => { role: 'elicitor', model: 'claude-sonnet', thinking: 'medium', - lens: 'intent', + lens: 'intent' as const, }, build: { version: 'v0.0.0', dev: 'dev abc123' }, contextUsage: { usedTokens: 1024, maxTokens: 2048 }, @@ -103,13 +70,11 @@ describe('Brunch chrome projection', () => { }; expect(projectBrunchChromeFooterLines(state)).toEqual([ - 'brunch · runtime: elicit-default · role elicitor · claude-sonnet · thinking medium · lens intent · build: v0.0.0 dev abc123', - 'context: [█████░░░░░] 1,024/2,048 tokens (50%)', - 'state: responding-to-elicitation · coherence: needs_review · worker: observer-review/queued', - 'spec: Spec One · session: Interview #1', + '/tmp/project claude-sonnet • medium', + 'no branch ctx ━━━━━━────── 50% 1.0k/2.0k', + 'proj: project | spec: Spec One | mode: not reported | strategy: not reported | lens: intent', '', ]); - expect(formatChromeWidgetLines(state)).toContain('context: [█████░░░░░] 1,024/2,048 tokens (50%)'); }); it('projects footer telemetry and foreign statuses without publishing a chrome status key', async () => { @@ -139,11 +104,13 @@ describe('Brunch chrome projection', () => { ).join('\n'); expect(footer).toContain('Spec One'); - expect(footer).toContain('Interview #1'); expect(footer).toContain('main'); expect(footer).toContain('claude-sonnet'); - expect(footer).toContain('thinking medium'); - expect(footer).toContain('[█████░░░░░] 1,024/2,048 tokens (50%)'); + expect(footer).toContain('medium'); + expect(footer).toContain('ctx ━━━━━━────── 50% 1.0k/2.0k'); + expect(footer).toContain( + 'proj: project | spec: Spec One | mode: not reported | strategy: not reported | lens: not reported', + ); expect(footer).toContain('reviewer queued'); expect(footer).not.toContain('should not echo'); }); @@ -168,23 +135,10 @@ describe('Brunch chrome projection', () => { chatMode: 'responding-to-elicitation', }); - expect(calls.map((call) => call.method)).toEqual(['setHeader', 'setFooter', 'setWidget', 'setTitle']); + expect(calls.map((call) => call.method)).toEqual(['setFooter', 'setTitle']); expect(calls.find((call) => call.method === 'setFooter')?.args[0]).toEqual(expect.any(Function)); expect(calls.some((call) => call.method === 'setStatus')).toBe(false); - expect(calls.find((call) => call.method === 'setWidget')?.args).toEqual([ - 'brunch.chrome', - [ - 'brunch: █▄▄ █▀█ █ █ █▄ █ █▀▀ █ █ / █▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█', - 'cwd: /tmp/project', - 'spec: Spec One', - 'session: session-1', - 'runtime: not reported', - 'context: not reported', - 'chat mode: responding-to-elicitation', - ], - { placement: 'aboveEditor' }, - ]); - expect(calls.find((call) => call.method === 'setTitle')?.args).toEqual(['brunch — Spec One']); + expect(calls.find((call) => call.method === 'setTitle')?.args).toEqual(['brunch — project · Spec One']); }); }); diff --git a/src/.pi/__tests__/extension-registry.test.ts b/src/.pi/__tests__/extension-registry.test.ts index 703431b5e..3f167c530 100644 --- a/src/.pi/__tests__/extension-registry.test.ts +++ b/src/.pi/__tests__/extension-registry.test.ts @@ -4,39 +4,41 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import alternatives from '../extensions/alternatives.js'; -import chrome from '../extensions/chrome.js'; -import commandPolicy from '../extensions/command-policy.js'; +import { createBrunchPiExtensions } from '../brunch-pi-extensions.js'; +import alternatives from '../components/alternatives.js'; +import chrome from '../extensions/chrome/index.js'; import commands, { BRUNCH_CONTINUE_COMMAND, BRUNCH_LENS_COMMAND, BRUNCH_MODE_COMMAND, BRUNCH_STRATEGY_COMMAND, BRUNCH_SWITCH_COMMAND, -} from '../extensions/commands.js'; -import mentionAutocomplete from '../extensions/mention-autocomplete.js'; -import operationalMode from '../extensions/operational-mode.js'; -import prompting from '../extensions/prompting.js'; -import sessionLifecycle from '../extensions/session-lifecycle.js'; +} from '../extensions/commands/index.js'; +import commandPolicy from '../extensions/commands/policy.js'; import structuredExchange, { PRESENT_OPTIONS_TOOL, PRESENT_QUESTION_TOOL, + PRESENT_REVIEW_SET_TOOL, REQUEST_ANSWER_TOOL, REQUEST_CHOICE_TOOL, REQUEST_CHOICES_TOOL, -} from '../extensions/structured-exchange/index.js'; -import { createBrunchPiExtensionShell } from '../pi-extension-shell.js'; + REQUEST_REVIEW_TOOL, +} 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'; const extensionDefaults = { - 'alternatives.ts': alternatives, - 'chrome.ts': chrome, - 'command-policy.ts': commandPolicy, - 'commands.ts': commands, - 'mention-autocomplete.ts': mentionAutocomplete, - 'operational-mode.ts': operationalMode, - 'prompting.ts': prompting, - 'session-lifecycle.ts': sessionLifecycle, - 'structured-exchange/index.ts': structuredExchange, + 'components/alternatives.ts': alternatives, + 'chrome/index.ts': chrome, + 'commands/policy.ts': commandPolicy, + 'commands/index.ts': commands, + 'mentions/index.ts': mentionAutocomplete, + 'runtime/index.ts': operationalMode, + 'system-prompts/index.ts': prompting, + 'session/lifecycle.ts': sessionLifecycle, + 'exchanges/index.ts': structuredExchange, }; describe('Brunch explicit Pi extension registry', () => { @@ -49,7 +51,7 @@ describe('Brunch explicit Pi extension registry', () => { it('registers product extensions from the shell in explicit order', async () => { const recording = createRecordingExtensionApi(); - await createBrunchPiExtensionShell(brunchChromeFixture, recording.onSessionBoundary, { + await createBrunchPiExtensions(brunchChromeFixture, recording.onSessionBoundary, { coordinator: {} as never, graphMentionSource: { listMentionCandidates: () => [] }, })(recording.api); @@ -62,9 +64,11 @@ describe('Brunch explicit Pi extension registry', () => { 'present_alternatives', PRESENT_QUESTION_TOOL, PRESENT_OPTIONS_TOOL, + PRESENT_REVIEW_SET_TOOL, REQUEST_ANSWER_TOOL, REQUEST_CHOICE_TOOL, REQUEST_CHOICES_TOOL, + REQUEST_REVIEW_TOOL, ]); expect(recording.commandNames).toEqual([ BRUNCH_SWITCH_COMMAND, @@ -80,6 +84,9 @@ describe('Brunch explicit Pi extension registry', () => { 'before_agent_start', 'message_start', 'session_start', + 'model_select', + 'thinking_level_select', + 'turn_end', 'session_before_tree', 'session_before_fork', 'session_start', @@ -97,7 +104,7 @@ describe('Brunch explicit Pi extension registry', () => { }); it('does not retain the filesystem-discovery product-extension protocol', async () => { - const shell = await readFile(join(projectRoot(), 'src/.pi/pi-extension-shell.ts'), 'utf8'); + const shell = await readFile(join(projectRoot(), 'src/.pi/brunch-pi-extensions.ts'), 'utf8'); const discoveryExport = ['discover', 'BrunchProductExtensionEntries'].join(''); expect(shell).not.toContain(`export async function ${discoveryExport}`); expect(shell).not.toContain('node:fs/promises'); diff --git a/src/.pi/__tests__/graph-tools.test.ts b/src/.pi/__tests__/graph-tools.test.ts index ee53a27cf..14b556cbc 100644 --- a/src/.pi/__tests__/graph-tools.test.ts +++ b/src/.pi/__tests__/graph-tools.test.ts @@ -12,7 +12,7 @@ import { describe, beforeEach, it, expect } from 'vitest'; import { createDb } from '../../db/connection.js'; import type { BrunchDb } from '../../db/connection.js'; -import { edges, specs } from '../../db/schema.js'; +import { edges } from '../../db/schema.js'; import { CommandExecutor } from '../../graph/command-executor.js'; import { getGraphOverview, getNodeNeighborhood, resolveGraphNodeCode } from '../../graph/snapshot.js'; import { createProductUpdatePublisher } from '../../rpc/product-updates.js'; @@ -34,16 +34,12 @@ function createTestDb(): BrunchDb { return createDb(':memory:'); } function seedSpec(db: BrunchDb): number { - const row = db - .insert(specs) - .values({ - name: 'Test Spec', - slug: `test-${nextSpecSlug++}`, - readiness_grade: 'grounding_onboarding', - }) - .returning({ id: specs.id }) - .get(); - return row!.id; + 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 createSnapshots(db: BrunchDb, specId: number): GraphSnapshotReaders { @@ -273,8 +269,8 @@ describe('graph tools end-to-end', () => { }); expect(observed).toEqual([ - { topic: 'graph.overview', specId, lsn: 1 }, - { topic: 'graph.nodeNeighborhood', specId, lsn: 1 }, + { topic: 'graph.overview', specId, lsn: 2 }, + { topic: 'graph.nodeNeighborhood', specId, lsn: 2 }, ]); }); diff --git a/src/.pi/__tests__/mention-autocomplete.test.ts b/src/.pi/__tests__/mention-autocomplete.test.ts index f3046ab66..c36d24a41 100644 --- a/src/.pi/__tests__/mention-autocomplete.test.ts +++ b/src/.pi/__tests__/mention-autocomplete.test.ts @@ -5,7 +5,7 @@ import { extractHashPrefix, registerBrunchMentionAutocomplete, type GraphMentionSource, -} from '../extensions/mention-autocomplete.js'; +} from '../extensions/mentions/index.js'; describe('Brunch mention autocomplete', () => { it('adds graph mention prompt guidance', async () => { diff --git a/src/.pi/__tests__/operational-mode.test.ts b/src/.pi/__tests__/operational-mode.test.ts index d392026f6..85292439f 100644 --- a/src/.pi/__tests__/operational-mode.test.ts +++ b/src/.pi/__tests__/operational-mode.test.ts @@ -14,7 +14,7 @@ import { registerBrunchOperationalModePolicy, type BrunchAgentState, type BrunchAgentStateEntryData, -} from '../extensions/operational-mode.js'; +} from '../extensions/runtime/index.js'; function runtimeEntry(state: BrunchAgentState, data: Record = {}) { return { @@ -193,7 +193,7 @@ describe('Brunch agent runtime-state projection', () => { expect(events.user_bash?.({ command: 'rm -rf .' } as never)).toMatchObject({ result: { exitCode: 1, - output: 'Brunch tool policy blocks shell commands: rm -rf .', + output: 'Brunch tool policy blocks shell commands in elicit mode (bash, edit, write): rm -rf .', }, }); }); diff --git a/src/.pi/__tests__/project-worktree-extension.test.ts b/src/.pi/__tests__/project-worktree-extension.test.ts deleted file mode 100644 index ab1ff88d6..000000000 --- a/src/.pi/__tests__/project-worktree-extension.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { execFile as execFileCallback } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { basename, dirname, join } from 'node:path'; -import { promisify } from 'node:util'; - -import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'; -import { describe, expect, it } from 'vitest'; - -import worktreeExtension, { - cleanForkedSessionHeader, - createRelocatedSession, - createSiblingWorktree, - planSiblingWorktree, - resolveSwitchTarget, - runSwitchWorktree, - validateGitWorktree, - WORKTREE_CREATE_COMMAND, - WORKTREE_CREATE_TOOL, - WORKTREE_SWITCH_COMMAND, - WORKTREE_SWITCH_TOOL, -} from '../../../.pi/extensions/worktree/index.js'; - -const execFile = promisify(execFileCallback); - -describe('project-local worktree Pi extension', () => { - it('registers the auto-discovered command and LLM staging tool', () => { - const recording = createRecordingApi(); - - worktreeExtension(recording.api); - - expect(WORKTREE_SWITCH_COMMAND).toBe('worktree:switch'); - expect(WORKTREE_CREATE_COMMAND).toBe('worktree:create'); - expect(recording.commandNames).toEqual(['worktree:switch', 'worktree:create']); - expect(recording.commandNames).not.toContain('switch-worktree'); - expect(recording.commandNames).not.toContain('create-worktree'); - expect(recording.toolNames).toEqual([WORKTREE_SWITCH_TOOL, WORKTREE_CREATE_TOOL]); - expect(recording.tools[0]?.promptGuidelines).toContain( - 'Call switch_worktree only after the user explicitly asks to move this Pi session to another git worktree.', - ); - expect(recording.tools[0]?.description).toContain('/worktree:switch '); - expect(recording.tools[0]?.description).not.toContain('/switch-worktree'); - expect(recording.tools[1]?.promptSnippet).toContain('/worktree:switch '); - expect(recording.tools[1]?.promptSnippet).not.toContain('/switch-worktree'); - }); - - it('normalizes targets against the caller cwd and validates git worktrees', async () => { - await withTempDir(async (dir) => { - const repo = join(dir, 'repo'); - const file = join(repo, 'file.txt'); - await git(dir, 'init', 'repo'); - await writeFile(file, 'tracked\n'); - await git(repo, 'add', 'file.txt'); - await git(repo, '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'initial'); - - const linked = join(dir, 'repo-linked'); - await git(repo, 'worktree', 'add', linked, 'HEAD'); - - expect(resolveSwitchTarget('repo-linked', dir)).toBe(linked); - await expect(validateGitWorktree(repo)).resolves.toEqual({ ok: true, cwd: repo }); - await expect(validateGitWorktree(linked)).resolves.toEqual({ ok: true, cwd: linked }); - await expect(validateGitWorktree(join(dir, 'missing'))).resolves.toEqual({ - ok: false, - reason: 'missing', - path: join(dir, 'missing'), - }); - await expect(validateGitWorktree(file)).resolves.toEqual({ - ok: false, - reason: 'not-directory', - path: file, - }); - - const nongit = join(dir, 'plain'); - await mkdir(nongit); - await git(dir, 'init', '--bare', 'bare.git'); - await expect(validateGitWorktree(nongit)).resolves.toEqual({ - ok: false, - reason: 'not-git-worktree', - path: nongit, - }); - await expect(validateGitWorktree(join(dir, 'bare.git'))).resolves.toEqual({ - ok: false, - reason: 'bare-repository', - path: join(dir, 'bare.git'), - }); - }); - }); - - it('forks the current session into the target cwd without retaining parent-session metadata', async () => { - await withTempDir(async (dir) => { - const sourceSession = join(dir, 'source.jsonl'); - const target = join(dir, 'target'); - const sessionDir = join(dir, 'sessions'); - await git(dir, 'init', 'target'); - await writeFile( - sourceSession, - [ - JSON.stringify({ - type: 'session', - version: 3, - id: 'source-session', - timestamp: '2026-06-05T00:00:00.000Z', - cwd: dir, - parentSession: '/old-parent.jsonl', - }), - JSON.stringify({ - type: 'message', - id: 'm1', - parentId: null, - timestamp: '2026-06-05T00:00:01.000Z', - message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }], timestamp: 0 }, - }), - '', - ].join('\n'), - ); - - const relocated = await createRelocatedSession(sourceSession, target, sessionDir); - const relocatedContent = await readFile(relocated, 'utf8'); - const relocatedHeader = JSON.parse(relocatedContent.split('\n')[0] ?? '{}') as Record; - - expect(relocatedHeader.cwd).toBe(target); - expect(relocatedHeader.parentSession).toBeUndefined(); - await expect(stat(sourceSession)).resolves.toBeTruthy(); - }); - }); - - it('confirms before switching and sends the continuation through the replacement context', async () => { - await withTempDir(async (dir) => { - const target = join(dir, 'target'); - const sourceSession = join(dir, 'source.jsonl'); - const sessionDir = join(dir, 'sessions'); - await git(dir, 'init', 'target'); - await writeFile( - sourceSession, - `${JSON.stringify({ type: 'session', version: 3, id: 's1', timestamp: '2026-06-05T00:00:00.000Z', cwd: dir })}\n${JSON.stringify({ type: 'message', id: 'm1', parentId: null, timestamp: '2026-06-05T00:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }], timestamp: 0 } })}\n`, - ); - const ctx = createSwitchContext({ cwd: dir, sourceSession, sessionDir, confirm: true }); - - await runSwitchWorktree(target, ctx, { sessionDir }); - - expect(ctx.confirmations).toHaveLength(1); - expect(ctx.switchedSessionFile).toContain(sessionDir); - expect(ctx.replacementMessages).toEqual([`Continue in the relocated Pi session from cwd: ${target}`]); - expect(ctx.notifications.at(-1)).toEqual({ - message: `Relocated Pi session to ${target}`, - type: 'info', - }); - }); - }); - - it('removes parentSession only from the session header line', async () => { - await withTempDir(async (dir) => { - const session = join(dir, 'session.jsonl'); - await writeFile( - session, - `${JSON.stringify({ type: 'session', id: 's1', timestamp: '2026-06-05T00:00:00.000Z', cwd: dir, parentSession: 'old' })}\n${JSON.stringify({ type: 'custom', id: 'c1', parentId: null, timestamp: '2026-06-05T00:00:01.000Z', data: { parentSession: 'kept' } })}\n`, - ); - - await cleanForkedSessionHeader(session); - - const [headerLine, customLine] = (await readFile(session, 'utf8')).trimEnd().split('\n'); - expect(JSON.parse(headerLine ?? '{}')).not.toHaveProperty('parentSession'); - expect(JSON.parse(customLine ?? '{}')).toHaveProperty('data.parentSession', 'kept'); - }); - }); - - it('plans sibling defaults from the caller worktree root and skips path and branch collisions', async () => { - await withTempDir(async (dir) => { - const repo = join(dir, 'repo'); - await initRepo(repo); - await mkdir(join(dir, 'repo-alpha')); - await git(repo, 'branch', 'repo-beta'); - - await expect( - planSiblingWorktree({ - sourceRoot: repo, - branchExists: async (branch) => branch === 'repo-beta', - pathExists: async (path) => path === join(dir, 'repo-alpha'), - greekWords: ['alpha', 'beta', 'gamma'], - chooseStartIndex: () => 0, - }), - ).resolves.toEqual({ - path: join(dir, 'repo-gamma'), - branch: 'repo-gamma', - attempted: ['repo-alpha', 'repo-beta', 'repo-gamma'], - }); - }); - }); - - it('creates sibling worktrees from caller HEAD in main and linked worktrees', async () => { - await withTempDir(async (dir) => { - const main = join(dir, 'repo'); - await initRepo(main); - const mainHead = await gitOutput(main, 'rev-parse', 'HEAD'); - - const linked = join(dir, 'repo-linked'); - await git(main, 'worktree', 'add', '-b', 'linked', linked, 'HEAD'); - await writeFile(join(linked, 'linked.txt'), 'linked\n'); - await git(linked, 'add', 'linked.txt'); - await git( - linked, - '-c', - 'user.email=test@example.com', - '-c', - 'user.name=Test', - 'commit', - '-m', - 'linked', - ); - const linkedHead = await gitOutput(linked, 'rev-parse', 'HEAD'); - - const mainCtx = createWorktreeCreationContext(main); - const mainResult = await createSiblingWorktree(mainCtx, { - greekWords: ['alpha'], - chooseStartIndex: () => 0, - }); - if (mainResult.status !== 'created') throw new Error(mainResult.reason); - expect(mainResult).toMatchObject({ - status: 'created', - sourceCommit: mainHead, - branch: 'repo-alpha', - path: join(dirname(mainResult.sourceRoot), 'repo-alpha'), - }); - expect(await gitOutput(mainResult.path, 'rev-parse', 'HEAD')).toBe(mainHead); - expect(mainCtx.editorText).toBe(`/worktree:switch ${mainResult.path}`); - - const linkedCtx = createWorktreeCreationContext(linked); - const linkedResult = await createSiblingWorktree(linkedCtx, { - greekWords: ['beta'], - chooseStartIndex: () => 0, - }); - if (linkedResult.status !== 'created') throw new Error(linkedResult.reason); - expect(linkedResult).toMatchObject({ - status: 'created', - sourceCommit: linkedHead, - branch: 'repo-linked-beta', - path: join(dirname(linkedResult.sourceRoot), 'repo-linked-beta'), - }); - expect(await gitOutput(linkedResult.path, 'rev-parse', 'HEAD')).toBe(linkedHead); - expect(linkedCtx.editorText).toBe(`/worktree:switch ${linkedResult.path}`); - }); - }, 15000); - - it('warns when the caller worktree is dirty but still creates from committed HEAD', async () => { - await withTempDir(async (dir) => { - const repo = join(dir, 'repo'); - await initRepo(repo); - const head = await gitOutput(repo, 'rev-parse', 'HEAD'); - await writeFile(join(repo, 'dirty.txt'), 'not committed\n'); - const ctx = createWorktreeCreationContext(repo); - - const result = await createSiblingWorktree(ctx, { greekWords: ['delta'], chooseStartIndex: () => 0 }); - if (result.status !== 'created') throw new Error(result.reason); - - expect(result).toMatchObject({ - status: 'created', - sourceCommit: head, - dirty: true, - dirtyWarning: - 'Caller worktree has uncommitted changes; the new worktree was created from committed HEAD only.', - }); - expect(ctx.notifications).toContainEqual({ - message: - 'Caller worktree has uncommitted changes; the new worktree was created from committed HEAD only.', - type: 'warning', - }); - await expect(stat(join(result.path, 'dirty.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); - }); - }); -}); - -function createRecordingApi() { - const commandNames: string[] = []; - const toolNames: string[] = []; - const tools: Array<{ - name: string; - description?: string; - promptSnippet?: string; - promptGuidelines?: string[]; - }> = []; - const api = { - registerCommand(name: string) { - commandNames.push(name); - }, - registerTool(tool: { - name: string; - description?: string; - promptSnippet?: string; - promptGuidelines?: string[]; - }) { - toolNames.push(tool.name); - tools.push(tool); - }, - }; - return { api: api as never as ExtensionAPI, commandNames, toolNames, tools }; -} - -function createSwitchContext({ - cwd, - sourceSession, - sessionDir, - confirm, -}: { - cwd: string; - sourceSession: string; - sessionDir: string; - confirm: boolean; -}) { - const confirmations: string[] = []; - const notifications: Array<{ message: string; type: 'info' | 'warning' | 'error' | undefined }> = []; - const replacementMessages: string[] = []; - const ctx = { - cwd, - hasUI: true, - ui: { - confirm: async (title: string, message: string) => { - confirmations.push(`${title}\n${message}`); - return confirm; - }, - notify: (message: string, type?: 'info' | 'warning' | 'error') => { - notifications.push({ message, type }); - }, - setEditorText() {}, - }, - sessionManager: { - getSessionFile: () => sourceSession, - getSessionDir: () => sessionDir, - }, - switchSession: async ( - sessionFile: string, - options: { - withSession?: (replacementCtx: { - sendUserMessage: (message: string) => Promise; - ui: { notify: (message: string, type?: 'info' | 'warning' | 'error') => void }; - }) => Promise; - }, - ) => { - ctx.switchedSessionFile = sessionFile; - await options.withSession?.({ - sendUserMessage: async (message: string) => { - replacementMessages.push(message); - }, - ui: ctx.ui, - }); - return { cancelled: false }; - }, - switchedSessionFile: undefined as string | undefined, - confirmations, - notifications, - replacementMessages, - }; - return ctx as typeof ctx & ExtensionCommandContext; -} - -function createWorktreeCreationContext(cwd: string) { - const notifications: Array<{ message: string; type: 'info' | 'warning' | 'error' | undefined }> = []; - const ctx = { - cwd, - hasUI: true, - editorText: undefined as string | undefined, - ui: { - notify: (message: string, type?: 'info' | 'warning' | 'error') => { - notifications.push({ message, type }); - }, - setEditorText: (text: string) => { - ctx.editorText = text; - }, - }, - notifications, - }; - return ctx; -} - -async function withTempDir(run: (dir: string) => Promise): Promise { - const dir = await mkdtemp(join(tmpdir(), 'brunch-pi-worktree-')); - try { - await run(dir); - } finally { - await rm(dir, { recursive: true, force: true }); - } -} - -async function initRepo(path: string): Promise { - await git(dirname(path), 'init', basename(path)); - await writeFile(join(path, 'tracked.txt'), 'tracked\n'); - await git(path, 'add', 'tracked.txt'); - await git(path, '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'initial'); -} - -async function gitOutput(cwd: string, ...args: string[]): Promise { - const { stdout } = await execFile('git', args, { cwd }); - return stdout.trim(); -} - -async function git(cwd: string, ...args: string[]): Promise { - await execFile('git', args, { cwd }); -} diff --git a/src/.pi/__tests__/prompting.test.ts b/src/.pi/__tests__/prompting.test.ts index 9bcbb9ba1..96837b364 100644 --- a/src/.pi/__tests__/prompting.test.ts +++ b/src/.pi/__tests__/prompting.test.ts @@ -4,9 +4,10 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { composeAgentPrompt } from '../../agents/compose.js'; -import type { ReadinessGrade } from '../../agents/state.js'; import type { WorkspacePostureState } from '../../session/workspace-session-coordinator.js'; +import { composeAgentPrompt } from '../agents/compose.js'; +import type { ReadinessGrade } from '../agents/state.js'; +import { createBrunchPiExtensions } from '../brunch-pi-extensions.js'; import { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, @@ -15,9 +16,8 @@ import { type BrunchAgentState, type BrunchAgentStateEntryData, registerBrunchOperationalModePolicy, -} from '../extensions/operational-mode.js'; -import { registerBrunchPrompting } from '../extensions/prompting.js'; -import { createBrunchPiExtensionShell } from '../pi-extension-shell.js'; +} from '../extensions/runtime/index.js'; +import { registerBrunchPrompting } from '../extensions/system-prompts/index.js'; function runtimeEntry(state: BrunchAgentState) { return { @@ -203,7 +203,7 @@ describe('Brunch prompt-pack topology', () => { nodeTitles: ['Launch-only node'], }; - await createBrunchPiExtensionShell( + await createBrunchPiExtensions( { cwd: '/tmp/brunch', chatMode: 'responding-to-elicitation', @@ -302,6 +302,8 @@ describe('Brunch prompt-pack topology', () => { 'request_answer', 'request_choice', 'request_choices', + 'present_review_set', + 'request_review', 'read_graph', 'commit_graph', ].map((name) => ({ name })), @@ -374,6 +376,8 @@ describe('Brunch prompt-pack topology', () => { 'request_answer', 'request_choice', 'request_choices', + 'present_review_set', + 'request_review', 'read_graph', 'commit_graph', ], @@ -393,6 +397,8 @@ describe('Brunch prompt-pack topology', () => { 'request_answer', 'request_choice', 'request_choices', + 'present_review_set', + 'request_review', 'read_graph', 'commit_graph', ], @@ -405,7 +411,7 @@ describe('Brunch prompt-pack topology', () => { }); expect(defaultPrompt).toMatchObject({ systemPrompt: expect.stringContaining( - '- active tools: read, grep, present_options, request_answer, request_choice, request_choices, read_graph, commit_graph', + '- active tools: read, grep, present_options, request_answer, request_choice, request_choices, present_review_set, request_review, read_graph, commit_graph', ), }); expect(defaultPrompt).toMatchObject({ @@ -425,7 +431,10 @@ describe('Brunch prompt-pack topology', () => { on: (event: string, handler: (event: never, ctx?: never) => unknown) => { events[event] = handler; }, - getAllTools: () => ['read', 'grep', 'read_graph', 'commit_graph'].map((name) => ({ name })), + getAllTools: () => + ['read', 'grep', 'read_graph', 'commit_graph', 'present_review_set', 'request_review'].map( + (name) => ({ name }), + ), setActiveTools: (tools: string[]) => activeTools.push(tools), } as never, { @@ -445,13 +454,15 @@ describe('Brunch prompt-pack topology', () => { await expect(activeToolsForGrade('grounding_onboarding')).resolves.not.toContain('commit_graph'); await expect(activeToolsForGrade('elicitation_ready')).resolves.toContain('commit_graph'); + await expect(activeToolsForGrade('elicitation_ready')).resolves.not.toContain('present_review_set'); + await expect(activeToolsForGrade('commitments_ready')).resolves.toContain('present_review_set'); }); it('is registered by the explicit shell after operational-mode policy and appends composed manifests', async () => { const eventNames: string[] = []; const events: Record unknown>> = {}; - await createBrunchPiExtensionShell( + await createBrunchPiExtensions( { cwd: '/tmp/brunch', chatMode: 'responding-to-elicitation', @@ -519,7 +530,7 @@ describe('Brunch prompt-pack topology', () => { it('proves transcript-backed strategy and lens switches change product prompt posture', async () => { const events: Record unknown>> = {}; - await createBrunchPiExtensionShell( + await createBrunchPiExtensions( { cwd: '/tmp/brunch', chatMode: 'responding-to-elicitation', @@ -604,8 +615,8 @@ describe('Brunch prompt-pack topology', () => { it('does not expose prompt manifests through Pi resource discovery or legacy context imports', async () => { const [promptingSource, shellSource] = await Promise.all([ - readFile(join(projectRoot(), 'src/.pi/extensions/prompting.ts'), 'utf8'), - readFile(join(projectRoot(), 'src/.pi/pi-extension-shell.ts'), 'utf8'), + readFile(join(projectRoot(), 'src/.pi/extensions/system-prompts/index.ts'), 'utf8'), + readFile(join(projectRoot(), 'src/.pi/brunch-pi-extensions.ts'), 'utf8'), ]); expect(promptingSource).not.toContain('resources_discover'); diff --git a/src/.pi/__tests__/review-set-proposal.test.ts b/src/.pi/__tests__/review-set-proposal.test.ts deleted file mode 100644 index 24e278ea5..000000000 --- a/src/.pi/__tests__/review-set-proposal.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { createDb } from '../../db/connection.js'; -import type { BrunchDb } from '../../db/connection.js'; -import { specs } from '../../db/schema.js'; -import { CommandExecutor } from '../../graph/command-executor.js'; -import { getGraphOverview } from '../../graph/snapshot.js'; -import { - translateReviewSetProposalToCommitGraph, - validateReviewSetProposalPayload, - type ReviewSetProposalDraft, -} from '../extensions/graph/review-set-proposal.js'; - -function seedSpec(db: BrunchDb): number { - db.insert(specs).values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }).run(); - return db.select({ id: specs.id }).from(specs).get()!.id; -} - -function validProposal(overrides: Partial = {}): ReviewSetProposalDraft { - return { - schemaVersion: 1, - lens: 'design', - epistemicStatus: 'inferred', - grounding: { - summary: 'The launch path is thin but enough to propose acceptance criteria.', - support: ['User accepted a launch-readiness concept.'], - }, - pitch: { - title: 'Launch readiness review set', - narrative: 'A small graph for deciding whether launch can proceed.', - }, - entityDrafts: [ - { - draftId: 'goal-launch', - plane: 'intent', - kind: 'goal', - title: 'Launch safely', - }, - { - draftId: 'req-rollback', - plane: 'intent', - kind: 'requirement', - title: 'Rollback path exists', - }, - { - draftId: 'crit-observable', - plane: 'intent', - kind: 'criterion', - title: 'Operators can observe failures', - }, - ], - edgeDrafts: [ - { - category: 'dependency', - sourceDraftId: 'req-rollback', - targetDraftId: 'goal-launch', - rationale: 'Rollback capability is required for safe launch.', - }, - { - category: 'support', - sourceDraftId: 'crit-observable', - targetDraftId: 'goal-launch', - stance: 'for', - rationale: 'Observability supports a safe launch decision.', - }, - ], - ...overrides, - }; -} - -describe('review-set proposal dry-run gate', () => { - it('validates dry-run-valid review-set proposal payloads for structured exchanges', () => { - const db = createDb(':memory:'); - const executor = new CommandExecutor(db); - const specId = seedSpec(db); - const result = validateReviewSetProposalPayload({ - specId, - proposal: validProposal(), - commandExecutor: executor, - }); - - expect(result).toMatchObject({ - status: 'success', - proposal: { - schemaVersion: 1, - lens: 'design', - epistemicStatus: 'inferred', - validation: { status: 'success' }, - }, - }); - expect(getGraphOverview(db, specId)).toMatchObject({ nodeCount: 0, edgeCount: 0, lsn: 0 }); - }); - - it('rejects structurally invalid review-set proposal payloads', () => { - const db = createDb(':memory:'); - const executor = new CommandExecutor(db); - const specId = seedSpec(db); - const result = validateReviewSetProposalPayload({ - specId, - proposal: validProposal({ - edgeDrafts: [ - { - category: 'support', - sourceDraftId: 'req-rollback', - targetDraftId: 'goal-launch', - }, - ], - }), - commandExecutor: executor, - }); - - expect(result).toMatchObject({ - status: 'structural_illegal', - diagnostics: [{ field: 'edges[0].stance', message: expect.stringContaining('required') }], - }); - expect(getGraphOverview(db, specId)).toMatchObject({ nodeCount: 0, edgeCount: 0, lsn: 0 }); - }); - - it('rejects proposal schema drift before CommandExecutor dry-run', () => { - const db = createDb(':memory:'); - const executor = new CommandExecutor(db); - const specId = seedSpec(db); - - for (const proposal of [ - { ...validProposal(), epistemicStatus: undefined }, - { ...validProposal(), lens: 'propose-scenarios-with-tradeoffs' }, - { ...validProposal(), grounding: { summary: 'No support.', support: [] } }, - { - ...validProposal(), - edgeDrafts: [ - { - relation: 'supports', - sourceDraftId: 'req-rollback', - targetDraftId: 'goal-launch', - }, - ], - }, - ]) { - const result = validateReviewSetProposalPayload({ - specId, - proposal: proposal as unknown as ReviewSetProposalDraft, - commandExecutor: executor, - }); - expect(result.status).toBe('structural_illegal'); - } - }); - - it('keeps dry-run validation in parity with commitGraph validation', () => { - const db = createDb(':memory:'); - const executor = new CommandExecutor(db); - const specId = seedSpec(db); - const proposal = validProposal(); - const entry = validateReviewSetProposalPayload({ - specId, - proposal, - commandExecutor: executor, - }); - expect(entry.status).toBe('success'); - - const command = translateReviewSetProposalToCommitGraph(proposal, specId); - expect(command.basis).toBe('explicit'); - expect(command.nodes.every((node) => !('basis' in node))).toBe(true); - expect(command.edges.every((edge) => !('basis' in edge))).toBe(true); - - const commitResult = executor.commitGraph(command); - expect(commitResult).toMatchObject({ status: 'success' }); - expect(getGraphOverview(db, specId).nodes.every((node) => node.basis === 'explicit')).toBe(true); - expect(getGraphOverview(db, specId).edges.every((edge) => edge.basis === 'explicit')).toBe(true); - expect(getGraphOverview(db, specId)).toMatchObject({ nodeCount: 3, edgeCount: 2 }); - }); -}); diff --git a/src/.pi/__tests__/structured-exchange-boundaries.test.ts b/src/.pi/__tests__/structured-exchange-boundaries.test.ts new file mode 100644 index 000000000..034b3b998 --- /dev/null +++ b/src/.pi/__tests__/structured-exchange-boundaries.test.ts @@ -0,0 +1,110 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const ROOT = process.cwd(); +const STRUCTURED_EXCHANGE_EXTENSION = 'src/.pi/extensions/exchanges'; +const STRUCTURED_EXCHANGE_PROJECT = 'src/projections/structured-exchange'; +const STRUCTURED_EXCHANGE_SCHEMAS = 'src/.pi/extensions/exchanges/schemas'; +const STRUCTURED_EXCHANGE_EMISSION_BOUNDARIES = [ + STRUCTURED_EXCHANGE_EXTENSION, + 'src/session/structured-exchange-loop.ts', +]; +const ACTIVE_PROJECTORS = new Set([ + 'src/projections/structured-exchange/present-options.ts', + 'src/projections/structured-exchange/present-question.ts', + 'src/projections/structured-exchange/present-review-set.ts', + 'src/projections/structured-exchange/request-answer.ts', + 'src/projections/structured-exchange/request-choice.ts', + 'src/projections/structured-exchange/request-choices.ts', + 'src/projections/structured-exchange/request-review.ts', +]); +const ALLOWED_TYPEBOX_FILES = new Set(['src/.pi/extensions/exchanges/pi-schema.ts']); + +function sourceFilesUnder(path: string): string[] { + const full = join(ROOT, path); + const entries = readdirSync(full); + const files: string[] = []; + for (const entry of entries) { + const candidate = join(full, entry); + const stat = statSync(candidate); + if (stat.isDirectory()) { + files.push(...sourceFilesUnder(relative(ROOT, candidate))); + } else if (candidate.endsWith('.ts') && !candidate.endsWith('.test.ts')) { + files.push(relative(ROOT, candidate)); + } + } + return files.sort(); +} + +function sourceFilesForPath(path: string): string[] { + return path.endsWith('.ts') ? [path] : sourceFilesUnder(path); +} + +function readSource(path: string): string { + return readFileSync(join(ROOT, path), 'utf8'); +} + +describe('structured-exchange source boundaries', () => { + it('keeps TypeBox authoring out of active structured-exchange tools', () => { + const offenders = sourceFilesUnder(STRUCTURED_EXCHANGE_EXTENSION).filter((file) => { + if (file.startsWith(STRUCTURED_EXCHANGE_SCHEMAS) || ALLOWED_TYPEBOX_FILES.has(file)) return false; + const source = readSource(file); + return source.includes("from 'typebox'") || source.includes('from "typebox"'); + }); + + expect(offenders).toEqual([]); + }); + + it('keeps tool result details construction inside canonical projectors', () => { + const offenders = STRUCTURED_EXCHANGE_EMISSION_BOUNDARIES.flatMap(sourceFilesForPath).filter((file) => { + if (file.startsWith(STRUCTURED_EXCHANGE_SCHEMAS)) return false; + const source = readSource(file); + return ( + source.includes('tool_meta:') || + source.includes('schema: STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA') || + source.includes('schema: STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA') || + source.includes('schema: STRUCTURED_EXCHANGE_CAPTURE_DETAILS_SCHEMA') || + source.includes("schema: 'brunch.structured_exchange.present'") || + source.includes("schema: 'brunch.structured_exchange.request'") || + source.includes("schema: 'brunch.structured_exchange.capture'") + ); + }); + + expect(offenders).toEqual([]); + }); + + it('validates active present/request details at the projector boundary', () => { + const offenders = sourceFilesUnder(STRUCTURED_EXCHANGE_PROJECT).filter((file) => { + if (!ACTIVE_PROJECTORS.has(file)) return false; + const source = readSource(file); + return !source.includes('.parse('); + }); + + expect(offenders).toEqual([]); + }); + + it('keeps structured-exchange TypeBox usage quarantined to the Pi schema adapter', () => { + const offenders = [ + ...sourceFilesUnder(STRUCTURED_EXCHANGE_EXTENSION), + ...sourceFilesUnder('src/session'), + ].filter((file) => { + if (ALLOWED_TYPEBOX_FILES.has(file)) return false; + const source = readSource(file); + return source.includes("from 'typebox'") || source.includes('from "typebox"'); + }); + + expect(offenders).toEqual([]); + }); + + it('keeps tool_meta atoms single-sourced in schemas/shared.ts', () => { + const offenders = sourceFilesUnder(STRUCTURED_EXCHANGE_SCHEMAS).filter((file) => { + if (file.endsWith('/shared.ts')) return false; + const source = readSource(file); + return source.includes('curr: z.literal(') || source.includes('prev: z.literal('); + }); + + expect(offenders).toEqual([]); + }); +}); diff --git a/src/.pi/__tests__/structured-exchange-extension.test.ts b/src/.pi/__tests__/structured-exchange-extension.test.ts index 5cc5442a5..21a772e2e 100644 --- a/src/.pi/__tests__/structured-exchange-extension.test.ts +++ b/src/.pi/__tests__/structured-exchange-extension.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import registerStructuredExchange, { PRESENT_OPTIONS_TOOL, REQUEST_CHOICE_TOOL, -} from '../extensions/structured-exchange/index.js'; +} 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 2c458b72f..b15d8aa73 100644 --- a/src/.pi/__tests__/structured-exchange-present-request.test.ts +++ b/src/.pi/__tests__/structured-exchange-present-request.test.ts @@ -1,15 +1,19 @@ import { describe, expect, it } from 'vitest'; +import { createDb } from '../../db/connection.js'; +import { CommandExecutor } from '../../graph/command-executor.js'; import registerStructuredExchange, { PRESENT_OPTIONS_TOOL, + PRESENT_REVIEW_SET_TOOL, REQUEST_CHOICE_TOOL, REQUEST_CHOICES_TOOL, -} from '../extensions/structured-exchange/index.js'; + REQUEST_REVIEW_TOOL, +} from '../extensions/exchanges/index.js'; import { findIncompleteStructuredExchangePresents, isStructuredExchangePresentDetails, isStructuredExchangeRequestDetails, -} from '../extensions/structured-exchange/shared/recovery.js'; +} from '../extensions/exchanges/shared/recovery.js'; interface ToolTextContent { type: 'text'; @@ -49,16 +53,56 @@ const theme: FakeTheme = { bold: (text) => text, }; -function registeredTools(): Map { +function registeredTools( + options: Parameters[1] = {}, +): Map { const tools = new Map(); - registerStructuredExchange({ - registerTool(tool: RegisteredTool) { - tools.set(tool.name, tool); - }, - } as never); + registerStructuredExchange( + { + registerTool(tool: RegisteredTool) { + tools.set(tool.name, tool); + }, + } as never, + options, + ); return tools; } +function reviewDeps() { + const db = createDb(':memory:'); + const commandExecutor = new CommandExecutor(db); + const spec = commandExecutor.createSpec({ name: 'Review Spec', slug: 'review-spec' }); + if (spec.status !== 'success') throw new Error('Unable to create review spec'); + return { specId: spec.specId, commandExecutor }; +} + +function validReviewPayload() { + return { + schemaVersion: 1, + lens: 'intent', + epistemicStatus: 'inferred', + grounding: { + summary: 'The user described a launch review flow.', + support: ['The transcript asks for exact approval before graph mutation.'], + }, + pitch: { + title: 'Review cycle wiring', + narrative: 'Commit review-set approvals as explicit graph truth only after user review.', + }, + entityDrafts: [ + { draftId: 'goal-review', plane: 'intent', kind: 'goal', title: 'Review graph proposals' }, + { draftId: 'req-approve', plane: 'intent', kind: 'requirement', title: 'Approval is atomic' }, + ], + edgeDrafts: [ + { + category: 'dependency', + source: { draftId: 'req-approve' }, + target: { draftId: 'goal-review' }, + }, + ], + }; +} + describe('structured exchange present/request tools', () => { it('registers implemented present/request tools as sequential', () => { const tools = registeredTools(); @@ -66,13 +110,17 @@ describe('structured exchange present/request tools', () => { expect([...tools.keys()]).toEqual([ 'present_question', PRESENT_OPTIONS_TOOL, + PRESENT_REVIEW_SET_TOOL, 'request_answer', REQUEST_CHOICE_TOOL, REQUEST_CHOICES_TOOL, + REQUEST_REVIEW_TOOL, ]); expect(tools.get(PRESENT_OPTIONS_TOOL)?.executionMode).toBe('sequential'); expect(tools.get(REQUEST_CHOICE_TOOL)?.executionMode).toBe('sequential'); + expect(tools.get(PRESENT_REVIEW_SET_TOOL)?.executionMode).toBe('sequential'); expect(tools.get(REQUEST_CHOICES_TOOL)?.executionMode).toBe('sequential'); + expect(tools.get(REQUEST_REVIEW_TOOL)?.executionMode).toBe('sequential'); }); it('persists a present_question result through the shared project and format seam', async () => { @@ -85,7 +133,6 @@ describe('structured exchange present/request tools', () => { exchangeId: 'problem-frame', heading: 'What problem are we solving?', body: 'Keep the answer grounded in current Brunch session behavior.', - expectedRequestTool: 'request_answer', }, undefined, undefined, @@ -99,12 +146,12 @@ describe('structured exchange present/request tools', () => { `); expect(isStructuredExchangePresentDetails(result.details)).toBe(true); expect(result.details).toMatchObject({ - exchangeId: 'problem-frame', - presentTool: 'present_question', - kind: 'question', - status: 'presented', - expectedRequest: { tool: 'request_answer', required: true }, - createdAtToolCallId: 'present-question-call-1', + exchange_id: 'problem-frame', + tool_meta: { curr: 'present_question', next: 'request_answer' }, + display: { + heading: 'What problem are we solving?', + body: 'Keep the answer grounded in current Brunch session behavior.', + }, }); }); @@ -141,12 +188,13 @@ describe('structured exchange present/request tools', () => { expect(result.content[0]?.text).toContain('Clearer ownership.'); expect(isStructuredExchangePresentDetails(result.details)).toBe(true); expect(result.details).toMatchObject({ - exchangeId: 'shell-location', - presentTool: PRESENT_OPTIONS_TOOL, - kind: 'options', - status: 'presented', - expectedRequest: { tool: REQUEST_CHOICE_TOOL, required: true }, - createdAtToolCallId: 'present-call-1', + exchange_id: 'shell-location', + tool_meta: { curr: PRESENT_OPTIONS_TOOL, next: REQUEST_CHOICE_TOOL }, + display: { + heading: 'Where should the shell live?', + body: 'Choose the module boundary for Brunch Pi extensions.', + }, + options: [{ id: 'root' }, { id: 'tui', rationale: 'Clearer ownership.' }], }); const rendered = result.content[0] ? present.renderResult(result, {}, theme).render?.(80).join('\n') : ''; @@ -187,18 +235,134 @@ describe('structured exchange present/request tools', () => { expect(result.content[0]?.text).not.toContain('Clearer ownership'); expect(isStructuredExchangeRequestDetails(result.details)).toBe(true); expect(result.details).toMatchObject({ - exchangeId: 'shell-location', - requestTool: REQUEST_CHOICE_TOOL, - status: 'answered', - respondsTo: { - exchangeId: 'shell-location', - presentTool: PRESENT_OPTIONS_TOOL, + exchange_id: 'shell-location', + tool_meta: { prev: PRESENT_OPTIONS_TOOL, curr: REQUEST_CHOICE_TOOL }, + answered: { + choice: { id: 'tui', label: 'Move under src/tui-client', kind: 'listed' }, + comment: 'Aligns ownership with /reload iteration.', }, - choice: { id: 'tui', label: 'Move under src/tui-client' }, - comment: 'Aligns ownership with /reload iteration.', }); }); + it('presents a dry-run-valid review-set payload as durable markdown and recoverable details', async () => { + const present = registeredTools({ review: reviewDeps() }).get(PRESENT_REVIEW_SET_TOOL); + if (!present) throw new Error('present_review_set was not registered'); + + const payload = validReviewPayload(); + const result = await present.execute( + 'present-review-call-1', + { exchangeId: 'review-cycle-1', proposalEntryId: 'proposal-entry-1', payload }, + undefined, + undefined, + {} as never, + ); + + expect(result.content[0]?.text).toContain('## Review cycle wiring'); + expect(result.content[0]?.text).toContain('Epistemic status: inferred'); + expect(result.content[0]?.text).toContain('### Entity drafts'); + expect(result.content[0]?.text).toContain('Approval is atomic'); + expect(result.content[0]?.text).toContain('### Edge drafts'); + expect(isStructuredExchangePresentDetails(result.details)).toBe(true); + expect(result.details).toMatchObject({ + exchange_id: 'review-cycle-1', + tool_meta: { curr: PRESENT_REVIEW_SET_TOOL, next: REQUEST_REVIEW_TOOL }, + review_set: { + nodes: [{ draft_id: 'goal-review' }, { draft_id: 'req-approve' }], + edges: [{ source: { draft_id: 'req-approve' }, target: { draft_id: 'goal-review' } }], + }, + }); + }); + + it('keeps structurally illegal review-set proposals non-reviewable', async () => { + const present = registeredTools({ review: reviewDeps() }).get(PRESENT_REVIEW_SET_TOOL); + if (!present) throw new Error('present_review_set was not registered'); + + const result = await present.execute( + 'present-review-call-bad', + { exchangeId: 'review-cycle-bad', payload: { ...validReviewPayload(), epistemicStatus: undefined } }, + undefined, + undefined, + {} as never, + ); + + expect(result.details).toMatchObject({ status: 'structural_illegal' }); + expect(isStructuredExchangePresentDetails(result.details)).toBe(false); + expect( + findIncompleteStructuredExchangePresents([ + { type: 'message', message: { role: 'toolResult', details: result.details } }, + ]), + ).toEqual([]); + }); + + it('persists request_review approve, change-request, and reject responses', async () => { + const request = registeredTools().get(REQUEST_REVIEW_TOOL); + if (!request) throw new Error('request_review was not registered'); + + for (const [selected, review, comment] of [ + ['Approve', 'approve', 'Looks right.'], + ['Request changes', 'request_changes', 'Tighten the grounding.'], + ['Reject', 'reject', 'Wrong direction.'], + ] as const) { + const result = await request.execute( + `request-review-${review}`, + { exchangeId: 'review-cycle-1', prompt: 'Review proposal' }, + undefined, + undefined, + { hasUI: true, ui: { select: async () => selected, input: async () => comment } } as never, + ); + + expect(result.content[0]?.text).toContain('### Review decision'); + expect(result.details).toMatchObject({ + exchange_id: 'review-cycle-1', + tool_meta: { prev: PRESENT_REVIEW_SET_TOOL, curr: REQUEST_REVIEW_TOOL }, + answered: { decision: review, comment }, + }); + } + }); + + it('requires request_review change requests to carry a non-empty comment', async () => { + const request = registeredTools().get(REQUEST_REVIEW_TOOL); + if (!request) throw new Error('request_review was not registered'); + + const result = await request.execute( + 'request-review-empty-change', + { exchangeId: 'review-cycle-1', prompt: 'Review proposal' }, + undefined, + undefined, + { hasUI: true, ui: { select: async () => 'Request changes', input: async () => ' ' } } as never, + ); + + expect(result.details).toMatchObject({ + tool_meta: { curr: REQUEST_REVIEW_TOOL }, + unavailable: { message: 'request_review request_changes requires a comment' }, + }); + }); + + it('records request_review cancellation and unavailable UI as terminal outcomes', async () => { + const request = registeredTools().get(REQUEST_REVIEW_TOOL); + if (!request) throw new Error('request_review was not registered'); + + const cancelled = await request.execute( + 'request-review-cancelled', + { exchangeId: 'review-cycle-1', prompt: 'Review proposal' }, + undefined, + undefined, + { hasUI: true, ui: { select: async () => undefined } } as never, + ); + const unavailable = await request.execute( + 'request-review-unavailable', + { exchangeId: 'review-cycle-1', prompt: 'Review proposal' }, + undefined, + undefined, + { hasUI: false, ui: {} } as never, + ); + + expect(cancelled.details).toMatchObject({ cancelled: {}, tool_meta: { curr: REQUEST_REVIEW_TOOL } }); + expect(unavailable.details).toMatchObject({ unavailable: {}, tool_meta: { curr: REQUEST_REVIEW_TOOL } }); + expect(isStructuredExchangeRequestDetails(cancelled.details)).toBe(true); + expect(isStructuredExchangeRequestDetails(unavailable.details)).toBe(true); + }); + it('persists a request_choices response through the editor fallback', async () => { const request = registeredTools().get(REQUEST_CHOICES_TOOL); if (!request) throw new Error('request_choices was not registered'); @@ -244,19 +408,15 @@ describe('structured exchange present/request tools', () => { expect(isStructuredExchangeRequestDetails(result.details)).toBe(true); expect(result.details).toMatchObject({ schema: 'brunch.structured_exchange.request', - exchangeId: 'priorities', - requestTool: REQUEST_CHOICES_TOOL, - status: 'answered', - respondsTo: { - exchangeId: 'priorities', - presentTool: PRESENT_OPTIONS_TOOL, + exchange_id: 'priorities', + tool_meta: { prev: PRESENT_OPTIONS_TOOL, curr: REQUEST_CHOICES_TOOL }, + answered: { + choices: [ + { id: 'speed', label: 'Move quickly', kind: 'listed' }, + { id: 'other', label: 'Other', kind: 'other' }, + ], + comment: 'Also keep the proof deterministic.', }, - choices: [ - { id: 'speed', label: 'Move quickly' }, - { id: 'other', label: 'Other' }, - ], - comment: 'Also keep the proof deterministic.', - createdAtToolCallId: 'request-choices-call-1', }); }); @@ -293,9 +453,8 @@ describe('structured exchange present/request tools', () => { ); expect(result.details).toMatchObject({ - requestTool: REQUEST_CHOICES_TOOL, - status: 'unavailable', - message: 'request_choices requires a comment for Other or None selections', + tool_meta: { curr: REQUEST_CHOICES_TOOL }, + unavailable: { message: 'request_choices requires a comment for Other or None selections' }, }); expect(result.content[0]?.text).toContain('request_choices requires a comment'); }); @@ -308,19 +467,17 @@ describe('structured exchange present/request tools', () => { role: 'toolResult', details: { schema: 'brunch.structured_exchange.present', - schemaVersion: 1, - exchangeId: 'shell-location', - presentTool: PRESENT_OPTIONS_TOOL, - kind: 'options', - status: 'presented', - expectedRequest: { tool: REQUEST_CHOICE_TOOL, required: true }, - createdAtToolCallId: 'present-call-1', + v: 1, + exchange_id: 'shell-location', + tool_meta: { curr: PRESENT_OPTIONS_TOOL, next: REQUEST_CHOICE_TOOL }, + display: { heading: 'Where should the shell live?' }, + options: [{ id: 'root', content: 'Keep src/pi-extensions.ts' }], }, }, }, ]); expect(incomplete).toHaveLength(1); - expect(incomplete[0]?.details.exchangeId).toBe('shell-location'); + expect(incomplete[0]?.details.exchange_id).toBe('shell-location'); }); }); diff --git a/src/.pi/__tests__/structured-exchange-schemas.test.ts b/src/.pi/__tests__/structured-exchange-schemas.test.ts index 1da2c9b1e..ff391dbf2 100644 --- a/src/.pi/__tests__/structured-exchange-schemas.test.ts +++ b/src/.pi/__tests__/structured-exchange-schemas.test.ts @@ -1,3 +1,6 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + import { describe, expect, it } from 'vitest'; import * as z from 'zod'; @@ -26,7 +29,7 @@ import { zRequestDetailsHeader, zRequestReviewDetails, zRequestToolMeta, -} from '../extensions/structured-exchange/schemas/index.js'; +} from '../extensions/exchanges/schemas/index.js'; function expectJsonSchemaExport(schema: z.ZodType) { expect(() => z.toJSONSchema(schema, { unrepresentable: 'throw' })).not.toThrow(); @@ -180,10 +183,21 @@ describe('structured exchange present schemas', () => { exchange_id: 'review-set-17', tool_meta: { curr: 'present_review_set', next: 'request_review' }, display: { heading: 'Review proposed requirements' }, - review_set: { proposal_entry_id: 'entry-review-proposal-17' }, + review_set: { + nodes: [ + { draft_id: 'req-approval', plane: 'intent', kind: 'requirement', title: 'Approval is atomic' }, + ], + edges: [ + { + category: 'dependency', + source: { draft_id: 'req-approval' }, + target: { existing_code: 'G1' }, + }, + ], + }, }), ).toMatchObject({ - review_set: { proposal_entry_id: 'entry-review-proposal-17' }, + review_set: { nodes: [{ draft_id: 'req-approval' }] }, }); expect(zPresentCandidatesDetails.parse(candidateDetails)).toMatchObject({ @@ -194,6 +208,66 @@ describe('structured exchange present schemas', () => { }); }); + it('keeps review-set details to nodes and edges only', () => { + const reviewSetDetails = { + schema: 'brunch.structured_exchange.present', + v: 1, + exchange_id: 'review-set-17', + tool_meta: { curr: 'present_review_set', next: 'request_review' }, + display: { heading: 'Review proposed requirements' }, + review_set: { + nodes: [ + { draft_id: 'req-approval', plane: 'intent', kind: 'requirement', title: 'Approval is atomic' }, + ], + edges: [ + { + category: 'dependency', + source: { draft_id: 'req-approval' }, + target: { existing_code: 'G1' }, + }, + ], + }, + }; + + for (const field of [ + 'proposal_entry_id', + 'pitch', + 'user_rubric', + 'meta_rubric', + 'graph_drafts', + 'entity_drafts', + 'edge_drafts', + 'command_payload', + 'basis', + ] as const) { + expect(() => + zPresentReviewSetDetails.parse({ + ...reviewSetDetails, + review_set: { ...reviewSetDetails.review_set, [field]: field }, + }), + ).toThrow(); + } + + expect(() => + zPresentReviewSetDetails.parse({ + ...reviewSetDetails, + review_set: { + ...reviewSetDetails.review_set, + nodes: [{ ...reviewSetDetails.review_set.nodes[0], basis: 'explicit' }], + }, + }), + ).toThrow(); + expect(() => + zPresentReviewSetDetails.parse({ + ...reviewSetDetails, + review_set: { + ...reviewSetDetails.review_set, + edges: [{ ...reviewSetDetails.review_set.edges[0], source: { existing: 1 } }], + }, + }), + ).toThrow(); + }); + it('rejects candidate graph refs and rubric drift fields', () => { expect(() => zPresentCandidatesDetails.parse({ @@ -544,3 +618,37 @@ describe('structured exchange capture schemas', () => { expectJsonSchemaExport(zCaptureDetails); }); }); + +describe('structured exchange schema source boundary', () => { + it('keeps semantic details contracts in the Zod schemas directory', () => { + const extensionRoot = join(process.cwd(), 'src/.pi/extensions/exchanges'); + const legacyModel = join(extensionRoot, 'shared/model.ts'); + expect(existsSync(legacyModel)).toBe(false); + + const offenders: string[] = []; + for (const file of sourceFiles(extensionRoot)) { + if (file.includes('/schemas/')) continue; + const source = readFileSync(file, 'utf8'); + if (/interface\s+StructuredExchange(?:Present|Request|Capture)?Details/.test(source)) { + offenders.push(file); + continue; + } + if ( + /schemaVersion:\s*1/.test(source) && + /brunch\\.structured_exchange\\.(?:present|request|capture)/.test(source) + ) { + offenders.push(file); + } + } + expect(offenders.map((file) => file.replace(`${process.cwd()}/`, ''))).toEqual([]); + }); +}); + +function sourceFiles(dir: string): string[] { + const entries = readdirSync(dir, { withFileTypes: true }); + return entries.flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return entry.isFile() && path.endsWith('.ts') ? [path] : []; + }); +} diff --git a/src/.pi/__tests__/structured-exchange.test.ts b/src/.pi/__tests__/structured-exchange.test.ts index b6440633c..1a7f423a1 100644 --- a/src/.pi/__tests__/structured-exchange.test.ts +++ b/src/.pi/__tests__/structured-exchange.test.ts @@ -4,7 +4,7 @@ import { buildStructuredExchangeEditorPrefill, parseStructuredExchangeEditorResponse, structuredExchangeResultFromEditor, -} from '../extensions/structured-exchange/index.js'; +} from '../extensions/exchanges/index.js'; describe('structured exchange JSON-editor fallback compatibility helpers', () => { it('builds schema-tagged editor prefill for the raw Pi RPC fallback proof', () => { @@ -50,10 +50,11 @@ describe('structured exchange JSON-editor fallback compatibility helpers', () => }); }); - it('returns legacy structured result details for the existing RPC proof', () => { + it('returns canonical request details for the existing RPC proof', () => { const prefill = JSON.parse( buildStructuredExchangeEditorPrefill({ question: 'Pick paths', + exchangeId: 'paths-1', mode: 'single-select', options: [{ label: 'Alpha', value: 'a' }], }), @@ -67,6 +68,7 @@ describe('structured exchange JSON-editor fallback compatibility helpers', () => const result = structuredExchangeResultFromEditor( { question: 'Pick paths', + exchangeId: 'paths-1', mode: 'single-select', options: [{ label: 'Alpha', value: 'a' }], }, @@ -74,12 +76,14 @@ describe('structured exchange JSON-editor fallback compatibility helpers', () => ); expect(result.details).toMatchObject({ - schema: 'brunch.structured_exchange.result', - status: 'answered', - mode: 'single-select', - answers: [{ type: 'option', label: 'Alpha', value: 'a', index: 1 }], - note: 'Add context', - transport: { surface: 'rpc-editor' }, + schema: 'brunch.structured_exchange.request', + v: 1, + exchange_id: 'paths-1', + tool_meta: { prev: 'present_options', curr: 'request_choice' }, + answered: { + choice: { id: 'a', label: 'Alpha', kind: 'listed' }, + comment: 'Add context', + }, }); }); }); diff --git a/src/.pi/__tests__/workspace-dialog.test.ts b/src/.pi/__tests__/workspace-dialog.test.ts index 98be16efe..6e02f2c6a 100644 --- a/src/.pi/__tests__/workspace-dialog.test.ts +++ b/src/.pi/__tests__/workspace-dialog.test.ts @@ -276,7 +276,8 @@ describe('spec/session picker', () => { expect(lines[0]).toContain('╭'); expect(lines[1]).toMatch(/^\[borderMuted\]│\[\/borderMuted\]\s+\[borderMuted\]│\[\/borderMuted\]$/); expect(lines.some((line) => line.includes('Choose a specification'))).toBe(true); - expect(lines.some((line) => line.includes('[accent]brunch v0.0.0[/accent]'))).toBe(true); + expect(lines.some((line) => line.includes('brunch v0.1.0'))).toBe(true); + expect(lines.some((line) => line.includes('brunch v0.0.0'))).toBe(false); expect(lines.some((line) => line.includes('[success](dev'))).toBe(true); expect(lines.some((line) => line.includes('built on Pi v'))).toBe(true); }); diff --git a/src/.pi/agents/README.md b/src/.pi/agents/README.md new file mode 100644 index 000000000..8d9ff6b00 --- /dev/null +++ b/src/.pi/agents/README.md @@ -0,0 +1,69 @@ +# .pi/agents/ — Pi-harness agent prompt assembly + +SPEC decisions: D25-L, D40-L, D52-L, D58-L, D59-L, D60-L + +## Owns + +Everything that shapes the foreground Pi session agent before a provider request: role definitions, legal runtime-state tuple filtering, active resource manifests, and compact agent-context orchestration. + +The markdown resources the agent reads on demand live beside this layer but are split by purpose: + +```text +.pi/agents/definitions/ keyed agent role prompts +.pi/skills/goals/ grade-derived objectives +.pi/skills/strategies/ interaction shapes +.pi/skills/lenses/ topical focus lenses +.pi/skills/methods/ tool-routing / sequencing guidance +``` + +## Does NOT own + +- Pi extension hook registration — `.pi/extensions/system-prompts/`. +- Pi tool definitions and UI collection — `.pi/extensions/*`. +- Reusable product DTO projection or markdown rendering — target `projections/` and `renderers/` seams. +- Graph domain logic or snapshot PULL — `graph/`. +- Session transcript/workspace semantics — `session/`. + +## Layout + +```text +agents/ +├── README.md +├── state.ts axis enums + legal (op_mode × goal × strategy × lens) tuple table; +│ also owns each resource's {name, description, location} manifest entry +├── compose.ts projection -> runtime header + gated manifest +├── index.ts public entry for prompt assembly imports +├── definitions/ keyed Pi session-agent roles; body = system-prompt resource +│ ├── elicitor.md +│ └── reviewer.md +└── contexts/ agent-context selection/render orchestration (D60-L) + ├── cwd.ts + ├── graph.ts + └── node.ts +``` + +## Composition model + +`composeAgentPrompt(agentId, sessionState, spec, workspace, snapshots)` emits: + +1. agent control header — identity, model/thinking expectation, role derived from `op_mode`, tool authority; +2. runtime-state header — current pinned/AUTO `goal`/`strategy`/`lens`, readiness grade, posture; +3. resource manifests — ``, ``, ``, `` entries, filtered by tuple/grade/`op_mode`/allow-list; +4. compact pushed context — minimal snapshot summary/handles. + +Detailed goal/strategy/lens/method bodies are markdown resources under `.pi/skills/` and are loaded with `read` when detail matters. Manifest metadata is code-owned in `state.ts`, not filesystem-discovered. + +## Snapshot/context split + +```pseudo +PULL -> graph/, session/ [typed, read-only] +RENDER -> reusable renderers eventually; .pi/agents/contexts chooses audience/detail +SURFACE -> extensions/system-prompts/ or snapshot/read_graph tools +``` + +`contexts/` is not a `` manifest resource family. It chooses which typed pull to expose, how much detail to include, and how lens/grade/mode shape the prompt-facing string. + +## Imported by + +- `.pi/extensions/system-prompts/` — calls `composeAgentPrompt()` at turn boundaries. +- `.pi/extensions/runtime/` — reads state helpers for active tool policy. diff --git a/src/agents/architecture.test.ts b/src/.pi/agents/architecture.test.ts similarity index 85% rename from src/agents/architecture.test.ts rename to src/.pi/agents/architecture.test.ts index d8e36cdbc..799405530 100644 --- a/src/agents/architecture.test.ts +++ b/src/.pi/agents/architecture.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -const projectRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const projectRoot = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))); const legacyContextPath = join(projectRoot, 'src/.pi/context'); const legacyImportNeedles = [ @@ -16,21 +16,21 @@ const legacyImportNeedles = [ const resourceExpectations = [ { - file: 'src/agents/methods/run-structured-exchange.md', + file: 'src/.pi/skills/methods/run-structured-exchange.md', needles: ['details.schema', 'schema` plus `v', 'answered`, `cancelled`, or `unavailable`'], }, { - file: 'src/agents/methods/infer-and-capture.md', + file: 'src/.pi/skills/methods/infer-and-capture.md', needles: ['transcript-native analysis', 'not graph mutation', 'must never imply a graph bypass'], }, { - file: 'src/agents/methods/generate-proposal.md', + file: 'src/.pi/skills/methods/generate-proposal.md', needles: ['legibility_cost_of_knowing', 'core_bet', 'graph_refs', '`{ node_id: string }` only'], }, ]; describe('agents topology', () => { - it('keeps prompt guidance in src/agents resources and removes the legacy .pi context source', async () => { + it('keeps prompt guidance in .pi resources and removes the legacy .pi context source', async () => { await expect(readdir(legacyContextPath)).rejects.toThrow(); for (const expectation of resourceExpectations) { diff --git a/src/agents/compose.test.ts b/src/.pi/agents/compose.test.ts similarity index 93% rename from src/agents/compose.test.ts rename to src/.pi/agents/compose.test.ts index 663f28e5b..124529bf2 100644 --- a/src/agents/compose.test.ts +++ b/src/.pi/agents/compose.test.ts @@ -4,11 +4,14 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { DEFAULT_BRUNCH_AGENT_STATE, projectBrunchAgentState } from '../session/runtime-state.js'; -import type { WorkspacePostureState } from '../session/workspace-session-coordinator.js'; +import { + DEFAULT_BRUNCH_AGENT_STATE, + projectBrunchAgentState, +} from '../../projections/session/runtime-state.js'; +import type { WorkspacePostureState } from '../../session/workspace-session-coordinator.js'; import { composeAgentPrompt } from './compose.js'; -const projectRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const projectRoot = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))); const groundingSpec = { id: 1, @@ -40,7 +43,9 @@ function workspacePosture(posture: WorkspacePostureState): WorkspacePostureState const snapshots = { contextHandles: ['graph-overview: compact selected-spec graph summary available via snapshot tools'], - renderedContexts: ['[Selected-spec graph context · intent lens]\n- lsn: 7; nodes: 1; edges: 0'], + renderedContexts: [ + '[Selected-spec graph context · intent lens]\n- selected-spec lsn: 7; nodes: 1; edges: 0', + ], }; describe('composeAgentPrompt', () => { @@ -224,7 +229,7 @@ describe('composeAgentPrompt', () => { ); }); - it('advertises only readable src/agents resources without filesystem discovery', async () => { + it('advertises only readable .pi prompt resources without filesystem discovery', async () => { const result = composeAgentPrompt({ agentId: 'elicitor', sessionState: projectBrunchAgentState([]), @@ -234,7 +239,7 @@ describe('composeAgentPrompt', () => { }); for (const entry of Object.values(result.manifests).flat()) { - expect(relative(projectRoot, entry.location).startsWith('src/agents/')).toBe(true); + expect(relative(projectRoot, entry.location).startsWith('src/.pi/')).toBe(true); await expect(access(entry.location)).resolves.toBeUndefined(); } }); diff --git a/src/agents/compose.ts b/src/.pi/agents/compose.ts similarity index 96% rename from src/agents/compose.ts rename to src/.pi/agents/compose.ts index 2bfe5d0cc..232cd2eab 100644 --- a/src/agents/compose.ts +++ b/src/.pi/agents/compose.ts @@ -1,5 +1,5 @@ -import type { ResolvedBrunchAgentState } from '../session/runtime-state.js'; -import type { WorkspacePostureState } from '../session/workspace-session-coordinator.js'; +import type { ResolvedBrunchAgentState } from '../../projections/session/runtime-state.js'; +import type { WorkspacePostureState } from '../../session/workspace-session-coordinator.js'; import { AGENT_PROMPT_DEFINITIONS, manifestsForState, diff --git a/src/agents/contexts/cwd.test.ts b/src/.pi/agents/contexts/cwd.test.ts similarity index 100% rename from src/agents/contexts/cwd.test.ts rename to src/.pi/agents/contexts/cwd.test.ts diff --git a/src/agents/contexts/cwd.ts b/src/.pi/agents/contexts/cwd.ts similarity index 100% rename from src/agents/contexts/cwd.ts rename to src/.pi/agents/contexts/cwd.ts diff --git a/src/agents/contexts/graph.test.ts b/src/.pi/agents/contexts/graph.test.ts similarity index 94% rename from src/agents/contexts/graph.test.ts rename to src/.pi/agents/contexts/graph.test.ts index 8bee6379c..322cd8c98 100644 --- a/src/agents/contexts/graph.test.ts +++ b/src/.pi/agents/contexts/graph.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { GraphOverview } from '../../graph/snapshot.js'; +import type { GraphOverview } from '../../../graph/snapshot.js'; import { renderGraphContext } from './graph.js'; const overview: GraphOverview = { @@ -47,6 +47,7 @@ describe('renderGraphContext', () => { expect(intent).toContain('[Selected-spec graph context · intent lens]'); expect(design).toContain('[Selected-spec graph context · design lens]'); expect(oracle).toContain('[Selected-spec graph context · oracle lens]'); + expect(intent).toContain('- selected-spec lsn: 7; nodes: 4; edges: 2'); expect(intent).toContain('intent claims, terms, assumptions'); expect(design).toContain('design modules/interfaces'); expect(oracle).toContain('verification checks, evidence'); diff --git a/src/agents/contexts/graph.ts b/src/.pi/agents/contexts/graph.ts similarity index 90% rename from src/agents/contexts/graph.ts rename to src/.pi/agents/contexts/graph.ts index d1fb43cc8..fc3c09abe 100644 --- a/src/agents/contexts/graph.ts +++ b/src/.pi/agents/contexts/graph.ts @@ -1,6 +1,6 @@ -import { formatGraphNodeCode, type GraphNode } from '../../graph/schema/nodes.js'; -import type { GraphOverview } from '../../graph/snapshot.js'; -import type { AgentLensSelection } from '../../session/runtime-state.js'; +import { formatGraphNodeCode, type GraphNode } from '../../../graph/schema/nodes.js'; +import type { GraphOverview } from '../../../graph/snapshot.js'; +import type { AgentLensSelection } from '../../../session/runtime-state.js'; export interface RenderGraphContextOptions { readonly lens: AgentLensSelection; @@ -22,7 +22,7 @@ export function renderGraphContext(overview: GraphOverview, options: RenderGraph const lines = [ `[Selected-spec graph context · ${options.lens} lens]`, - `- lsn: ${overview.lsn}; nodes: ${overview.nodeCount}; edges: ${overview.edgeCount}`, + `- selected-spec lsn: ${overview.lsn}; nodes: ${overview.nodeCount}; edges: ${overview.edgeCount}`, `- emphasis: ${lensEmphasis(options.lens)}`, ]; diff --git a/src/agents/contexts/index.ts b/src/.pi/agents/contexts/index.ts similarity index 100% rename from src/agents/contexts/index.ts rename to src/.pi/agents/contexts/index.ts diff --git a/src/agents/contexts/node.test.ts b/src/.pi/agents/contexts/node.test.ts similarity index 93% rename from src/agents/contexts/node.test.ts rename to src/.pi/agents/contexts/node.test.ts index 10b33ae91..fdbc0f44d 100644 --- a/src/agents/contexts/node.test.ts +++ b/src/.pi/agents/contexts/node.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { GraphNode } from '../../graph/schema/nodes.js'; -import type { NeighborhoodResult } from '../../graph/snapshot.js'; +import type { GraphNode } from '../../../graph/schema/nodes.js'; +import type { NeighborhoodResult } from '../../../graph/snapshot.js'; import { renderNodeContext } from './node.js'; const neighborhood: NeighborhoodResult = { diff --git a/src/agents/contexts/node.ts b/src/.pi/agents/contexts/node.ts similarity index 68% rename from src/agents/contexts/node.ts rename to src/.pi/agents/contexts/node.ts index 6caa1a6c7..6431cf0ae 100644 --- a/src/agents/contexts/node.ts +++ b/src/.pi/agents/contexts/node.ts @@ -1,6 +1,6 @@ -import { formatNeighborhood } from '../../graph/format/neighborhood.js'; -import { projectNeighborhood } from '../../graph/project/neighborhood.js'; -import type { NeighborhoodResult } from '../../graph/snapshot.js'; +import type { NeighborhoodResult } from '../../../graph/snapshot.js'; +import { projectNeighborhood } from '../../../projections/graph/neighborhood.js'; +import { formatNeighborhood } from '../../../renderers/graph/neighborhood.js'; export interface RenderNodeContextOptions { readonly maxNeighbors?: number; diff --git a/src/agents/definitions/elicitor.md b/src/.pi/agents/definitions/elicitor.md similarity index 100% rename from src/agents/definitions/elicitor.md rename to src/.pi/agents/definitions/elicitor.md diff --git a/src/agents/definitions/reviewer.md b/src/.pi/agents/definitions/reviewer.md similarity index 100% rename from src/agents/definitions/reviewer.md rename to src/.pi/agents/definitions/reviewer.md diff --git a/src/agents/index.ts b/src/.pi/agents/index.ts similarity index 100% rename from src/agents/index.ts rename to src/.pi/agents/index.ts diff --git a/src/agents/state.test.ts b/src/.pi/agents/state.test.ts similarity index 86% rename from src/agents/state.test.ts rename to src/.pi/agents/state.test.ts index bdd9782d0..5d0c26ca4 100644 --- a/src/agents/state.test.ts +++ b/src/.pi/agents/state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { projectBrunchAgentState } from '../session/runtime-state.js'; +import { projectBrunchAgentState } from '../../projections/session/runtime-state.js'; import { activeToolNamesForPosture, manifestsForState } from './state.js'; const registeredToolNames = [ @@ -16,6 +16,8 @@ const registeredToolNames = [ 'request_answer', 'request_choice', 'request_choices', + 'present_review_set', + 'request_review', 'read_graph', 'commit_graph', ]; @@ -61,5 +63,7 @@ describe('agent posture policy', () => { expect(elicitationTools).toContain('commit_graph'); expect(commitmentsMethods).toContain('generate-proposal'); expect(commitmentsTools).toContain('commit_graph'); + expect(commitmentsTools).toEqual(expect.arrayContaining(['present_review_set', 'request_review'])); + expect(elicitationTools).not.toContain('present_review_set'); }); }); diff --git a/src/agents/state.ts b/src/.pi/agents/state.ts similarity index 92% rename from src/agents/state.ts rename to src/.pi/agents/state.ts index c555ac9fb..b1f274233 100644 --- a/src/agents/state.ts +++ b/src/.pi/agents/state.ts @@ -1,13 +1,11 @@ import { fileURLToPath } from 'node:url'; -import type { ReadinessGrade } from '../graph/index.js'; -import type { - AgentGoalId, - AgentLensId, - AgentRoleId, - AgentStrategyId, - ResolvedBrunchAgentState, -} from '../session/runtime-state.js'; +import type { ReadinessGrade } from '../../graph/index.js'; +import { + toolPolicyForRuntimeState, + type ResolvedBrunchAgentState, +} from '../../projections/session/runtime-policy.js'; +import type { AgentGoalId, AgentLensId, AgentRoleId, AgentStrategyId } from '../../session/runtime-state.js'; export type { ReadinessGrade }; export type PromptResourceFamily = 'goals' | 'strategies' | 'lenses' | 'methods' | 'definitions'; @@ -96,11 +94,9 @@ const METHOD_TOOL_NAMES: Partial> = { ], 'read-snapshot': ['read_graph'], 'commit-graph': ['commit_graph'], + 'generate-proposal': ['present_review_set', 'request_review'], }; -const ELICIT_BASE_TOOL_NAMES = ['read', 'grep', 'find', 'ls'] as const; -const ELICIT_BLOCKED_TOOL_NAMES = ['bash', 'edit', 'write'] as const; - export const AGENT_PROMPT_DEFINITIONS: Record = { elicitor: { id: 'elicitor', @@ -282,16 +278,15 @@ export function activeToolNamesForPosture({ state, readinessGrade, }: BrunchPostureToolPolicyInput): string[] { - if (state.operationalModeDefinition.toolPolicyId !== 'elicit-read-only') return []; - - const legalTools = new Set(ELICIT_BASE_TOOL_NAMES); + const toolPolicy = toolPolicyForRuntimeState(state); + const legalTools = new Set(toolPolicy.baseAllowedToolNames); for (const method of methodIdsForState(state, readinessGrade)) { for (const toolName of METHOD_TOOL_NAMES[method] ?? []) { legalTools.add(toolName); } } - const blockedTools = new Set(ELICIT_BLOCKED_TOOL_NAMES); + const blockedTools = new Set(toolPolicy.blockedToolNames); return registeredToolNames.filter((toolName) => legalTools.has(toolName) && !blockedTools.has(toolName)); } @@ -331,6 +326,11 @@ function isGradeLegal( return GRADE_RANK[readinessGrade] >= GRADE_RANK[minGrades[id]]; } +function promptResourceLocation(family: PromptResourceFamily, id: string): string { + const root = family === 'definitions' ? './agents' : './skills'; + return fileURLToPath(new URL(`../${root}/${family}/${id}.md`, import.meta.url)); +} + function resource( family: PromptResourceFamily, id: string, @@ -339,6 +339,6 @@ function resource( return { name: id, description, - location: fileURLToPath(new URL(`./${family}/${id}.md`, import.meta.url)), + location: promptResourceLocation(family, id), }; } diff --git a/src/.pi/pi-extension-shell.ts b/src/.pi/brunch-pi-extensions.ts similarity index 71% rename from src/.pi/pi-extension-shell.ts rename to src/.pi/brunch-pi-extensions.ts index a44d665cc..e490ca258 100644 --- a/src/.pi/pi-extension-shell.ts +++ b/src/.pi/brunch-pi-extensions.ts @@ -1,29 +1,32 @@ import { type ExtensionAPI, type ExtensionFactory } from '@earendil-works/pi-coding-agent'; -import { registerBrunchAlternatives } from './extensions/alternatives.js'; -import { registerBrunchChrome } from './extensions/chrome.js'; -import { type BrunchChromeState } from './extensions/chrome.js'; -import { registerBrunchBranchPolicyHandlers } from './extensions/command-policy.js'; -import { registerBrunchCommands, type BrunchCommandsOptions } from './extensions/commands.js'; +import { registerBrunchAlternatives } from './components/alternatives.js'; +import { registerBrunchChrome } from './extensions/chrome/index.js'; +import { type BrunchChromeState } from './extensions/chrome/index.js'; +import { registerBrunchCommands, type BrunchCommandsOptions } from './extensions/commands/index.js'; +import { registerBrunchBranchPolicyHandlers } from './extensions/commands/policy.js'; +import { registerStructuredExchange } from './extensions/exchanges/index.js'; import { registerBrunchGraph, type BrunchGraphDeps } from './extensions/graph/index.js'; -import { type GraphMentionSource } from './extensions/mention-autocomplete.js'; +import { type GraphMentionSource } from './extensions/mentions/index.js'; import { FIXTURE_GRAPH_MENTION_SOURCE, registerBrunchMentionAutocomplete, -} from './extensions/mention-autocomplete.js'; -import { registerBrunchOperationalModePolicy } from './extensions/operational-mode.js'; -import { registerBrunchPrompting, type BrunchPromptContextProvider } from './extensions/prompting.js'; -import { registerBrunchSessionBoundary } from './extensions/session-lifecycle.js'; -import { type BrunchSessionBoundaryHandler } from './extensions/session-lifecycle.js'; -import { registerStructuredExchange } from './extensions/structured-exchange/index.js'; +} 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'; +import { + registerBrunchPrompting, + type BrunchPromptContextProvider, +} from './extensions/system-prompts/index.js'; -export { registerBrunchAlternatives } from './extensions/alternatives.js'; -export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from './extensions/command-policy.js'; +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/mention-autocomplete.js'; +} from './extensions/mentions/index.js'; export { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, @@ -47,8 +50,8 @@ export { type OperationalModeDefinition, type OperationalModeId, type ResolvedBrunchAgentState, -} from './extensions/operational-mode.js'; -export { registerBrunchPrompting } from './extensions/prompting.js'; +} from './extensions/runtime/index.js'; +export { registerBrunchPrompting } from './extensions/system-prompts/index.js'; export { chromeStateForWorkspace, projectBrunchChromeFooterLines, @@ -60,13 +63,13 @@ export { type BrunchChromeState, type BrunchChromeUi, type BrunchChromeWorkerStatus, -} from './extensions/chrome.js'; +} from './extensions/chrome/index.js'; export { bindBrunchSessionBoundary, registerBrunchSessionBoundary, registerBrunchSessionBoundaryRefreshHandlers, type BrunchSessionBoundaryHandler, -} from './extensions/session-lifecycle.js'; +} from './extensions/session/lifecycle.js'; export { BRUNCH_COMMAND_PREFIX, BRUNCH_CONTINUE_COMMAND, @@ -77,12 +80,12 @@ export { BRUNCH_SWITCH_SHORTCUT, registerBrunchCommands, type BrunchCommandsOptions, -} from './extensions/commands.js'; +} from './extensions/commands/index.js'; export { runBrunchWorkspaceAction, runBrunchWorkspaceCommand, type BrunchSpecSessionPickerOptions, -} from './extensions/workspace-dialog.js'; +} from './extensions/workspace/index.js'; export { registerBrunchGraph, @@ -90,7 +93,7 @@ export { type GraphSnapshotReaders, } from './extensions/graph/index.js'; -export interface BrunchPiExtensionShellOptions extends BrunchCommandsOptions { +export interface BrunchPiExtensionsOptions extends BrunchCommandsOptions { graphMentionSource?: GraphMentionSource; graph?: BrunchGraphDeps; promptContext?: BrunchPromptContextProvider; @@ -98,10 +101,10 @@ export interface BrunchPiExtensionShellOptions extends BrunchCommandsOptions { type BrunchProductExtensionRegistrar = (pi: ExtensionAPI) => void | Promise; -export function createBrunchPiExtensionShell( +export function createBrunchPiExtensions( chrome: BrunchChromeState, onSessionBoundary: BrunchSessionBoundaryHandler | undefined, - options: BrunchPiExtensionShellOptions, + options: BrunchPiExtensionsOptions, ): ExtensionFactory { return async (pi) => { const graphMentionSource = options.graphMentionSource ?? FIXTURE_GRAPH_MENTION_SOURCE; @@ -112,7 +115,12 @@ export function createBrunchPiExtensionShell( registerBrunchOperationalModePolicy, (api) => registerBrunchMentionAutocomplete(api, graphMentionSource), registerBrunchAlternatives, - registerStructuredExchange, + (api) => + registerStructuredExchange(api, { + review: options.graph + ? { specId: options.graph.specId, commandExecutor: options.graph.commandExecutor } + : undefined, + }), (api) => registerBrunchCommands(api, options), ...(options.graph ? [(api: ExtensionAPI) => registerBrunchGraph(api, options.graph!)] : []), ]; diff --git a/src/.pi/brunch-pi-profile.ts b/src/.pi/brunch-pi-settings.ts similarity index 95% rename from src/.pi/brunch-pi-profile.ts rename to src/.pi/brunch-pi-settings.ts index 74986301f..524d5d87c 100644 --- a/src/.pi/brunch-pi-profile.ts +++ b/src/.pi/brunch-pi-settings.ts @@ -102,13 +102,13 @@ export const BRUNCH_SETTINGS_AUDITED_GETTERS = [ 'getWarnings', ] as const; -export interface BrunchPiProfileOptions { +export interface BrunchPiSettingsOptions { cwd: string; agentDir: string; extensionFactories: ExtensionFactory[]; } -export interface BrunchPiProfile { +export interface BrunchPiSettings { settingsManager: SettingsManager; resourceLoaderOptions: BrunchResourceLoaderOptions; } @@ -122,11 +122,11 @@ export interface BrunchResourceLoaderOptions { extensionFactories: ExtensionFactory[]; } -export function createBrunchPiProfile({ +export function createBrunchPiSettings({ cwd, agentDir, extensionFactories, -}: BrunchPiProfileOptions): BrunchPiProfile { +}: BrunchPiSettingsOptions): BrunchPiSettings { return { settingsManager: createBrunchSettingsManager(cwd, agentDir), resourceLoaderOptions: brunchResourceLoaderOptions(extensionFactories), diff --git a/src/.pi/extensions/alternatives.ts b/src/.pi/components/alternatives.ts similarity index 98% rename from src/.pi/extensions/alternatives.ts rename to src/.pi/components/alternatives.ts index 05302d2ef..6b47bd13d 100644 --- a/src/.pi/extensions/alternatives.ts +++ b/src/.pi/components/alternatives.ts @@ -15,7 +15,7 @@ import type { ExtensionAPI, ThemeColor } from '@earendil-works/pi-coding-agent'; import { Container, Text } from '@earendil-works/pi-tui'; import { Type } from 'typebox'; -import { CardComponent, ResponsiveColumns, chunk } from '../components/cards.js'; +import { CardComponent, ResponsiveColumns, chunk } from './cards.js'; // ── Types & schema ───────────────────────────────────────────────────── const FLAVOR = StringEnum(['accent', 'success', 'warning', 'muted'] as const); diff --git a/src/.pi/components/workspace-dialog/component.ts b/src/.pi/components/workspace-dialog/component.ts index 9cb6d70e2..de192c3d0 100644 --- a/src/.pi/components/workspace-dialog/component.ts +++ b/src/.pi/components/workspace-dialog/component.ts @@ -24,7 +24,7 @@ import { export const WORKSPACE_DIALOG_WIDTH = 80; const CTRL_C = '\x03'; const ASSET_DIR = new URL('./assets/', import.meta.url); -const PACKAGE_JSON_URL = new URL('../../../../../package.json', import.meta.url); +const PACKAGE_JSON_URL = new URL('../../../../package.json', import.meta.url); const LOCAL_BUILD_TIME = formatBuildTime(new Date()); export type WorkspaceDialogTheme = Pick; diff --git a/src/.pi/extensions/AUDIT.md b/src/.pi/extensions/AUDIT.md new file mode 100644 index 000000000..6d872c53d --- /dev/null +++ b/src/.pi/extensions/AUDIT.md @@ -0,0 +1,71 @@ +Audited `src/.pi/extensions/` read-only. Family: `matrix`. + +## Responsibility rendering + +```text +legend: + R = runtime hook + T = agent tool + UI = interactive Pi UI + C = config/topology only + . = no direct Pi API + +| extension | kind | owns | Pi/API surface | +| -------------------------------- | ---- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| session-lifecycle.ts | R | session-boundary refresh | pi.on(session_start,before_agent_start,message_start); ctx.sessionManager | +| chrome.ts | R/UI | TUI title/footer chrome | pi.on(session_start,model_select,thinking_level_select,turn_end); pi.getThinkingLevel; ctx.getContextUsage; ctx.ui.setFooter/setTitle | +| command-policy.ts | R/UI | block Pi branch/tree flows | pi.on(session_before_tree,session_before_fork); ctx.ui.notify | +| operational-mode.ts | R/T | read-only tool posture + hard blocks | pi.registerTool(read,grep,find,ls); pi.getAllTools; pi.setActiveTools; pi.on(session_start,before_agent_start,tool_call,user_bash) | +| prompting.ts | R | Brunch prompt injection/tool posture | pi.on(before_agent_start); pi.getAllTools; pi.setActiveTools | +| mention-autocomplete.ts | R/UI | #graph mention prompt + autocomplete | pi.on(before_agent_start,session_start); ctx.ui.addAutocompleteProvider | +| alternatives.ts | T/UI | durable alternatives card transcript | pi.registerMessageRenderer; pi.registerTool(present_alternatives); pi.sendMessage | +| structured-exchange/index.ts | T | present/request exchange tool bundle | pi.registerTool(...) | +| structured-exchange/request-*.ts | T/UI | collect answer/choice/review | defineTool; ctx.hasUI; ctx.ui.editor/select/input | +| structured-exchange/present-*.ts | T | persist displayable offers | defineTool; renderCall/renderResult | +| graph/index.ts | T | commit_graph/read_graph registrar | pi.registerTool(commit_graph,read_graph) | +| graph/command-adapter.ts | . | Pi params -> CommandExecutor adapter | . | +| graph/tool-schemas.ts | . | Pi-facing TypeBox schemas | pi-ai Type/StringEnum only | +| commands.ts | R/UI | /brunch:* commands + shortcut | pi.registerCommand; pi.registerShortcut; ctx.ui.notify | +| workspace-dialog.ts | UI | spec/session picker + switching | ctx.waitForIdle; ctx.ui.custom/notify; ctx.switchSession; ctx.sessionManager.getSessionFile | +| snapshot-cwd.ts | C | future snapshot-tool concept note | . | +| auto-compaction-anchors.json | C | compaction preservation contract | . | +| subagents/config.json | C | future subagent config | . | +| present-candidates.ts | C | named but unregistered stub | . | +``` + +## Pi API caller index + +```text +| Pi API / context API | callers | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| pi.registerTool | operational-mode.ts; alternatives.ts; structured-exchange/index.ts; graph/index.ts | +| defineTool | structured-exchange/present-question.ts; present-options.ts; present-review-set.ts; request-answer.ts; request-choice.ts; request-choices.ts; request-review.ts | +| pi.registerMessageRenderer | alternatives.ts | +| pi.sendMessage | alternatives.ts | +| pi.registerCommand | commands.ts | +| pi.registerShortcut | commands.ts | +| pi.on | session-lifecycle.ts; chrome.ts; command-policy.ts; operational-mode.ts; prompting.ts; mention-autocomplete.ts | +| pi.getAllTools | operational-mode.ts; prompting.ts | +| pi.setActiveTools | operational-mode.ts; prompting.ts | +| pi.getThinkingLevel | chrome.ts | +| ctx.sessionManager | session-lifecycle.ts; chrome.ts; operational-mode.ts; workspace-dialog.ts | +| ctx.getContextUsage | chrome.ts | +| ctx.waitForIdle | workspace-dialog.ts | +| ctx.switchSession | workspace-dialog.ts | +| ctx.ui.setFooter/setTitle | chrome.ts | +| ctx.ui.notify | command-policy.ts; commands.ts; workspace-dialog.ts | +| ctx.ui.custom | workspace-dialog.ts | +| ctx.ui.addAutocompleteProvider | mention-autocomplete.ts | +| ctx.hasUI | structured-exchange/request-answer.ts; request-choice.ts; request-choices.ts; request-review.ts | +| ctx.ui.editor | structured-exchange/request-answer.ts; request-choices.ts | +| ctx.ui.select | structured-exchange/request-choice.ts; request-review.ts | +| ctx.ui.input | structured-exchange/request-choice.ts; request-review.ts | +``` + +## Audit notes + +- `graph/*` keeps the intended boundary: no `db/` imports found under `src/.pi/extensions/`; graph access is injected through `CommandExecutor` / snapshot readers. +- `structured-exchange` is the largest Pi tool surface. `present_*` tools produce durable transcript content; `request_*` tools are the only ones that require interactive UI. +- `commands.ts` owns registration; `workspace-dialog.ts` owns command implementation. That split is clean. +- `snapshot-cwd.ts`, `auto-compaction-anchors.json`, `subagents/config.json`, and `present-candidates.ts` are topology/config/stub surfaces, not active Pi registrations. +- `.DS_Store` is non-project noise in the extension directory. diff --git a/src/.pi/extensions/README.md b/src/.pi/extensions/README.md new file mode 100644 index 000000000..96829b174 --- /dev/null +++ b/src/.pi/extensions/README.md @@ -0,0 +1,53 @@ +# .pi/extensions/ — Pi adapter registrars + +SPEC decisions: D34-L, D35-L, D37-L, D39-L, D40-L, D52-L + +## Owns + +Pi-facing registration and adaptation only: lifecycle hooks, agent tool definitions, command/shortcut handlers, TUI chrome affordances, autocomplete wrappers, per-turn system-prompt append hooks, workspace dialogs, and Pi-specific tool result renderers. + +## Does NOT own + +- Agent prompt-resource semantics or manifest composition — `.pi/agents/` and `.pi/skills/`. +- Graph truth, graph mutation policy, or graph readers — `graph/`. +- Pi JSONL/session semantics, runtime-state projection, workspace coordination, or transcript exchange projection — `session/` until the runtime-state follow-up split lands. +- Reusable DTO projection or reusable markdown/text rendering — top-level `projections/` and `renderers/`. +- Product transport handlers — `rpc/`, `app/`, and `web/`. + +## Directory layout + +```text +extensions/ +├── README.md +├── AUDIT.md temporary audit note; do not treat as topology source +├── chrome/ TUI title/footer/chrome projection +├── commands/ /brunch:* commands, shortcut, branch/tree policy +├── compaction/ auto-compaction anchor contract and future hook +├── context/ snapshot/context Pi tools +├── exchanges/ structured-exchange present_* / request_* Pi tools +├── graph/ commit_graph/read_graph Pi tools +├── mentions/ #graph mention prompt hint + autocomplete provider +├── runtime/ active-tool policy and tool/user_bash guards +├── session/ session lifecycle hooks +├── system-prompts/ before_agent_start dynamic prompt append +├── workspace/ spec/session picker command adapter +└── subagents/ future subagent config/tool surface +``` + +## Boundary rules + +```pseudo +rules: + .pi/extensions/* -> .pi/agents/, .pi/components/, graph/, session/, projections/, renderers/ [adapter imports allowed] + .pi/extensions/* x> db/ [no direct storage] + graph/, session/ x> .pi/ [domain layers never import adapters] + .pi/agents/ x> .pi/extensions/ [prompt assembly does not register Pi hooks] + projections/ x> .pi/, rpc/, app/, web/ [no transport/UI imports] + renderers/ x> .pi/, rpc/, app/, web/ [no transport/UI imports] +``` + +## Migration notes + +`exchanges/schemas/` is the intentional current exception to "adapter-only": it owns the Zod-authored structured-exchange details schema per D37-L/D41-L until a separate schema-ownership slice moves or names that seam. `exchanges/pi-schema.ts` remains the only Zod-to-Pi `TSchema` adapter. + +`exchanges/shared/markdown.ts` contains Pi-rendering helpers. Move only reusable product markdown/text rendering into the future renderer seam; keep Pi `renderCall` / `renderResult` widgets and UI-only message components local to `.pi/`. diff --git a/src/.pi/extensions/auto-compaction-anchors.json b/src/.pi/extensions/auto-compaction-anchors.json deleted file mode 100644 index 97550832c..000000000 --- a/src/.pi/extensions/auto-compaction-anchors.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$comment": "Canonical anchor preservation contract for the auto-compaction extension (D43-L, I28-L). Reviewable and editable without SPEC churn. Validated through a TypeBox schema when src/pi-extensions/auto-compaction.ts lands; until then, treat additions as SPEC-aware data changes. Selection rules: 'first' = first matching entry in branch order (singletons like session_binding); 'latest' = most recent matching entry (singleton-by-recency); 'active-leaves' = matching entries that are leaves of their supersedes chain and not yet terminal; 'all-unresolved' = matching entries whose effect has not yet been consumed by the agent or settled by user action.", - "version": 1, - "anchors": [ - { - "kind": "brunch.session_binding", - "select": "first", - "rationale": "I8-L — exactly one binding per session; must survive compaction byte-stable to keep the JSONL self-describing." - }, - { - "kind": "brunch.agent_runtime_state", - "select": "latest", - "rationale": "D40-L — turn preparation reconstructs operational mode / role preset / strategy / lens from the latest valid runtime-state snapshot; losing it after compaction breaks I25-L." - }, - { - "kind": "brunch.establishment_offer", - "select": "latest", - "rationale": "PLAN compaction-and-conflict-widening — ambient-affordance chrome reads the latest establishment offer to render the current orientation surface." - }, - { - "kind": "brunch.lens_switch", - "select": "latest", - "rationale": "D25-L — observer/reviewer routing and prompt composition depend on the active lens; the latest switch is the authoritative lens marker post-compaction." - }, - { - "kind": "brunch.side_task_result", - "select": "all-unresolved", - "rationale": "D15-L, I12-L — succeeded side-task results awaiting next-turn-boundary delivery must remain deliverable after compaction; mid-turn delivery remains forbidden." - }, - { - "kind": "brunch.mention_staleness_hint", - "select": "all-unresolved", - "rationale": "D14-L, I9-L — staleness hints the agent has not yet acted upon must survive so the re-read affordance is not silently dropped." - }, - { - "kind": "worldUpdate", - "select": "latest", - "rationale": "R13, I4-L — the latest cross-session graph delta must remain available so the agent does not re-derive world state from an outdated snapshot." - } - ] -} diff --git a/src/.pi/extensions/chrome.ts b/src/.pi/extensions/chrome.ts deleted file mode 100644 index 6f5de6f7b..000000000 --- a/src/.pi/extensions/chrome.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { ExtensionAPI, ExtensionUIContext } from '@earendil-works/pi-coding-agent'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; - -import type { - WorkspaceSessionChromeState, - WorkspaceSessionReadyState, -} from '../../session/workspace-session-coordinator.js'; -import { BRUNCH_COMPACT_WORDMARK } from '../components/brunch-identity.js'; - -export type BrunchChromeStage = 'idle' | 'streaming' | 'observer-review'; -export type BrunchChromeWorkerStatus = 'idle' | 'queued' | 'running' | 'blocked'; -export type BrunchChromeCoherenceVerdict = 'unknown' | 'coherent' | 'needs_review' | 'incoherent'; - -export interface BrunchChromeContextUsage { - usedTokens: number; - maxTokens: number; -} - -export interface BrunchChromeRuntimeState { - bundle?: string; - role?: string; - model?: string; - thinking?: string; - lens?: string; -} - -export interface BrunchChromeBuildState { - version?: string; - dev?: string; -} - -export interface BrunchChromeFooterTelemetry { - gitBranch?: string | null; - statuses?: ReadonlyMap; -} - -export interface BrunchChromeState extends WorkspaceSessionChromeState { - session: { - id: string; - label?: string; - }; - runtime?: BrunchChromeRuntimeState; - build?: BrunchChromeBuildState; - contextUsage?: BrunchChromeContextUsage; - worker?: { - stage?: BrunchChromeStage; - status?: BrunchChromeWorkerStatus; - }; - coherence?: BrunchChromeCoherenceVerdict; -} - -export type BrunchChromeUi = Pick; - -export function formatBrunchChromeHeaderLines(chrome: BrunchChromeState): string[] { - return [ - ...BRUNCH_COMPACT_WORDMARK, - `runtime: ${formatRuntime(chrome)}`, - `${formatChromeIdentity(chrome)} · phase: ${chrome.phase}`, - ]; -} - -export function projectBrunchChromeFooterLines( - chrome: BrunchChromeState, - telemetry?: BrunchChromeFooterTelemetry, - width?: number, -): string[] { - const statuses = sanitizeChromeStatuses(telemetry?.statuses); - const branch = telemetry?.gitBranch; - const identity = `${formatChromeIdentity(chrome)}${branch ? ` · branch: ${branch}` : ''}`; - const runtime = `brunch · runtime: ${formatRuntime(chrome)} · build: ${formatBuild(chrome)}`; - const context = `context: ${formatContextUsage(chrome.contextUsage)}`; - return [ - width === undefined ? runtime : alignChromeColumns(runtime, context, width), - ...(width === undefined ? [context] : []), - `state: ${chrome.chatMode} · coherence: ${chrome.coherence ?? 'unknown'} · worker: ${formatWorker(chrome)}`, - identity, - statuses.length > 0 ? `status: ${statuses.join(' · ')}` : '', - ]; -} - -export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { - return [ - `brunch: ${formatCompactWordmark()}`, - `cwd: ${chrome.cwd}`, - `spec: ${formatSpec(chrome)}`, - `session: ${formatSession(chrome)}`, - `runtime: ${formatRuntime(chrome)}`, - `context: ${formatContextUsage(chrome.contextUsage)}`, - `chat mode: ${chrome.chatMode}`, - ]; -} - -function formatChromeIdentity(chrome: BrunchChromeState): string { - return `spec: ${formatSpec(chrome)} · session: ${formatSession(chrome)}`; -} - -function formatCompactWordmark(): string { - return BRUNCH_COMPACT_WORDMARK.join(' / '); -} - -function sanitizeChromeStatuses(statuses: ReadonlyMap | undefined): string[] { - return [...(statuses ?? new Map())] - .filter(([key, value]) => key !== 'brunch.chrome' && value.trim().length > 0) - .map(([, value]) => value.trim()); -} - -function alignChromeColumns(left: string, right: string, width: number): string { - const available = Math.max(0, width); - const gap = Math.max(1, available - visibleWidth(left) - visibleWidth(right)); - return truncateToWidth(`${left}${' '.repeat(gap)}${right}`, available); -} - -export function chromeStateForWorkspace(workspace: WorkspaceSessionReadyState): BrunchChromeState { - return { - ...workspace.chrome, - session: { - id: workspace.session.id, - label: workspace.session.name ?? workspace.session.id, - }, - }; -} - -export function renderBrunchChrome(ui: BrunchChromeUi, chrome: BrunchChromeState): void { - ui.setHeader(() => ({ - render: () => formatBrunchChromeHeaderLines(chrome), - invalidate: () => {}, - })); - ui.setFooter((tui, _theme, footerData) => { - const unsubscribe = footerData.onBranchChange(() => tui.requestRender()); - return { - render: (width: number) => - projectBrunchChromeFooterLines( - chrome, - { - gitBranch: footerData.getGitBranch(), - statuses: footerData.getExtensionStatuses(), - }, - width, - ), - invalidate: () => {}, - dispose: unsubscribe, - }; - }); - ui.setWidget('brunch.chrome', formatChromeWidgetLines(chrome), { - placement: 'aboveEditor', - }); - ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`); -} - -export function registerBrunchChrome(pi: ExtensionAPI, chrome: BrunchChromeState): void { - pi.on('session_start', async (_event, ctx) => { - renderBrunchChrome(ctx.ui, chrome); - }); -} - -export default function brunchChrome(_pi: ExtensionAPI): void {} - -function formatSpec(chrome: BrunchChromeState): string { - return chrome.spec?.title ?? 'no spec selected'; -} - -function formatSession(chrome: BrunchChromeState): string { - return chrome.session.label ?? chrome.session.id; -} - -function formatRuntime(chrome: BrunchChromeState): string { - const runtime = chrome.runtime; - if (!runtime) return 'not reported'; - const parts = [ - runtime.bundle, - runtime.role ? `role ${runtime.role}` : undefined, - runtime.model, - runtime.thinking ? `thinking ${runtime.thinking}` : undefined, - runtime.lens ? `lens ${runtime.lens}` : undefined, - ].filter((part): part is string => Boolean(part)); - return parts.length > 0 ? parts.join(' · ') : 'not reported'; -} - -function formatBuild(chrome: BrunchChromeState): string { - const build = chrome.build; - if (!build) return 'not reported'; - return [build.version, build.dev].filter(Boolean).join(' ') || 'not reported'; -} - -function formatContextUsage(usage: BrunchChromeContextUsage | undefined): string { - if (!usage) return 'not reported'; - const max = Math.max(0, usage.maxTokens); - const used = Math.max(0, usage.usedTokens); - if (max === 0) return `${used.toLocaleString()} tokens · no limit reported`; - const ratio = Math.min(1, used / max); - const filled = Math.round(ratio * 10); - const bar = `${'█'.repeat(filled)}${'░'.repeat(10 - filled)}`; - const percent = Math.round(ratio * 100); - return `[${bar}] ${used.toLocaleString()}/${max.toLocaleString()} tokens (${percent}%)`; -} - -function formatWorker(chrome: BrunchChromeState): string { - const worker = chrome.worker; - if (!worker) return 'not reported'; - return [worker.stage, worker.status].filter(Boolean).join('/') || 'not reported'; -} diff --git a/src/.pi/extensions/chrome/index.ts b/src/.pi/extensions/chrome/index.ts new file mode 100644 index 000000000..86db40cb2 --- /dev/null +++ b/src/.pi/extensions/chrome/index.ts @@ -0,0 +1,353 @@ +import { basename, resolve } from 'node:path'; + +import type { + ExtensionAPI, + ExtensionContext, + ExtensionUIContext, + Theme, + ThemeColor, +} from '@earendil-works/pi-coding-agent'; +import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; + +import { + projectBrunchAgentState, + type ResolvedBrunchAgentState, +} from '../../../projections/session/runtime-state.js'; +import type { + AgentLensSelection, + AgentStrategySelection, + OperationalModeId, +} from '../../../session/runtime-state.js'; +import type { + WorkspaceProjectState, + WorkspaceSessionChromeState, + 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'; + +export interface BrunchChromeContextUsage { + usedTokens: number; + maxTokens: number; +} + +export interface BrunchChromeRuntimeState { + bundle?: string; + role?: string; + model?: string; + thinking?: string; + mode?: OperationalModeId; + strategy?: AgentStrategySelection; + lens?: AgentLensSelection; +} + +export interface BrunchChromeBuildState { + version?: string; + dev?: string; +} + +export interface BrunchChromeLiveContextUsage { + tokens?: number | null; + contextWindow?: number | null; + percent?: number | null; +} + +export interface BrunchChromeModelTelemetry { + id: string; + provider?: string; + reasoning?: boolean; + contextWindow?: number; +} + +export interface BrunchChromeFooterTelemetry { + gitBranch?: string | null; + statuses?: ReadonlyMap; + contextUsage?: BrunchChromeContextUsage; + liveContextUsage?: BrunchChromeLiveContextUsage; + model?: BrunchChromeModelTelemetry | null; + thinkingLevel?: string; + availableProviderCount?: number; + agentState?: ResolvedBrunchAgentState; +} + +export interface BrunchChromeRenderOptions { + telemetry?: () => BrunchChromeFooterTelemetry; + bindFooterRenderRequest?: (requestRender: (() => void) | null) => void; +} + +export interface BrunchChromeState extends WorkspaceSessionChromeState { + project?: WorkspaceProjectState; + session: { + id: string; + label?: string; + }; + runtime?: BrunchChromeRuntimeState; + build?: BrunchChromeBuildState; + contextUsage?: BrunchChromeContextUsage; + worker?: { + stage?: BrunchChromeStage; + status?: BrunchChromeWorkerStatus; + }; + coherence?: BrunchChromeCoherenceVerdict; +} + +export type BrunchChromeUi = Pick; + +type BrunchChromeTheme = Pick; + +const CONTEXT_GAUGE_WIDTH = 12; +const BAR_FILLED = '━'; +const BAR_EMPTY = '─'; + +export function projectBrunchChromeFooterLines( + chrome: BrunchChromeState, + telemetry?: BrunchChromeFooterTelemetry, + width?: number, + theme?: BrunchChromeTheme, +): string[] { + const available = width ?? Number.POSITIVE_INFINITY; + const statuses = sanitizeChromeStatuses(telemetry?.statuses); + const branch = telemetry?.gitBranch ?? 'no branch'; + + const rootLine = alignChromeColumns( + style(theme, 'dim', shortenPath(resolve(chrome.cwd))), + style(theme, 'dim', formatModel(chrome, telemetry)), + available, + ); + const branchLine = alignChromeColumns( + style(theme, 'dim', branch), + renderContextGauge(chrome, telemetry, theme), + available, + ); + + const lines = [ + rootLine, + branchLine, + truncateChromeLine(renderBrunchStatusLine(chrome, telemetry, theme), available, theme), + ]; + if (statuses.length > 0) { + lines.push(truncateChromeLine(statuses.join(' '), available, theme)); + } + lines.push(''); + return lines; +} + +function sanitizeChromeStatuses(statuses: ReadonlyMap | undefined): string[] { + return [...(statuses ?? new Map())] + .filter(([key, value]) => key !== 'brunch.chrome' && value.trim().length > 0) + .map(([, value]) => sanitizeStatusText(value)); +} + +function sanitizeStatusText(text: string): string { + return text + .replace(/[\r\n\t]/g, ' ') + .replace(/ +/g, ' ') + .trim(); +} + +function alignChromeColumns(left: string, right: string, width: number): string { + if (!Number.isFinite(width)) return `${left} ${right}`; + + const leftWidth = visibleWidth(left); + const rightWidth = visibleWidth(right); + const minPadding = 2; + if (leftWidth + minPadding + rightWidth <= width) { + return left + ' '.repeat(width - leftWidth - rightWidth) + right; + } + + const availableForRight = width - leftWidth - minPadding; + if (availableForRight <= 0) return truncateToWidth(left, width); + const truncatedRight = truncateToWidth(right, availableForRight, ''); + return ( + left + ' '.repeat(Math.max(minPadding, width - leftWidth - visibleWidth(truncatedRight))) + truncatedRight + ); +} + +function truncateChromeLine(text: string, width: number, theme: BrunchChromeTheme | undefined): string { + return Number.isFinite(width) ? truncateToWidth(text, width, style(theme, 'dim', '...')) : text; +} + +export function chromeStateForWorkspace(workspace: WorkspaceSessionReadyState): BrunchChromeState { + return { + ...workspace.chrome, + session: { + id: workspace.session.id, + label: workspace.session.name ?? workspace.session.id, + }, + }; +} + +export function renderBrunchChrome( + ui: BrunchChromeUi, + chrome: BrunchChromeState, + options?: BrunchChromeRenderOptions, +): void { + ui.setFooter((tui, theme, footerData) => { + options?.bindFooterRenderRequest?.(() => tui.requestRender()); + const unsubscribe = footerData.onBranchChange(() => tui.requestRender()); + return { + render: (width: number) => + projectBrunchChromeFooterLines( + chrome, + { + ...options?.telemetry?.(), + gitBranch: footerData.getGitBranch(), + statuses: footerData.getExtensionStatuses(), + availableProviderCount: footerData.getAvailableProviderCount(), + }, + width, + theme, + ), + invalidate: () => {}, + dispose: () => { + unsubscribe(); + options?.bindFooterRenderRequest?.(null); + }, + }; + }); + ui.setTitle(formatChromeTitle(chrome)); +} + +export function registerBrunchChrome(pi: ExtensionAPI, chrome: BrunchChromeState): void { + let requestFooterRender: (() => void) | null = null; + + pi.on('session_start', async (_event, ctx) => { + renderBrunchChrome(ctx.ui, chrome, { + telemetry: () => footerTelemetryFromContext(ctx, pi), + bindFooterRenderRequest: (requestRender) => { + requestFooterRender = requestRender; + }, + }); + }); + + pi.on('model_select', async () => { + requestFooterRender?.(); + }); + pi.on('thinking_level_select', async () => { + requestFooterRender?.(); + }); + pi.on('turn_end', async () => { + requestFooterRender?.(); + }); +} + +export default function brunchChrome(_pi: ExtensionAPI): void {} + +function footerTelemetryFromContext(ctx: ExtensionContext, pi: ExtensionAPI): BrunchChromeFooterTelemetry { + const liveContextUsage = ctx.getContextUsage(); + return { + ...(liveContextUsage ? { liveContextUsage } : {}), + model: ctx.model + ? { + id: ctx.model.id, + provider: ctx.model.provider, + reasoning: ctx.model.reasoning, + contextWindow: ctx.model.contextWindow, + } + : null, + thinkingLevel: pi.getThinkingLevel(), + agentState: projectBrunchAgentState(ctx.sessionManager.getEntries()), + }; +} + +function formatChromeTitle(chrome: BrunchChromeState): string { + const spec = chrome.spec?.title; + return spec ? `brunch — ${formatProject(chrome)} · ${spec}` : `brunch — ${formatProject(chrome)}`; +} + +function formatProject(chrome: BrunchChromeState): string { + return chrome.project?.name ?? basename(resolve(chrome.cwd)); +} + +function formatSpec(chrome: BrunchChromeState): string { + return chrome.spec?.title ?? 'no spec selected'; +} + +function renderBrunchStatusLine( + chrome: BrunchChromeState, + telemetry: BrunchChromeFooterTelemetry | undefined, + theme: BrunchChromeTheme | undefined, +): string { + const runtime = telemetry?.agentState; + const parts = [ + statusPart(theme, 'proj', formatProject(chrome)), + statusPart(theme, 'spec', formatSpec(chrome)), + statusPart(theme, 'mode', runtime?.operationalMode ?? chrome.runtime?.mode ?? 'not reported'), + statusPart(theme, 'strategy', runtime?.agentStrategy ?? chrome.runtime?.strategy ?? 'not reported'), + statusPart(theme, 'lens', runtime?.agentLens ?? chrome.runtime?.lens ?? 'not reported'), + ]; + return parts.join(style(theme, 'dim', ' | ')); +} + +function statusPart(theme: BrunchChromeTheme | undefined, label: string, value: string): string { + return `${style(theme, 'accent', `${label}:`)} ${style(theme, 'success', value)}`; +} + +function formatModel(chrome: BrunchChromeState, telemetry: BrunchChromeFooterTelemetry | undefined): string { + const model = telemetry?.model; + const modelName = model?.id ?? chrome.runtime?.model ?? 'no model'; + const thinking = telemetry?.thinkingLevel ?? chrome.runtime?.thinking; + let label = modelName; + if (thinking && (model?.reasoning !== false || chrome.runtime?.thinking)) { + label = thinking === 'off' ? `${modelName} • thinking off` : `${modelName} • ${thinking}`; + } + if ((telemetry?.availableProviderCount ?? 0) > 1 && model?.provider) { + return `(${model.provider}) ${label}`; + } + return label; +} + +function renderContextGauge( + chrome: BrunchChromeState, + telemetry: BrunchChromeFooterTelemetry | undefined, + theme: BrunchChromeTheme | undefined, +): string { + const live = telemetry?.liveContextUsage; + const usage = telemetry?.contextUsage ?? chrome.contextUsage; + const modelWindow = telemetry?.model?.contextWindow ?? 0; + const contextWindow = live?.contextWindow ?? usage?.maxTokens ?? modelWindow; + const tokens = live?.tokens ?? usage?.usedTokens ?? null; + const percent = live?.percent ?? percentageFromUsage(tokens, contextWindow); + + const clamped = Math.max(0, Math.min(100, percent ?? 0)); + const filled = percent === null ? 0 : Math.round((clamped / 100) * CONTEXT_GAUGE_WIDTH); + const empty = CONTEXT_GAUGE_WIDTH - filled; + const color = clamped >= 90 ? 'error' : clamped >= 70 ? 'warning' : 'accent'; + const bar = style(theme, color, BAR_FILLED.repeat(filled)) + style(theme, 'dim', BAR_EMPTY.repeat(empty)); + const percentText = percent === null ? '?%' : `${Math.round(clamped)}%`; + const counts = + tokens === null + ? `?/${formatTokens(contextWindow)}` + : `${formatTokens(tokens)}/${formatTokens(contextWindow)}`; + + return `${style(theme, 'dim', 'ctx ')}${bar} ${style(theme, 'dim', `${percentText} ${counts}`)}`; +} + +function percentageFromUsage( + tokens: number | null | undefined, + contextWindow: number | null | undefined, +): number | null { + if (tokens === null || tokens === undefined || !contextWindow || contextWindow <= 0) return null; + return (tokens / contextWindow) * 100; +} + +function formatTokens(count: number | null | undefined): string { + const safeCount = Math.max(0, count ?? 0); + if (safeCount < 1000) return safeCount.toString(); + if (safeCount < 10000) return `${(safeCount / 1000).toFixed(1)}k`; + if (safeCount < 1000000) return `${Math.round(safeCount / 1000)}k`; + if (safeCount < 10000000) return `${(safeCount / 1000000).toFixed(1)}M`; + return `${Math.round(safeCount / 1000000)}M`; +} + +function shortenPath(path: string): string { + const home = process.env.HOME ?? process.env.USERPROFILE; + if (home && path.startsWith(home)) return `~${path.slice(home.length)}`; + return path; +} + +function style(theme: BrunchChromeTheme | undefined, color: ThemeColor, text: string): string { + return theme ? theme.fg(color, text) : text; +} diff --git a/src/.pi/extensions/commands.ts b/src/.pi/extensions/commands/index.ts similarity index 98% rename from src/.pi/extensions/commands.ts rename to src/.pi/extensions/commands/index.ts index 88682d794..0fb58713e 100644 --- a/src/.pi/extensions/commands.ts +++ b/src/.pi/extensions/commands/index.ts @@ -26,7 +26,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'; -import { type BrunchSpecSessionPickerOptions, runBrunchWorkspaceAction } from './workspace-dialog.js'; +import { type BrunchSpecSessionPickerOptions, runBrunchWorkspaceAction } from '../workspace/index.js'; export const BRUNCH_COMMAND_PREFIX = 'brunch:'; export const BRUNCH_SWITCH_COMMAND = 'brunch:switch'; diff --git a/src/.pi/extensions/command-policy.ts b/src/.pi/extensions/commands/policy.ts similarity index 100% rename from src/.pi/extensions/command-policy.ts rename to src/.pi/extensions/commands/policy.ts diff --git a/src/.pi/extensions/compaction/index.ts b/src/.pi/extensions/compaction/index.ts new file mode 100644 index 000000000..e0c21c968 --- /dev/null +++ b/src/.pi/extensions/compaction/index.ts @@ -0,0 +1,70 @@ +export type CompactionAnchorSelect = 'first' | 'latest' | 'active-leaves' | 'all-unresolved'; + +export interface CompactionAnchorContractEntry { + kind: string; + select: CompactionAnchorSelect; + rationale: string; +} + +export interface CompactionAnchorContract { + version: 1; + anchors: readonly CompactionAnchorContractEntry[]; +} + +/** + * Canonical anchor preservation contract for the auto-compaction extension + * (D43-L, I28-L). Reviewable without SPEC churn. + * + * Selection rules: + * - `first`: first matching entry in branch order (singletons like session_binding) + * - `latest`: most recent matching entry (singleton-by-recency) + * - `active-leaves`: matching entries that are leaves of their supersedes chain and not yet terminal + * - `all-unresolved`: matching entries whose effect has not yet been consumed by the agent or settled by user action + */ +export const compactionAnchorContract = { + version: 1, + anchors: [ + { + kind: 'brunch.session_binding', + select: 'first', + rationale: + 'I8-L — exactly one binding per session; must survive compaction byte-stable to keep the JSONL self-describing.', + }, + { + kind: 'brunch.agent_runtime_state', + select: 'latest', + rationale: + 'D40-L — turn preparation reconstructs operational mode / role preset / strategy / lens from the latest valid runtime-state snapshot; losing it after compaction breaks I25-L.', + }, + { + kind: 'brunch.establishment_offer', + select: 'latest', + rationale: + 'PLAN compaction-and-conflict-widening — ambient-affordance chrome reads the latest establishment offer to render the current orientation surface.', + }, + { + kind: 'brunch.lens_switch', + select: 'latest', + rationale: + 'D25-L — observer/reviewer routing and prompt composition depend on the active lens; the latest switch is the authoritative lens marker post-compaction.', + }, + { + kind: 'brunch.side_task_result', + select: 'all-unresolved', + rationale: + 'D15-L, I12-L — succeeded side-task results awaiting next-turn-boundary delivery must remain deliverable after compaction; mid-turn delivery remains forbidden.', + }, + { + kind: 'brunch.mention_staleness_hint', + select: 'all-unresolved', + rationale: + 'D14-L, I9-L — staleness hints the agent has not yet acted upon must survive so the re-read affordance is not silently dropped.', + }, + { + kind: 'worldUpdate', + select: 'latest', + rationale: + 'R13, I4-L — the latest cross-session graph delta must remain available so the agent does not re-derive world state from an outdated snapshot.', + }, + ], +} as const satisfies CompactionAnchorContract; diff --git a/src/.pi/extensions/snapshot-cwd.ts b/src/.pi/extensions/context/get-cwd.ts similarity index 100% rename from src/.pi/extensions/snapshot-cwd.ts rename to src/.pi/extensions/context/get-cwd.ts diff --git a/src/.pi/extensions/structured-exchange/index.ts b/src/.pi/extensions/exchanges/index.ts similarity index 56% rename from src/.pi/extensions/structured-exchange/index.ts rename to src/.pi/extensions/exchanges/index.ts index 1e3b53393..79fd2abf5 100644 --- a/src/.pi/extensions/structured-exchange/index.ts +++ b/src/.pi/extensions/exchanges/index.ts @@ -3,13 +3,15 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { PRESENT_CANDIDATES_TOOL } from './present-candidates.js'; import { PRESENT_OPTIONS_TOOL, presentOptionsTool } from './present-options.js'; import { PRESENT_QUESTION_TOOL, presentQuestionTool } from './present-question.js'; -import { PRESENT_REVIEW_SET_TOOL } from './present-review-set.js'; +import { + PRESENT_REVIEW_SET_TOOL, + createPresentReviewSetTool, + type ReviewSetStructuredExchangeDeps, +} from './present-review-set.js'; import { REQUEST_ANSWER_TOOL, requestAnswerTool } from './request-answer.js'; import { REQUEST_CHOICE_TOOL, requestChoiceTool } from './request-choice.js'; import { REQUEST_CHOICES_TOOL, requestChoicesTool } from './request-choices.js'; -import { REQUEST_REVIEW_TOOL } from './request-review.js'; - -export type { StructuredExchangeResultDetails as StructuredExchangeToolResultDetails } from '../../../session/structured-exchange.js'; +import { REQUEST_REVIEW_TOOL, requestReviewTool } from './request-review.js'; export { buildStructuredExchangeEditorPrefill, @@ -23,13 +25,14 @@ export { isStructuredExchangeRequestDetails, } from './shared/recovery.js'; export { - STRUCTURED_EXCHANGE_PRESENT_SCHEMA, - STRUCTURED_EXCHANGE_REQUEST_SCHEMA, + 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 RequestChoiceDetails as StructuredExchangeToolResultDetails, type RequestToolName, - type StructuredExchangePresentDetails, - type StructuredExchangeRequestDetails, -} from './shared/model.js'; +} from './schemas/index.js'; export { PRESENT_CANDIDATES_TOOL, PRESENT_OPTIONS_TOOL, @@ -47,16 +50,25 @@ export const STRUCTURED_EXCHANGE_IMPLEMENTED_TOOLS = [ requestAnswerTool, requestChoiceTool, requestChoicesTool, + requestReviewTool, ] as const; -export const STRUCTURED_EXCHANGE_STUB_TOOL_NAMES = [ - PRESENT_REVIEW_SET_TOOL, - PRESENT_CANDIDATES_TOOL, - REQUEST_REVIEW_TOOL, -] as const; +export const STRUCTURED_EXCHANGE_STUB_TOOL_NAMES = [PRESENT_CANDIDATES_TOOL] as const; + +export interface StructuredExchangeDeps { + readonly review?: ReviewSetStructuredExchangeDeps | undefined; +} -export function registerStructuredExchange(pi: ExtensionAPI) { - for (const tool of STRUCTURED_EXCHANGE_IMPLEMENTED_TOOLS) { +export function registerStructuredExchange(pi: ExtensionAPI, deps: StructuredExchangeDeps = {}) { + for (const tool of [ + presentQuestionTool, + presentOptionsTool, + createPresentReviewSetTool(deps.review), + requestAnswerTool, + requestChoiceTool, + requestChoicesTool, + requestReviewTool, + ]) { pi.registerTool(tool); } } diff --git a/src/.pi/extensions/exchanges/pi-schema.ts b/src/.pi/extensions/exchanges/pi-schema.ts new file mode 100644 index 000000000..2f7879ff8 --- /dev/null +++ b/src/.pi/extensions/exchanges/pi-schema.ts @@ -0,0 +1,8 @@ +import type { TSchema } from 'typebox'; +import type { z } from 'zod'; + +import { toStructuredExchangeJsonSchema } from './schemas/index.js'; + +export function piSchema(schema: z.ZodType): TSchema { + return toStructuredExchangeJsonSchema(schema) as TSchema; +} diff --git a/src/.pi/extensions/structured-exchange/present-candidates.ts b/src/.pi/extensions/exchanges/present-candidates.ts similarity index 100% rename from src/.pi/extensions/structured-exchange/present-candidates.ts rename to src/.pi/extensions/exchanges/present-candidates.ts diff --git a/src/.pi/extensions/exchanges/present-options.ts b/src/.pi/extensions/exchanges/present-options.ts new file mode 100644 index 000000000..dbc985bec --- /dev/null +++ b/src/.pi/extensions/exchanges/present-options.ts @@ -0,0 +1,40 @@ +import { defineTool } from '@earendil-works/pi-coding-agent'; + +import { projectPresentOptions } from '../../../projections/structured-exchange/present-options.js'; +import { formatPresentOptions } from '../../../renderers/structured-exchange/present-options.js'; +import { piSchema } from './pi-schema.js'; +import { zPresentOptionsParams, type PresentOptionsParams } from './schemas/index.js'; +import { renderMarkdownResult } from './shared/markdown.js'; + +export const PRESENT_OPTIONS_TOOL = 'present_options' as const; + +export const presentOptionsTool = defineTool({ + name: PRESENT_OPTIONS_TOOL, + label: 'Present options', + description: + 'Persist and display a set of structured options as the present half of a Brunch structured exchange. Call the matching request_choice/request_choices tool after this result is available.', + promptSnippet: 'Present structured options before requesting a choice', + promptGuidelines: [ + 'Use present_options before request_choice or request_choices.', + 'Do not rely on renderCall for semantic display; the durable offer is this tool result.', + ], + parameters: piSchema(zPresentOptionsParams), + executionMode: 'sequential', + + async execute(_toolCallId, rawParams) { + const params = zPresentOptionsParams.parse(rawParams) satisfies PresentOptionsParams; + const projection = projectPresentOptions(params); + return { + content: [{ type: 'text' as const, text: formatPresentOptions(projection) }], + details: projection.details, + }; + }, + + renderCall() { + return renderMarkdownResult({ content: [] }); + }, + + renderResult(result, _options, theme) { + return renderMarkdownResult(result, theme); + }, +}); diff --git a/src/.pi/extensions/structured-exchange/present-question.ts b/src/.pi/extensions/exchanges/present-question.ts similarity index 54% rename from src/.pi/extensions/structured-exchange/present-question.ts rename to src/.pi/extensions/exchanges/present-question.ts index 64b60e325..4fce2cb2b 100644 --- a/src/.pi/extensions/structured-exchange/present-question.ts +++ b/src/.pi/extensions/exchanges/present-question.ts @@ -1,25 +1,13 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; -import { Type } from 'typebox'; -import { formatPresentQuestion } from '../../../structured-exchange/format/present-question.js'; -import { projectPresentQuestion } from '../../../structured-exchange/project/present-question.js'; +import { projectPresentQuestion } from '../../../projections/structured-exchange/present-question.js'; +import { formatPresentQuestion } from '../../../renderers/structured-exchange/present-question.js'; +import { piSchema } from './pi-schema.js'; +import { zPresentQuestionParams, type PresentQuestionParams } from './schemas/index.js'; import { renderMarkdownResult } from './shared/markdown.js'; export const PRESENT_QUESTION_TOOL = 'present_question' as const; -export const PresentQuestionParams = Type.Object({ - exchangeId: Type.String({ - description: 'Stable id tying this question to the later request_answer response.', - }), - heading: Type.String({ description: 'Question heading.' }), - body: Type.Optional( - Type.String({ - description: 'Markdown body for context before the answer request.', - }), - ), - expectedRequestTool: Type.Optional(Type.Literal('request_answer')), -}); - export const presentQuestionTool = defineTool({ name: PRESENT_QUESTION_TOOL, label: 'Present question', @@ -30,17 +18,12 @@ export const presentQuestionTool = defineTool({ 'Use present_question before request_answer.', 'The durable user-visible question is this tool result, not renderCall.', ], - parameters: PresentQuestionParams, + parameters: piSchema(zPresentQuestionParams), executionMode: 'sequential', - async execute(toolCallId, params) { - const projection = projectPresentQuestion({ - toolCallId, - exchangeId: params.exchangeId, - heading: params.heading, - body: params.body, - expectedRequestTool: params.expectedRequestTool, - }); + async execute(_toolCallId, rawParams) { + const params = zPresentQuestionParams.parse(rawParams) satisfies PresentQuestionParams; + const projection = projectPresentQuestion(params); return { content: [{ type: 'text' as const, text: formatPresentQuestion(projection) }], details: projection.details, diff --git a/src/.pi/extensions/exchanges/present-review-set.ts b/src/.pi/extensions/exchanges/present-review-set.ts new file mode 100644 index 000000000..300e8cc21 --- /dev/null +++ b/src/.pi/extensions/exchanges/present-review-set.ts @@ -0,0 +1,93 @@ +import { defineTool } from '@earendil-works/pi-coding-agent'; + +import type { CommandExecutor, StructuralIllegal } from '../../../graph/command-executor.js'; +import type { ReviewSetProposalPayload } from '../../../graph/review-set.js'; +import { projectPresentReviewSet } from '../../../projections/structured-exchange/present-review-set.js'; +import { formatPresentReviewSet } from '../../../renderers/structured-exchange/present-review-set.js'; +import { piSchema } from './pi-schema.js'; +import { + zPresentReviewSetParams, + type PresentReviewSetDetails, + type PresentReviewSetParams, +} from './schemas/index.js'; +import { renderMarkdownResult } from './shared/markdown.js'; + +export const PRESENT_REVIEW_SET_TOOL = 'present_review_set' as const; + +export interface ReviewSetStructuredExchangeDeps { + readonly specId: number; + readonly commandExecutor: Pick; +} + +type PresentReviewSetToolDetails = StructuralIllegal | PresentReviewSetDetails; + +const PresentReviewSetParams = piSchema(zPresentReviewSetParams); + +export function createPresentReviewSetTool(deps?: ReviewSetStructuredExchangeDeps) { + return defineTool({ + name: PRESENT_REVIEW_SET_TOOL, + label: 'Present review set', + description: + 'Dry-run validate and display a Brunch graph review-set proposal. Use request_review after this result is available.', + promptSnippet: 'Present a graph review set for exact human approval', + promptGuidelines: [ + 'Use present_review_set only for exact graph drafts the user can approve or reject as a batch.', + 'If the tool returns structural_illegal, fix the payload and retry; do not ask the user to review invalid graph drafts.', + 'Call request_review only after a successful present_review_set result.', + ], + parameters: PresentReviewSetParams, + executionMode: 'sequential', + + async execute(_toolCallId, rawParams) { + const params = zPresentReviewSetParams.parse(rawParams) satisfies PresentReviewSetParams; + if (!deps) { + const details = { + status: 'structural_illegal' as const, + diagnostics: [ + { field: 'present_review_set', message: 'review-set graph dependencies unavailable' }, + ], + }; + return { content: [{ type: 'text' as const, text: formatStructuralIllegal(details) }], details }; + } + + const dryRun = deps.commandExecutor.dryRunAcceptReviewSet({ + specId: deps.specId, + proposalEntryId: params.proposalEntryId, + payload: params.payload, + }); + if (dryRun.status === 'structural_illegal') { + return { + content: [{ type: 'text' as const, text: formatStructuralIllegal(dryRun) }], + details: dryRun, + }; + } + + const projection = projectPresentReviewSet({ + exchangeId: params.exchangeId, + payload: params.payload as ReviewSetProposalPayload, + }); + return { + content: [{ type: 'text' as const, text: formatPresentReviewSet(projection) }], + details: projection.details, + }; + }, + + renderCall() { + return renderMarkdownResult({ content: [] }); + }, + + renderResult(result, _options, theme) { + return renderMarkdownResult(result, theme); + }, + }); +} + +export const presentReviewSetTool = createPresentReviewSetTool(); + +function formatStructuralIllegal(result: { + readonly diagnostics: readonly { readonly field: string; readonly message: string }[]; +}): string { + return ['STRUCTURAL_ILLEGAL', '', ...result.diagnostics.map((d) => `- ${d.field}: ${d.message}`)].join( + '\n', + ); +} diff --git a/src/.pi/extensions/exchanges/request-answer.ts b/src/.pi/extensions/exchanges/request-answer.ts new file mode 100644 index 000000000..4a1cdbe68 --- /dev/null +++ b/src/.pi/extensions/exchanges/request-answer.ts @@ -0,0 +1,50 @@ +import { defineTool } from '@earendil-works/pi-coding-agent'; + +import { projectRequestAnswer } from '../../../projections/structured-exchange/request-answer.js'; +import { formatRequestAnswer } from '../../../renderers/structured-exchange/request-answer.js'; +import { piSchema } from './pi-schema.js'; +import { zRequestAnswerParams, type RequestAnswerParams } from './schemas/index.js'; +import { renderMarkdownResult } from './shared/markdown.js'; + +export const REQUEST_ANSWER_TOOL = 'request_answer' as const; + +export const requestAnswerTool = defineTool({ + name: REQUEST_ANSWER_TOOL, + label: 'Request answer', + description: + 'Collect a freeform user answer as the request half of a Brunch structured exchange. Use only after present_question.', + promptSnippet: 'Request a freeform answer after presenting a question', + promptGuidelines: [ + 'Use request_answer only after the matching present_question tool.', + 'Do not repeat the present_question markdown content in request_answer parameters; reference it by exchangeId.', + ], + parameters: piSchema(zRequestAnswerParams), + executionMode: 'sequential', + + async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) { + const params = zRequestAnswerParams.parse(rawParams) satisfies RequestAnswerParams; + if (!ctx.hasUI || typeof ctx.ui.editor !== 'function') { + const details = projectRequestAnswer({ + exchangeId: params.exchangeId, + status: 'unavailable', + message: 'request_answer requires interactive UI', + }); + return { content: [{ type: 'text' as const, text: formatRequestAnswer(details) }], details }; + } + + const answer = await ctx.ui.editor(params.prompt); + const details = + answer === undefined + ? projectRequestAnswer({ exchangeId: params.exchangeId, status: 'cancelled' }) + : projectRequestAnswer({ exchangeId: params.exchangeId, status: 'answered', answer }); + return { content: [{ type: 'text' as const, text: formatRequestAnswer(details) }], details }; + }, + + renderCall() { + return renderMarkdownResult({ content: [] }); + }, + + renderResult(result, _options, theme) { + return renderMarkdownResult(result, theme); + }, +}); diff --git a/src/.pi/extensions/exchanges/request-choice.ts b/src/.pi/extensions/exchanges/request-choice.ts new file mode 100644 index 000000000..cd65660b3 --- /dev/null +++ b/src/.pi/extensions/exchanges/request-choice.ts @@ -0,0 +1,96 @@ +import { defineTool } from '@earendil-works/pi-coding-agent'; + +import { projectRequestChoice } from '../../../projections/structured-exchange/request-choice.js'; +import { formatRequestChoice } from '../../../renderers/structured-exchange/request-choice.js'; +import { piSchema } from './pi-schema.js'; +import { + zRequestChoiceParams, + type RequestChoiceParam, + type RequestChoiceParams, + type SelectedChoice, +} from './schemas/index.js'; +import { normalizeOptionalText, renderMarkdownResult } from './shared/markdown.js'; + +export const REQUEST_CHOICE_TOOL = 'request_choice' as const; + +type StructuredExchangeChoice = RequestChoiceParam; + +function choiceByLabel( + choices: readonly StructuredExchangeChoice[], + selected: string, +): StructuredExchangeChoice | undefined { + return choices.find((choice) => choice.label === selected || choice.id === selected); +} + +function selectedChoice(choice: StructuredExchangeChoice, kind: SelectedChoice['kind']): SelectedChoice { + return { id: choice.id, label: choice.label, kind }; +} + +export const requestChoiceTool = defineTool({ + name: REQUEST_CHOICE_TOOL, + label: 'Request choice', + description: + 'Collect one user choice as the request half of a Brunch structured exchange. Use only after the corresponding present_* tool result has displayed the offer content.', + promptSnippet: 'Request one choice after presenting a structured offer', + promptGuidelines: [ + 'Use request_choice only after the matching present_options or present_candidates tool.', + 'Do not repeat the present_* markdown content in request_choice parameters; reference it by exchangeId.', + ], + parameters: piSchema(zRequestChoiceParams), + executionMode: 'sequential', + + async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) { + const params = zRequestChoiceParams.parse(rawParams) satisfies RequestChoiceParams; + const choices = params.choices.map((choice) => ({ id: choice.id, label: choice.label })); + const terminal = (status: 'cancelled' | 'unavailable', message?: string) => { + const details = projectRequestChoice({ + exchangeId: params.exchangeId, + respondsToPresentTool: params.respondsToPresentTool, + status, + message, + }); + return { content: [{ type: 'text' as const, text: formatRequestChoice(details) }], details }; + }; + + if (!ctx.hasUI || typeof ctx.ui.select !== 'function') { + return terminal('unavailable', 'request_choice requires interactive UI'); + } + + const labels = [...choices.map((choice) => choice.label), ...(params.allowOther ? ['Other'] : [])]; + const selected = await ctx.ui.select(params.prompt, labels); + if (selected === undefined) return terminal('cancelled'); + + const picked = choiceByLabel(choices, selected); + let choice: SelectedChoice; + let comment = ''; + if (!picked) { + const other = + typeof ctx.ui.input === 'function' ? await ctx.ui.input('Other', 'Describe your answer') : undefined; + if (other === undefined || other.trim().length === 0) return terminal('cancelled'); + choice = { id: 'other', label: other.trim(), kind: 'other' }; + comment = other.trim(); + } else { + choice = selectedChoice(picked, 'listed'); + if (typeof ctx.ui.input === 'function') { + comment = (await ctx.ui.input(params.commentPrompt ?? 'Optional comment')) ?? ''; + } + } + + const details = projectRequestChoice({ + exchangeId: params.exchangeId, + respondsToPresentTool: params.respondsToPresentTool, + status: 'answered', + choice, + comment: normalizeOptionalText(comment), + }); + return { content: [{ type: 'text' as const, text: formatRequestChoice(details) }], details }; + }, + + renderCall() { + return renderMarkdownResult({ content: [] }); + }, + + renderResult(result, _options, theme) { + return renderMarkdownResult(result, theme); + }, +}); diff --git a/src/.pi/extensions/structured-exchange/request-choices.ts b/src/.pi/extensions/exchanges/request-choices.ts similarity index 51% rename from src/.pi/extensions/structured-exchange/request-choices.ts rename to src/.pi/extensions/exchanges/request-choices.ts index b6eb92426..85adafd9f 100644 --- a/src/.pi/extensions/structured-exchange/request-choices.ts +++ b/src/.pi/extensions/exchanges/request-choices.ts @@ -1,44 +1,19 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; -import { Type } from 'typebox'; -import { markdownEscape, normalizeOptionalText, renderMarkdownResult } from './shared/markdown.js'; +import { projectRequestChoices } from '../../../projections/structured-exchange/request-choices.js'; +import { formatRequestChoices } from '../../../renderers/structured-exchange/request-choices.js'; +import { piSchema } from './pi-schema.js'; import { - isRecord, - STRUCTURED_EXCHANGE_REQUEST_SCHEMA, - type StructuredExchangeChoice, - type StructuredExchangeRequestDetails, -} from './shared/model.js'; + zRequestChoicesParams, + type RequestChoiceParam, + type RequestChoicesParams, + type SelectedChoice, +} from './schemas/index.js'; +import { normalizeOptionalText, renderMarkdownResult } from './shared/markdown.js'; export const REQUEST_CHOICES_TOOL = 'request_choices' as const; -const ChoiceSchema = Type.Object({ - id: Type.String({ - description: 'Stable choice id from the corresponding present_* entry.', - }), - label: Type.String({ - description: 'Short choice label shown in the live selection UI.', - }), -}); - -export const RequestChoicesParams = Type.Object({ - exchangeId: Type.String({ - description: 'The structured exchange id from the corresponding present_options entry.', - }), - respondsToPresentTool: Type.Literal('present_options'), - prompt: Type.String({ - description: 'Short live-input prompt. Do not repeat the presented content.', - }), - choices: Type.Array(ChoiceSchema, { - description: 'Listed choices available for this multi-choice response.', - }), - allowOther: Type.Optional(Type.Boolean({ description: 'Whether the user may choose Other.' })), - allowNone: Type.Optional(Type.Boolean({ description: 'Whether the user may choose None.' })), - commentPrompt: Type.Optional( - Type.String({ - description: 'Prompt for an optional comment. Required when Other or None is selected.', - }), - ), -}); +type StructuredExchangeChoice = RequestChoiceParam; interface EditorChoice { id: string; @@ -95,9 +70,7 @@ function parseEditorResponse(value: string): EditorResponse | null { const response = parsed.response; if (!isRecord(response)) return null; - if (response.status === 'cancelled') { - return { status: 'cancelled', choices: [], comment: '' }; - } + if (response.status === 'cancelled') return { status: 'cancelled', choices: [], comment: '' }; if (response.status !== 'answered') return null; if (!Array.isArray(response.choices)) return null; if (typeof response.comment !== 'string') return null; @@ -110,37 +83,7 @@ function parseEditorResponse(value: string): EditorResponse | null { }; }); if (choices.some((choice) => choice === null)) return null; - return { - status: 'answered', - choices: choices as EditorChoice[], - comment: response.comment, - }; -} - -function requestMarkdown(details: StructuredExchangeRequestDetails): string { - if (details.status === 'cancelled') return '### Response\n\n_User cancelled the request._'; - if (details.status === 'unavailable') { - return `### Response\n\n_${details.message ?? 'Response UI unavailable.'}_`; - } - - const lines = ['### Response']; - if (details.choices && details.choices.length > 0) { - lines.push('', ...details.choices.map((choice) => `- ${markdownEscape(choice.label)}`)); - } - if (details.comment) lines.push('', 'Comment:', '', `> ${details.comment}`); - return lines.join('\n'); -} - -function unavailable(base: Omit, message: string) { - const details: StructuredExchangeRequestDetails = { - ...base, - status: 'unavailable', - message, - }; - return { - content: [{ type: 'text' as const, text: requestMarkdown(details) }], - details, - }; + return { status: 'answered', choices: choices as EditorChoice[], comment: response.comment }; } function matchSelectedChoices( @@ -150,19 +93,21 @@ function matchSelectedChoices( allowOther?: boolean; allowNone?: boolean; }, -): StructuredExchangeChoice[] | string { - const allowed = new Map(params.choices.map((choice) => [choice.id, choice])); - if (params.allowOther) allowed.set('other', { id: 'other', label: 'Other' }); - if (params.allowNone) allowed.set('none', { id: 'none', label: 'None' }); +): SelectedChoice[] | string { + const allowed = new Map( + params.choices.map((choice) => [choice.id, { id: choice.id, label: choice.label, kind: 'listed' }]), + ); + if (params.allowOther) allowed.set('other', { id: 'other', label: 'Other', kind: 'other' }); + if (params.allowNone) allowed.set('none', { id: 'none', label: 'None', kind: 'none' }); - const matched: StructuredExchangeChoice[] = []; + const matched: SelectedChoice[] = []; const seen = new Set(); for (const choice of selected) { const known = allowed.get(choice.id); if (!known) return `request_choices received unknown choice id: ${choice.id}`; if (seen.has(choice.id)) continue; seen.add(choice.id); - matched.push({ id: known.id, label: choice.label ?? known.label }); + matched.push({ id: known.id, label: choice.label ?? known.label, kind: known.kind }); } if (matched.length === 0) return 'request_choices requires at least one choice'; return matched; @@ -179,87 +124,55 @@ export const requestChoicesTool = defineTool({ 'Do not repeat the present_options markdown content in request_choices parameters; reference it by exchangeId.', 'Require a comment when the response selects Other or None.', ], - parameters: RequestChoicesParams, + parameters: piSchema(zRequestChoicesParams), executionMode: 'sequential', - async execute(toolCallId, params, _signal, _onUpdate, ctx) { - const choices: StructuredExchangeChoice[] = params.choices.map((choice) => ({ - id: choice.id, - label: choice.label, - })); - const base = { - schema: STRUCTURED_EXCHANGE_REQUEST_SCHEMA, - schemaVersion: 1 as const, - exchangeId: params.exchangeId, - requestTool: REQUEST_CHOICES_TOOL, - respondsTo: { - exchangeId: params.exchangeId, - presentTool: params.respondsToPresentTool, - }, - createdAtToolCallId: toolCallId, + async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) { + const params = zRequestChoicesParams.parse(rawParams) satisfies RequestChoicesParams; + const choices = params.choices.map((choice) => ({ id: choice.id, label: choice.label })); + const terminal = (status: 'cancelled' | 'unavailable', message?: string) => { + const details = projectRequestChoices({ exchangeId: params.exchangeId, status, message }); + return { content: [{ type: 'text' as const, text: formatRequestChoices(details) }], details }; }; if (!ctx.hasUI || typeof ctx.ui.editor !== 'function') { - return unavailable(base, 'request_choices requires interactive UI'); + return terminal('unavailable', 'request_choices requires interactive UI'); } - const editorPrefillParams: Parameters[0] = { - prompt: params.prompt, - choices, - }; + const editorPrefillParams: Parameters[0] = { prompt: params.prompt, choices }; if (params.allowOther !== undefined) editorPrefillParams.allowOther = params.allowOther; if (params.allowNone !== undefined) editorPrefillParams.allowNone = params.allowNone; if (params.commentPrompt !== undefined) editorPrefillParams.commentPrompt = params.commentPrompt; const edited = await ctx.ui.editor(buildEditorPrefill(editorPrefillParams)); - if (edited === undefined) { - const details: StructuredExchangeRequestDetails = { - ...base, - status: 'cancelled', - }; - return { - content: [{ type: 'text' as const, text: requestMarkdown(details) }], - details, - }; - } + if (edited === undefined) return terminal('cancelled'); const response = parseEditorResponse(edited); - if (!response) { - return unavailable(base, 'request_choices editor fallback returned invalid JSON'); - } - if (response.status === 'cancelled') { - const details: StructuredExchangeRequestDetails = { - ...base, - status: 'cancelled', - }; - return { - content: [{ type: 'text' as const, text: requestMarkdown(details) }], - details, - }; - } + if (!response) return terminal('unavailable', 'request_choices editor fallback returned invalid JSON'); + if (response.status === 'cancelled') return terminal('cancelled'); const matchParams: Parameters[1] = { choices }; if (params.allowOther !== undefined) matchParams.allowOther = params.allowOther; if (params.allowNone !== undefined) matchParams.allowNone = params.allowNone; const matched = matchSelectedChoices(response.choices, matchParams); - if (typeof matched === 'string') return unavailable(base, matched); + if (typeof matched === 'string') return terminal('unavailable', matched); const comment = normalizeOptionalText(response.comment); - if (matched.some((choice) => choice.id === 'other' || choice.id === 'none') && comment === undefined) { - return unavailable(base, 'request_choices requires a comment for Other or None selections'); + if ( + matched.some((choice) => choice.kind === 'other' || choice.kind === 'none') && + comment === undefined + ) { + return terminal('unavailable', 'request_choices requires a comment for Other or None selections'); } - const details: StructuredExchangeRequestDetails = { - ...base, + const details = projectRequestChoices({ + exchangeId: params.exchangeId, status: 'answered', choices: matched, - ...(comment !== undefined ? { comment } : {}), - }; - return { - content: [{ type: 'text' as const, text: requestMarkdown(details) }], - details, - }; + comment, + }); + return { content: [{ type: 'text' as const, text: formatRequestChoices(details) }], details }; }, renderCall() { @@ -270,3 +183,7 @@ export const requestChoicesTool = defineTool({ return renderMarkdownResult(result, theme); }, }); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/.pi/extensions/exchanges/request-review.ts b/src/.pi/extensions/exchanges/request-review.ts new file mode 100644 index 000000000..1f39ce4e8 --- /dev/null +++ b/src/.pi/extensions/exchanges/request-review.ts @@ -0,0 +1,80 @@ +import { defineTool } from '@earendil-works/pi-coding-agent'; + +import { + projectRequestReview, + type ReviewDecision, +} from '../../../projections/structured-exchange/request-review.js'; +import { formatRequestReview } from '../../../renderers/structured-exchange/request-review.js'; +import { piSchema } from './pi-schema.js'; +import { zRequestReviewParams, type RequestReviewParams } from './schemas/index.js'; +import { normalizeOptionalText, renderMarkdownResult } from './shared/markdown.js'; + +export const REQUEST_REVIEW_TOOL = 'request_review' as const; + +const REVIEW_LABELS = ['Approve', 'Request changes', 'Reject'] as const; + +function decisionForLabel(label: string): ReviewDecision | undefined { + if (label === 'Approve') return 'approve'; + if (label === 'Request changes') return 'request_changes'; + if (label === 'Reject') return 'reject'; + return undefined; +} + +export const requestReviewTool = defineTool({ + name: REQUEST_REVIEW_TOOL, + label: 'Request review', + description: + 'Collect approve / request changes / reject as the request half of a Brunch review-set structured exchange.', + promptSnippet: 'Request a terminal decision after presenting a graph review set', + promptGuidelines: [ + 'Use request_review only after a successful matching present_review_set result.', + 'Do not repeat the presented review-set markdown in request_review parameters; reference it by exchangeId.', + 'Request-changes decisions require a concrete user comment.', + ], + parameters: piSchema(zRequestReviewParams), + executionMode: 'sequential', + + async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) { + const params = zRequestReviewParams.parse(rawParams) satisfies RequestReviewParams; + const terminal = (status: 'cancelled' | 'unavailable', message?: string) => { + const details = projectRequestReview({ exchangeId: params.exchangeId, status, message }); + return { content: [{ type: 'text' as const, text: formatRequestReview(details) }], details }; + }; + + if (!ctx.hasUI || typeof ctx.ui.select !== 'function') { + return terminal('unavailable', 'request_review requires interactive UI'); + } + + const selected = await ctx.ui.select(params.prompt ?? 'Review proposal', [...REVIEW_LABELS]); + if (selected === undefined) return terminal('cancelled'); + + const review = decisionForLabel(selected); + if (!review) return terminal('unavailable', `request_review received unknown decision ${selected}`); + + const comment = + typeof ctx.ui.input === 'function' + ? normalizeOptionalText( + await ctx.ui.input(review === 'request_changes' ? 'Required change request' : 'Optional comment'), + ) + : undefined; + if (review === 'request_changes' && comment === undefined) { + return terminal('unavailable', 'request_review request_changes requires a comment'); + } + + const details = projectRequestReview({ + exchangeId: params.exchangeId, + status: 'answered', + review, + comment, + }); + return { content: [{ type: 'text' as const, text: formatRequestReview(details) }], details }; + }, + + renderCall() { + return renderMarkdownResult({ content: [] }); + }, + + renderResult(result, _options, theme) { + return renderMarkdownResult(result, theme); + }, +}); diff --git a/src/.pi/extensions/structured-exchange/schemas/README.md b/src/.pi/extensions/exchanges/schemas/README.md similarity index 81% rename from src/.pi/extensions/structured-exchange/schemas/README.md rename to src/.pi/extensions/exchanges/schemas/README.md index 55edd97da..240a55c62 100644 --- a/src/.pi/extensions/structured-exchange/schemas/README.md +++ b/src/.pi/extensions/exchanges/schemas/README.md @@ -1,6 +1,6 @@ # Structured-exchange schema contract -This directory owns the Zod-authored, JSON-Schema-exportable details model for structured-exchange transcript tool results. It records the exact contract for the schema pass; runtime migration is separate work. +This directory owns the Zod-authored, JSON-Schema-exportable details model for structured-exchange transcript tool results. Runtime tools, session projection, pending-exchange recovery, and tests consume these schemas as the semantic source of truth. ## Naming @@ -15,8 +15,8 @@ const PresentCandidatesDetailsSchema = z.toJSONSchema(zPresentCandidatesDetails) - Zod source values use the `z` prefix and are not named `*Schema`. - Inferred TypeScript types use the bare domain name. -- `*Schema` means JSON-Schema-shaped output: either generated with `z.toJSONSchema(...)` or authored directly with TypeBox. -- If TypeBox source values need a prefix in non-boundary helpers, use `tb*`. +- `*Schema` means JSON-Schema-shaped output generated from Zod with `z.toJSONSchema(...)`. +- TypeBox is not a schema authoring layer for this seam; the only permitted TypeBox reference is the Pi `TSchema` cast adapter in `../pi-schema.ts`. - `Details`, `Params`, `Payload`, and `Result` are data-type name parts, not schema-library markers. ## File layout @@ -28,10 +28,29 @@ schemas/ present.ts request.ts capture.ts + params.ts index.ts ``` -The organization is layer-first: shared vocabulary, present details, request details, capture details, and one public export barrel. +The organization is layer-first: shared vocabulary, tool parameter schemas, present details, request details, capture details, and one public export barrel. + +## Source boundaries + +```pseudo +chain active Pi tool / session trigger / RPC editor relay + -> parse params or relay payload at the entry boundary + -> projections/structured-exchange/* constructs details + -> relevant details Zod schema parses result + -> renderers/structured-exchange/* renders durable markdown +``` + +- Active `.pi/extensions/exchanges/*.ts` files own Pi registration and UI collection only. +- `../pi-schema.ts` is the only Zod JSON Schema to Pi `TSchema` adapter. +- `projections/structured-exchange/*` is the only construction boundary for active present/request `toolResult.details`. +- `renderers/structured-exchange/*` owns durable markdown for active present/request emissions. +- Session pending exchange recovery projects from canonical present/request details; it does not author a TypeBox semantic schema. +- The RPC/editor relay is an intentional current product fallback and must still emit canonical details through projectors. +- The proof-era `brunch.structured_exchange.result` details model is retired. ## Global details header @@ -158,9 +177,28 @@ display: heading: "Review proposed requirements" body: "Approve the set, request changes, or reject it." review_set: - proposal_entry_id: "entry-review-proposal-17" + nodes: + - draft_id: "req-approval" + plane: intent + kind: requirement + title: "Approval is atomic" + body?: markdown + detail?: object + edges: + - category: dependency | proof | support | realization | boundary | composition | association | supersession + source: { draft_id: "req-approval" } | { existing_code: "G1" } + target: { draft_id: "goal-review" } | { existing_code: "G1" } + stance?: for | against + rationale?: markdown ``` +Rules: + +- `review_set` contains only `nodes` and `edges` in transcript details. +- Proposal audit ids and graph command payloads stay outside `toolResult.details`; later acceptance derives graph commands at the graph adapter/domain boundary. +- Do not add `proposal_entry_id`, `pitch`, `user_rubric`, `meta_rubric`, `graph_drafts`, `entity_drafts`, `edge_drafts`, `command_payload`, per-item `basis`, or raw DB ids to this details shape. +- Candidate rubrics are candidate-specific; do not copy candidate comparison facets into review-set details. + ### `present_candidates` Exact approved shape: diff --git a/src/.pi/extensions/structured-exchange/schemas/capture.ts b/src/.pi/extensions/exchanges/schemas/capture.ts similarity index 69% rename from src/.pi/extensions/structured-exchange/schemas/capture.ts rename to src/.pi/extensions/exchanges/schemas/capture.ts index 28ef07fd0..4754e27ee 100644 --- a/src/.pi/extensions/structured-exchange/schemas/capture.ts +++ b/src/.pi/extensions/exchanges/schemas/capture.ts @@ -1,15 +1,17 @@ import * as z from 'zod'; -import { zCaptureDetailsHeader } from './shared.js'; +import { + zCaptureAnswerToolMeta, + zCaptureCandidateToolMeta, + zCaptureChoiceToolMeta, + zCaptureChoicesToolMeta, + zCaptureDetailsHeader, + zCaptureReviewToolMeta, +} from './shared.js'; export const zCaptureAnswerDetails = zCaptureDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('request_answer'), - curr: z.literal('capture_answer'), - }) - .strict(), + tool_meta: zCaptureAnswerToolMeta, }) .strict(); export type CaptureAnswerDetails = z.infer; @@ -17,12 +19,7 @@ export const CaptureAnswerDetailsSchema = z.toJSONSchema(zCaptureAnswerDetails, export const zCaptureChoiceDetails = zCaptureDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('request_choice'), - curr: z.literal('capture_choice'), - }) - .strict(), + tool_meta: zCaptureChoiceToolMeta, }) .strict(); export type CaptureChoiceDetails = z.infer; @@ -30,12 +27,7 @@ export const CaptureChoiceDetailsSchema = z.toJSONSchema(zCaptureChoiceDetails, export const zCaptureChoicesDetails = zCaptureDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('request_choices'), - curr: z.literal('capture_choices'), - }) - .strict(), + tool_meta: zCaptureChoicesToolMeta, }) .strict(); export type CaptureChoicesDetails = z.infer; @@ -45,12 +37,7 @@ export const CaptureChoicesDetailsSchema = z.toJSONSchema(zCaptureChoicesDetails export const zCaptureReviewDetails = zCaptureDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('request_review'), - curr: z.literal('capture_review'), - }) - .strict(), + tool_meta: zCaptureReviewToolMeta, }) .strict(); export type CaptureReviewDetails = z.infer; @@ -58,12 +45,7 @@ export const CaptureReviewDetailsSchema = z.toJSONSchema(zCaptureReviewDetails, export const zCaptureCandidateDetails = zCaptureDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('request_choice'), - curr: z.literal('capture_candidate'), - }) - .strict(), + tool_meta: zCaptureCandidateToolMeta, }) .strict(); export type CaptureCandidateDetails = z.infer; diff --git a/src/.pi/extensions/structured-exchange/schemas/index.ts b/src/.pi/extensions/exchanges/schemas/index.ts similarity index 80% rename from src/.pi/extensions/structured-exchange/schemas/index.ts rename to src/.pi/extensions/exchanges/schemas/index.ts index cb6d5e274..a381857c7 100644 --- a/src/.pi/extensions/structured-exchange/schemas/index.ts +++ b/src/.pi/extensions/exchanges/schemas/index.ts @@ -1,4 +1,5 @@ export * from './capture.js'; export * from './present.js'; +export * from './params.js'; export * from './request.js'; export * from './shared.js'; diff --git a/src/.pi/extensions/exchanges/schemas/params.ts b/src/.pi/extensions/exchanges/schemas/params.ts new file mode 100644 index 000000000..a3b14ef62 --- /dev/null +++ b/src/.pi/extensions/exchanges/schemas/params.ts @@ -0,0 +1,121 @@ +import * as z from 'zod'; + +export const zPresentQuestionParams = z + .object({ + exchangeId: z + .string() + .min(1) + .describe('Stable id tying this question to the later request_answer response.'), + heading: z.string().describe('Question heading.'), + body: z.string().describe('Markdown body for context before the answer request.').optional(), + }) + .strict(); +export type PresentQuestionParams = z.infer; + +export const zPresentedOptionParam = z + .object({ + id: z.string().min(1).describe('Stable option id for later request_* response correlation.'), + content: z.string().describe('Markdown-readable option content.'), + rationale: z.string().describe('Why this option is plausible or recommended.').optional(), + }) + .strict(); + +export const zPresentOptionsParams = z + .object({ + exchangeId: z + .string() + .min(1) + .describe('Stable id tying this presented offer to the later request_* response.'), + heading: z.string().describe('Heading for the presented options.'), + body: z.string().describe('Markdown body shown before the options.').optional(), + options: z.array(zPresentedOptionParam).describe('Options to display.'), + expectedRequestTool: z.enum(['request_choice', 'request_choices']).optional(), + }) + .strict(); +export type PresentOptionsParams = z.infer; + +export const zPresentReviewSetParams = z + .object({ + exchangeId: z + .string() + .min(1) + .describe('Stable id tying this review-set proposal to the later request_review response.'), + proposalEntryId: z + .string() + .describe('Optional transcript/proposal entry id to carry into later acceptance audit.') + .optional(), + payload: z.unknown().describe('Canonical review-set proposal payload.'), + }) + .strict(); +export type PresentReviewSetParams = z.infer; + +export const zRequestAnswerParams = z + .object({ + exchangeId: z + .string() + .min(1) + .describe('The structured exchange id from the corresponding present_question entry.'), + respondsToPresentTool: z.literal('present_question').optional(), + prompt: z.string().describe('Short live-input prompt. Do not repeat the presented question body.'), + }) + .strict(); +export type RequestAnswerParams = z.infer; + +export const zRequestChoiceParam = z + .object({ + id: z.string().min(1).describe('Stable choice id from the corresponding present_* entry.'), + label: z.string().min(1).describe('Short choice label shown in the live selection UI.'), + }) + .strict(); +export type RequestChoiceParam = z.infer; + +export const zRequestChoiceParams = z + .object({ + exchangeId: z + .string() + .min(1) + .describe('The structured exchange id from the corresponding present_* entry.'), + respondsToPresentTool: z.enum(['present_options', 'present_candidates']), + prompt: z.string().describe('Short live-input prompt. Do not repeat the presented content.'), + choices: z.array(zRequestChoiceParam).describe('Choices available for this response.'), + allowOther: z.boolean().describe('Whether the user may choose Other.').optional(), + commentPrompt: z.string().describe('Prompt for optional comment after a listed choice.').optional(), + }) + .strict(); +export type RequestChoiceParams = z.infer; + +export const zRequestChoicesParams = z + .object({ + exchangeId: z + .string() + .min(1) + .describe('The structured exchange id from the corresponding present_options entry.'), + respondsToPresentTool: z.literal('present_options'), + prompt: z.string().describe('Short live-input prompt. Do not repeat the presented content.'), + choices: z + .array(zRequestChoiceParam) + .describe('Listed choices available for this multi-choice response.'), + allowOther: z.boolean().describe('Whether the user may choose Other.').optional(), + allowNone: z.boolean().describe('Whether the user may choose None.').optional(), + commentPrompt: z + .string() + .describe('Prompt for an optional comment. Required when Other or None is selected.') + .optional(), + }) + .strict(); +export type RequestChoicesParams = z.infer; + +export const zRequestReviewParams = z + .object({ + exchangeId: z + .string() + .min(1) + .describe('The structured exchange id from the corresponding present_review_set entry.'), + prompt: z.string().describe('Short live-input prompt. Do not repeat the review set.').optional(), + }) + .strict(); +export type RequestReviewParams = z.infer; + +export function toStructuredExchangeJsonSchema(schema: z.ZodType): unknown { + return z.toJSONSchema(schema, { unrepresentable: 'throw' }); +} diff --git a/src/.pi/extensions/structured-exchange/schemas/present.ts b/src/.pi/extensions/exchanges/schemas/present.ts similarity index 62% rename from src/.pi/extensions/structured-exchange/schemas/present.ts rename to src/.pi/extensions/exchanges/schemas/present.ts index fe195fb8b..c8db5ab27 100644 --- a/src/.pi/extensions/structured-exchange/schemas/present.ts +++ b/src/.pi/extensions/exchanges/schemas/present.ts @@ -1,14 +1,17 @@ import * as z from 'zod'; -import { zGraphNodeRef, zMarkdown, zPresentDetailsHeader } from './shared.js'; +import { + zDisplayBase, + zGraphNodeRef, + zMarkdown, + zPresentCandidatesToolMeta, + zPresentDetailsHeader, + zPresentOptionsToolMeta, + zPresentQuestionToolMeta, + zPresentReviewSetToolMeta, +} from './shared.js'; -export const zPresentDisplay = z - .object({ - heading: z.string().min(1), - body: zMarkdown.optional(), - preface: zMarkdown.optional(), - }) - .strict(); +export const zPresentDisplay = zDisplayBase.extend({ preface: zMarkdown.optional() }).strict(); export type PresentDisplay = z.infer; export const PresentDisplaySchema = z.toJSONSchema(zPresentDisplay, { unrepresentable: 'throw', @@ -16,12 +19,7 @@ export const PresentDisplaySchema = z.toJSONSchema(zPresentDisplay, { export const zPresentQuestionDetails = zPresentDetailsHeader .extend({ - tool_meta: z - .object({ - curr: z.literal('present_question'), - next: z.literal('request_answer'), - }) - .strict(), + tool_meta: zPresentQuestionToolMeta, display: zPresentDisplay, }) .strict(); @@ -44,12 +42,7 @@ export const PresentOptionSchema = z.toJSONSchema(zPresentOption, { export const zPresentOptionsDetails = zPresentDetailsHeader .extend({ - tool_meta: z - .object({ - curr: z.literal('present_options'), - next: z.enum(['request_choice', 'request_choices']), - }) - .strict(), + tool_meta: zPresentOptionsToolMeta, display: zPresentDisplay, options: z.array(zPresentOption).min(1), }) @@ -59,20 +52,60 @@ export const PresentOptionsDetailsSchema = z.toJSONSchema(zPresentOptionsDetails unrepresentable: 'throw', }); +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', +}); + +export const zReviewSetNodeDraft = z + .object({ + draft_id: z.string().min(1), + plane: z.enum(['intent', 'oracle', 'design', 'plan']), + kind: z.string().min(1), + title: z.string().min(1), + body: zMarkdown.optional(), + detail: z.unknown().optional(), + }) + .strict(); +export type ReviewSetNodeDraft = z.infer; +export const ReviewSetNodeDraftSchema = z.toJSONSchema(zReviewSetNodeDraft, { + unrepresentable: 'throw', +}); + +export const zReviewSetEdgeDraft = z + .object({ + category: z.string().min(1), + source: zReviewSetEndpointRef, + target: zReviewSetEndpointRef, + stance: z.enum(['for', 'against']).optional(), + rationale: zMarkdown.optional(), + }) + .strict(); +export type ReviewSetEdgeDraft = z.infer; +export const ReviewSetEdgeDraftSchema = z.toJSONSchema(zReviewSetEdgeDraft, { + unrepresentable: 'throw', +}); + +export const zReviewSetDetailsPayload = z + .object({ + nodes: z.array(zReviewSetNodeDraft).min(1), + edges: z.array(zReviewSetEdgeDraft), + }) + .strict(); +export type ReviewSetDetailsPayload = z.infer; +export const ReviewSetDetailsPayloadSchema = z.toJSONSchema(zReviewSetDetailsPayload, { + unrepresentable: 'throw', +}); + export const zPresentReviewSetDetails = zPresentDetailsHeader .extend({ - tool_meta: z - .object({ - curr: z.literal('present_review_set'), - next: z.literal('request_review'), - }) - .strict(), - display: zPresentDisplay, - review_set: z - .object({ - proposal_entry_id: z.string().min(1), - }) - .strict(), + tool_meta: zPresentReviewSetToolMeta, + display: zDisplayBase, + review_set: zReviewSetDetailsPayload, }) .strict(); export type PresentReviewSetDetails = z.infer; @@ -125,18 +158,8 @@ export const PresentedCandidateSchema = z.toJSONSchema(zPresentedCandidate, { export const zPresentCandidatesDetails = zPresentDetailsHeader .extend({ - tool_meta: z - .object({ - curr: z.literal('present_candidates'), - next: z.literal('request_choice'), - }) - .strict(), - display: z - .object({ - heading: z.string().min(1), - body: zMarkdown.optional(), - }) - .strict(), + tool_meta: zPresentCandidatesToolMeta, + display: zDisplayBase, candidates: z.array(zPresentedCandidate).min(1), }) .strict(); diff --git a/src/.pi/extensions/structured-exchange/schemas/request.ts b/src/.pi/extensions/exchanges/schemas/request.ts similarity index 67% rename from src/.pi/extensions/structured-exchange/schemas/request.ts rename to src/.pi/extensions/exchanges/schemas/request.ts index 5c93eb729..a87fdf2b9 100644 --- a/src/.pi/extensions/structured-exchange/schemas/request.ts +++ b/src/.pi/extensions/exchanges/schemas/request.ts @@ -1,6 +1,13 @@ import * as z from 'zod'; -import { zMarkdown, zRequestDetailsHeader } from './shared.js'; +import { + zMarkdown, + zRequestAnswerToolMeta, + zRequestChoiceToolMeta, + zRequestChoicesToolMeta, + zRequestDetailsHeader, + zRequestReviewToolMeta, +} from './shared.js'; export const zCancelledOutcome = z .object({ @@ -93,13 +100,7 @@ export type RequestChoicesAnswered = z.infer; export const zRequestAnswerDetails = z.union([ zRequestDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('present_question'), - curr: z.literal('request_answer'), - next: z.literal('capture_answer').optional(), - }) - .strict(), + tool_meta: zRequestAnswerToolMeta, answered: z .object({ text: zMarkdown, @@ -109,23 +110,13 @@ export const zRequestAnswerDetails = z.union([ .strict(), zRequestDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('present_question'), - curr: z.literal('request_answer'), - }) - .strict(), + tool_meta: zRequestAnswerToolMeta.omit({ next: true }), cancelled: zCancelledOutcome.shape.cancelled, }) .strict(), zRequestDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('present_question'), - curr: z.literal('request_answer'), - }) - .strict(), + tool_meta: zRequestAnswerToolMeta.omit({ next: true }), unavailable: zUnavailableOutcome.shape.unavailable, }) .strict(), @@ -136,60 +127,19 @@ export const RequestAnswerDetailsSchema = z.toJSONSchema(zRequestAnswerDetails, export const zRequestChoiceDetails = z.union([ zRequestDetailsHeader .extend({ - tool_meta: z.union([ - z - .object({ - prev: z.literal('present_options'), - curr: z.literal('request_choice'), - next: z.literal('capture_choice').optional(), - }) - .strict(), - z - .object({ - prev: z.literal('present_candidates'), - curr: z.literal('request_choice'), - next: z.literal('capture_candidate').optional(), - }) - .strict(), - ]), + tool_meta: zRequestChoiceToolMeta, answered: zRequestChoiceAnswered, }) .strict(), zRequestDetailsHeader .extend({ - tool_meta: z.union([ - z - .object({ - prev: z.literal('present_options'), - curr: z.literal('request_choice'), - }) - .strict(), - z - .object({ - prev: z.literal('present_candidates'), - curr: z.literal('request_choice'), - }) - .strict(), - ]), + tool_meta: zRequestChoiceToolMeta, cancelled: zCancelledOutcome.shape.cancelled, }) .strict(), zRequestDetailsHeader .extend({ - tool_meta: z.union([ - z - .object({ - prev: z.literal('present_options'), - curr: z.literal('request_choice'), - }) - .strict(), - z - .object({ - prev: z.literal('present_candidates'), - curr: z.literal('request_choice'), - }) - .strict(), - ]), + tool_meta: zRequestChoiceToolMeta, unavailable: zUnavailableOutcome.shape.unavailable, }) .strict(), @@ -200,35 +150,19 @@ export const RequestChoiceDetailsSchema = z.toJSONSchema(zRequestChoiceDetails, export const zRequestChoicesDetails = z.union([ zRequestDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('present_options'), - curr: z.literal('request_choices'), - next: z.literal('capture_choices').optional(), - }) - .strict(), + tool_meta: zRequestChoicesToolMeta, answered: zRequestChoicesAnswered, }) .strict(), zRequestDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('present_options'), - curr: z.literal('request_choices'), - }) - .strict(), + tool_meta: zRequestChoicesToolMeta.omit({ next: true }), cancelled: zCancelledOutcome.shape.cancelled, }) .strict(), zRequestDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('present_options'), - curr: z.literal('request_choices'), - }) - .strict(), + tool_meta: zRequestChoicesToolMeta.omit({ next: true }), unavailable: zUnavailableOutcome.shape.unavailable, }) .strict(), @@ -272,35 +206,19 @@ export type RequestReviewAnswered = z.infer; export const zRequestReviewDetails = z.union([ zRequestDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('present_review_set'), - curr: z.literal('request_review'), - next: z.literal('capture_review').optional(), - }) - .strict(), + tool_meta: zRequestReviewToolMeta, answered: zRequestReviewAnswered, }) .strict(), zRequestDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('present_review_set'), - curr: z.literal('request_review'), - }) - .strict(), + tool_meta: zRequestReviewToolMeta.omit({ next: true }), cancelled: zCancelledOutcome.shape.cancelled, }) .strict(), zRequestDetailsHeader .extend({ - tool_meta: z - .object({ - prev: z.literal('present_review_set'), - curr: z.literal('request_review'), - }) - .strict(), + tool_meta: zRequestReviewToolMeta.omit({ next: true }), unavailable: zUnavailableOutcome.shape.unavailable, }) .strict(), diff --git a/src/.pi/extensions/exchanges/schemas/shared.ts b/src/.pi/extensions/exchanges/schemas/shared.ts new file mode 100644 index 000000000..4048bb563 --- /dev/null +++ b/src/.pi/extensions/exchanges/schemas/shared.ts @@ -0,0 +1,167 @@ +import * as z from 'zod'; + +export const STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA = 'brunch.structured_exchange.present' as const; +export const STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA = 'brunch.structured_exchange.request' as const; +export const STRUCTURED_EXCHANGE_CAPTURE_DETAILS_SCHEMA = 'brunch.structured_exchange.capture' as const; +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([ + 'present_question', + 'present_options', + 'present_review_set', + 'present_candidates', +]); +export type PresentToolName = z.infer; +export const PresentToolNameSchema = z.toJSONSchema(zPresentToolName, { unrepresentable: 'throw' }); + +export const zRequestToolName = z.enum([ + 'request_answer', + 'request_choice', + 'request_choices', + 'request_review', +]); +export type RequestToolName = z.infer; +export const RequestToolNameSchema = z.toJSONSchema(zRequestToolName, { unrepresentable: 'throw' }); + +export const zCaptureToolName = z.enum([ + 'capture_answer', + 'capture_choice', + 'capture_choices', + 'capture_review', + 'capture_candidate', +]); +export type CaptureToolName = z.infer; +export const CaptureToolNameSchema = z.toJSONSchema(zCaptureToolName, { unrepresentable: 'throw' }); + +const zDetailsHeaderFields = { + v: z.literal(STRUCTURED_EXCHANGE_DETAILS_VERSION), + exchange_id: z.string().min(1), +} as const; + +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 + .object({ curr: z.literal('present_question'), next: z.literal('request_answer') }) + .strict(); +export const zPresentOptionsToolMeta = z + .object({ curr: z.literal('present_options'), next: z.enum(['request_choice', 'request_choices']) }) + .strict(); +export const zPresentReviewSetToolMeta = z + .object({ curr: z.literal('present_review_set'), next: z.literal('request_review') }) + .strict(); +export const zPresentCandidatesToolMeta = z + .object({ curr: z.literal('present_candidates'), next: z.literal('request_choice') }) + .strict(); + +export const zPresentToolMeta = z.discriminatedUnion('curr', [ + zPresentQuestionToolMeta, + zPresentOptionsToolMeta, + zPresentReviewSetToolMeta, + zPresentCandidatesToolMeta, +]); +export type PresentToolMeta = z.infer; +export const PresentToolMetaSchema = z.toJSONSchema(zPresentToolMeta, { unrepresentable: 'throw' }); + +export const zRequestAnswerToolMeta = z + .object({ + prev: z.literal('present_question'), + curr: z.literal('request_answer'), + next: z.literal('capture_answer').optional(), + }) + .strict(); +export const zRequestChoiceFromOptionsToolMeta = z + .object({ + prev: z.literal('present_options'), + curr: z.literal('request_choice'), + next: z.literal('capture_choice').optional(), + }) + .strict(); +export const zRequestChoiceFromCandidatesToolMeta = z + .object({ + prev: z.literal('present_candidates'), + curr: z.literal('request_choice'), + next: z.literal('capture_candidate').optional(), + }) + .strict(); +export const zRequestChoicesToolMeta = z + .object({ + prev: z.literal('present_options'), + curr: z.literal('request_choices'), + next: z.literal('capture_choices').optional(), + }) + .strict(); +export const zRequestReviewToolMeta = z + .object({ + prev: z.literal('present_review_set'), + curr: z.literal('request_review'), + next: z.literal('capture_review').optional(), + }) + .strict(); + +export const zRequestChoiceToolMeta = z.union([ + zRequestChoiceFromOptionsToolMeta, + zRequestChoiceFromCandidatesToolMeta, +]); +export const zRequestToolMeta = z.union([ + zRequestAnswerToolMeta, + zRequestChoiceFromOptionsToolMeta, + zRequestChoiceFromCandidatesToolMeta, + zRequestChoicesToolMeta, + zRequestReviewToolMeta, +]); +export type RequestToolMeta = z.infer; +export const RequestToolMetaSchema = z.toJSONSchema(zRequestToolMeta, { unrepresentable: 'throw' }); + +export const zCaptureAnswerToolMeta = z + .object({ prev: z.literal('request_answer'), curr: z.literal('capture_answer') }) + .strict(); +export const zCaptureChoiceToolMeta = z + .object({ prev: z.literal('request_choice'), curr: z.literal('capture_choice') }) + .strict(); +export const zCaptureChoicesToolMeta = z + .object({ prev: z.literal('request_choices'), curr: z.literal('capture_choices') }) + .strict(); +export const zCaptureReviewToolMeta = z + .object({ prev: z.literal('request_review'), curr: z.literal('capture_review') }) + .strict(); +export const zCaptureCandidateToolMeta = z + .object({ prev: z.literal('request_choice'), curr: z.literal('capture_candidate') }) + .strict(); + +export const zCaptureToolMeta = z.union([ + zCaptureAnswerToolMeta, + zCaptureChoiceToolMeta, + zCaptureChoicesToolMeta, + 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 new file mode 100644 index 000000000..154694d93 --- /dev/null +++ b/src/.pi/extensions/exchanges/shared/editor-fallback.ts @@ -0,0 +1,185 @@ +import { projectRequestChoice } from '../../../../projections/structured-exchange/request-choice.js'; +import { projectRequestChoices } from '../../../../projections/structured-exchange/request-choices.js'; +import { formatRequestChoice } from '../../../../renderers/structured-exchange/request-choice.js'; +import { formatRequestChoices } from '../../../../renderers/structured-exchange/request-choices.js'; +import type { SelectedChoice } from '../schemas/index.js'; + +export type StructuredExchangeMode = 'single-select' | 'multi-select'; + +export interface StructuredExchangeOption { + label: string; + value: string; + description?: string; +} + +export type StructuredExchangeAnswer = + | { type: 'option'; label: string; value: string; index: number } + | { type: 'other'; label: string; value: string }; + +export interface StructuredExchangeEditorPrefillParams { + question: string; + context?: string; + exchangeId?: string; + mode: StructuredExchangeMode; + options: StructuredExchangeOption[]; +} + +interface StructuredExchangeEditorResponse { + status: 'answered' | 'cancelled'; + answers: StructuredExchangeAnswer[]; + note: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function answerSortRank(answer: StructuredExchangeAnswer): number { + return answer.type === 'option' ? answer.index : Number.MAX_SAFE_INTEGER - 1; +} + +function sortAnswers(answers: StructuredExchangeAnswer[]): StructuredExchangeAnswer[] { + return [...answers].sort((a, b) => answerSortRank(a) - answerSortRank(b)); +} + +function parseEditorAnswer(value: unknown): StructuredExchangeAnswer | null { + if (!isRecord(value)) return null; + + if (value.type === 'option') { + if ( + typeof value.label !== 'string' || + typeof value.value !== 'string' || + typeof value.index !== 'number' || + !Number.isInteger(value.index) || + value.index < 1 + ) { + return null; + } + return { type: 'option', label: value.label, value: value.value, index: value.index }; + } + + if (value.type === 'other') { + if (typeof value.label !== 'string' || typeof value.value !== 'string') return null; + return { type: 'other', label: value.label, value: value.value }; + } + + return null; +} + +function selectedChoice(answer: StructuredExchangeAnswer): SelectedChoice { + if (answer.type === 'other') return { id: 'other', label: answer.label, kind: 'other' }; + return { id: answer.value, label: answer.label, kind: 'listed' }; +} + +export function buildStructuredExchangeEditorPrefill(params: StructuredExchangeEditorPrefillParams): string { + const payload: Record = { + schema: 'brunch.structured_exchange.editor', + schemaVersion: 1, + question: params.question, + mode: params.mode, + options: params.options.map((option, index) => ({ + index: index + 1, + label: option.label, + value: option.value, + ...(option.description ? { description: option.description } : {}), + })), + instructions: [ + 'Edit only response.', + 'For a selected listed option, add an answer like {"type":"option","label":"Alpha","value":"alpha","index":1}.', + 'For Other, add an answer like {"type":"other","label":"Custom answer","value":"Custom answer"}.', + 'Set response.note to a string. Use "" when there is no additional note.', + ], + response: { status: 'cancelled', answers: [], note: '' }, + }; + if (params.context !== undefined) payload.context = params.context; + return JSON.stringify(payload, null, 2); +} + +export function parseStructuredExchangeEditorResponse( + value: string, +): StructuredExchangeEditorResponse | null { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return null; + } + + if (!isRecord(parsed)) return null; + const response = parsed.response; + if (!isRecord(response)) return null; + if (response.status === 'cancelled') return { status: 'cancelled', answers: [], note: '' }; + if (response.status !== 'answered') return null; + if (!Array.isArray(response.answers) || typeof response.note !== 'string') return null; + + const answers = response.answers.map(parseEditorAnswer); + if (answers.some((answer) => answer === null)) return null; + return { + status: 'answered', + answers: sortAnswers(answers as StructuredExchangeAnswer[]), + note: response.note, + }; +} + +export function structuredExchangeResultFromEditor( + params: StructuredExchangeEditorPrefillParams, + edited: string | undefined, +) { + const response = parseStructuredExchangeEditorResponse(edited ?? ''); + const exchangeId = params.exchangeId ?? `rpc-editor:${params.question}`; + if (edited === undefined || response?.status === 'cancelled') { + if (params.mode === 'multi-select') { + const details = projectRequestChoices({ exchangeId, status: 'cancelled' }); + return { content: [{ type: 'text' as const, text: formatRequestChoices(details) }], details }; + } + const details = projectRequestChoice({ + exchangeId, + respondsToPresentTool: 'present_options', + status: 'cancelled', + }); + return { content: [{ type: 'text' as const, text: formatRequestChoice(details) }], details }; + } + + if (!response || response.answers.length === 0) { + if (params.mode === 'multi-select') { + const details = projectRequestChoices({ + exchangeId, + status: 'unavailable', + message: 'Editor response did not include a valid answer', + }); + return { content: [{ type: 'text' as const, text: formatRequestChoices(details) }], details }; + } + const details = projectRequestChoice({ + exchangeId, + respondsToPresentTool: 'present_options', + status: 'unavailable', + message: 'Editor response did not include a valid answer', + }); + return { content: [{ type: 'text' as const, text: formatRequestChoice(details) }], details }; + } + + if (params.mode === 'multi-select') { + const details = projectRequestChoices({ + exchangeId, + status: 'answered', + choices: response.answers.map(selectedChoice), + comment: response.note.trim() || undefined, + }); + return { + content: [{ type: 'text' as const, text: formatRequestChoices(details) }], + details, + }; + } + + const details = projectRequestChoice({ + exchangeId, + respondsToPresentTool: 'present_options', + status: 'answered', + choice: selectedChoice(response.answers[0]!), + comment: response.note.trim() || undefined, + }); + return { + content: [{ type: 'text' as const, text: formatRequestChoice(details) }], + details, + }; +} diff --git a/src/.pi/extensions/structured-exchange/shared/markdown.ts b/src/.pi/extensions/exchanges/shared/markdown.ts similarity index 100% rename from src/.pi/extensions/structured-exchange/shared/markdown.ts rename to src/.pi/extensions/exchanges/shared/markdown.ts diff --git a/src/.pi/extensions/exchanges/shared/recovery.ts b/src/.pi/extensions/exchanges/shared/recovery.ts new file mode 100644 index 000000000..b203296e5 --- /dev/null +++ b/src/.pi/extensions/exchanges/shared/recovery.ts @@ -0,0 +1,45 @@ +import type { PresentDetails, RequestDetails } from '../schemas/index.js'; +import { zPresentDetails, zRequestDetails } from '../schemas/index.js'; + +export function isStructuredExchangePresentDetails(value: unknown): value is PresentDetails { + return zPresentDetails.safeParse(value).success; +} + +export function isStructuredExchangeRequestDetails(value: unknown): value is RequestDetails { + return zRequestDetails.safeParse(value).success; +} + +interface EntryLike { + type?: unknown; + message?: { + role?: unknown; + details?: unknown; + }; +} + +function toolResultDetails(entry: EntryLike): unknown { + return entry.type === 'message' && entry.message?.role === 'toolResult' ? entry.message.details : undefined; +} + +export interface IncompleteStructuredExchangePresent { + entry: EntryLike; + details: PresentDetails; +} + +export function findIncompleteStructuredExchangePresents( + entries: readonly EntryLike[], +): IncompleteStructuredExchangePresent[] { + const presents = new Map(); + const completed = new Set(); + + for (const entry of entries) { + const details = toolResultDetails(entry); + if (isStructuredExchangePresentDetails(details)) { + presents.set(details.exchange_id, { entry, details }); + } else if (isStructuredExchangeRequestDetails(details)) { + completed.add(details.exchange_id); + } + } + + return [...presents.values()].filter((present) => !completed.has(present.details.exchange_id)); +} diff --git a/src/.pi/extensions/graph/index.ts b/src/.pi/extensions/graph/index.ts index b11d73899..4429e663c 100644 --- a/src/.pi/extensions/graph/index.ts +++ b/src/.pi/extensions/graph/index.ts @@ -13,9 +13,9 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import type { CommandExecutor } from '../../../graph/command-executor.js'; -import { formatNeighborhood } from '../../../graph/format/neighborhood.js'; -import { projectNeighborhood } from '../../../graph/project/neighborhood.js'; import type { GraphOverview, NeighborhoodResult } from '../../../graph/snapshot.js'; +import { projectNeighborhood } from '../../../projections/graph/neighborhood.js'; +import { formatNeighborhood } from '../../../renderers/graph/neighborhood.js'; import { graphMutationProductUpdates, type ProductUpdatePublisher } from '../../../rpc/product-updates.js'; import { translateCommitGraph, diff --git a/src/.pi/extensions/graph/review-set-proposal.ts b/src/.pi/extensions/graph/review-set-proposal.ts deleted file mode 100644 index 275549366..000000000 --- a/src/.pi/extensions/graph/review-set-proposal.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { - EDGE_CATEGORIES, - EDGE_STANCES, - type BatchEdgeInput, - type BatchNodeInput, - type CommandExecutor, - type CommitGraphInput, - type CommitGraphDryRunResult, - type Diagnostic, - type NodePlane, - type StructuralIllegal, -} from '../../../graph/index.js'; - -export type ReviewSetLens = 'intent' | 'design' | 'oracle'; -export type EpistemicStatus = 'inferred' | 'assumed' | 'asserted' | 'observed'; - -export interface ReviewSetProposalGrounding { - readonly summary: string; - readonly support: readonly string[]; -} - -export interface ReviewSetProposalPitch { - readonly title: string; - readonly narrative: string; -} - -export interface ReviewSetEntityDraft { - readonly draftId: string; - readonly plane: NodePlane; - readonly kind: string; - readonly title: string; - readonly body?: string; - readonly detail?: unknown; -} - -export interface ReviewSetEdgeDraft { - readonly category: string; - readonly sourceDraftId: string; - readonly targetDraftId: string; - readonly stance?: string; - readonly rationale?: string; -} - -export interface ReviewSetProposalDraft { - readonly schemaVersion: 1; - readonly lens: ReviewSetLens; - readonly epistemicStatus: EpistemicStatus; - readonly grounding: ReviewSetProposalGrounding; - readonly pitch: ReviewSetProposalPitch; - readonly entityDrafts: readonly ReviewSetEntityDraft[]; - readonly edgeDrafts: readonly ReviewSetEdgeDraft[]; - readonly proposalVersion?: number; - readonly supersedes?: string; -} - -export interface ReviewSetProposalPayload extends ReviewSetProposalDraft { - readonly validation: CommitGraphDryRunResult; -} - -export interface ReviewSetProposalValidationSuccess { - readonly status: 'success'; - readonly proposal: ReviewSetProposalPayload; -} - -export type ReviewSetProposalValidationResult = ReviewSetProposalValidationSuccess | StructuralIllegal; - -const VALID_LENSES = ['intent', 'design', 'oracle'] as const; -const VALID_EPISTEMIC_STATUSES = ['inferred', 'assumed', 'asserted', 'observed'] as const; -const VALID_PLANES = ['intent', 'oracle', 'design', 'plan'] as const; - -export function translateReviewSetProposalToCommitGraph( - proposal: ReviewSetProposalDraft, - specId: number, -): CommitGraphInput { - return { - specId, - basis: 'explicit', - nodes: proposal.entityDrafts.map( - (draft): BatchNodeInput => ({ - ref: draft.draftId, - plane: draft.plane, - kind: draft.kind, - title: draft.title, - ...(draft.body !== undefined ? { body: draft.body } : {}), - ...(draft.detail !== undefined ? { detail: draft.detail } : {}), - }), - ), - edges: proposal.edgeDrafts.map( - (draft): BatchEdgeInput => ({ - category: draft.category, - source: draft.sourceDraftId, - target: draft.targetDraftId, - ...(draft.stance !== undefined ? { stance: draft.stance } : {}), - ...(draft.rationale !== undefined ? { rationale: draft.rationale } : {}), - }), - ), - }; -} - -export function validateReviewSetProposalPayload(options: { - readonly proposal: ReviewSetProposalDraft; - readonly commandExecutor: CommandExecutor; - readonly specId: number; -}): ReviewSetProposalValidationResult { - const diagnostics = validateReviewSetProposalDraft(options.proposal); - if (diagnostics.length > 0) { - return { status: 'structural_illegal', diagnostics }; - } - - const validation = options.commandExecutor.dryRunCommitGraph( - translateReviewSetProposalToCommitGraph(options.proposal, options.specId), - ); - if (validation.status !== 'success') { - return validation; - } - - return { - status: 'success', - proposal: { - ...options.proposal, - validation, - }, - }; -} - -function validateReviewSetProposalDraft(value: ReviewSetProposalDraft): Diagnostic[] { - const diagnostics: Diagnostic[] = []; - const candidate = value as unknown; - if (!isRecord(candidate)) { - return [{ field: 'proposal', message: 'proposal must be an object' }]; - } - - if (candidate.schemaVersion !== 1) { - diagnostics.push({ field: 'schemaVersion', message: 'schemaVersion must be 1' }); - } - if (!isOneOf(candidate.lens, VALID_LENSES)) { - diagnostics.push({ field: 'lens', message: 'lens must be intent, design, or oracle' }); - } - if (!isOneOf(candidate.epistemicStatus, VALID_EPISTEMIC_STATUSES)) { - diagnostics.push({ field: 'epistemicStatus', message: 'epistemicStatus is required' }); - } - - validateGrounding(candidate.grounding, diagnostics); - validatePitch(candidate.pitch, diagnostics); - validateEntityDrafts(candidate.entityDrafts, diagnostics); - validateEdgeDrafts(candidate.edgeDrafts, diagnostics); - return diagnostics; -} - -function validateGrounding(value: unknown, diagnostics: Diagnostic[]): void { - if (!isRecord(value)) { - diagnostics.push({ field: 'grounding', message: 'grounding is required' }); - return; - } - if (typeof value.summary !== 'string' || value.summary.trim().length === 0) { - diagnostics.push({ field: 'grounding.summary', message: 'summary must be non-empty' }); - } - if (!isNonEmptyStringArray(value.support)) { - diagnostics.push({ field: 'grounding.support', message: 'support must be a non-empty string array' }); - } -} - -function validatePitch(value: unknown, diagnostics: Diagnostic[]): void { - if (!isRecord(value)) { - diagnostics.push({ field: 'pitch', message: 'pitch is required' }); - return; - } - if (typeof value.title !== 'string' || value.title.trim().length === 0) { - diagnostics.push({ field: 'pitch.title', message: 'title must be non-empty' }); - } - if (typeof value.narrative !== 'string' || value.narrative.trim().length === 0) { - diagnostics.push({ field: 'pitch.narrative', message: 'narrative must be non-empty' }); - } -} - -function validateEntityDrafts(value: unknown, diagnostics: Diagnostic[]): void { - if (!Array.isArray(value) || value.length === 0) { - diagnostics.push({ field: 'entityDrafts', message: 'entityDrafts must be non-empty' }); - return; - } - - const seen = new Set(); - value.forEach((draft, index) => { - const path = `entityDrafts[${index}]`; - if (!isRecord(draft)) { - diagnostics.push({ field: path, message: 'entity draft must be an object' }); - return; - } - if (typeof draft.draftId !== 'string' || draft.draftId.trim().length === 0) { - diagnostics.push({ field: `${path}.draftId`, message: 'draftId must be non-empty' }); - } else if (seen.has(draft.draftId)) { - diagnostics.push({ field: `${path}.draftId`, message: `duplicate draftId "${draft.draftId}"` }); - } else { - seen.add(draft.draftId); - } - if (!isOneOf(draft.plane, VALID_PLANES)) { - diagnostics.push({ field: `${path}.plane`, message: 'invalid plane' }); - } - if (typeof draft.kind !== 'string' || draft.kind.trim().length === 0) { - diagnostics.push({ field: `${path}.kind`, message: 'kind must be non-empty' }); - } - if (typeof draft.title !== 'string' || draft.title.trim().length === 0) { - diagnostics.push({ field: `${path}.title`, message: 'title must be non-empty' }); - } - }); -} - -function validateEdgeDrafts(value: unknown, diagnostics: Diagnostic[]): void { - if (!Array.isArray(value) || value.length === 0) { - diagnostics.push({ field: 'edgeDrafts', message: 'edgeDrafts must be non-empty' }); - return; - } - - value.forEach((draft, index) => { - const path = `edgeDrafts[${index}]`; - if (!isRecord(draft)) { - diagnostics.push({ field: path, message: 'edge draft must be an object' }); - return; - } - if ('relation' in draft) { - diagnostics.push({ field: `${path}.relation`, message: 'relation is retired; use category' }); - } - if (!isOneOf(draft.category, EDGE_CATEGORIES)) { - diagnostics.push({ field: `${path}.category`, message: 'invalid edge category' }); - } - if (draft.stance !== undefined && !isOneOf(draft.stance, EDGE_STANCES)) { - diagnostics.push({ field: `${path}.stance`, message: 'invalid stance' }); - } - if (typeof draft.sourceDraftId !== 'string' || draft.sourceDraftId.trim().length === 0) { - diagnostics.push({ field: `${path}.sourceDraftId`, message: 'sourceDraftId must be non-empty' }); - } - if (typeof draft.targetDraftId !== 'string' || draft.targetDraftId.trim().length === 0) { - diagnostics.push({ field: `${path}.targetDraftId`, message: 'targetDraftId must be non-empty' }); - } - }); -} - -function isNonEmptyStringArray(value: unknown): value is readonly string[] { - return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string'); -} - -function isOneOf(value: unknown, allowed: readonly T[]): value is T { - return typeof value === 'string' && allowed.includes(value as T); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} diff --git a/src/.pi/extensions/mention-autocomplete.ts b/src/.pi/extensions/mentions/index.ts similarity index 100% rename from src/.pi/extensions/mention-autocomplete.ts rename to src/.pi/extensions/mentions/index.ts diff --git a/src/.pi/extensions/operational-mode.ts b/src/.pi/extensions/runtime/index.ts similarity index 84% rename from src/.pi/extensions/operational-mode.ts rename to src/.pi/extensions/runtime/index.ts index fff53cbb1..0a2513a4b 100644 --- a/src/.pi/extensions/operational-mode.ts +++ b/src/.pi/extensions/runtime/index.ts @@ -1,11 +1,9 @@ /** * Brunch operational-mode policy. * - * The current product runtime has one safe state: `elicit`. In that state the - * embedded Pi harness exposes only Brunch's read-only inspection tools and - * blocks side-effecting tools (`bash`, `edit`, `write`, etc.) at multiple Pi - * seams. Later cards replace this fixed posture with transcript-backed - * BrunchAgentState projection, but the policy remains operational-mode owned. + * Runtime state is transcript-backed: `.pi` registers concrete Pi tools and + * applies active/blocked names from the projected Brunch runtime policy rather + * than owning a second authority list. */ import { homedir } from 'node:os'; @@ -19,23 +17,30 @@ import { } from '@earendil-works/pi-coding-agent'; import { Text } from '@earendil-works/pi-tui'; +import { + isToolBlockedForRuntimeState, + toolPolicyForRuntimeState, +} from '../../../projections/session/runtime-policy.js'; import { activeToolNamesForPosture, type ReadinessGrade } from '../../agents/state.js'; -const ELICIT_BLOCKED_TOOLS = ['bash', 'edit', 'write'] as const; -type ElicitBlockedToolName = (typeof ELICIT_BLOCKED_TOOLS)[number]; - export { - BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, + 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, - projectBrunchAgentState, type AgentGoalId, type AgentGoalSelection, type AgentLensId, type AgentLensSelection, type AgentRoleId, - type AgentRoleDefinition, type AgentStrategyId, type AgentStrategySelection, type AutoAxisSelection, @@ -43,19 +48,19 @@ export { type BrunchAgentStateEntryData, type BrunchAgentStateEntrySessionManager, type ModelPreference, - type OperationalModeDefinition, type OperationalModeId, type PromptPackId, - type ResolvedBrunchAgentState, type ThinkingLevel, type ToolPolicyId, -} from '../../session/runtime-state.js'; +} from '../../../session/runtime-state.js'; import { - appendBrunchAgentRuntimeInit, projectBrunchAgentState, type ResolvedBrunchAgentState, +} from '../../../projections/session/runtime-state.js'; +import { + appendBrunchAgentRuntimeInit, type BrunchAgentStateEntrySessionManager, -} from '../../session/runtime-state.js'; +} from '../../../session/runtime-state.js'; interface CustomEntryLike { type?: unknown; @@ -100,10 +105,6 @@ export function activeToolNamesForBrunchAgentState( }); } -function isBlockedElicitTool(toolName: string): toolName is ElicitBlockedToolName { - return ELICIT_BLOCKED_TOOLS.includes(toolName as ElicitBlockedToolName); -} - function applyBrunchToolPolicy(pi: ExtensionAPI, state: ResolvedBrunchAgentState): void { pi.setActiveTools(activeToolNamesForBrunchAgentState(pi, state)); } @@ -283,25 +284,33 @@ export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { applyBrunchToolPolicy(pi, state); }); - pi.on('tool_call', async (event) => { - if (!isBlockedElicitTool(event.toolName)) return; + pi.on('tool_call', async (event, ctx) => { + const state = projectBrunchAgentStateFromSessionManager(ctx?.sessionManager); + if (!isToolBlockedForRuntimeState(state, event.toolName)) return; + const blockedToolNames = toolPolicyForRuntimeState(state).blockedToolNames.join(', '); return { block: true, reason: `Brunch tool policy blocks "${event.toolName}". ` + - `Blocked tools in elicit mode: ${ELICIT_BLOCKED_TOOLS.join(', ')}.`, + `Blocked tools in ${state.operationalMode} mode: ${blockedToolNames}.`, }; }); - pi.on('user_bash', (event) => ({ - result: { - output: `Brunch tool policy blocks shell commands: ${event.command}`, - exitCode: 1, - cancelled: false, - truncated: false, - }, - })); + pi.on('user_bash', (event, ctx) => { + const state = projectBrunchAgentStateFromSessionManager(ctx?.sessionManager); + const blockedToolNames = toolPolicyForRuntimeState(state).blockedToolNames.join(', '); + return { + result: { + output: + `Brunch tool policy blocks shell commands in ${state.operationalMode} mode ` + + `(${blockedToolNames}): ${event.command}`, + exitCode: 1, + cancelled: false, + truncated: false, + }, + }; + }); } export default registerBrunchOperationalModePolicy; diff --git a/src/.pi/extensions/session-lifecycle.ts b/src/.pi/extensions/session/lifecycle.ts similarity index 100% rename from src/.pi/extensions/session-lifecycle.ts rename to src/.pi/extensions/session/lifecycle.ts diff --git a/src/.pi/extensions/structured-exchange/present-options.ts b/src/.pi/extensions/structured-exchange/present-options.ts deleted file mode 100644 index 24526b07e..000000000 --- a/src/.pi/extensions/structured-exchange/present-options.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { defineTool } from '@earendil-works/pi-coding-agent'; -import { Type } from 'typebox'; - -import { markdownEscape, renderMarkdownResult } from './shared/markdown.js'; -import { STRUCTURED_EXCHANGE_PRESENT_SCHEMA, type StructuredExchangePresentDetails } from './shared/model.js'; - -export const PRESENT_OPTIONS_TOOL = 'present_options' as const; - -const PresentedOptionSchema = Type.Object({ - id: Type.String({ - description: 'Stable option id for later request_* response correlation.', - }), - content: Type.String({ description: 'Markdown-readable option content.' }), - rationale: Type.Optional( - Type.String({ - description: 'Why this option is plausible or recommended.', - }), - ), -}); - -export const PresentOptionsParams = Type.Object({ - exchangeId: Type.String({ - description: 'Stable id tying this presented offer to the later request_* response.', - }), - heading: Type.String({ description: 'Heading for the presented options.' }), - body: Type.Optional(Type.String({ description: 'Markdown body shown before the options.' })), - options: Type.Array(PresentedOptionSchema, { - description: 'Options to display.', - }), - expectedRequestTool: Type.Optional( - Type.Union([Type.Literal('request_choice'), Type.Literal('request_choices')], { - description: 'The request_* tool expected to collect the response.', - }), - ), -}); - -interface OptionsMarkdownParams { - heading: string; - body?: string; - options: Array<{ - id: string; - content: string; - rationale?: string; - }>; -} - -function optionsMarkdown(params: OptionsMarkdownParams): string { - const lines = [`## ${params.heading.trim()}`]; - const body = params.body?.trim(); - if (body) lines.push('', body); - params.options.forEach((option, index) => { - lines.push('', `### ${index + 1}. ${option.content.trim()}`); - const rationale = option.rationale?.trim(); - if (rationale) lines.push('', `**Rationale:** ${rationale}`); - lines.push('', ``); - }); - return lines.join('\n'); -} - -export const presentOptionsTool = defineTool({ - name: PRESENT_OPTIONS_TOOL, - label: 'Present options', - description: - 'Persist and display a set of structured options as the present half of a Brunch structured exchange. Call the matching request_choice/request_choices tool after this result is available.', - promptSnippet: 'Present structured options before requesting a choice', - promptGuidelines: [ - 'Use present_options before request_choice or request_choices.', - 'Do not rely on renderCall for semantic display; the durable offer is this tool result.', - ], - parameters: PresentOptionsParams, - executionMode: 'sequential', - - async execute(toolCallId, params) { - const markdown = optionsMarkdown(params); - const details: StructuredExchangePresentDetails = { - schema: STRUCTURED_EXCHANGE_PRESENT_SCHEMA, - schemaVersion: 1, - exchangeId: params.exchangeId, - presentTool: PRESENT_OPTIONS_TOOL, - kind: 'options', - status: 'presented', - expectedRequest: { - tool: params.expectedRequestTool ?? 'request_choice', - required: true, - }, - createdAtToolCallId: toolCallId, - }; - return { content: [{ type: 'text' as const, text: markdown }], details }; - }, - - renderCall() { - return renderMarkdownResult({ content: [] }); - }, - - renderResult(result, _options, theme) { - return renderMarkdownResult(result, theme); - }, -}); diff --git a/src/.pi/extensions/structured-exchange/present-review-set.ts b/src/.pi/extensions/structured-exchange/present-review-set.ts deleted file mode 100644 index b30977de7..000000000 --- a/src/.pi/extensions/structured-exchange/present-review-set.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const PRESENT_REVIEW_SET_TOOL = 'present_review_set' as const; - -// Stubbed intentionally: review-set presentation semantics are named now, but -// not registered until review-set proposal/acceptance flow lands. -export const presentReviewSetTool = undefined; diff --git a/src/.pi/extensions/structured-exchange/request-answer.ts b/src/.pi/extensions/structured-exchange/request-answer.ts deleted file mode 100644 index ba65dcb53..000000000 --- a/src/.pi/extensions/structured-exchange/request-answer.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { defineTool } from '@earendil-works/pi-coding-agent'; -import { Type } from 'typebox'; - -import { renderMarkdownResult } from './shared/markdown.js'; -import { STRUCTURED_EXCHANGE_REQUEST_SCHEMA, type StructuredExchangeRequestDetails } from './shared/model.js'; - -export const REQUEST_ANSWER_TOOL = 'request_answer' as const; - -export const RequestAnswerParams = Type.Object({ - exchangeId: Type.String({ - description: 'The structured exchange id from the corresponding present_question entry.', - }), - respondsToPresentTool: Type.Optional(Type.Literal('present_question')), - prompt: Type.String({ - description: 'Short live-input prompt. Do not repeat the presented question body.', - }), -}); - -function responseMarkdown(details: StructuredExchangeRequestDetails): string { - if (details.status === 'cancelled') return '### Response\n\n_User cancelled the request._'; - if (details.status === 'unavailable') { - return `### Response\n\n_${details.message ?? 'Response UI unavailable.'}_`; - } - return ['### Response', '', details.answer ?? ''].join('\n'); -} - -export const requestAnswerTool = defineTool({ - name: REQUEST_ANSWER_TOOL, - label: 'Request answer', - description: - 'Collect a freeform user answer as the request half of a Brunch structured exchange. Use only after present_question.', - promptSnippet: 'Request a freeform answer after presenting a question', - promptGuidelines: [ - 'Use request_answer only after the matching present_question tool.', - 'Do not repeat the present_question markdown content in request_answer parameters; reference it by exchangeId.', - ], - parameters: RequestAnswerParams, - executionMode: 'sequential', - - async execute(toolCallId, params, _signal, _onUpdate, ctx) { - const base = { - schema: STRUCTURED_EXCHANGE_REQUEST_SCHEMA, - schemaVersion: 1 as const, - exchangeId: params.exchangeId, - requestTool: REQUEST_ANSWER_TOOL, - respondsTo: { - exchangeId: params.exchangeId, - presentTool: params.respondsToPresentTool ?? 'present_question', - }, - createdAtToolCallId: toolCallId, - }; - - if (!ctx.hasUI || typeof ctx.ui.editor !== 'function') { - const details: StructuredExchangeRequestDetails = { - ...base, - status: 'unavailable', - message: 'request_answer requires interactive UI', - }; - return { - content: [{ type: 'text' as const, text: responseMarkdown(details) }], - details, - }; - } - - const answer = await ctx.ui.editor(params.prompt); - if (answer === undefined) { - const details: StructuredExchangeRequestDetails = { - ...base, - status: 'cancelled', - }; - return { - content: [{ type: 'text' as const, text: responseMarkdown(details) }], - details, - }; - } - - const details: StructuredExchangeRequestDetails = { - ...base, - status: 'answered', - answer: answer.trim(), - }; - return { - content: [{ type: 'text' as const, text: responseMarkdown(details) }], - details, - }; - }, - - renderCall() { - return renderMarkdownResult({ content: [] }); - }, - - renderResult(result, _options, theme) { - return renderMarkdownResult(result, theme); - }, -}); diff --git a/src/.pi/extensions/structured-exchange/request-choice.ts b/src/.pi/extensions/structured-exchange/request-choice.ts deleted file mode 100644 index 1f140cede..000000000 --- a/src/.pi/extensions/structured-exchange/request-choice.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { defineTool } from '@earendil-works/pi-coding-agent'; -import { Type } from 'typebox'; - -import { normalizeOptionalText, renderMarkdownResult } from './shared/markdown.js'; -import { - STRUCTURED_EXCHANGE_REQUEST_SCHEMA, - type StructuredExchangeChoice, - type StructuredExchangeRequestDetails, -} from './shared/model.js'; - -export const REQUEST_CHOICE_TOOL = 'request_choice' as const; - -const ChoiceSchema = Type.Object({ - id: Type.String({ - description: 'Stable choice id from the corresponding present_* entry.', - }), - label: Type.String({ - description: 'Short choice label shown in the live selection UI.', - }), -}); - -export const RequestChoiceParams = Type.Object({ - exchangeId: Type.String({ - description: 'The structured exchange id from the corresponding present_* entry.', - }), - respondsToPresentTool: Type.Union([Type.Literal('present_options'), Type.Literal('present_candidates')]), - prompt: Type.String({ - description: 'Short live-input prompt. Do not repeat the presented content.', - }), - choices: Type.Array(ChoiceSchema, { - description: 'Choices available for this response.', - }), - allowOther: Type.Optional(Type.Boolean({ description: 'Whether the user may choose Other.' })), - commentPrompt: Type.Optional( - Type.String({ - description: 'Prompt for optional comment after a listed choice.', - }), - ), -}); - -function responseMarkdown(details: StructuredExchangeRequestDetails): string { - if (details.status === 'cancelled') return '### Response\n\n_User cancelled the request._'; - if (details.status === 'unavailable') { - return `### Response\n\n_${details.message ?? 'Response UI unavailable.'}_`; - } - const lines = ['### Response']; - if (details.choice) lines.push('', `Selected: **${details.choice.label}**`); - if (details.comment) lines.push('', 'Comment:', '', `> ${details.comment}`); - return lines.join('\n'); -} - -function choiceByLabel( - choices: readonly StructuredExchangeChoice[], - selected: string, -): StructuredExchangeChoice | undefined { - return choices.find((choice) => choice.label === selected || choice.id === selected); -} - -export const requestChoiceTool = defineTool({ - name: REQUEST_CHOICE_TOOL, - label: 'Request choice', - description: - 'Collect one user choice as the request half of a Brunch structured exchange. Use only after the corresponding present_* tool result has displayed the offer content.', - promptSnippet: 'Request one choice after presenting a structured offer', - promptGuidelines: [ - 'Use request_choice only after the matching present_options or present_candidates tool.', - 'Do not repeat the present_* markdown content in request_choice parameters; reference it by exchangeId.', - ], - parameters: RequestChoiceParams, - executionMode: 'sequential', - - async execute(toolCallId, params, _signal, _onUpdate, ctx) { - const choices: StructuredExchangeChoice[] = params.choices.map((choice) => ({ - id: choice.id, - label: choice.label, - })); - const unavailable = (message: string) => { - const details: StructuredExchangeRequestDetails = { - schema: STRUCTURED_EXCHANGE_REQUEST_SCHEMA, - schemaVersion: 1, - exchangeId: params.exchangeId, - requestTool: REQUEST_CHOICE_TOOL, - status: 'unavailable', - respondsTo: { - exchangeId: params.exchangeId, - presentTool: params.respondsToPresentTool, - }, - message, - createdAtToolCallId: toolCallId, - }; - return { - content: [{ type: 'text' as const, text: responseMarkdown(details) }], - details, - }; - }; - - if (!ctx.hasUI || typeof ctx.ui.select !== 'function') { - return unavailable('request_choice requires interactive UI'); - } - - const labels = [...choices.map((choice) => choice.label), ...(params.allowOther ? ['Other'] : [])]; - const selected = await ctx.ui.select(params.prompt, labels); - if (selected === undefined) { - const details: StructuredExchangeRequestDetails = { - schema: STRUCTURED_EXCHANGE_REQUEST_SCHEMA, - schemaVersion: 1, - exchangeId: params.exchangeId, - requestTool: REQUEST_CHOICE_TOOL, - status: 'cancelled', - respondsTo: { - exchangeId: params.exchangeId, - presentTool: params.respondsToPresentTool, - }, - createdAtToolCallId: toolCallId, - }; - return { - content: [{ type: 'text' as const, text: responseMarkdown(details) }], - details, - }; - } - - const picked = choiceByLabel(choices, selected); - let choice = picked; - let comment = ''; - if (!choice) { - const other = - typeof ctx.ui.input === 'function' ? await ctx.ui.input('Other', 'Describe your answer') : undefined; - if (other === undefined || other.trim().length === 0) { - const details: StructuredExchangeRequestDetails = { - schema: STRUCTURED_EXCHANGE_REQUEST_SCHEMA, - schemaVersion: 1, - exchangeId: params.exchangeId, - requestTool: REQUEST_CHOICE_TOOL, - status: 'cancelled', - respondsTo: { - exchangeId: params.exchangeId, - presentTool: params.respondsToPresentTool, - }, - createdAtToolCallId: toolCallId, - }; - return { - content: [{ type: 'text' as const, text: responseMarkdown(details) }], - details, - }; - } - choice = { id: 'other', label: other.trim() }; - } else if (typeof ctx.ui.input === 'function') { - comment = (await ctx.ui.input(params.commentPrompt ?? 'Optional comment')) ?? ''; - } - - const details: StructuredExchangeRequestDetails = { - schema: STRUCTURED_EXCHANGE_REQUEST_SCHEMA, - schemaVersion: 1, - exchangeId: params.exchangeId, - requestTool: REQUEST_CHOICE_TOOL, - status: 'answered', - respondsTo: { - exchangeId: params.exchangeId, - presentTool: params.respondsToPresentTool, - }, - choice, - createdAtToolCallId: toolCallId, - }; - const normalizedComment = normalizeOptionalText(comment); - if (normalizedComment !== undefined) details.comment = normalizedComment; - return { - content: [{ type: 'text' as const, text: responseMarkdown(details) }], - details, - }; - }, - - renderCall() { - return renderMarkdownResult({ content: [] }); - }, - - renderResult(result, _options, theme) { - return renderMarkdownResult(result, theme); - }, -}); diff --git a/src/.pi/extensions/structured-exchange/request-review.ts b/src/.pi/extensions/structured-exchange/request-review.ts deleted file mode 100644 index 24401b868..000000000 --- a/src/.pi/extensions/structured-exchange/request-review.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const REQUEST_REVIEW_TOOL = 'request_review' as const; - -// Stubbed intentionally: review response semantics are named now, but not -// registered until review-set proposal/acceptance flow lands. -export const requestReviewTool = undefined; diff --git a/src/.pi/extensions/structured-exchange/schemas/shared.ts b/src/.pi/extensions/structured-exchange/schemas/shared.ts deleted file mode 100644 index ff8331118..000000000 --- a/src/.pi/extensions/structured-exchange/schemas/shared.ts +++ /dev/null @@ -1,192 +0,0 @@ -import * as z from 'zod'; - -export const STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA = 'brunch.structured_exchange.present' as const; -export const STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA = 'brunch.structured_exchange.request' as const; -export const STRUCTURED_EXCHANGE_CAPTURE_DETAILS_SCHEMA = 'brunch.structured_exchange.capture' as const; -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([ - 'present_question', - 'present_options', - 'present_review_set', - 'present_candidates', -]); -export type PresentToolName = z.infer; -export const PresentToolNameSchema = z.toJSONSchema(zPresentToolName, { - unrepresentable: 'throw', -}); - -export const zRequestToolName = z.enum([ - 'request_answer', - 'request_choice', - 'request_choices', - 'request_review', -]); -export type RequestToolName = z.infer; -export const RequestToolNameSchema = z.toJSONSchema(zRequestToolName, { - unrepresentable: 'throw', -}); - -export const zCaptureToolName = z.enum([ - 'capture_answer', - 'capture_choice', - 'capture_choices', - 'capture_review', - 'capture_candidate', -]); -export type CaptureToolName = z.infer; -export const CaptureToolNameSchema = z.toJSONSchema(zCaptureToolName, { - unrepresentable: 'throw', -}); - -export const zPresentDetailsHeader = z - .object({ - schema: z.literal(STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA), - v: z.literal(STRUCTURED_EXCHANGE_DETAILS_VERSION), - exchange_id: z.string().min(1), - }) - .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), - v: z.literal(STRUCTURED_EXCHANGE_DETAILS_VERSION), - exchange_id: z.string().min(1), - }) - .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), - v: z.literal(STRUCTURED_EXCHANGE_DETAILS_VERSION), - exchange_id: z.string().min(1), - }) - .strict(); -export type CaptureDetailsHeader = z.infer; -export const CaptureDetailsHeaderSchema = z.toJSONSchema(zCaptureDetailsHeader, { unrepresentable: 'throw' }); - -export const zPresentToolMeta = z.discriminatedUnion('curr', [ - z - .object({ - curr: z.literal('present_question'), - next: z.literal('request_answer'), - }) - .strict(), - z - .object({ - curr: z.literal('present_options'), - next: z.enum(['request_choice', 'request_choices']), - }) - .strict(), - z - .object({ - curr: z.literal('present_review_set'), - next: z.literal('request_review'), - }) - .strict(), - z - .object({ - curr: z.literal('present_candidates'), - next: z.literal('request_choice'), - }) - .strict(), -]); -export type PresentToolMeta = z.infer; -export const PresentToolMetaSchema = z.toJSONSchema(zPresentToolMeta, { - unrepresentable: 'throw', -}); - -export const zRequestToolMeta = z.union([ - z - .object({ - prev: z.literal('present_question'), - curr: z.literal('request_answer'), - next: z.literal('capture_answer').optional(), - }) - .strict(), - z - .object({ - prev: z.literal('present_options'), - curr: z.literal('request_choice'), - next: z.literal('capture_choice').optional(), - }) - .strict(), - z - .object({ - prev: z.literal('present_candidates'), - curr: z.literal('request_choice'), - next: z.literal('capture_candidate').optional(), - }) - .strict(), - z - .object({ - prev: z.literal('present_options'), - curr: z.literal('request_choices'), - next: z.literal('capture_choices').optional(), - }) - .strict(), - z - .object({ - prev: z.literal('present_review_set'), - curr: z.literal('request_review'), - next: z.literal('capture_review').optional(), - }) - .strict(), -]); -export type RequestToolMeta = z.infer; -export const RequestToolMetaSchema = z.toJSONSchema(zRequestToolMeta, { - unrepresentable: 'throw', -}); - -export const zCaptureToolMeta = z.union([ - z - .object({ - prev: z.literal('request_answer'), - curr: z.literal('capture_answer'), - }) - .strict(), - z - .object({ - prev: z.literal('request_choice'), - curr: z.literal('capture_choice'), - }) - .strict(), - z - .object({ - prev: z.literal('request_choices'), - curr: z.literal('capture_choices'), - }) - .strict(), - z - .object({ - prev: z.literal('request_review'), - curr: z.literal('capture_review'), - }) - .strict(), - z - .object({ - prev: z.literal('request_choice'), - curr: z.literal('capture_candidate'), - }) - .strict(), -]); -export type CaptureToolMeta = z.infer; -export const CaptureToolMetaSchema = z.toJSONSchema(zCaptureToolMeta, { - unrepresentable: 'throw', -}); diff --git a/src/.pi/extensions/structured-exchange/shared/editor-fallback.ts b/src/.pi/extensions/structured-exchange/shared/editor-fallback.ts deleted file mode 100644 index c847d93bf..000000000 --- a/src/.pi/extensions/structured-exchange/shared/editor-fallback.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { - STRUCTURED_EXCHANGE_RESULT_SCHEMA, - type StructuredExchangeAnswer, - type StructuredExchangeMode, - type StructuredExchangeOption, -} from '../../../../session/structured-exchange.js'; -import { isRecord } from './model.js'; - -export interface StructuredExchangeEditorPrefillParams { - question: string; - context?: string; - mode: Exclude; - options: StructuredExchangeOption[]; -} - -interface StructuredExchangeEditorResponse { - status: 'answered' | 'cancelled'; - answers: StructuredExchangeAnswer[]; - note: string; -} - -function answerSortRank(answer: StructuredExchangeAnswer): number { - switch (answer.type) { - case 'option': - return answer.index; - case 'other': - return Number.MAX_SAFE_INTEGER - 1; - case 'text': - return Number.MAX_SAFE_INTEGER; - } -} - -function sortAnswers(answers: StructuredExchangeAnswer[]): StructuredExchangeAnswer[] { - return [...answers].sort((a, b) => answerSortRank(a) - answerSortRank(b)); -} - -function parseEditorAnswer(value: unknown): StructuredExchangeAnswer | null { - if (!isRecord(value)) return null; - - if (value.type === 'option') { - if ( - typeof value.label !== 'string' || - typeof value.value !== 'string' || - typeof value.index !== 'number' || - !Number.isInteger(value.index) || - value.index < 1 - ) { - return null; - } - return { - type: 'option', - label: value.label, - value: value.value, - index: value.index, - }; - } - - if (value.type === 'other') { - if (typeof value.label !== 'string' || typeof value.value !== 'string') { - return null; - } - return { type: 'other', label: value.label, value: value.value }; - } - - return null; -} - -function buildLegacyResult( - status: 'answered' | 'cancelled' | 'unavailable', - params: StructuredExchangeEditorPrefillParams, - answers: StructuredExchangeAnswer[], - note: string, - message?: string, -) { - const selected = answers - .map((answer) => - answer.type === 'option' - ? `${answer.index}. ${answer.label}` - : answer.type === 'other' - ? `Other: ${answer.label}` - : answer.label, - ) - .join('\n'); - const text = - status === 'answered' - ? [`User selected:${selected ? `\n${selected}` : ''}`, note ? `Note: ${note}` : undefined] - .filter(Boolean) - .join('\n') - : (message ?? `User ${status} the question`); - - return { - content: [{ type: 'text' as const, text }], - details: { - schema: STRUCTURED_EXCHANGE_RESULT_SCHEMA, - schemaVersion: 1 as const, - status, - question: params.question, - ...(params.context !== undefined ? { context: params.context } : {}), - mode: params.mode, - options: params.options, - answers, - rejectedOptions: params.options.filter( - (option) => - !answers.some( - (answer) => - answer.type === 'option' && answer.label === option.label && answer.value === option.value, - ), - ), - note, - transport: { surface: 'rpc-editor' as const }, - ...(message !== undefined ? { message } : {}), - }, - }; -} - -export function buildStructuredExchangeEditorPrefill(params: StructuredExchangeEditorPrefillParams): string { - const payload: Record = { - schema: 'brunch.structured_exchange.editor', - schemaVersion: 1, - question: params.question, - mode: params.mode, - options: params.options.map((option, index) => ({ - index: index + 1, - label: option.label, - value: option.value, - ...(option.description ? { description: option.description } : {}), - })), - instructions: [ - 'Edit only response.', - 'For a selected listed option, add an answer like {"type":"option","label":"Alpha","value":"alpha","index":1}.', - 'For Other, add an answer like {"type":"other","label":"Custom answer","value":"Custom answer"}.', - 'Set response.note to a string. Use "" when there is no additional note.', - ], - response: { status: 'cancelled', answers: [], note: '' }, - }; - if (params.context !== undefined) payload.context = params.context; - return JSON.stringify(payload, null, 2); -} - -export function parseStructuredExchangeEditorResponse( - value: string, -): StructuredExchangeEditorResponse | null { - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch { - return null; - } - - if (!isRecord(parsed)) return null; - const response = parsed.response; - if (!isRecord(response)) return null; - - if (response.status === 'cancelled') { - return { status: 'cancelled', answers: [], note: '' }; - } - if (response.status !== 'answered') return null; - if (!Array.isArray(response.answers)) return null; - if (typeof response.note !== 'string') return null; - - const answers = response.answers.map(parseEditorAnswer); - if (answers.some((answer) => answer === null)) return null; - return { - status: 'answered', - answers: sortAnswers(answers as StructuredExchangeAnswer[]), - note: response.note.trim(), - }; -} - -export function structuredExchangeResultFromEditor( - params: StructuredExchangeEditorPrefillParams, - edited: string | undefined, -) { - const response = parseStructuredExchangeEditorResponse(edited ?? ''); - if (edited === undefined || response?.status === 'cancelled') { - return buildLegacyResult('cancelled', params, [], '', 'User cancelled the question'); - } - if (!response) { - return buildLegacyResult( - 'unavailable', - params, - [], - '', - 'structured_exchange editor fallback returned invalid JSON', - ); - } - return buildLegacyResult('answered', params, response.answers, response.note); -} diff --git a/src/.pi/extensions/structured-exchange/shared/model.ts b/src/.pi/extensions/structured-exchange/shared/model.ts deleted file mode 100644 index bd9850f39..000000000 --- a/src/.pi/extensions/structured-exchange/shared/model.ts +++ /dev/null @@ -1,65 +0,0 @@ -export const STRUCTURED_EXCHANGE_PRESENT_SCHEMA = 'brunch.structured_exchange.present' as const; -export const STRUCTURED_EXCHANGE_REQUEST_SCHEMA = 'brunch.structured_exchange.request' as const; - -export type PresentToolName = - | 'present_question' - | 'present_options' - | 'present_review_set' - | 'present_candidates'; -export type RequestToolName = 'request_answer' | 'request_choice' | 'request_choices' | 'request_review'; - -export type StructuredExchangePresentKind = 'question' | 'options' | 'review_set' | 'candidates'; - -export interface StructuredExchangeExpectedRequest { - tool: RequestToolName; - required: boolean; -} - -export interface StructuredExchangePresentDetails { - schema: typeof STRUCTURED_EXCHANGE_PRESENT_SCHEMA; - schemaVersion: 1; - exchangeId: string; - presentTool: PresentToolName; - kind: StructuredExchangePresentKind; - status: 'presented'; - expectedRequest?: StructuredExchangeExpectedRequest; - createdAtToolCallId: string; -} - -export interface StructuredExchangeChoice { - id: string; - label: string; -} - -export interface StructuredExchangeRequestDetails { - schema: typeof STRUCTURED_EXCHANGE_REQUEST_SCHEMA; - schemaVersion: 1; - exchangeId: string; - requestTool: RequestToolName; - status: 'answered' | 'cancelled' | 'unavailable'; - respondsTo: { - exchangeId: string; - presentTool: PresentToolName; - }; - choice?: StructuredExchangeChoice; - choices?: StructuredExchangeChoice[]; - answer?: string; - review?: 'approve' | 'change-request' | 'reject'; - comment?: string; - message?: string; - createdAtToolCallId: string; -} - -export interface ToolTextContent { - type: 'text'; - text: string; -} - -export interface ToolTextResult { - content: ToolTextContent[]; - details: TDetails; -} - -export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} diff --git a/src/.pi/extensions/structured-exchange/shared/recovery.ts b/src/.pi/extensions/structured-exchange/shared/recovery.ts deleted file mode 100644 index b1c3a9606..000000000 --- a/src/.pi/extensions/structured-exchange/shared/recovery.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { - STRUCTURED_EXCHANGE_PRESENT_SCHEMA, - STRUCTURED_EXCHANGE_REQUEST_SCHEMA, - type PresentToolName, - type RequestToolName, - type StructuredExchangePresentDetails, - type StructuredExchangeRequestDetails, - isRecord, -} from './model.js'; - -const PRESENT_TOOLS: readonly PresentToolName[] = [ - 'present_question', - 'present_options', - 'present_review_set', - 'present_candidates', -]; -const REQUEST_TOOLS: readonly RequestToolName[] = [ - 'request_answer', - 'request_choice', - 'request_choices', - 'request_review', -]; - -function isPresentToolName(value: unknown): value is PresentToolName { - return typeof value === 'string' && PRESENT_TOOLS.includes(value as PresentToolName); -} - -function isRequestToolName(value: unknown): value is RequestToolName { - return typeof value === 'string' && REQUEST_TOOLS.includes(value as RequestToolName); -} - -export function isStructuredExchangePresentDetails( - value: unknown, -): value is StructuredExchangePresentDetails { - if (!isRecord(value)) return false; - if (value.schema !== STRUCTURED_EXCHANGE_PRESENT_SCHEMA) return false; - if (value.schemaVersion !== 1) return false; - if (typeof value.exchangeId !== 'string' || value.exchangeId.length === 0) { - return false; - } - if (!isPresentToolName(value.presentTool)) return false; - if ( - value.kind !== 'question' && - value.kind !== 'options' && - value.kind !== 'review_set' && - value.kind !== 'candidates' - ) { - return false; - } - if (value.status !== 'presented') return false; - if (typeof value.createdAtToolCallId !== 'string') return false; - if (value.expectedRequest !== undefined) { - if (!isRecord(value.expectedRequest)) return false; - if (!isRequestToolName(value.expectedRequest.tool)) return false; - if (typeof value.expectedRequest.required !== 'boolean') return false; - } - return true; -} - -export function isStructuredExchangeRequestDetails( - value: unknown, -): value is StructuredExchangeRequestDetails { - if (!isRecord(value)) return false; - if (value.schema !== STRUCTURED_EXCHANGE_REQUEST_SCHEMA) return false; - if (value.schemaVersion !== 1) return false; - if (typeof value.exchangeId !== 'string' || value.exchangeId.length === 0) { - return false; - } - if (!isRequestToolName(value.requestTool)) return false; - if (value.status !== 'answered' && value.status !== 'cancelled' && value.status !== 'unavailable') { - return false; - } - if (!isRecord(value.respondsTo)) return false; - if (value.respondsTo.exchangeId !== value.exchangeId) return false; - if (!isPresentToolName(value.respondsTo.presentTool)) return false; - if (typeof value.createdAtToolCallId !== 'string') return false; - return true; -} - -interface EntryLike { - type?: unknown; - message?: { - role?: unknown; - details?: unknown; - }; -} - -function toolResultDetails(entry: EntryLike): unknown { - return entry.type === 'message' && entry.message?.role === 'toolResult' ? entry.message.details : undefined; -} - -export interface IncompleteStructuredExchangePresent { - entry: EntryLike; - details: StructuredExchangePresentDetails; -} - -export function findIncompleteStructuredExchangePresents( - entries: readonly EntryLike[], -): IncompleteStructuredExchangePresent[] { - const presents = new Map(); - const completed = new Set(); - - for (const entry of entries) { - const details = toolResultDetails(entry); - if (isStructuredExchangePresentDetails(details)) { - if (details.expectedRequest?.required !== false) { - presents.set(details.exchangeId, { entry, details }); - } - } else if (isStructuredExchangeRequestDetails(details)) { - completed.add(details.exchangeId); - } - } - - return [...presents.values()].filter((present) => !completed.has(present.details.exchangeId)); -} diff --git a/src/.pi/extensions/prompting.ts b/src/.pi/extensions/system-prompts/index.ts similarity index 97% rename from src/.pi/extensions/prompting.ts rename to src/.pi/extensions/system-prompts/index.ts index d398cef58..7dc5c66aa 100644 --- a/src/.pi/extensions/prompting.ts +++ b/src/.pi/extensions/system-prompts/index.ts @@ -9,8 +9,8 @@ import { type AgentPromptSpecContext, type AgentPromptWorkspaceContext, } from '../../agents/index.js'; -import type { GraphSnapshotReaders } from './graph/index.js'; -import { activeToolNamesForBrunchAgentState, projectBrunchAgentState } from './operational-mode.js'; +import type { GraphSnapshotReaders } from '../graph/index.js'; +import { activeToolNamesForBrunchAgentState, projectBrunchAgentState } from '../runtime/index.js'; type BrunchAgentStateEntries = Parameters[0]; diff --git a/src/.pi/extensions/workspace-dialog.ts b/src/.pi/extensions/workspace/index.ts similarity index 94% rename from src/.pi/extensions/workspace-dialog.ts rename to src/.pi/extensions/workspace/index.ts index 1f12480e5..bd0302f15 100644 --- a/src/.pi/extensions/workspace-dialog.ts +++ b/src/.pi/extensions/workspace/index.ts @@ -4,12 +4,12 @@ import { type WorkspaceSessionReadyState, type SpecSessionActivationCoordinator, type SpecSessionActivationDecision, -} from '../../session/workspace-session-coordinator.js'; +} from '../../../session/workspace-session-coordinator.js'; import { WORKSPACE_DIALOG_WIDTH, createWorkspaceDialogComponent, -} from '../components/workspace-dialog/index.js'; -import { chromeStateForWorkspace, renderBrunchChrome } from './chrome.js'; +} from '../../components/workspace-dialog/index.js'; +import { chromeStateForWorkspace, renderBrunchChrome } from '../chrome/index.js'; export interface BrunchSpecSessionPickerOptions { coordinator: SpecSessionActivationCoordinator; diff --git a/src/.pi/settings.json b/src/.pi/settings.json index 29bbb4911..e8f1f2852 100644 --- a/src/.pi/settings.json +++ b/src/.pi/settings.json @@ -1,3 +1,3 @@ { - "extensions": ["-extensions/operational-mode.ts", "-extensions/command-policy.ts"] + "extensions": ["-extensions/runtime/index.ts", "-extensions/commands/policy.ts"] } diff --git a/src/.pi/skills/README.md b/src/.pi/skills/README.md new file mode 100644 index 000000000..08b308515 --- /dev/null +++ b/src/.pi/skills/README.md @@ -0,0 +1,31 @@ +# .pi/skills/ — Brunch prompt resources + +SPEC decisions: D25-L, D39-L, D52-L, D58-L, D59-L + +## Owns + +Markdown resources the Brunch Pi session agent reads on demand after `.pi/agents/state.ts` advertises them in a runtime-filtered manifest. + +These are Pi-harness prompt resources, not product data models and not ambient filesystem discovery inputs. + +## Layout + +```text +skills/ +├── README.md +├── goals/ what objective the session agent is pursuing +├── strategies/ reusable interaction shapes +├── lenses/ topical focus lenses +└── methods/ tool-routing and sequencing guidance +``` + +## Boundary rules + +```pseudo +rules: + .pi/agents/state.ts -> .pi/skills/*/*.md [manifest locations] + .pi/skills/*.md x> TypeScript imports [read-only prompt resources] + .pi/skills/ x> graph mutation [guidance only] +``` + +The legal set is sealed by code-owned manifest metadata in `.pi/agents/state.ts`; adding a markdown file does not make it available until the state table advertises it. diff --git a/src/agents/goals/capture-posture.md b/src/.pi/skills/goals/capture-posture.md similarity index 100% rename from src/agents/goals/capture-posture.md rename to src/.pi/skills/goals/capture-posture.md diff --git a/src/agents/goals/commit-converge.md b/src/.pi/skills/goals/commit-converge.md similarity index 100% rename from src/agents/goals/commit-converge.md rename to src/.pi/skills/goals/commit-converge.md diff --git a/src/agents/goals/elicit-expand.md b/src/.pi/skills/goals/elicit-expand.md similarity index 100% rename from src/agents/goals/elicit-expand.md rename to src/.pi/skills/goals/elicit-expand.md diff --git a/src/agents/goals/grounding-advance.md b/src/.pi/skills/goals/grounding-advance.md similarity index 100% rename from src/agents/goals/grounding-advance.md rename to src/.pi/skills/goals/grounding-advance.md diff --git a/src/agents/lenses/README.md b/src/.pi/skills/lenses/README.md similarity index 100% rename from src/agents/lenses/README.md rename to src/.pi/skills/lenses/README.md diff --git a/src/agents/lenses/design.md b/src/.pi/skills/lenses/design.md similarity index 100% rename from src/agents/lenses/design.md rename to src/.pi/skills/lenses/design.md diff --git a/src/agents/lenses/intent.md b/src/.pi/skills/lenses/intent.md similarity index 100% rename from src/agents/lenses/intent.md rename to src/.pi/skills/lenses/intent.md diff --git a/src/agents/lenses/oracle.md b/src/.pi/skills/lenses/oracle.md similarity index 100% rename from src/agents/lenses/oracle.md rename to src/.pi/skills/lenses/oracle.md diff --git a/src/agents/methods/commit-graph.md b/src/.pi/skills/methods/commit-graph.md similarity index 100% rename from src/agents/methods/commit-graph.md rename to src/.pi/skills/methods/commit-graph.md diff --git a/src/agents/methods/generate-proposal.md b/src/.pi/skills/methods/generate-proposal.md similarity index 100% rename from src/agents/methods/generate-proposal.md rename to src/.pi/skills/methods/generate-proposal.md diff --git a/src/agents/methods/infer-and-capture.md b/src/.pi/skills/methods/infer-and-capture.md similarity index 100% rename from src/agents/methods/infer-and-capture.md rename to src/.pi/skills/methods/infer-and-capture.md diff --git a/src/agents/methods/read-snapshot.md b/src/.pi/skills/methods/read-snapshot.md similarity index 100% rename from src/agents/methods/read-snapshot.md rename to src/.pi/skills/methods/read-snapshot.md diff --git a/src/agents/methods/review-for-gaps.md b/src/.pi/skills/methods/review-for-gaps.md similarity index 100% rename from src/agents/methods/review-for-gaps.md rename to src/.pi/skills/methods/review-for-gaps.md diff --git a/src/agents/methods/run-structured-exchange.md b/src/.pi/skills/methods/run-structured-exchange.md similarity index 100% rename from src/agents/methods/run-structured-exchange.md rename to src/.pi/skills/methods/run-structured-exchange.md diff --git a/src/agents/strategies/README.md b/src/.pi/skills/strategies/README.md similarity index 100% rename from src/agents/strategies/README.md rename to src/.pi/skills/strategies/README.md diff --git a/src/agents/strategies/project-graph.md b/src/.pi/skills/strategies/project-graph.md similarity index 100% rename from src/agents/strategies/project-graph.md rename to src/.pi/skills/strategies/project-graph.md diff --git a/src/agents/strategies/propose-graph.md b/src/.pi/skills/strategies/propose-graph.md similarity index 100% rename from src/agents/strategies/propose-graph.md rename to src/.pi/skills/strategies/propose-graph.md diff --git a/src/agents/strategies/step-wise-decision-tree.md b/src/.pi/skills/strategies/step-wise-decision-tree.md similarity index 100% rename from src/agents/strategies/step-wise-decision-tree.md rename to src/.pi/skills/strategies/step-wise-decision-tree.md diff --git a/src/agents/strategies/step-wise-disambiguate.md b/src/.pi/skills/strategies/step-wise-disambiguate.md similarity index 100% rename from src/agents/strategies/step-wise-disambiguate.md rename to src/.pi/skills/strategies/step-wise-disambiguate.md diff --git a/src/README.md b/src/README.md index d2f6a0a94..6599bf348 100644 --- a/src/README.md +++ b/src/README.md @@ -1,20 +1,18 @@ # src/ — Brunch source topology -Decision D52-L in `memory/SPEC.md` locks this layout. +Decision D52-L in `memory/SPEC.md` locks the target layout. Runtime-state projection remains a planned follow-up split under Cards 4–5 of the active topology chain. -``` +```text src/ -├── .pi/ Pi adapter layer (TUI) -│ ├── components/ reusable TUI components -│ └── extensions/ Pi registrars: agent tools, TUI commands, enhancements +├── app/ Product host entrypoints and wiring +├── workspace/ Cwd/package/workspace identity helpers +├── scripts/ Local executable utilities │ -├── agents/ Agent intelligence layer -│ ├── definitions/ keyed agent prompt definitions -│ ├── goals/ runtime goal resources -│ ├── strategies/ interaction-shape resources (propose-graph, project-graph, etc.) -│ ├── lenses/ topical-focus resources (intent, design, oracle, etc.) -│ ├── methods/ tool-routing and sequencing resources -│ └── contexts/ snapshot rendering over graph/session typed pulls +├── .pi/ Sealed Pi-harness runtime surface +│ ├── agents/ Pi session-agent prompt assembly and definitions +│ ├── skills/ goal/strategy/lens/method resources read on demand +│ ├── components/ reusable Pi TUI/message components +│ └── extensions/ Pi registrars: tools, hooks, commands, TUI affordances │ ├── db/ Persistence substrate │ Drizzle schema, migrations, connection lifecycle @@ -27,6 +25,9 @@ src/ │ transcript projection, exchange extraction, │ workspace coordination, session binding, LSN staleness │ +├── projections/ Structured DTOs derived from domain/session/tool facts +├── renderers/ Lossy text/markdown/toon/tool-content rendering +│ ├── rpc/ Brunch JSON-RPC handlers │ protocol, method handlers, WebSocket adapter │ @@ -36,33 +37,37 @@ src/ ## Dependency direction -``` -.pi/extensions/ ──┐ - ├──▶ graph/ ──▶ db/ -rpc/ ────────────┤ - ├──▶ session/ -agents/ ─────────┘ - (Pi JSONL — not Brunch-owned storage) - -web/ ── standalone build, imports from rpc/ types only +```pseudo +rules: + graph/ -> db/ [allowed] + projections/* -> graph/, session/, workspace/ [read/domain imports allowed] + renderers/* -> projections/, graph/, session/ as needed for input types + .pi/ -> graph/, session/, projections/, renderers/ [Pi runtime adapters/resources] + rpc/ -> graph/, session/, projections/, renderers/ + app/ -> graph/, session/, projections/, renderers/ + graph/, session/ x> .pi/, rpc/, app/, web/ + projections/ x> .pi/, rpc/, app/, web/ + renderers/ x> .pi/, rpc/, app/, web/ + web/ -> rpc/ types only ``` Rules: + - `graph/` imports from `db/`. No other layer imports `db/` directly. -- `agents/` imports snapshot functions from `graph/` and `session/`. -- `.pi/extensions/` and `rpc/` may import from `graph/`, `session/`, and `agents/`. +- `.pi/` owns Pi-harness agents/resources/extensions/components. It is not just an adapter folder; it is the product's sealed Pi runtime surface. +- `.pi/extensions/` registers Pi tools/hooks/UI affordances and delegates product semantics outward. +- `.pi/agents/` owns runtime prompt assembly and legal resource manifests; `.pi/skills/` owns read-on-demand markdown resources. +- `projections/` owns reusable structured output; `renderers/` owns reusable lossy text output. - `web/` is a separate Vite build target. ## Migration notes -The session-domain files (workspace-session-coordinator, session-binding, -session-projection-reader, brunch-session-envelope, session-transcript, -exchange-projection, structured-exchange, project-identity) now live in -`src/session/`; `brunch-pi-profile.ts` in `src/.pi/`; `web-host` in `src/rpc/`; -the React client in `src/web/` (formerly `web-client/`); shared test helpers -in `src/probes/`. The active workspace file is `.brunch/workspace.json` -(`state.json` is retired). +Product entrypoints now live in `app/`, package identity tests live in `workspace/`, reusable workspace snapshot DTOs live in `projections/workspace/`, and reusable print-mode snapshot text lives in `renderers/workspace/`. No compatibility root files remain for the old `src/brunch*`, `src/print-snapshot*`, or `src/package-identity*` paths. + +The old domain-local `src/{graph,session,structured-exchange}/project/` folders now live under `projections/{graph,session,structured-exchange}/`. + +The old domain-local `src/{graph,session,structured-exchange}/format/` folders and `src/render/` now live under `renderers/{graph,session,structured-exchange}/` and `renderers/`. + +Runtime-state transcript entry facts live in `session/runtime-state.ts`; reusable flattened runtime-state projection/policy now lives in `projections/session/runtime-state.ts` and `projections/session/runtime-policy.ts`. -Prompt composition and prompt resources live in `src/agents/` per D52-L/D58-L. -The old `src/.pi/context/` prompt-pack subtree is retired; `.pi/` remains an -adapter layer only. +The old `src/agents/` top-level prompt subtree has moved under `src/.pi/{agents,skills}/` because these agents/resources live only inside the Pi harness. The old `src/.pi/context/` prompt-pack subtree remains retired. diff --git a/src/agents/README.md b/src/agents/README.md deleted file mode 100644 index 46ce02b95..000000000 --- a/src/agents/README.md +++ /dev/null @@ -1,122 +0,0 @@ -# agents/ — Agent intelligence layer - -SPEC decisions: D25-L, D40-L, D52-L, D58-L, D59-L, D60-L - -## Owns - -Everything that shapes what the LLM sees and does: the session-agent state -definitions and legal-combination table, per-turn prompt composition, the -Brunch-owned prompt resources (markdown the agent reads on demand), and the -snapshot render layer. - -## Session-agent state (D40-L, D59-L) - -Projected from linear `brunch.agent_runtime_state` entries at turn start -(last-writer-wins). One WHO field, three optional objective axes: - -``` -op_mode = elicit | execute (future) ← the only stored WHO field - foreground role (elicitor) is DERIVED from op_mode, never stored -goal = grounding-advance | elicit-expand | commit-converge - | capture-posture [pinned | AUTO] grade-derived (D59-L) -strategy = step-wise-decision-tree | step-wise-disambiguate - | propose-graph | project-graph [pinned | AUTO] (D25-L) -lens = intent | design | oracle [pinned | AUTO] (future: plan/sync/scope) -``` - -Gates that condition composition but are not session-agent axes: - -``` -spec.readiness_grade grounding_onboarding → elicitation_ready - → commitments_ready → planning_ready (forward gate, D45-L) -workspace posture persisted in .brunch/workspace.json as workspace-scoped state; - surfaced in the runtime header and refined via capture-posture -agent allow-list per-definition: which goals/strategies/lenses/methods are legal -``` - -The legal `(op_mode × goal × strategy × lens)` tuple table lives in `state.ts`. - -## Composition model (D58-L) — thin header + gated manifest, not eager packs - -`compose(agentId, sessionState, spec, workspace, snapshots)` is **projection, -not a state machine**. It runs before Pi provider requests and emits: - -1. **agent control header** — identity, model/thinking, role derived from `op_mode`, tool authority. -2. **runtime-state header** — current pinned/AUTO `goal`/`strategy`/`lens`, `readiness_grade`, posture. -3. **resource manifests** — ``, ``, ``, - ``: each entry `{name, description, location}`, filtered by tuple/grade/`op_mode`/allow-list. -4. **compact pushed context** — minimal snapshot summary/handles (detail governed by D60-L). - -Detailed goal/strategy/lens/method bodies are **markdown the agent loads with -`read`** when detail matters — the same mechanism Pi uses for skills. The -composer never concatenates large semantic bodies on the agent's behalf. - -- **AUTO** axis → the manifest lists exactly the legal set; a router rule tells the agent to - choose only from that manifest. **Pinned** axis → the manifest points at the pinned resource. -- Manifest `{name, description, location}` metadata is **code-owned in `state.ts`**, never - filesystem-discovered (honors the D39-L profile seal). - -## Directory layout - -``` -agents/ -├── README.md -├── state.ts axis enums + legal (op_mode × goal × strategy × lens) tuple table; -│ also owns each resource's {name, description, location} manifest entry -├── compose.ts projection → runtime header + gated manifest -├── index.ts public entry / resource registry -├── definitions/ keyed agents; frontmatter = model/thinking + tool authority + allow-lists, -│ ├── elicitor.md body = system prompt -│ └── reviewer.md -├── goals/ grounding-advance, elicit-expand, commit-converge, capture-posture -├── strategies/ step-wise-decision-tree, step-wise-disambiguate, propose-graph, project-graph -├── lenses/ intent, design, oracle -├── methods/ run-structured-exchange, infer-and-capture, generate-proposal, -│ read-snapshot, commit-graph, review-for-gaps -└── contexts/ snapshot RENDER (D60-L) — TypeScript, NOT a manifest resource family - ├── cwd.ts - ├── graph.ts - └── node.ts -``` - -## Snapshots (D60-L) — pull / render / surface - -- **PULL** — typed, read-only; owned by the data layer (`graph/snapshot.ts` for graph/node, - `session/` for cwd). The typed value *is* the JSON form. `agents/` never re-implements pulls. -- **RENDER** — `agents/contexts/*.ts` turn a typed snapshot into an LLM string, scaled by - lens-plane and grade-depth (I35-L). This is the only place LLM-string rendering lives. -- **SURFACE** — *pushed* (compose injects the compact summary) or *pulled* (`snapshot-{cwd,graph,nodes}` - Pi tools wrap the renderer: markdown in `toolResult.content`, typed JSON in `toolResult.details`). - -`contexts/` is render-only and carries no `` manifest family. Reserve -"snapshot" for this agent-context family; `workspace.snapshot` is product/UI state (D60-L). - -## Does NOT own - -- Pi extension registration, tool definitions, `snapshot-*` tool wrappers — `.pi/extensions/`. -- Graph domain logic, CommandExecutor, snapshot PULL — `graph/`. -- Session projection, transcript reading, cwd PULL — `session/`. - -## Imported by - -- `.pi/extensions/` prompt registrar — calls `compose()` at turn boundaries. -- `.pi/extensions/operational-mode.ts` — reads the state enums from `state.ts`. - -## Migration from .pi/context/ (complete) - -Product prompting imports `agents/compose.ts`; prompt-resource metadata is -code-owned in `state.ts`; detailed prompt resources live under -`definitions/`, `goals/`, `strategies/`, `lenses/`, and `methods/`; context -rendering lives under `contexts/`. The old `src/.pi/context/` prompt-pack -subtree is deleted rather than retained as a compatibility path. - -| Former (.pi/context/) | Current home | -|-------------------------------------------------|-------------------------------------| -| `compose-brunch-prompt.ts` | `agents/compose.ts` | -| `prompt-packs/{brunch-base,elicit,elicitor}.md` | `agents/definitions/elicitor.md` | -| `prompt-packs/structured-exchange.md` | `agents/methods/run-structured-exchange.md` | -| `prompt-packs/capture-analysis.md` | `agents/methods/infer-and-capture.md` | -| `prompt-packs/candidate-proposals.md` | `agents/methods/generate-proposal.md` | -| `builders/graph-context.ts` | `agents/contexts/graph.ts` | -| `builders/readiness-context.ts` | `agents/compose.ts` runtime header | -| `builders/structured-exchange-context.ts` | `agents/methods/run-structured-exchange.md` | diff --git a/src/app/README.md b/src/app/README.md new file mode 100644 index 000000000..d4d0dd0f9 --- /dev/null +++ b/src/app/README.md @@ -0,0 +1,24 @@ +# app/ + +SPEC decisions: D52-L + +## Owns + +Product host entrypoints and wiring for Brunch runtime modes. + +Current entrypoints: + +- `brunch.ts` — CLI mode dispatch for TUI, RPC, web, and print. +- `brunch-tui.ts` — TUI launch path and embedded Pi session runtime wiring. + +## Does not own + +- Graph truth, command execution, or persistence — `graph/` and `db/`. +- Pi registrars, prompt resources, and reusable Pi UI components — `.pi/`. +- Session transcript semantics, binding, and workspace/session coordination — `session/`. +- JSON-RPC method semantics — `rpc/`. +- React client code — `web/`. + +## Dependency direction + +`app/` may import from `.pi/`, `graph/`, `session/`, `rpc/`, `projections/`, and `renderers/` to compose product modes. Domain layers must not import `app/`. diff --git a/src/brunch-tui.test.ts b/src/app/brunch-tui.test.ts similarity index 92% rename from src/brunch-tui.test.ts rename to src/app/brunch-tui.test.ts index 7d9e9a0cb..c9e29127b 100644 --- a/src/brunch-tui.test.ts +++ b/src/app/brunch-tui.test.ts @@ -1,6 +1,6 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { SessionManager, @@ -11,7 +11,6 @@ import { } from '@earendil-works/pi-coding-agent'; import { describe, expect, it } from 'vitest'; -import { createBrunchPiProfile } from './.pi/brunch-pi-profile.js'; import { BRUNCH_CONTINUE_COMMAND, BRUNCH_LENS_COMMAND, @@ -20,12 +19,22 @@ import { BRUNCH_SWITCH_COMMAND, BRUNCH_SWITCH_SHORTCUT, chromeStateForWorkspace, - createBrunchPiExtensionShell, + createBrunchPiExtensions, registerBrunchAlternatives, registerBrunchOperationalModePolicy, runBrunchWorkspaceCommand, runBrunchWorkspaceAction, -} from './.pi/pi-extension-shell.js'; +} from '../.pi/brunch-pi-extensions.js'; +import { createBrunchPiSettings } from '../.pi/brunch-pi-settings.js'; +import { openWorkspaceGraphRuntime } from '../graph/index.js'; +import { userMessage } from '../probes/test-helpers.js'; +import { createProductUpdatePublisher } from '../rpc/product-updates.js'; +import { + createWorkspaceSessionCoordinator, + verifyWorkspaceSessionStores, + type WorkspaceLaunchInventory, + type WorkspaceSessionReadyState, +} from '../session/workspace-session-coordinator.js'; import { BRUNCH_SETTINGS_AUDITED_GETTERS, BRUNCH_SETTINGS_POLICY, @@ -35,15 +44,6 @@ import { createBrunchAgentSessionRuntimeFactory, runBrunchTui, } from './brunch-tui.js'; -import { openWorkspaceGraphRuntime } from './graph/index.js'; -import { userMessage } from './probes/test-helpers.js'; -import { createProductUpdatePublisher } from './rpc/product-updates.js'; -import { - createWorkspaceSessionCoordinator, - verifyWorkspaceSessionStores, - type WorkspaceLaunchInventory, - type WorkspaceSessionReadyState, -} from './session/workspace-session-coordinator.js'; describe('Brunch TUI boot', () => { it('gates spec selection through the coordinator before launching interactive mode', async () => { @@ -52,6 +52,7 @@ describe('Brunch TUI boot', () => { await runBrunchTui({ cwd, + autoOpen: false, selectSpecTitle: async () => { events.push('select-spec'); return 'Gated spec'; @@ -176,6 +177,7 @@ describe('Brunch TUI boot', () => { await runBrunchTui({ cwd: '/tmp/project', + autoOpen: false, coordinator: { inspectWorkspace: async () => { events.push('inspect'); @@ -222,6 +224,7 @@ describe('Brunch TUI boot', () => { await runBrunchTui({ cwd: '/tmp/project', + autoOpen: false, coordinator: { inspectWorkspace: async () => { events.push('inspect'); @@ -280,6 +283,53 @@ describe('Brunch TUI boot', () => { ]); }); + it('opens the advertised sidecar route only through the injected opener', async () => { + const events: string[] = []; + const workspace = readyWorkspace('/tmp/project', 'session-ready'); + + await runBrunchTui({ + cwd: '/tmp/project', + coordinator: { + inspectWorkspace: async () => ({ + cwd: '/tmp/project', + currentSpec: workspace.spec, + currentSessionFile: workspace.session.file, + needsNewSpec: false, + specs: [], + unavailableSessions: [], + }), + activateWorkspace: async () => workspace, + bindCurrentSpecToReplacementSession: async () => workspace, + }, + runWorkspaceDialogPreflight: async () => ({ + action: 'continue', + specId: workspace.spec.id, + sessionFile: workspace.session.file, + }), + webSidecarRunner: async () => ({ + url: 'http://127.0.0.1:49152', + async close() { + events.push('sidecar-close'); + }, + }), + advertiseWebSidecar: (url) => { + events.push(`advertise:${url}`); + }, + openBrowser: async (url) => { + events.push(`open:${url}`); + }, + launchInteractive: async () => { + events.push('launch'); + }, + }); + + expect(events).toEqual([ + 'advertise:http://127.0.0.1:49152/spec/1', + 'open:http://127.0.0.1:49152/spec/1', + 'launch', + 'sidecar-close', + ]); + }); it('can disable browser auto-open while still advertising the active spec sidecar route', async () => { const events: string[] = []; const workspace = readyWorkspace('/tmp/project', 'session-ready'); @@ -384,6 +434,7 @@ describe('Brunch TUI boot', () => { await runBrunchTui({ cwd, + autoOpen: false, coordinator, runWorkspaceDialogPreflight: async () => ({ action: 'newSession', @@ -424,7 +475,7 @@ describe('Brunch TUI boot', () => { const beforeAgentStart: Array<(event: unknown, ctx: FakeExtensionContext) => Promise> = []; const messageStart: Array<(event: unknown, ctx: FakeExtensionContext) => Promise> = []; - await createBrunchPiExtensionShell( + await createBrunchPiExtensions( chromeStateForWorkspace(readyWorkspace(cwd, manager.getSessionId())), (sessionManager) => { boundSessionIds.push(sessionManager.getSessionId()); @@ -462,8 +513,8 @@ describe('Brunch TUI boot', () => { } expect(boundSessionIds).toEqual([manager.getSessionId(), manager.getSessionId(), manager.getSessionId()]); - expect(widgets.get('brunch.chrome')?.join('\n')).toContain('chat mode: responding-to-elicitation'); - expect(titles).toEqual(['brunch — Spec One']); + expect(widgets.has('brunch.chrome')).toBe(false); + expect(titles).toEqual([`brunch — ${basename(cwd)} · Spec One`]); }); it('registers the Brunch spec/session picker command and shortcut', async () => { @@ -471,7 +522,7 @@ describe('Brunch TUI boot', () => { const shortcuts = new Map>(); const registeredTools: string[] = []; - await createBrunchPiExtensionShell( + await createBrunchPiExtensions( chromeStateForWorkspace(readyWorkspace('/tmp/project', 'session-1')), undefined, { @@ -499,9 +550,11 @@ describe('Brunch TUI boot', () => { 'present_alternatives', 'present_question', 'present_options', + 'present_review_set', 'request_answer', 'request_choice', 'request_choices', + 'request_review', ]); expect(commands.get(BRUNCH_SWITCH_COMMAND)?.description).toBe('Open the Brunch spec/session picker'); const retiredWorkspaceCommand = ['brunch', 'workspace'].join('-'); @@ -615,9 +668,7 @@ describe('Brunch TUI boot', () => { 'custom', 'activate:openSession', `switch:${target.session.file}`, - 'replacement:setHeader', 'replacement:setFooter', - 'replacement:setWidget', 'replacement:setTitle', 'replacement:notify', ]); @@ -744,7 +795,7 @@ describe('Brunch TUI boot', () => { }; const handlers = new Map unknown>(); - await createBrunchPiExtensionShell( + await createBrunchPiExtensions( chromeStateForWorkspace(readyWorkspace(cwd, manager.getSessionId())), undefined, { coordinator: noOpWorkspaceCoordinator(cwd) }, @@ -827,7 +878,7 @@ describe('Brunch TUI boot', () => { let providerFactory: ((current: FakeAutocompleteProvider) => FakeAutocompleteProvider) | undefined; const sessionStart: Array<(event: unknown, ctx: FakeExtensionContext) => Promise | void> = []; - await createBrunchPiExtensionShell( + await createBrunchPiExtensions( chromeStateForWorkspace(readyWorkspace('/tmp/project', 'session-1')), undefined, { coordinator: noOpWorkspaceCoordinator('/tmp/project') }, @@ -933,7 +984,7 @@ describe('Brunch TUI boot', () => { expect(events.user_bash?.({ command: 'rm -rf .' } as never)).toMatchObject({ result: { exitCode: 1, - output: 'Brunch tool policy blocks shell commands: rm -rf .', + output: 'Brunch tool policy blocks shell commands in elicit mode (bash, edit, write): rm -rf .', }, }); }); @@ -1027,17 +1078,17 @@ describe('Brunch TUI boot', () => { expect(settingsManager.getNpmCommand()).toBeUndefined(); }); - it('keeps ambient resource suppression and explicit product extensions behind one profile boundary', async () => { + it('keeps ambient resource suppression and explicit product extensions behind one settings boundary', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-tui-')); const extension = () => {}; - const profile = createBrunchPiProfile({ + const settings = createBrunchPiSettings({ cwd, agentDir: cwd, extensionFactories: [extension], }); - expect(profile.settingsManager.getQuietStartup()).toBe(true); - expect(profile.resourceLoaderOptions).toEqual({ + expect(settings.settingsManager.getQuietStartup()).toBe(true); + expect(settings.resourceLoaderOptions).toEqual({ noContextFiles: true, noExtensions: true, noPromptTemplates: true, @@ -1049,22 +1100,29 @@ describe('Brunch TUI boot', () => { it('keeps Pi settings/resource policy out of the TUI launcher', async () => { const launcherSource = await readFile(join(import.meta.dirname, 'brunch-tui.ts'), 'utf8'); - const profileSource = await readFile(join(import.meta.dirname, '.pi', 'brunch-pi-profile.ts'), 'utf8'); + const settingsSource = await readFile( + join(import.meta.dirname, '..', '.pi', 'brunch-pi-settings.ts'), + 'utf8', + ); - expect(launcherSource).toContain('createBrunchPiProfile'); + expect(launcherSource).toContain('createBrunchPiSettings'); expect(launcherSource).not.toContain('SettingsManager.create'); expect(launcherSource).not.toContain('noContextFiles'); - expect(profileSource).toContain('SettingsManager.inMemory'); - expect(profileSource).toContain('noContextFiles: true'); + expect(settingsSource).toContain('SettingsManager.inMemory'); + expect(settingsSource).toContain('noContextFiles: true'); }); - it('keeps the Brunch settings override and audit list in the profile boundary', async () => { + it('keeps the Brunch settings override and audit list in the settings boundary', async () => { const launcherSource = await readFile(join(import.meta.dirname, 'brunch-tui.ts'), 'utf8'); - const profileSource = await readFile(join(import.meta.dirname, '.pi', 'brunch-pi-profile.ts'), 'utf8'); + const settingsSource = await readFile( + join(import.meta.dirname, '..', '.pi', 'brunch-pi-settings.ts'), + 'utf8', + ); const settingsManagerTypes = await readFile( join( import.meta.dirname, '..', + '..', 'node_modules', '@earendil-works', 'pi-coding-agent', @@ -1091,8 +1149,8 @@ describe('Brunch TUI boot', () => { }); expect(getterNames.sort()).toEqual([...BRUNCH_SETTINGS_AUDITED_GETTERS].sort()); expect(launcherSource).not.toContain('SettingsManager.inMemory'); - expect(profileSource).toContain('BRUNCH_SETTINGS_POLICY'); - expect(profileSource).toContain('SettingsManager.inMemory'); + expect(settingsSource).toContain('BRUNCH_SETTINGS_POLICY'); + expect(settingsSource).toContain('SettingsManager.inMemory'); }); }); diff --git a/src/brunch-tui.ts b/src/app/brunch-tui.ts similarity index 89% rename from src/brunch-tui.ts rename to src/app/brunch-tui.ts index 2d0e76460..9106b5748 100644 --- a/src/brunch-tui.ts +++ b/src/app/brunch-tui.ts @@ -10,12 +10,12 @@ import { type CreateAgentSessionRuntimeFactory, } from '@earendil-works/pi-coding-agent'; -import { applyBrunchOfflineDefault, createBrunchPiProfile } from './.pi/brunch-pi-profile.js'; -import { runWorkspaceDialogPreflight } from './.pi/components/workspace-dialog.js'; -import { chromeStateForWorkspace, createBrunchPiExtensionShell } from './.pi/pi-extension-shell.js'; -import { openWorkspaceGraphRuntime } from './graph/index.js'; -import { createProductUpdatePublisher, type ProductUpdatePublisher } from './rpc/product-updates.js'; -import { startWebHost, type RunningWebHost } from './rpc/web-host.js'; +import { 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 { createProductUpdatePublisher, type ProductUpdatePublisher } from '../rpc/product-updates.js'; +import { startWebHost, type RunningWebHost } from '../rpc/web-host.js'; import { createWorkspaceSessionCoordinator, type WorkspaceLaunchInventory, @@ -24,19 +24,19 @@ import { type WorkspaceSessionReadyState, type SpecSessionActivationCoordinator, type SpecSessionActivationDecision, -} from './session/workspace-session-coordinator.js'; +} from '../session/workspace-session-coordinator.js'; export { BRUNCH_SETTINGS_AUDITED_GETTERS, BRUNCH_SETTINGS_POLICY, applyBrunchOfflineDefault, brunchResourceLoaderOptions, - createBrunchPiProfile, + createBrunchPiSettings, createBrunchSettingsManager, -} from './.pi/brunch-pi-profile.js'; +} from '../.pi/brunch-pi-settings.js'; export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, chromeStateForWorkspace, - createBrunchPiExtensionShell, + createBrunchPiExtensions, projectBrunchChromeFooterLines, renderBrunchChrome, type BrunchChromeCoherenceVerdict, @@ -44,8 +44,8 @@ export { type BrunchChromeStage, type BrunchChromeState, type BrunchChromeWorkerStatus, -} from './.pi/pi-extension-shell.js'; -export { runWorkspaceDialogPreflight } from './.pi/components/workspace-dialog.js'; +} from '../.pi/brunch-pi-extensions.js'; +export { runWorkspaceDialogPreflight } from '../.pi/components/workspace-dialog.js'; export type BrunchTuiCoordinator = SpecSessionActivationCoordinator & WorkspaceSessionBoundaryCoordinator; @@ -156,11 +156,11 @@ export function createBrunchAgentSessionRuntimeFactory({ const bindCurrentWorkspace = async (replacementSessionManager: typeof sessionManager) => { currentWorkspace = await coordinator.bindCurrentSpecToReplacementSession(replacementSessionManager); }; - const profile = createBrunchPiProfile({ + const profile = createBrunchPiSettings({ cwd, agentDir: runtimeAgentDir, extensionFactories: [ - createBrunchPiExtensionShell(chromeStateForWorkspace(currentWorkspace), bindCurrentWorkspace, { + createBrunchPiExtensions(chromeStateForWorkspace(currentWorkspace), bindCurrentWorkspace, { coordinator, graph: graphDeps, promptContext: () => { diff --git a/src/brunch.smoke.test.ts b/src/app/brunch.smoke.test.ts similarity index 100% rename from src/brunch.smoke.test.ts rename to src/app/brunch.smoke.test.ts diff --git a/src/brunch.test.ts b/src/app/brunch.test.ts similarity index 97% rename from src/brunch.test.ts rename to src/app/brunch.test.ts index d1fd6be33..cdb97e0ac 100644 --- a/src/brunch.test.ts +++ b/src/app/brunch.test.ts @@ -6,13 +6,13 @@ import { PassThrough } from 'node:stream'; import { SessionManager } from '@earendil-works/pi-coding-agent'; import { describe, expect, it } from 'vitest'; -import { runBrunchCli, type WebHostRunnerOptions } from './brunch.js'; -import { assistantMessage, userMessage } from './probes/test-helpers.js'; -import { createSessionBindingData } from './session/session-binding.js'; +import { assistantMessage, userMessage } from '../probes/test-helpers.js'; +import { createSessionBindingData } from '../session/session-binding.js'; import { createWorkspaceSessionCoordinator, type WorkspaceSessionCoordinator, -} from './session/workspace-session-coordinator.js'; +} from '../session/workspace-session-coordinator.js'; +import { runBrunchCli, type WebHostRunnerOptions } from './brunch.js'; function coordinator(sessionFile?: string): WorkspaceSessionCoordinator { return { diff --git a/src/brunch.ts b/src/app/brunch.ts similarity index 89% rename from src/brunch.ts rename to src/app/brunch.ts index c2f82e888..8d64f69c9 100644 --- a/src/brunch.ts +++ b/src/app/brunch.ts @@ -2,15 +2,16 @@ import process from 'node:process'; import type { Readable, Writable } from 'node:stream'; import { fileURLToPath } from 'node:url'; -import { runBrunchTui } from './brunch-tui.js'; -import { renderWorkspaceSnapshot, workspaceSnapshotFromState } from './print-snapshot.js'; -import { createRpcHandlers, runJsonRpcLineServer } from './rpc/handlers.js'; -import { createProductUpdatePublisher } from './rpc/product-updates.js'; -import { startWebHost } from './rpc/web-host.js'; +import { workspaceSnapshotFromState } from '../projections/workspace/workspace-snapshot.js'; +import { renderWorkspaceSnapshot } from '../renderers/workspace/workspace-snapshot.js'; +import { createRpcHandlers, runJsonRpcLineServer } from '../rpc/handlers.js'; +import { createProductUpdatePublisher } from '../rpc/product-updates.js'; +import { startWebHost } from '../rpc/web-host.js'; import { createWorkspaceSessionCoordinator, type WorkspaceSessionCoordinator, -} from './session/workspace-session-coordinator.js'; +} from '../session/workspace-session-coordinator.js'; +import { runBrunchTui } from './brunch-tui.js'; export interface WebHostRunnerOptions { cwd: string; diff --git a/src/db/README.md b/src/db/README.md index 2b0c5c230..ee3cd5d2a 100644 --- a/src/db/README.md +++ b/src/db/README.md @@ -18,9 +18,11 @@ SPEC decisions: D16-L, D41-L, D52-L, D54-L, D62-L execution. - **Migrations** (`../../drizzle/`) — generated by `npm run db:generate` from - `src/db/schema.ts` and run by `createDb`. Custom data/bootstrap statements - that are part of schema initialization, such as the singleton `graph_clock` - seed row, live in migrations. + `src/db/schema.ts` and run by `createDb`. Custom migration statements may + reshape pre-release graph tables and backfill spec-owned clock/change-log + rows, but there is no workspace-global graph-clock seed row. New live specs + get their `graph_clock` row from `CommandExecutor.createSpec`, and later live + mutations do not repair missing clock rows. ## Does not own @@ -94,7 +96,9 @@ owned by their boundary. The current graph tables are spec-scoped: `specs`, `nodes`, `edges`, `node_kind_counters`, `graph_clock`, `change_log`, and -`reconciliation_need`. +`reconciliation_need`. `graph_clock` is keyed by `spec_id`; `change_log` carries +`spec_id` and is keyed by `(spec_id, lsn)`, so a bare LSN is comparable only +inside one spec. `nodes.kind_ordinal` is persisted as the storage half of the D62-L projected-code contract. `node_kind_counters` owns monotonic per-`(spec_id, plane, kind)` diff --git a/src/db/connection.test.ts b/src/db/connection.test.ts index 2f8561adb..997f29f21 100644 --- a/src/db/connection.test.ts +++ b/src/db/connection.test.ts @@ -7,7 +7,7 @@ import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; import { createDb } from './connection.js'; -import { edges, graphClock, nodeKindCounters, nodes, specs } from './schema.js'; +import { changeLog, edges, graphClock, nodeKindCounters, nodes, specs } from './schema.js'; describe('createDb', () => { it('creates a missing database file and can reopen it idempotently', async () => { @@ -20,11 +20,13 @@ describe('createDb', () => { .values({ name: 'Spec A', slug: 'spec-a', readiness_grade: 'grounding_onboarding' }) .run(); + const specId = db.select({ id: specs.id }).from(specs).get()!.id; + db.insert(graphClock).values({ spec_id: specId, lsn: 0 }).run(); expect((await stat(dbPath)).isFile()).toBe(true); const reopened = createDb(dbPath); expect(reopened.select().from(specs).all()).toHaveLength(1); - expect(reopened.select().from(graphClock).all()[0]!.lsn).toBe(0); + expect(reopened.select().from(graphClock).all()).toHaveLength(1); } finally { await rm(dir, { recursive: true, force: true }); } @@ -52,6 +54,47 @@ describe('createDb', () => { ['intent', 'goal', 3], ['intent', 'requirement', 2], ]); + expect(db.select({ specId: graphClock.spec_id, lsn: graphClock.lsn }).from(graphClock).all()).toEqual([ + { specId: 1, lsn: 9 }, + ]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('migrates legacy spec-only change-log history into a matching graph clock row', async () => { + const dir = await mkdtemp(join(tmpdir(), 'brunch-db-legacy-spec-only-')); + const dbPath = join(dir, 'legacy.db'); + + try { + await createLegacy0000SpecOnlyHistoryDatabase(dbPath); + + const db = createDb(dbPath); + + expect(db.select({ specId: graphClock.spec_id, lsn: graphClock.lsn }).from(graphClock).all()).toEqual([ + { specId: 1, lsn: 4 }, + ]); + expect(db.select({ specId: changeLog.spec_id, lsn: changeLog.lsn }).from(changeLog).all()).toEqual([ + { specId: 1, lsn: 1 }, + { specId: 1, lsn: 4 }, + ]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('migrates a legacy spec with no local history into a zero-valued graph clock row', async () => { + const dir = await mkdtemp(join(tmpdir(), 'brunch-db-legacy-empty-spec-')); + const dbPath = join(dir, 'legacy.db'); + + try { + await createLegacy0000EmptySpecDatabase(dbPath); + + const db = createDb(dbPath); + + expect(db.select({ specId: graphClock.spec_id, lsn: graphClock.lsn }).from(graphClock).all()).toEqual([ + { specId: 1, lsn: 0 }, + ]); } finally { await rm(dir, { recursive: true, force: true }); } @@ -71,14 +114,70 @@ async function createLegacy0000Database(dbPath: string): Promise { id, spec_id, plane, kind, title, body, basis, source, detail, created_at_lsn, updated_at_lsn ) VALUES - (1, 1, 'intent', 'goal', 'First goal', NULL, 'accepted_review_set', NULL, NULL, 0, 0), - (2, 1, 'intent', 'goal', 'Second goal', NULL, 'explicit', NULL, NULL, 0, 0), - (3, 1, 'intent', 'requirement', 'Requirement', NULL, 'accepted_review_set', NULL, NULL, 0, 0); + (1, 1, 'intent', 'goal', 'First goal', NULL, 'accepted_review_set', NULL, NULL, 2, 5), + (2, 1, 'intent', 'goal', 'Second goal', NULL, 'explicit', NULL, NULL, 3, 3), + (3, 1, 'intent', 'requirement', 'Requirement', NULL, 'accepted_review_set', NULL, NULL, 4, 7); INSERT INTO edges ( id, spec_id, category, source_id, target_id, stance, basis, rationale, created_at_lsn, updated_at_lsn ) - VALUES (1, 1, 'support', 1, 3, 'for', 'accepted_review_set', NULL, 0, 0); + VALUES (1, 1, 'support', 1, 3, 'for', 'accepted_review_set', NULL, 6, 8); + + INSERT INTO reconciliation_need ( + id, spec_id, target_kind, target_edge_id, target_a_id, target_b_id, kind, status, reason, created_at_lsn, resolved_at_lsn + ) + VALUES (1, 1, 'edge', 1, NULL, NULL, 'semantic_conflict', 'open', NULL, 9, NULL); + + CREATE TABLE "__drizzle_migrations" ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at numeric + ); + `); + sqlite + .prepare('INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)') + .run(createHash('sha256').update(migration).digest('hex'), 1780478757603); + } finally { + sqlite.close(); + } +} + +async function createLegacy0000SpecOnlyHistoryDatabase(dbPath: string): Promise { + const migration = await readFile(new URL('../../drizzle/0000_deep_maria_hill.sql', import.meta.url)); + const sqlite = new Database(dbPath); + try { + sqlite.exec(migration.toString('utf8')); + sqlite.exec(` + INSERT INTO specs (id, name, slug, readiness_grade) + VALUES (1, 'Spec-only history', 'spec-only-history', 'grounding_onboarding'); + + INSERT INTO change_log (lsn, operation, payload) + VALUES + (1, 'create_spec', '{"specId":1,"name":"Spec-only history","slug":"spec-only-history","readinessGrade":"grounding_onboarding"}'), + (4, 'update_spec_readiness_grade', '{"specId":1,"readinessGrade":"elicitation_ready"}'); + + CREATE TABLE "__drizzle_migrations" ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at numeric + ); + `); + sqlite + .prepare('INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)') + .run(createHash('sha256').update(migration).digest('hex'), 1780478757603); + } finally { + sqlite.close(); + } +} + +async function createLegacy0000EmptySpecDatabase(dbPath: string): Promise { + const migration = await readFile(new URL('../../drizzle/0000_deep_maria_hill.sql', import.meta.url)); + const sqlite = new Database(dbPath); + try { + sqlite.exec(migration.toString('utf8')); + sqlite.exec(` + INSERT INTO specs (id, name, slug, readiness_grade) + VALUES (1, 'Empty legacy spec', 'empty-legacy-spec', 'grounding_onboarding'); CREATE TABLE "__drizzle_migrations" ( id SERIAL PRIMARY KEY, diff --git a/src/db/schema.ts b/src/db/schema.ts index e207287eb..a33f3d5a9 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -9,7 +9,7 @@ */ import { sql } from 'drizzle-orm'; -import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; // --------------------------------------------------------------------------- // Shared enum arrays — the single source for text enum columns, @@ -117,7 +117,9 @@ export const edges = sqliteTable('edges', { }); export const graphClock = sqliteTable('graph_clock', { - id: integer().primaryKey(), // always row 1 + spec_id: integer() + .primaryKey() + .references(() => specs.id), lsn: integer().notNull().default(0), }); @@ -137,14 +139,21 @@ export const nodeKindCounters = sqliteTable( ], ); -export const changeLog = sqliteTable('change_log', { - lsn: integer().primaryKey(), - operation: text().notNull(), - payload: text().notNull(), // JSON summary of the mutation - created_at: text() - .notNull() - .default(sql`(datetime('now'))`), -}); +export const changeLog = sqliteTable( + 'change_log', + { + spec_id: integer() + .notNull() + .references(() => specs.id), + lsn: integer().notNull(), + operation: text().notNull(), + payload: text().notNull(), // JSON summary of the mutation + created_at: text() + .notNull() + .default(sql`(datetime('now'))`), + }, + (table) => [primaryKey({ columns: [table.spec_id, table.lsn], name: 'change_log_spec_lsn_pk' })], +); export const reconciliationNeed = sqliteTable('reconciliation_need', { id: integer().primaryKey({ autoIncrement: true }), diff --git a/src/dev/workspace-rpc.ts b/src/dev/workspace-rpc.ts new file mode 100644 index 000000000..b79d50121 --- /dev/null +++ b/src/dev/workspace-rpc.ts @@ -0,0 +1,165 @@ +/** + * One-shot Brunch workspace RPC helper for local development. + * + * It hides the JSON-RPC stdio ceremony used by `src/app/brunch.ts --mode=rpc` and + * prints only the response result, filtering product-update notifications. + */ + +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +interface CliArgs { + readonly workspace: string; + readonly method: string; + readonly params?: unknown; + readonly fullResponse: boolean; + readonly devRpc: boolean; +} + +interface JsonRpcResponse { + readonly jsonrpc: '2.0'; + readonly id?: number | string | null; + readonly result?: unknown; + readonly error?: unknown; +} + +function parseCliArgs(argv: readonly string[]): CliArgs { + let workspace = process.cwd(); + let fullResponse = false; + let devRpc = true; + const positional: string[] = []; + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg == null) throw new Error(`missing argument at index ${index}`); + if (arg === '--workspace' || arg === '-w') { + workspace = requiredValue(argv, ++index, arg); + } else if (arg === '--full-response') { + fullResponse = true; + } else if (arg === '--no-dev-rpc') { + devRpc = false; + } else if (arg === '--help' || arg === '-h') { + throw new UsageRequested(); + } else if (arg.startsWith('-')) { + throw new Error(`unknown argument: ${arg}`); + } else { + positional.push(arg); + } + } + + const [method, paramsText] = positional; + if (!method) throw new Error('method is required'); + if (positional.length > 2) throw new Error('expected at most one params JSON argument'); + + const base = { workspace, method, fullResponse, devRpc }; + return paramsText == null ? base : { ...base, params: parseParams(paramsText) }; +} + +function parseParams(text: string): unknown { + try { + return JSON.parse(text); + } catch (error) { + throw new Error(`params must be valid JSON: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function requiredValue(argv: readonly string[], index: number, flag: string): string { + const value = argv[index]; + if (!value) throw new Error(`${flag} requires a value`); + return value; +} + +class UsageRequested extends Error {} + +function usage(): string { + return [ + 'Usage:', + ' tsx src/dev/workspace-rpc.ts --workspace [params-json]', + '', + 'Examples:', + ' tsx src/dev/workspace-rpc.ts -w .fixtures/workbenches/bilal-curation workspace.selectionState', + ' tsx src/dev/workspace-rpc.ts -w .fixtures/workbenches/bilal-curation graph.overview \'{"specId":4}\'', + '', + 'Options:', + ' -w, --workspace Brunch workspace directory (default: cwd)', + ' --full-response Print the full JSON-RPC response instead of result only', + ' --no-dev-rpc Do not set BRUNCH_DEV_RPC=1', + ].join('\n'); +} + +function repoRoot(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +} + +function runRpc(args: CliArgs): JsonRpcResponse { + const root = repoRoot(); + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: args.method, + ...(args.params === undefined ? {} : { params: args.params }), + }; + + const child = spawnSync( + resolve(root, 'node_modules/.bin/tsx'), + [resolve(root, 'src/app/brunch.ts'), '--mode=rpc'], + { + cwd: resolve(args.workspace), + input: `${JSON.stringify(request)}\n`, + encoding: 'utf8', + env: { + ...process.env, + ...(args.devRpc ? { BRUNCH_DEV_RPC: '1' } : {}), + }, + }, + ); + + if (child.status !== 0) { + if (child.stdout) process.stderr.write(child.stdout); + if (child.stderr) process.stderr.write(child.stderr); + throw new Error(`brunch RPC process exited with status ${child.status ?? 'unknown'}`); + } + + const response = child.stdout + .split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line) as JsonRpcResponse) + .find((message) => message.id === 1); + + if (!response) { + if (child.stdout) process.stderr.write(child.stdout); + if (child.stderr) process.stderr.write(child.stderr); + throw new Error('RPC response with id 1 was not found'); + } + + return response; +} + +function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function main(): void { + const args = parseCliArgs(process.argv.slice(2)); + const response = runRpc(args); + if (response.error != null) { + printJson(args.fullResponse ? response : response.error); + process.exit(1); + } + printJson(args.fullResponse ? response : response.result); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + try { + main(); + } catch (error) { + if (error instanceof UsageRequested) { + console.log(usage()); + process.exit(0); + } + console.error(error instanceof Error ? error.message : String(error)); + console.error(`\n${usage()}`); + process.exit(1); + } +} diff --git a/src/graph/README.md b/src/graph/README.md index 6e5958e1c..ebc4b0a7f 100644 --- a/src/graph/README.md +++ b/src/graph/README.md @@ -1,20 +1,26 @@ # graph/ — Graph domain layer Canonical reference: `docs/design/GRAPH_MODEL.md` -SPEC decisions: D4-L, D20-L, D51-L, D52-L, D53-L, D54-L, D62-L, D63-L +SPEC decisions: D4-L, D20-L, D27-L, D51-L, D52-L, D53-L, D54-L, D62-L, D63-L ## Owns - **CommandExecutor** (`command-executor.ts`) — the single mutation boundary for - graph/spec writes. It hides structural validation, transaction mechanics, LSN - allocation, per-kind node ordinal allocation, change-log append, and - structured command results. + graph/spec writes. It hides structural validation, transaction mechanics, + spec-local LSN allocation, per-kind node ordinal allocation, change-log append, + and structured command results. - **commitGraph** — atomic batch mutation for `propose-graph`: one tool call, - one transaction, one LSN, all-or-nothing. It accepts product command input - (`nodes[]` with batch refs, `edges[]` with batch/existing refs), not raw DB - rows. `command-executor/commit-graph-batch.ts` owns the private shared planner - used by both dry-run and commit before any batch writes occur. + one transaction, one selected-spec LSN, all-or-nothing. It accepts product + command input (`nodes[]` with batch refs, `edges[]` with batch/existing refs), + not raw DB rows. `command-executor/commit-graph-batch.ts` owns the private + shared planner used by both dry-run and commit before any batch writes occur. + +- **review-set payload translation** (`review-set.ts`) — validates exact + user-reviewable review-set payloads, resolves projected existing-node codes + inside the selected spec, and translates them to explicit-basis graph batches. + `CommandExecutor.acceptReviewSet` is the only graph mutation entrypoint for + accepted review sets and records `operation: "accept_review_set"`. - **Capture translators** (`capture/`) — narrow, high-confidence structured response translators that turn transcript-native answers into `commitGraph` @@ -36,6 +42,17 @@ SPEC decisions: D4-L, D20-L, D51-L, D52-L, D53-L, D54-L, D62-L, D63-L through `db/connection.ts` and returns a `CommandExecutor` plus bound snapshot readers for adapters. +## Clock and audit posture + +`graph_clock` and `change_log` are spec-scoped. `CommandExecutor.createSpec` +creates the spec's initial `graph_clock` row at LSN 1 with the `create_spec` +audit entry. Later graph/spec mutations use an update-only bump on the target +spec's existing clock row, append a `change_log` row keyed by `(spec_id, lsn)`, +and write the same local LSN to that spec's graph rows or reconciliation needs. +Missing clock rows for existing specs are invariant failures; runtime code does +not repair them. Product updates therefore carry `{specId, lsn}`; callers must +not compare bare LSN values across sibling specs. + ## Imports from - `db/` — Drizzle table definitions, enum arrays, and connection handle. @@ -45,7 +62,9 @@ SPEC decisions: D4-L, D20-L, D51-L, D52-L, D53-L, D54-L, D62-L, D63-L - `.pi/extensions/graph/` — Pi tool adapters for `commit_graph` and `read_graph`. - `rpc/` — graph projection handlers and synchronous response-capture wiring. -- `agents/contexts/` — future prompt context renderers. +- `projections/graph/` — reusable DTO projection over graph reader/command outputs. +- `renderers/graph/` — reusable lossy markdown/text rendering over projected graph DTOs. +- `.pi/agents/contexts/` — future prompt context renderers. - `probes/` — graph proof drivers. ## Current topology @@ -67,6 +86,7 @@ graph/ createNode per-kind node ordinal allocation commitGraph / dryRunCommitGraph + acceptReviewSet create/resolve reconciliation need command-executor/ @@ -77,6 +97,11 @@ graph/ dry-run/commit structural parity temporary endpoint graph for supersession acyclicity + review-set.ts + review-set payload contract + selected-spec projected-code resolution + explicit-basis command translation + capture/ structured-response.ts deterministic labeled-answer capture to explicit-basis commitGraph input @@ -117,14 +142,14 @@ CommandExecutor writes rows transactionally appends change_log │ - ├─► .pi/extensions/graph + ├─►.pi/extensions/graph │ agent tool adapter │ ├─► rpc/ │ public product projections │ session.submitExchangeResponse capture wiring │ - └─► agents/contexts future renderers + └─► .pi/agents/contexts future context orchestration prompt context snapshots ``` diff --git a/src/graph/command-executor.test.ts b/src/graph/command-executor.test.ts index bf3110393..984cc426d 100644 --- a/src/graph/command-executor.test.ts +++ b/src/graph/command-executor.test.ts @@ -16,6 +16,12 @@ function createTestDb(): BrunchDb { return createDb(':memory:'); } +function graphClockLsn(db: BrunchDb, specId: number): number { + return ( + db.select({ lsn: graphClock.lsn }).from(graphClock).where(eq(graphClock.spec_id, specId)).get()?.lsn ?? 0 + ); +} + describe('CommandExecutor', () => { let db: BrunchDb; let executor: CommandExecutor; @@ -28,14 +34,15 @@ describe('CommandExecutor', () => { .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(); }); // --- graph_clock initialization --- - it('initializes graph_clock with lsn=0', () => { - const rows = db.select().from(graphClock).all(); - expect(rows).toHaveLength(1); - expect(rows[0]!.lsn).toBe(0); + it('stores a spec-local graph clock row for the persisted test spec', () => { + expect(db.select({ specId: graphClock.spec_id, lsn: graphClock.lsn }).from(graphClock).all()).toEqual([ + { specId, lsn: 0 }, + ]); }); // --- createNode: success path --- @@ -214,7 +221,7 @@ describe('CommandExecutor', () => { expect(db.select().from(nodes).all()).toHaveLength(0); expect(db.select().from(changeLog).all()).toHaveLength(0); expect(db.select().from(nodeKindCounters).all()).toHaveLength(0); - expect(db.select().from(graphClock).all()[0]!.lsn).toBe(0); + expect(graphClockLsn(db, specId)).toBe(0); }); it('persists explicit and implicit createNode basis values unchanged', () => { @@ -248,8 +255,7 @@ describe('CommandExecutor', () => { executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'First' }); executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'Second' }); - const [clock] = db.select().from(graphClock).all(); - expect(clock!.lsn).toBe(2); + expect(graphClockLsn(db, specId)).toBe(2); }); it('assigns matching created_at_lsn and updated_at_lsn on new nodes', () => { @@ -323,8 +329,7 @@ describe('CommandExecutor', () => { title: 'Should fail', }); - const [clock] = db.select().from(graphClock).all(); - expect(clock!.lsn).toBe(0); + expect(graphClockLsn(db, specId)).toBe(0); expect(db.select().from(nodes).all()).toHaveLength(0); expect(db.select().from(changeLog).all()).toHaveLength(0); }); @@ -378,6 +383,59 @@ describe('CommandExecutor', () => { expect(row.readiness_grade).toBe('grounding_onboarding'); }); + it('creates exactly one graph clock row for a new spec at LSN 1', () => { + const result = executor.createSpec({ name: 'Clocked Spec', slug: 'clocked-spec' }); + + expect(result.status).toBe('success'); + if (result.status !== 'success') throw new Error('unreachable'); + expect( + db + .select({ specId: graphClock.spec_id, lsn: graphClock.lsn }) + .from(graphClock) + .where(eq(graphClock.spec_id, result.specId)) + .all(), + ).toEqual([{ specId: result.specId, lsn: 1 }]); + }); + + it('scopes create_spec audit LSNs to the newly created spec', () => { + 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'); + + expect(specA.lsn).toBe(1); + expect(specB.lsn).toBe(1); + expect(graphClockLsn(db, specA.specId)).toBe(1); + expect(graphClockLsn(db, specB.specId)).toBe(1); + expect( + db + .select({ specId: changeLog.spec_id, lsn: changeLog.lsn, operation: changeLog.operation }) + .from(changeLog) + .all(), + ).toEqual([ + { specId: specA.specId, lsn: 1, operation: 'create_spec' }, + { specId: specB.specId, lsn: 1, operation: 'create_spec' }, + ]); + }); + + it('mutating one spec does not advance sibling spec clocks', () => { + 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 result = executor.createNode({ + specId: specA.specId, + plane: 'intent', + kind: 'goal', + title: 'Spec A goal', + }); + + expect(result.status).toBe('success'); + if (result.status !== 'success') throw new Error('unreachable'); + expect(result.lsn).toBe(2); + expect(graphClockLsn(db, specA.specId)).toBe(2); + expect(graphClockLsn(db, specB.specId)).toBe(1); + }); + it('reads a spec row by integer id', () => { const created = executor.createSpec({ name: 'Spec A', slug: 'spec-a' }); if (created.status !== 'success') throw new Error('unreachable'); @@ -407,6 +465,21 @@ describe('CommandExecutor', () => { expect(executor.getSpec(created.specId)?.readinessGrade).toBe('elicitation_ready'); }); + it('fails loud when an existing spec is missing its graph clock row', () => { + const created = executor.createSpec({ name: 'Corrupt Spec', slug: 'corrupt-spec' }); + if (created.status !== 'success') throw new Error('unreachable'); + db.delete(graphClock).where(eq(graphClock.spec_id, created.specId)).run(); + + expect(() => + executor.createNode({ + specId: created.specId, + plane: 'intent', + kind: 'goal', + title: 'This mutation should not repair storage', + }), + ).toThrow(/graph_clock invariant failed/); + }); + it('rejects an invalid readiness grade without writing', () => { const created = executor.createSpec({ name: 'Spec A', slug: 'spec-a' }); if (created.status !== 'success') throw new Error('unreachable'); diff --git a/src/graph/command-executor.ts b/src/graph/command-executor.ts index 919e438b2..d97a0bc4b 100644 --- a/src/graph/command-executor.ts +++ b/src/graph/command-executor.ts @@ -8,7 +8,7 @@ * Every graph mutation routes through this class. The executor owns: * - structural validation * - one SQLite transaction per command - * - monotonic LSN allocation from graph_clock + * - monotonic spec-local LSN allocation from graph_clock * - change_log append * - structured result return * @@ -25,6 +25,7 @@ import { formatCreatedGraphNode, planCommitGraphBatch, type PlannedBatchEndpoint, + type PlannedBatchEdge, } from './command-executor/commit-graph-batch.js'; import type { CommitGraphDryRunResult, @@ -34,6 +35,7 @@ import type { Diagnostic, StructuralIllegal, } from './command-executor/commit-graph-types.js'; +import { translateReviewSetPayloadToCommitGraph } from './review-set.js'; import { type NodeBasis, type NodePlane } from './schema/nodes.js'; export type ReadinessGrade = (typeof schema.READINESS_GRADES)[number]; @@ -116,6 +118,7 @@ export interface SpecRecord { export type CommandResult = | CommandSuccess | CommitGraphSuccess + | AcceptReviewSetSuccess | ReconNeedSuccess | ReconNeedResolveSuccess | CreateSpecSuccess @@ -140,6 +143,15 @@ export type CreateSpecResult = CreateSpecSuccess | StructuralIllegal; /** Result of an updateReadinessGrade command. */ export type UpdateReadinessGradeResult = UpdateReadinessGradeSuccess | StructuralIllegal; +/** Successful accepted review-set graph batch execution. */ +export interface AcceptReviewSetSuccess extends CommitGraphSuccess {} + +/** Result of an acceptReviewSet command. */ +export type AcceptReviewSetResult = AcceptReviewSetSuccess | StructuralIllegal; + +/** Result of validating a review-set payload before user presentation. */ +export type AcceptReviewSetDryRunResult = { readonly status: 'success' } | StructuralIllegal; + // --------------------------------------------------------------------------- // Input types // --------------------------------------------------------------------------- @@ -157,6 +169,13 @@ export interface UpdateReadinessGradeInput { readonly readinessGrade: ReadinessGrade; } +/** Input for accepting an exact user-reviewed graph batch. */ +export interface AcceptReviewSetInput { + readonly specId: number; + readonly proposalEntryId?: string | undefined; + readonly payload: unknown; +} + /** Input for creating a single graph node. */ export interface CreateNodeInput { readonly specId: number; @@ -353,13 +372,46 @@ function validateTermDetail(detail: unknown, diagnostics: Diagnostic[]): void { } } +function specRecordFromRow(row: typeof schema.specs.$inferSelect): SpecRecord { + return { + id: row.id, + name: row.name, + slug: row.slug, + readinessGrade: row.readiness_grade, + }; +} + // --------------------------------------------------------------------------- // CommandExecutor // --------------------------------------------------------------------------- +class GraphClockInvariantError extends Error { + constructor(specId: number) { + super(`graph_clock invariant failed: spec ${specId} has no clock row`); + this.name = 'GraphClockInvariantError'; + } +} + export class CommandExecutor { constructor(private readonly db: BrunchDb) {} + private createInitialSpecClock(tx: Pick, specId: number): number { + tx.insert(schema.graphClock).values({ spec_id: specId, lsn: 1 }).run(); + return 1; + } + + private bumpExistingSpecLsn(tx: Pick, specId: number): number { + const clock = tx + .update(schema.graphClock) + .set({ lsn: sql`${schema.graphClock.lsn} + 1` }) + .where(eq(schema.graphClock.spec_id, specId)) + .returning({ lsn: schema.graphClock.lsn }) + .get(); + + if (!clock) throw new GraphClockInvariantError(specId); + return clock.lsn; + } + private allocateNodeKindOrdinal( tx: Pick, specId: number, @@ -411,22 +463,17 @@ export class CommandExecutor { if (diagnostics.length > 0) return { status: 'structural_illegal', diagnostics }; return this.db.transaction((tx) => { - const clock = tx - .update(schema.graphClock) - .set({ lsn: sql`${schema.graphClock.lsn} + 1` }) - .where(eq(schema.graphClock.id, 1)) - .returning() - .get(); - const lsn = clock!.lsn; - const row = tx .insert(schema.specs) .values({ name, slug, readiness_grade: readinessGrade }) .returning() .get(); + const lsn = this.createInitialSpecClock(tx, row!.id); + tx.insert(schema.changeLog) .values({ + spec_id: row!.id, lsn, operation: 'create_spec', payload: JSON.stringify({ specId: row!.id, name, slug, readinessGrade }), @@ -437,16 +484,15 @@ export class CommandExecutor { }); } + /** Read all spec rows. */ + listSpecs(): SpecRecord[] { + return this.db.select().from(schema.specs).all().map(specRecordFromRow); + } + /** Read a spec row by id. */ getSpec(specId: number): SpecRecord | undefined { const row = this.db.select().from(schema.specs).where(eq(schema.specs.id, specId)).get(); - if (!row) return undefined; - return { - id: row.id, - name: row.name, - slug: row.slug, - readinessGrade: row.readiness_grade, - }; + return row ? specRecordFromRow(row) : undefined; } /** Update a spec's readiness grade through the command boundary. */ @@ -476,14 +522,7 @@ export class CommandExecutor { }; } - const clock = tx - .update(schema.graphClock) - .set({ lsn: sql`${schema.graphClock.lsn} + 1` }) - .where(eq(schema.graphClock.id, 1)) - .returning() - .get(); - const lsn = clock!.lsn; - + const lsn = this.bumpExistingSpecLsn(tx, input.specId); tx.update(schema.specs) .set({ readiness_grade: input.readinessGrade }) .where(eq(schema.specs.id, input.specId)) @@ -491,6 +530,7 @@ export class CommandExecutor { tx.insert(schema.changeLog) .values({ + spec_id: input.specId, lsn, operation: 'update_spec_readiness_grade', payload: JSON.stringify({ specId: input.specId, readinessGrade: input.readinessGrade }), @@ -529,14 +569,8 @@ export class CommandExecutor { }; } - // 2. Allocate LSN (atomic increment) - const clock = tx - .update(schema.graphClock) - .set({ lsn: sql`${schema.graphClock.lsn} + 1` }) - .where(eq(schema.graphClock.id, 1)) - .returning() - .get(); - const lsn = clock!.lsn; + // 2. Allocate spec-local LSN (atomic within this transaction) + const lsn = this.bumpExistingSpecLsn(tx, input.specId); const kindOrdinal = this.allocateNodeKindOrdinal(tx, input.specId, input.plane, input.kind); // 3. Insert node @@ -562,6 +596,7 @@ export class CommandExecutor { // 4. Append change_log tx.insert(schema.changeLog) .values({ + spec_id: input.specId, lsn, operation: 'create_node', payload: JSON.stringify({ @@ -606,82 +641,133 @@ export class CommandExecutor { return { status: 'structural_illegal' as const, diagnostics: planned.diagnostics }; } - const clock = tx - .update(schema.graphClock) - .set({ lsn: sql`${schema.graphClock.lsn} + 1` }) - .where(eq(schema.graphClock.id, 1)) - .returning() - .get(); - const lsn = clock!.lsn; + return this.writePlannedGraphBatch(tx, input, planned.plan.edges, 'commit_graph'); + }); + } - const createdNodes: Record = {}; - for (const bn of input.nodes) { - const kindOrdinal = this.allocateNodeKindOrdinal(tx, input.specId, bn.plane, bn.kind); - const row = tx - .insert(schema.nodes) - .values({ - spec_id: input.specId, - plane: bn.plane, - kind: bn.kind, - kind_ordinal: kindOrdinal, - title: bn.title, - body: bn.body ?? null, - basis: input.basis ?? 'explicit', - source: bn.source ?? null, - detail: bn.detail != null ? JSON.stringify(bn.detail) : null, - created_at_lsn: lsn, - updated_at_lsn: lsn, - }) - .returning() - .get(); - createdNodes[bn.ref] = formatCreatedGraphNode(row!); - } + /** + * Validate a review-set payload before it becomes user-reviewable. + * + * This performs the same payload translation and graph batch structural + * checks as `acceptReviewSet`, but does not allocate an LSN or mutate graph + * truth. + */ + dryRunAcceptReviewSet(input: AcceptReviewSetInput): AcceptReviewSetDryRunResult { + const translated = translateReviewSetPayloadToCommitGraph({ + db: this.db, + specId: input.specId, + payload: input.payload, + }); + if (translated.status === 'structural_illegal') return translated; + return this.dryRunCommitGraph(translated.command); + } - const resolvePlannedEndpoint = (endpoint: PlannedBatchEndpoint): number => { - if (endpoint.kind === 'existing') return endpoint.ref as number; - return createdNodes[endpoint.ref as string]!.id; - }; + /** + * Atomic acceptance of an exact review-set payload (D27-L/I15-L). + * + * Review-set payloads use projected existing-node codes at the product + * boundary. This command resolves them for the selected spec, validates the + * resulting explicit-basis graph batch, and writes one transaction/change-log + * row with operation `accept_review_set`. + */ + acceptReviewSet(input: AcceptReviewSetInput): AcceptReviewSetResult { + const translated = translateReviewSetPayloadToCommitGraph({ + db: this.db, + specId: input.specId, + payload: input.payload, + }); + if (translated.status === 'structural_illegal') return translated; - const edgeIds: number[] = []; - for (const edge of planned.plan.edges) { - const row = tx - .insert(schema.edges) - .values({ - spec_id: input.specId, - category: edge.category, - source_id: resolvePlannedEndpoint(edge.source), - target_id: resolvePlannedEndpoint(edge.target), - stance: edge.stance, - basis: input.basis ?? 'explicit', - rationale: edge.rationale, - created_at_lsn: lsn, - updated_at_lsn: lsn, - }) - .returning() - .get(); - edgeIds.push(row!.id); + return this.db.transaction((tx) => { + const planned = this.planCommitGraph(translated.command, tx); + if (planned.status === 'structural_illegal') { + return { status: 'structural_illegal' as const, diagnostics: planned.diagnostics }; } - tx.insert(schema.changeLog) + return this.writePlannedGraphBatch(tx, translated.command, planned.plan.edges, 'accept_review_set', { + proposalEntryId: input.proposalEntryId, + }); + }); + } + + private writePlannedGraphBatch( + tx: Pick, + input: CommitGraphInput, + plannedEdges: readonly PlannedBatchEdge[], + operation: 'commit_graph' | 'accept_review_set', + payloadExtras: Record = {}, + ): CommitGraphSuccess { + const lsn = this.bumpExistingSpecLsn(tx, input.specId); + + const createdNodes: Record = {}; + for (const bn of input.nodes) { + const kindOrdinal = this.allocateNodeKindOrdinal(tx, input.specId, bn.plane, bn.kind); + const row = tx + .insert(schema.nodes) .values({ - lsn, - operation: 'commit_graph', - payload: JSON.stringify({ - basis: input.basis ?? 'explicit', - specId: input.specId, - nodes: Object.fromEntries(Object.entries(createdNodes).map(([ref, node]) => [ref, node.id])), - edges: edgeIds, - }), + spec_id: input.specId, + plane: bn.plane, + kind: bn.kind, + kind_ordinal: kindOrdinal, + title: bn.title, + body: bn.body ?? null, + basis: input.basis ?? 'explicit', + source: bn.source ?? null, + detail: bn.detail != null ? JSON.stringify(bn.detail) : null, + created_at_lsn: lsn, + updated_at_lsn: lsn, }) - .run(); + .returning() + .get(); + createdNodes[bn.ref] = formatCreatedGraphNode(row!); + } - return { - status: 'success' as const, + const resolvePlannedEndpoint = (endpoint: PlannedBatchEndpoint): number => { + if (endpoint.kind === 'existing') return endpoint.ref as number; + return createdNodes[endpoint.ref as string]!.id; + }; + + const edgeIds: number[] = []; + for (const edge of plannedEdges) { + const row = tx + .insert(schema.edges) + .values({ + spec_id: input.specId, + category: edge.category, + source_id: resolvePlannedEndpoint(edge.source), + target_id: resolvePlannedEndpoint(edge.target), + stance: edge.stance, + basis: input.basis ?? 'explicit', + rationale: edge.rationale, + created_at_lsn: lsn, + updated_at_lsn: lsn, + }) + .returning() + .get(); + edgeIds.push(row!.id); + } + + tx.insert(schema.changeLog) + .values({ + spec_id: input.specId, lsn, - createdNodes, - edges: edgeIds, - }; - }); + operation, + payload: JSON.stringify({ + ...payloadExtras, + basis: input.basis ?? 'explicit', + specId: input.specId, + nodes: Object.fromEntries(Object.entries(createdNodes).map(([ref, node]) => [ref, node.id])), + edges: edgeIds, + }), + }) + .run(); + + return { + status: 'success', + lsn, + createdNodes, + edges: edgeIds, + }; } private planCommitGraph(input: CommitGraphInput, db: Pick) { @@ -756,14 +842,8 @@ export class CommandExecutor { return { status: 'structural_illegal' as const, diagnostics }; } - // Allocate LSN - const clock = tx - .update(schema.graphClock) - .set({ lsn: sql`${schema.graphClock.lsn} + 1` }) - .where(eq(schema.graphClock.id, 1)) - .returning() - .get(); - const lsn = clock!.lsn; + // Allocate spec-local LSN + const lsn = this.bumpExistingSpecLsn(tx, input.specId); // Insert reconciliation need const row = tx @@ -784,6 +864,7 @@ export class CommandExecutor { // Append change_log tx.insert(schema.changeLog) .values({ + spec_id: input.specId, lsn, operation: 'create_reconciliation_need', payload: JSON.stringify({ @@ -842,14 +923,8 @@ export class CommandExecutor { }; } - // Allocate LSN - const clock = tx - .update(schema.graphClock) - .set({ lsn: sql`${schema.graphClock.lsn} + 1` }) - .where(eq(schema.graphClock.id, 1)) - .returning() - .get(); - const lsn = clock!.lsn; + // Allocate spec-local LSN + const lsn = this.bumpExistingSpecLsn(tx, input.specId); // Update status tx.update(schema.reconciliationNeed) @@ -865,6 +940,7 @@ export class CommandExecutor { // Append change_log tx.insert(schema.changeLog) .values({ + spec_id: input.specId, lsn, operation: 'resolve_reconciliation_need', payload: JSON.stringify({ id: input.id, specId: input.specId }), diff --git a/src/graph/command-executor/accept-review-set.test.ts b/src/graph/command-executor/accept-review-set.test.ts new file mode 100644 index 000000000..5b4932476 --- /dev/null +++ b/src/graph/command-executor/accept-review-set.test.ts @@ -0,0 +1,157 @@ +import { eq } from 'drizzle-orm'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { createDb, type BrunchDb } from '../../db/connection.js'; +import { changeLog, edges, graphClock, nodeKindCounters, nodes, specs } from '../../db/schema.js'; +import { CommandExecutor } from '../command-executor.js'; +import type { ReviewSetProposalPayload } from '../review-set.js'; + +function graphClockLsn(db: BrunchDb, specId: number): number { + return ( + db.select({ lsn: graphClock.lsn }).from(graphClock).where(eq(graphClock.spec_id, specId)).get()?.lsn ?? 0 + ); +} + +function validPayload(overrides: Partial = {}): ReviewSetProposalPayload { + return { + schemaVersion: 1, + lens: 'design', + epistemicStatus: 'asserted', + grounding: { + summary: 'The reviewed graph is grounded in launch planning discussion.', + support: ['The user requested a launch-readiness review set.'], + }, + pitch: { + title: 'Launch readiness review set', + narrative: 'Two exact items and their relationship are ready for review.', + }, + entityDrafts: [ + { draftId: 'goal-launch', plane: 'intent', kind: 'goal', title: 'Launch safely' }, + { draftId: 'req-rollback', plane: 'intent', kind: 'requirement', title: 'Rollback path exists' }, + ], + edgeDrafts: [ + { + category: 'realization', + source: { draftId: 'req-rollback' }, + target: { draftId: 'goal-launch' }, + }, + ], + ...overrides, + }; +} + +describe('CommandExecutor.acceptReviewSet', () => { + let db: BrunchDb; + let executor: CommandExecutor; + let specId: number; + + beforeEach(() => { + db = createDb(':memory:'); + 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('writes all reviewed nodes and edges with explicit basis', () => { + const result = executor.acceptReviewSet({ + specId, + proposalEntryId: 'entry-review-1', + payload: validPayload(), + }); + + expect(result.status).toBe('success'); + expect( + db + .select() + .from(nodes) + .all() + .map((row) => row.basis), + ).toEqual(['explicit', 'explicit']); + expect( + db + .select() + .from(edges) + .all() + .map((row) => row.basis), + ).toEqual(['explicit']); + }); + + it('uses one LSN and one accept_review_set change-log row with proposalEntryId audit metadata', () => { + const result = executor.acceptReviewSet({ + specId, + proposalEntryId: 'tool-result-42', + payload: validPayload(), + }); + + expect(result.status).toBe('success'); + if (result.status !== 'success') throw new Error('unreachable'); + expect(result.lsn).toBe(1); + expect(graphClockLsn(db, specId)).toBe(1); + + const logs = db.select().from(changeLog).all(); + expect(logs).toHaveLength(1); + expect(logs[0]).toMatchObject({ spec_id: specId, lsn: 1, operation: 'accept_review_set' }); + expect(JSON.parse(logs[0]!.payload)).toMatchObject({ + specId, + proposalEntryId: 'tool-result-42', + basis: 'explicit', + nodes: { + 'goal-launch': expect.any(Number), + 'req-rollback': expect.any(Number), + }, + edges: [expect.any(Number)], + }); + }); + + it('leaves graph rows, graph clock, and kind counters unchanged on structural failure', () => { + const before = { + nodes: db.select().from(nodes).all().length, + edges: db.select().from(edges).all().length, + logs: db.select().from(changeLog).all().length, + counters: db.select().from(nodeKindCounters).all().length, + lsn: graphClockLsn(db, specId), + }; + + const result = executor.acceptReviewSet({ + specId, + proposalEntryId: 'bad-entry', + payload: validPayload({ + edgeDrafts: [ + { + category: 'support', + source: { draftId: 'req-rollback' }, + target: { draftId: 'goal-launch' }, + }, + ], + }), + }); + + expect(result.status).toBe('structural_illegal'); + expect(db.select().from(nodes).all()).toHaveLength(before.nodes); + expect(db.select().from(edges).all()).toHaveLength(before.edges); + expect(db.select().from(changeLog).all()).toHaveLength(before.logs); + expect(db.select().from(nodeKindCounters).all()).toHaveLength(before.counters); + expect(graphClockLsn(db, specId)).toBe(before.lsn); + }); + + it('rejects per-item basis and retired accepted_review_set payload fields', () => { + for (const payload of [ + validPayload({ entityDrafts: [{ ...validPayload().entityDrafts[0]!, basis: 'explicit' } as never] }), + validPayload({ + edgeDrafts: [{ ...validPayload().edgeDrafts[0]!, basis: 'accepted_review_set' } as never], + }), + ]) { + const result = executor.acceptReviewSet({ specId, proposalEntryId: 'bad-basis', payload }); + expect(result.status).toBe('structural_illegal'); + } + + expect(db.select().from(nodes).all()).toHaveLength(0); + expect(db.select().from(edges).all()).toHaveLength(0); + expect(db.select().from(changeLog).all()).toHaveLength(0); + expect(db.select().from(nodeKindCounters).all()).toHaveLength(0); + expect(graphClockLsn(db, specId)).toBe(0); + }); +}); diff --git a/src/graph/command-executor/commit-graph-batch.test.ts b/src/graph/command-executor/commit-graph-batch.test.ts index 8c840bf35..19f71fc74 100644 --- a/src/graph/command-executor/commit-graph-batch.test.ts +++ b/src/graph/command-executor/commit-graph-batch.test.ts @@ -1,3 +1,4 @@ +import { eq } from 'drizzle-orm'; import { describe, expect, it, beforeEach } from 'vitest'; import { createDb, type BrunchDb } from '../../db/connection.js'; @@ -9,6 +10,12 @@ function createTestDb(): BrunchDb { return createDb(':memory:'); } +function graphClockLsn(db: BrunchDb, specId: number): number { + return ( + db.select({ lsn: graphClock.lsn }).from(graphClock).where(eq(graphClock.spec_id, specId)).get()?.lsn ?? 0 + ); +} + function expectMatchingStructuralDiagnostics( dryRun: ReturnType, commit: CommitGraphResult, @@ -33,6 +40,7 @@ describe('CommandExecutor commitGraph', () => { .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(); }); // ========================================================================== @@ -84,7 +92,7 @@ describe('CommandExecutor commitGraph', () => { } expect(result?.status).toBe('success'); - expect(db.select().from(graphClock).get()!.lsn).toBe(1); + expect(graphClockLsn(db, specId)).toBe(1); expect(db.select().from(nodes).all()).toHaveLength(1); }); @@ -461,8 +469,7 @@ describe('CommandExecutor commitGraph', () => { expect(result.status).toBe('structural_illegal'); expect(db.select().from(nodes).all()).toHaveLength(0); - const [clock] = db.select().from(graphClock).all(); - expect(clock!.lsn).toBe(0); + expect(graphClockLsn(db, specId)).toBe(0); }); it('if any edge fails validation, no nodes written', () => { @@ -479,8 +486,31 @@ describe('CommandExecutor commitGraph', () => { expect(result.status).toBe('structural_illegal'); expect(db.select().from(nodes).all()).toHaveLength(0); - const [clock] = db.select().from(graphClock).all(); - expect(clock!.lsn).toBe(0); + expect(graphClockLsn(db, specId)).toBe(0); + }); + + it('does not advance the target spec clock when a batch rolls back after sibling-spec mutations', () => { + const otherSpec = executor.createSpec({ name: 'Other Spec', slug: 'other' }); + if (otherSpec.status !== 'success') throw new Error('unreachable'); + executor.commitGraph({ + specId: otherSpec.specId, + nodes: [{ ref: 'other-goal', plane: 'intent', kind: 'goal', title: 'Other goal' }], + edges: [], + }); + + const before = graphClockLsn(db, specId); + const result = executor.commitGraph({ + specId, + nodes: [ + { ref: 'valid', plane: 'intent', kind: 'goal', title: 'Valid goal' }, + { ref: 'invalid', plane: 'intent', kind: 'check', title: 'Invalid kind' }, + ], + edges: [], + }); + + expect(result.status).toBe('structural_illegal'); + expect(graphClockLsn(db, specId)).toBe(before); + expect(graphClockLsn(db, otherSpec.specId)).toBe(2); }); it('rejects supersession cycles against existing edges', () => { diff --git a/src/graph/export-fixtures.test.ts b/src/graph/export-fixtures.test.ts new file mode 100644 index 000000000..9cb6542f6 --- /dev/null +++ b/src/graph/export-fixtures.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; + +import { createDb, type BrunchDb } from '../db/connection.js'; +import { CommandExecutor } from './command-executor.js'; +import { exportSeedFixture, formatSeedFixture } from './export-fixtures.js'; +import { seedFixture, type SeedFixture } from './seed-fixtures.js'; + +function normalizeFixture(fixture: SeedFixture): SeedFixture { + return { + spec: fixture.spec, + nodes: fixture.nodes.map((node) => ({ + local_id: node.local_id, + plane: node.plane, + kind: node.kind, + title: node.title, + body: node.body ?? null, + basis: node.basis ?? 'explicit', + source: node.source ?? null, + detail: node.detail ?? null, + })), + edges: fixture.edges.map((edge) => ({ + category: edge.category, + source_local_id: edge.source_local_id, + target_local_id: edge.target_local_id, + stance: edge.stance ?? null, + basis: edge.basis ?? 'explicit', + rationale: edge.rationale ?? null, + })), + }; +} + +function makeFixture(): SeedFixture { + return { + spec: { + slug: 'curation-export', + name: 'Curation Export', + readiness_grade: 'elicitation_ready', + }, + nodes: [ + { + local_id: 1, + plane: 'intent', + kind: 'goal', + title: 'Capture curated graph truth.', + body: 'The persisted graph can be captured back into reusable seed truth.', + basis: 'explicit', + source: 'manual-test', + detail: null, + }, + { + local_id: 2, + plane: 'intent', + kind: 'term', + title: 'Curated fixture', + body: 'A fixture captured after manual refinement.', + basis: 'explicit', + source: 'manual-test', + detail: { definition: 'A DB-backed graph exported as seed JSON.', aliases: ['reference fixture'] }, + }, + ], + edges: [ + { + category: 'support', + source_local_id: 2, + target_local_id: 1, + stance: 'for', + basis: 'explicit', + rationale: 'The term explains the goal.', + }, + ], + }; +} + +function seed(db: BrunchDb, fixture: SeedFixture): number { + const result = seedFixture(new CommandExecutor(db), fixture); + return result.specId; +} + +describe('exportSeedFixture', () => { + it('captures a persisted spec back into the consolidated seed contract', () => { + const db = createDb(':memory:'); + const fixture = makeFixture(); + const specId = seed(db, fixture); + + expect(exportSeedFixture(db, { specId })).toEqual(normalizeFixture(fixture)); + }); + + it('defaults to graph truth so superseded predecessors remain capturable', () => { + const db = createDb(':memory:'); + const executor = new CommandExecutor(db); + const created = executor.createSpec({ + slug: 'supersession-capture', + name: 'Supersession Capture', + readinessGrade: 'elicitation_ready', + }); + expect(created.status).toBe('success'); + if (created.status !== 'success') return; + + const committed = executor.commitGraph({ + specId: created.specId, + basis: 'explicit', + nodes: [ + { ref: 'old', plane: 'intent', kind: 'requirement', title: 'Old requirement' }, + { ref: 'new', plane: 'intent', kind: 'requirement', title: 'New requirement' }, + ], + edges: [{ category: 'supersession', source: 'new', target: 'old' }], + }); + expect(committed.status).toBe('success'); + + const graphTruth = exportSeedFixture(db, { specId: created.specId }); + const activeContext = exportSeedFixture(db, { specId: created.specId, projection: 'active_context' }); + + expect(graphTruth.nodes.map((node) => node.title)).toEqual(['Old requirement', 'New requirement']); + expect(graphTruth.edges).toHaveLength(1); + expect(activeContext.nodes.map((node) => node.title)).toEqual(['New requirement']); + expect(activeContext.edges).toHaveLength(0); + }); + + it('renders deterministic newline-terminated JSON', () => { + const rendered = formatSeedFixture(makeFixture()); + + expect(rendered).toMatch(/^\{\n "spec": \{/); + expect(rendered.endsWith('\n')).toBe(true); + }); + + it('rejects missing specs', () => { + const db = createDb(':memory:'); + + expect(() => exportSeedFixture(db, { specId: 404 })).toThrow(/spec 404 does not exist/); + }); +}); diff --git a/src/graph/export-fixtures.ts b/src/graph/export-fixtures.ts new file mode 100644 index 000000000..26e0ee640 --- /dev/null +++ b/src/graph/export-fixtures.ts @@ -0,0 +1,181 @@ +/** + * Export a persisted Brunch spec graph back into the consolidated seed-fixture + * contract consumed by `seed-fixtures.ts`. + * + * This is a dev curation tool: use it after manually refining a local SQLite + * workspace so the curated graph can become reusable fixture truth. + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; + +import { eq } from 'drizzle-orm'; + +import { createDb, type BrunchDb } from '../db/connection.js'; +import * as schema from '../db/schema.js'; +import type { SeedFixture, SeedFixtureEdge, SeedFixtureNode } from './seed-fixtures.js'; +import { getGraphOverview, type GraphProjection } from './snapshot.js'; + +export interface ExportSeedFixtureInput { + readonly specId: number; + /** + * Defaults to graph truth so captured fixtures preserve any superseded + * predecessors that remain in accepted graph history. + */ + readonly projection?: GraphProjection; +} + +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 orderedNodes = [...overview.nodes].sort((a, b) => a.id - b.id); + const localIdByNodeId = new Map(orderedNodes.map((node, index) => [node.id, index + 1])); + + const nodes: SeedFixtureNode[] = orderedNodes.map((node, index) => ({ + local_id: index + 1, + plane: node.plane, + kind: node.kind, + title: node.title, + body: node.body ?? null, + basis: node.basis, + source: node.source ?? null, + detail: node.detail ?? null, + })); + + const edges: SeedFixtureEdge[] = [...overview.edges] + .sort((a, b) => a.id - b.id) + .map((edge) => { + const sourceLocalId = localIdByNodeId.get(edge.sourceId); + 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`, + ); + } + return { + category: edge.category, + source_local_id: sourceLocalId, + target_local_id: targetLocalId, + stance: edge.stance ?? null, + basis: edge.basis, + rationale: edge.rationale ?? null, + }; + }); + + return { + spec: { + slug: spec.slug, + name: spec.name, + readiness_grade: spec.readiness_grade, + }, + nodes, + edges, + }; +} + +export function formatSeedFixture(fixture: SeedFixture): string { + return `${JSON.stringify(fixture, null, 2)}\n`; +} + +interface CliArgs { + readonly workspace: string; + readonly specId: number; + readonly out?: string; + readonly projection?: GraphProjection; +} + +function parseCliArgs(argv: readonly string[]): CliArgs { + let workspace = process.cwd(); + let specId: number | undefined; + let out: string | undefined; + let projection: GraphProjection | undefined; + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg == null) throw new Error(`missing argument at index ${index}`); + if (arg === '--workspace' || arg === '-w') { + workspace = requiredValue(argv, ++index, arg); + } else if (arg === '--spec-id') { + specId = parsePositiveInt(requiredValue(argv, ++index, arg), arg); + } else if (arg === '--out' || arg === '-o') { + out = requiredValue(argv, ++index, arg); + } else if (arg === '--projection') { + const value = requiredValue(argv, ++index, arg); + if (value !== 'graph_truth' && value !== 'active_context') { + throw new Error('--projection must be graph_truth or active_context'); + } + projection = value; + } else if (arg === '--help' || arg === '-h') { + throw new UsageRequested(); + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + + if (specId == null) throw new Error('--spec-id is required'); + return { + workspace, + specId, + ...(out === undefined ? {} : { out }), + ...(projection === undefined ? {} : { projection }), + }; +} + +function requiredValue(argv: readonly string[], index: number, flag: string): string { + const value = argv[index]; + if (!value) throw new Error(`${flag} requires a value`); + return value; +} + +function parsePositiveInt(value: string, flag: string): number { + if (!/^[1-9]\d*$/.test(value)) throw new Error(`${flag} must be a positive integer`); + return Number(value); +} + +class UsageRequested extends Error {} + +function usage(): string { + return [ + 'Usage:', + ' tsx src/graph/export-fixtures.ts --workspace --spec-id --out ', + '', + 'Options:', + ' -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)', + ].join('\n'); +} + +async function main(): Promise { + const args = parseCliArgs(process.argv.slice(2)); + const db = createDb(join(resolve(args.workspace), '.brunch', 'data.db')); + const fixture = exportSeedFixture(db, { + specId: args.specId, + ...(args.projection === undefined ? {} : { projection: args.projection }), + }); + const rendered = formatSeedFixture(fixture); + + if (args.out) { + const outPath = resolve(args.out); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, rendered, 'utf8'); + console.log(`wrote ${outPath}`); + } else { + process.stdout.write(rendered); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error: unknown) => { + if (error instanceof UsageRequested) { + console.log(usage()); + return; + } + console.error(error instanceof Error ? error.message : String(error)); + console.error(`\n${usage()}`); + process.exit(1); + }); +} diff --git a/src/graph/index.ts b/src/graph/index.ts index d9c167ed6..a788fd309 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -72,6 +72,9 @@ 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, @@ -105,3 +108,17 @@ export type { 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/review-set.test.ts b/src/graph/review-set.test.ts new file mode 100644 index 000000000..cc9ab290d --- /dev/null +++ b/src/graph/review-set.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; + +import { createDb, type BrunchDb } from '../db/connection.js'; +import { CommandExecutor } from './command-executor.js'; +import { translateReviewSetPayloadToCommitGraph, type ReviewSetProposalPayload } from './review-set.js'; +import { getGraphOverview } from './snapshot.js'; + +function seedSpec(db: BrunchDb): number { + const result = new CommandExecutor(db).createSpec({ name: 'Test Spec', slug: 'test' }); + if (result.status !== 'success') throw new Error('Unable to create test spec'); + return result.specId; +} + +function validPayload(overrides: Partial = {}): ReviewSetProposalPayload { + return { + schemaVersion: 1, + lens: 'design', + epistemicStatus: 'inferred', + grounding: { + summary: 'The launch path is thin but enough to propose acceptance criteria.', + support: ['User accepted a launch-readiness concept.'], + }, + pitch: { + title: 'Launch readiness review set', + narrative: 'A small graph for deciding whether launch can proceed.', + }, + entityDrafts: [ + { + draftId: 'goal-launch', + plane: 'intent', + kind: 'goal', + title: 'Launch safely', + }, + { + draftId: 'req-rollback', + plane: 'intent', + kind: 'requirement', + title: 'Rollback path exists', + }, + { + draftId: 'crit-observable', + plane: 'intent', + kind: 'criterion', + title: 'Operators can observe failures', + }, + ], + edgeDrafts: [ + { + category: 'dependency', + source: { draftId: 'req-rollback' }, + target: { draftId: 'goal-launch' }, + rationale: 'Rollback capability is required for safe launch.', + }, + { + category: 'support', + source: { draftId: 'crit-observable' }, + target: { draftId: 'goal-launch' }, + stance: 'for', + rationale: 'Observability supports a safe launch decision.', + }, + ], + ...overrides, + }; +} + +describe('review-set graph payload translation', () => { + it('turns dry-run-valid review payloads into explicit-basis command input without graph mutation', () => { + const db = createDb(':memory:'); + const executor = new CommandExecutor(db); + const specId = seedSpec(db); + + const result = translateReviewSetPayloadToCommitGraph({ db, specId, payload: validPayload() }); + + expect(result).toMatchObject({ status: 'success' }); + if (result.status !== 'success') throw new Error('unreachable'); + expect(result.command).toMatchObject({ specId, basis: 'explicit' }); + 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 }); + }); + + it('rejects retired relation fields, missing epistemic or grounding data, and invalid edge stance', () => { + const db = createDb(':memory:'); + const specId = seedSpec(db); + const cases: unknown[] = [ + { ...validPayload(), epistemicStatus: undefined }, + { ...validPayload(), grounding: { summary: 'No support.', support: [] } }, + { + ...validPayload(), + edgeDrafts: [ + { + relation: 'supports', + source: { draftId: 'crit-observable' }, + target: { draftId: 'goal-launch' }, + }, + ], + }, + { + ...validPayload(), + edgeDrafts: [ + { + category: 'support', + source: { draftId: 'crit-observable' }, + target: { draftId: 'goal-launch' }, + stance: 'maybe', + }, + ], + }, + ]; + + for (const payload of cases) { + const result = translateReviewSetPayloadToCommitGraph({ db, specId, payload }); + expect(result.status).toBe('structural_illegal'); + } + }); + + it('resolves projected existing-node codes only inside the selected spec', () => { + const db = createDb(':memory:'); + const executor = new CommandExecutor(db); + const specA = seedSpec(db); + const specB = executor.createSpec({ name: 'Other Spec', slug: 'other' }); + if (specB.status !== 'success') throw new Error('unreachable'); + + const existingA = executor.createNode({ + specId: specA, + plane: 'intent', + kind: 'goal', + title: 'Existing goal A', + }); + const existingB = executor.createNode({ + specId: specB.specId, + plane: 'intent', + kind: 'goal', + title: 'Existing goal B', + }); + if (existingA.status !== 'success' || existingB.status !== 'success') throw new Error('unreachable'); + + const valid = translateReviewSetPayloadToCommitGraph({ + db, + specId: specA, + payload: validPayload({ + edgeDrafts: [ + { + category: 'realization', + source: { existingCode: 'G1' }, + target: { draftId: 'req-rollback' }, + }, + ], + }), + }); + expect(valid.status).toBe('success'); + if (valid.status !== 'success') throw new Error('unreachable'); + expect(valid.command.edges[0]!.source).toEqual({ existing: existingA.nodeId }); + + const unresolved = translateReviewSetPayloadToCommitGraph({ + db, + specId: specB.specId, + payload: validPayload({ + edgeDrafts: [ + { + category: 'realization', + source: { existingCode: 'R1' }, + target: { draftId: 'req-rollback' }, + }, + ], + }), + }); + expect(unresolved).toMatchObject({ + status: 'structural_illegal', + diagnostics: [{ field: 'edgeDrafts[0].source.existingCode' }], + }); + }); + + it('rejects raw existing DB ids and per-item basis fields in the review payload contract', () => { + const db = createDb(':memory:'); + const specId = seedSpec(db); + const cases: unknown[] = [ + validPayload({ entityDrafts: [{ ...validPayload().entityDrafts[0]!, basis: 'explicit' } as never] }), + validPayload({ + edgeDrafts: [{ ...validPayload().edgeDrafts[0]!, basis: 'accepted_review_set' } as never], + }), + validPayload({ + edgeDrafts: [ + { + category: 'realization', + source: { existing: 1 }, + target: { draftId: 'goal-launch' }, + } as never, + ], + }), + ]; + + for (const payload of cases) { + const result = translateReviewSetPayloadToCommitGraph({ db, specId, payload }); + expect(result.status).toBe('structural_illegal'); + } + }); +}); diff --git a/src/graph/review-set.ts b/src/graph/review-set.ts new file mode 100644 index 000000000..8b2e55263 --- /dev/null +++ b/src/graph/review-set.ts @@ -0,0 +1,334 @@ +import { and, eq } from 'drizzle-orm'; + +import type { BrunchDb } from '../db/connection.js'; +import * as schema from '../db/schema.js'; +import type { + BatchEdgeInput, + BatchEdgeRef, + BatchNodeInput, + CommitGraphInput, + Diagnostic, + StructuralIllegal, +} from './command-executor.js'; +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'; + +export interface ReviewSetProposalGrounding { + readonly summary: string; + readonly support: readonly string[]; +} + +export interface ReviewSetProposalPitch { + readonly title: string; + readonly narrative: string; +} + +export interface ReviewSetEntityDraft { + readonly draftId: string; + readonly plane: NodePlane; + readonly kind: string; + readonly title: string; + readonly body?: string | undefined; + readonly detail?: unknown; +} + +export type ReviewSetEndpointRef = { readonly draftId: string } | { readonly existingCode: string }; + +export interface ReviewSetEdgeDraft { + readonly category: string; + readonly source: ReviewSetEndpointRef; + readonly target: ReviewSetEndpointRef; + readonly stance?: string | undefined; + readonly rationale?: string | undefined; +} + +export interface ReviewSetProposalPayload { + readonly schemaVersion: 1; + readonly lens: ReviewSetLens; + readonly epistemicStatus: ReviewSetEpistemicStatus; + readonly grounding: ReviewSetProposalGrounding; + readonly pitch: ReviewSetProposalPitch; + readonly entityDrafts: readonly ReviewSetEntityDraft[]; + readonly edgeDrafts: readonly ReviewSetEdgeDraft[]; + readonly proposalVersion?: number | undefined; + readonly supersedes?: string | undefined; +} + +export interface ReviewSetTranslationSuccess { + readonly status: 'success'; + readonly payload: ReviewSetProposalPayload; + readonly command: CommitGraphInput; +} + +export type ReviewSetTranslationResult = ReviewSetTranslationSuccess | StructuralIllegal; + +const VALID_LENSES = ['intent', 'design', 'oracle'] as const; +const VALID_EPISTEMIC_STATUSES = ['inferred', 'assumed', 'asserted', 'observed'] as const; +const VALID_PLANES = ['intent', 'oracle', 'design', 'plan'] as const; +const VALID_CATEGORIES = schema.EDGE_CATEGORIES as unknown as readonly string[]; +const VALID_STANCES = schema.EDGE_STANCES as unknown as readonly string[]; + +export function translateReviewSetPayloadToCommitGraph(options: { + readonly db: Pick; + readonly specId: number; + readonly payload: unknown; +}): ReviewSetTranslationResult { + const diagnostics = validateReviewSetPayloadShape(options.payload); + if (diagnostics.length > 0) return { status: 'structural_illegal', diagnostics }; + + const payload = options.payload as ReviewSetProposalPayload; + const edges: BatchEdgeInput[] = []; + for (let index = 0; index < payload.edgeDrafts.length; index++) { + const edge = payload.edgeDrafts[index]!; + const source = resolveReviewSetEndpoint( + options.db, + options.specId, + edge.source, + `edgeDrafts[${index}].source`, + ); + const target = resolveReviewSetEndpoint( + options.db, + options.specId, + edge.target, + `edgeDrafts[${index}].target`, + ); + if (source.status === 'structural_illegal') diagnostics.push(...source.diagnostics); + if (target.status === 'structural_illegal') diagnostics.push(...target.diagnostics); + if (source.status === 'success' && target.status === 'success') { + edges.push({ + category: edge.category, + source: source.ref, + target: target.ref, + ...(edge.stance !== undefined ? { stance: edge.stance } : {}), + ...(edge.rationale !== undefined ? { rationale: edge.rationale } : {}), + }); + } + } + + if (diagnostics.length > 0) return { status: 'structural_illegal', diagnostics }; + + return { + status: 'success', + payload, + command: { + specId: options.specId, + basis: 'explicit', + nodes: payload.entityDrafts.map(toBatchNodeInput), + edges, + }, + }; +} + +function toBatchNodeInput(draft: ReviewSetEntityDraft): BatchNodeInput { + return { + ref: draft.draftId, + plane: draft.plane, + kind: draft.kind, + title: draft.title, + ...(draft.body !== undefined ? { body: draft.body } : {}), + ...(draft.detail !== undefined ? { detail: draft.detail } : {}), + }; +} + +function validateReviewSetPayloadShape(value: unknown): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + if (!isRecord(value)) return [{ field: 'payload', message: 'review-set payload must be an object' }]; + + if ('basis' in value) + diagnostics.push({ field: 'basis', message: 'review-set payload basis is always explicit' }); + if (value.schemaVersion !== 1) + diagnostics.push({ field: 'schemaVersion', message: 'schemaVersion must be 1' }); + if (!isOneOf(value.lens, VALID_LENSES)) { + diagnostics.push({ field: 'lens', message: 'lens must be intent, design, or oracle' }); + } + if (!isOneOf(value.epistemicStatus, VALID_EPISTEMIC_STATUSES)) { + diagnostics.push({ field: 'epistemicStatus', message: 'epistemicStatus is required' }); + } + + validateGrounding(value.grounding, diagnostics); + validatePitch(value.pitch, diagnostics); + validateEntityDrafts(value.entityDrafts, diagnostics); + validateEdgeDrafts(value.edgeDrafts, diagnostics); + return diagnostics; +} + +function validateGrounding(value: unknown, diagnostics: Diagnostic[]): void { + if (!isRecord(value)) { + diagnostics.push({ field: 'grounding', message: 'grounding is required' }); + return; + } + if (typeof value.summary !== 'string' || value.summary.trim().length === 0) { + diagnostics.push({ field: 'grounding.summary', message: 'summary must be non-empty' }); + } + if (!isNonEmptyStringArray(value.support)) { + diagnostics.push({ field: 'grounding.support', message: 'support must be a non-empty string array' }); + } +} + +function validatePitch(value: unknown, diagnostics: Diagnostic[]): void { + if (!isRecord(value)) { + diagnostics.push({ field: 'pitch', message: 'pitch is required' }); + return; + } + if (typeof value.title !== 'string' || value.title.trim().length === 0) { + diagnostics.push({ field: 'pitch.title', message: 'title must be non-empty' }); + } + if (typeof value.narrative !== 'string' || value.narrative.trim().length === 0) { + diagnostics.push({ field: 'pitch.narrative', message: 'narrative must be non-empty' }); + } +} + +function validateEntityDrafts(value: unknown, diagnostics: Diagnostic[]): void { + if (!Array.isArray(value) || value.length === 0) { + diagnostics.push({ field: 'entityDrafts', message: 'entityDrafts must be non-empty' }); + return; + } + + const seen = new Set(); + value.forEach((draft, index) => { + const path = `entityDrafts[${index}]`; + if (!isRecord(draft)) { + diagnostics.push({ field: path, message: 'entity draft must be an object' }); + return; + } + if ('basis' in draft) + diagnostics.push({ field: `${path}.basis`, message: 'per-item basis is not accepted' }); + if (typeof draft.draftId !== 'string' || draft.draftId.trim().length === 0) { + diagnostics.push({ field: `${path}.draftId`, message: 'draftId must be non-empty' }); + } else if (seen.has(draft.draftId)) { + diagnostics.push({ field: `${path}.draftId`, message: `duplicate draftId "${draft.draftId}"` }); + } else { + seen.add(draft.draftId); + } + if (!isOneOf(draft.plane, VALID_PLANES)) + diagnostics.push({ field: `${path}.plane`, message: 'invalid plane' }); + if (typeof draft.kind !== 'string' || draft.kind.trim().length === 0) { + diagnostics.push({ field: `${path}.kind`, message: 'kind must be non-empty' }); + } + if (typeof draft.title !== 'string' || draft.title.trim().length === 0) { + diagnostics.push({ field: `${path}.title`, message: 'title must be non-empty' }); + } + }); +} + +function validateEdgeDrafts(value: unknown, diagnostics: Diagnostic[]): void { + if (!Array.isArray(value) || value.length === 0) { + diagnostics.push({ field: 'edgeDrafts', message: 'edgeDrafts must be non-empty' }); + return; + } + + value.forEach((draft, index) => { + const path = `edgeDrafts[${index}]`; + if (!isRecord(draft)) { + diagnostics.push({ field: path, message: 'edge draft must be an object' }); + return; + } + if ('relation' in draft) + diagnostics.push({ field: `${path}.relation`, message: 'relation is retired; use category' }); + if ('basis' in draft) + diagnostics.push({ field: `${path}.basis`, message: 'per-item basis is not accepted' }); + if ('sourceDraftId' in draft) { + diagnostics.push({ + field: `${path}.sourceDraftId`, + message: 'sourceDraftId is retired; use source.draftId', + }); + } + if ('targetDraftId' in draft) { + diagnostics.push({ + field: `${path}.targetDraftId`, + message: 'targetDraftId is retired; use target.draftId', + }); + } + if (!isOneOf(draft.category, VALID_CATEGORIES)) + diagnostics.push({ field: `${path}.category`, message: 'invalid edge category' }); + if (draft.stance !== undefined && !isOneOf(draft.stance, VALID_STANCES)) { + diagnostics.push({ field: `${path}.stance`, message: 'invalid stance' }); + } + validateEndpointShape(draft.source, `${path}.source`, diagnostics); + validateEndpointShape(draft.target, `${path}.target`, diagnostics); + }); +} + +function validateEndpointShape(value: unknown, path: string, diagnostics: Diagnostic[]): void { + if (!isRecord(value)) { + diagnostics.push({ field: path, message: 'endpoint must be an object' }); + return; + } + if ('existing' in value) + diagnostics.push({ field: `${path}.existing`, message: 'raw existing DB ids are not accepted' }); + const hasDraft = 'draftId' in value; + const hasCode = 'existingCode' in value; + if (hasDraft === hasCode) { + diagnostics.push({ field: path, message: 'endpoint must have exactly one of draftId or existingCode' }); + return; + } + if (hasDraft && (typeof value.draftId !== 'string' || value.draftId.trim().length === 0)) { + diagnostics.push({ field: `${path}.draftId`, message: 'draftId must be non-empty' }); + } + if (hasCode && (typeof value.existingCode !== 'string' || value.existingCode.trim().length === 0)) { + diagnostics.push({ field: `${path}.existingCode`, message: 'existingCode must be non-empty' }); + } +} + +function resolveReviewSetEndpoint( + db: Pick, + specId: number, + endpoint: ReviewSetEndpointRef, + path: string, +): { readonly status: 'success'; readonly ref: BatchEdgeRef } | StructuralIllegal { + if ('draftId' in endpoint) return { status: 'success', ref: endpoint.draftId }; + + const parsed = parseGraphNodeCode(endpoint.existingCode); + if (!parsed) { + return { + status: 'structural_illegal', + diagnostics: [ + { field: `${path}.existingCode`, message: `unrecognized graph node code "${endpoint.existingCode}"` }, + ], + }; + } + + const row = 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(); + if (!row) { + return { + status: 'structural_illegal', + diagnostics: [ + { + field: `${path}.existingCode`, + message: `graph node code "${endpoint.existingCode}" not found in selected spec ${specId}`, + }, + ], + }; + } + + return { status: 'success', ref: { existing: row.id } }; +} + +function isNonEmptyStringArray(value: unknown): value is readonly string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((item) => typeof item === 'string' && item.trim().length > 0) + ); +} + +function isOneOf(value: unknown, allowed: readonly T[]): value is T { + return typeof value === 'string' && allowed.includes(value as T); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/graph/seed-fixtures.test.ts b/src/graph/seed-fixtures.test.ts index 61682a3a4..2ab69dc3e 100644 --- a/src/graph/seed-fixtures.test.ts +++ b/src/graph/seed-fixtures.test.ts @@ -23,6 +23,12 @@ function loadFixture(slug: string, set = 'bilal-port'): SeedFixture { return JSON.parse(readFileSync(path, 'utf8')) as SeedFixture; } +function graphClockLsn(db: BrunchDb, specId: number): number { + return ( + db.select({ lsn: graphClock.lsn }).from(graphClock).where(eq(graphClock.spec_id, specId)).get()?.lsn ?? 0 + ); +} + describe('seedFixture', () => { it('seeds the code-health fixture into a real DB via the command layer', () => { const db: BrunchDb = createDb(':memory:'); @@ -49,16 +55,15 @@ describe('seedFixture', () => { expect(edgeRows).toHaveLength(fixture.edges.length); expect(nodeRows.every((row) => row.basis === 'explicit')).toBe(true); - // Graph clock advanced once per command: createSpec + commitGraph = lsn 2. - expect(db.select().from(graphClock).get()!.lsn).toBe(2); + // Graph clock advanced once per command for this spec: createSpec + commitGraph = lsn 2. + expect(graphClockLsn(db, result.specId)).toBe(2); - // Change log records both mutations in order. - const ops = db - .select() - .from(changeLog) - .all() - .map((row) => row.operation); - expect(ops).toEqual(['create_spec', 'commit_graph']); + // Change log records both mutations in order for this spec. + const logs = db.select().from(changeLog).all(); + expect(logs.map((row) => [row.spec_id, row.operation])).toEqual([ + [result.specId, 'create_spec'], + [result.specId, 'commit_graph'], + ]); }); it('loads the macro-view grounded-intent variant as explicit intent-only seed truth', () => { @@ -80,6 +85,27 @@ describe('seedFixture', () => { expect(nodeRows.every((row) => row.plane === 'intent' && row.basis === 'explicit')).toBe(true); }); + it('keeps seeded spec LSNs coherent independent of seed order', () => { + const db: BrunchDb = createDb(':memory:'); + const executor = new CommandExecutor(db); + const first = seedFixture(executor, loadFixture('code-health')); + const second = seedFixture(executor, loadFixture('macro-view-grounded-intent', 'bilal-port-variants')); + + expect(graphClockLsn(db, first.specId)).toBe(2); + expect(graphClockLsn(db, second.specId)).toBe(2); + expect( + db + .select({ specId: changeLog.spec_id, lsn: changeLog.lsn, operation: changeLog.operation }) + .from(changeLog) + .all(), + ).toEqual([ + { specId: first.specId, lsn: 1, operation: 'create_spec' }, + { specId: first.specId, lsn: 2, operation: 'commit_graph' }, + { specId: second.specId, lsn: 1, operation: 'create_spec' }, + { specId: second.specId, lsn: 2, operation: 'commit_graph' }, + ]); + }); + it('rejects fixtures carrying a non-explicit basis', () => { const db: BrunchDb = createDb(':memory:'); const executor = new CommandExecutor(db); diff --git a/src/graph/snapshot.test.ts b/src/graph/snapshot.test.ts index 116b10bd3..dde0d2b8b 100644 --- a/src/graph/snapshot.test.ts +++ b/src/graph/snapshot.test.ts @@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createDb, type BrunchDb } from '../db/connection.js'; -import { specs } from '../db/schema.js'; +import { graphClock, specs } from '../db/schema.js'; import { CommandExecutor } from './command-executor.js'; import { NODE_KIND_METADATA, parseGraphNodeCode } from './schema/nodes.js'; import { getGraphOverview, getNodeNeighborhood, getOpenReconciliationNeeds } from './snapshot.js'; @@ -44,6 +44,7 @@ describe('getGraphOverview', () => { .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', () => { @@ -62,6 +63,19 @@ describe('getGraphOverview', () => { 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, @@ -167,6 +181,7 @@ describe('getNodeNeighborhood', () => { .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', () => { @@ -324,6 +339,7 @@ describe('getOpenReconciliationNeeds', () => { .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 array when no needs exist', () => { diff --git a/src/graph/snapshot.ts b/src/graph/snapshot.ts index 384065833..4b3736b8e 100644 --- a/src/graph/snapshot.ts +++ b/src/graph/snapshot.ts @@ -167,7 +167,7 @@ export function getGraphOverview( ) .map(rowToEdge); - const clockRow = db.select().from(schema.graphClock).get(); + const clockRow = db.select().from(schema.graphClock).where(eq(schema.graphClock.spec_id, specId)).get(); const lsn = clockRow?.lsn ?? 0; return { diff --git a/src/probes/fixture-curation-loop.ts b/src/probes/fixture-curation-loop.ts index fc23c77b6..3a77ec3b9 100644 --- a/src/probes/fixture-curation-loop.ts +++ b/src/probes/fixture-curation-loop.ts @@ -6,8 +6,8 @@ import { fileURLToPath } from 'node:url'; import { getAgentDir } from '@earendil-works/pi-coding-agent'; -import { appendBrunchAgentRuntimeSwitch, type BrunchAgentState } from '../.pi/extensions/operational-mode.js'; -import { createBrunchAgentSessionRuntimeFactory } from '../brunch-tui.js'; +import { appendBrunchAgentRuntimeSwitch, type BrunchAgentState } from '../.pi/extensions/runtime/index.js'; +import { createBrunchAgentSessionRuntimeFactory } from '../app/brunch-tui.js'; import { formatGraphNodeCode, openWorkspaceGraphRuntime, diff --git a/src/probes/project-graph-review-cycle-proof.test.ts b/src/probes/project-graph-review-cycle-proof.test.ts new file mode 100644 index 000000000..adb01f862 --- /dev/null +++ b/src/probes/project-graph-review-cycle-proof.test.ts @@ -0,0 +1,268 @@ +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import type { GraphOverview } from '../graph/snapshot.js'; +import type { JsonRpcResponse } from '../rpc/protocol.js'; +import { + summarizeProjectGraphReviewCycleProof, + writeProjectGraphReviewCycleArtifacts, + type ProjectGraphReviewCycleReport, +} from './project-graph-review-cycle-proof.js'; + +const baseOverview: GraphOverview = { + nodes: [ + { + id: 1, + specId: 7, + plane: 'intent', + kind: 'goal', + kindOrdinal: 1, + title: 'Macro view explains derivation history', + basis: 'explicit', + createdAtLsn: 2, + updatedAtLsn: 2, + }, + ], + edges: [], + nodeCount: 1, + edgeCount: 0, + lsn: 2, +}; + +const approvedOverview: GraphOverview = { + nodes: [ + ...baseOverview.nodes, + { + id: 2, + specId: 7, + plane: 'intent', + kind: 'requirement', + kindOrdinal: 1, + title: 'Macro view names impasse resolution state', + basis: 'explicit', + createdAtLsn: 3, + updatedAtLsn: 3, + }, + ], + edges: [ + { + id: 1, + specId: 7, + sourceId: 2, + targetId: 1, + category: 'support', + stance: 'for', + basis: 'explicit', + createdAtLsn: 3, + updatedAtLsn: 3, + }, + ], + nodeCount: 2, + edgeCount: 1, + lsn: 3, +}; + +const runtimeState = { + operationalMode: 'elicit', + agentStrategy: 'project-graph', + agentLens: 'intent', + agentGoal: 'commit-converge', +} as const; + +function toolResultEntry(toolName: string, details: unknown): string { + return JSON.stringify({ + type: 'message', + message: { + role: 'toolResult', + toolName, + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + }, + }); +} + +function presentReviewSetEntry(): string { + return toolResultEntry('present_review_set', { + schema: 'brunch.structured_exchange.present', + v: 1, + exchange_id: 'review-1', + tool_meta: { curr: 'present_review_set', next: 'request_review' }, + display: { heading: 'Derived macro-view requirement' }, + review_set: { + nodes: [ + { + draft_id: 'req-resolution-state', + plane: 'intent', + kind: 'requirement', + title: 'Macro view names impasse resolution state', + }, + ], + edges: [ + { + category: 'support', + source: { draft_id: 'req-resolution-state' }, + target: { existing_code: 'G1' }, + stance: 'for', + }, + ], + }, + }); +} + +function requestReviewEntry(): string { + return toolResultEntry('request_review', { + schema: 'brunch.structured_exchange.request', + v: 1, + exchange_id: 'review-1', + tool_meta: { prev: 'present_review_set', curr: 'request_review' }, + answered: { decision: 'approve', comment: 'Probe approval.' }, + }); +} + +function pendingReviewResponse(): JsonRpcResponse { + return { + jsonrpc: '2.0', + id: 1, + result: { + status: 'pending', + exchange: { + exchangeId: 'review-1', + mode: 'review', + reviewSet: { + nodes: [{ draft_id: 'req-resolution-state' }], + edges: [{ category: 'support' }], + }, + }, + }, + }; +} + +function approvedResponse(): JsonRpcResponse { + return { + jsonrpc: '2.0', + id: 2, + result: { + status: 'accepted', + exchangeId: 'review-1', + answer: { review: { decision: 'approve', comment: 'Probe approval.' } }, + capture: { status: 'no_capture', reason: 'review responses do not run synchronous capture' }, + review: { status: 'approved', lsn: 3, createdNodes: { 'req-resolution-state': { id: 2 } } }, + }, + }; +} + +describe('project-graph review-cycle proof report', () => { + it('requires present_review_set transcript evidence, public RPC approval, and explicit graph readback', () => { + const report = summarizeProjectGraphReviewCycleProof({ + runId: 'project-graph-review-test', + generatedAt: '2026-06-06T00:00:00.000Z', + cwd: '/tmp/brunch-project-graph-review-test', + seedSlug: 'macro-view-grounded-intent', + specId: 7, + sessionId: 'session-1', + prompt: 'Present a review set.', + runtimeState, + model: 'test-model', + sessionText: [presentReviewSetEntry(), requestReviewEntry()].join('\n'), + baseOverview, + finalOverview: approvedOverview, + pendingResponse: pendingReviewResponse(), + approvalResponse: approvedResponse(), + productUpdates: [{ topic: 'graph.overview', specId: 7, lsn: 3 }], + }); + + expect(report.success).toBe(true); + expect(report.toolEvidence).toMatchObject({ + presentReviewSetCount: 1, + requestReviewCount: 1, + successfulPresentReviewSetCount: 1, + }); + expect(report.pendingReview).toMatchObject({ + observed: true, + exchangeId: 'review-1', + nodeDraftCount: 1, + edgeDraftCount: 1, + }); + expect(report.approval).toMatchObject({ attempted: true, status: 'approved', lsn: 3 }); + expect(report.graphDelta).toEqual({ lsnAdvanced: true, nodeDelta: 1, edgeDelta: 1 }); + expect(report.createdNodes).toEqual([ + { + id: 2, + code: 'R1', + plane: 'intent', + kind: 'requirement', + title: 'Macro view names impasse resolution state', + basis: 'explicit', + }, + ]); + expect(report.friction).toEqual([]); + }); + + it('fails closed when the agent never leaves a pending review exchange to approve', () => { + const report = summarizeProjectGraphReviewCycleProof({ + runId: 'project-graph-review-test', + generatedAt: '2026-06-06T00:00:00.000Z', + cwd: '/tmp/brunch-project-graph-review-test', + seedSlug: 'macro-view-grounded-intent', + specId: 7, + sessionId: 'session-1', + prompt: 'Present a review set.', + runtimeState, + sessionText: toolResultEntry('present_review_set', { + status: 'structural_illegal', + diagnostics: [{ field: 'edgeDrafts', message: 'invalid edge category' }], + }), + baseOverview, + finalOverview: baseOverview, + pendingResponse: { jsonrpc: '2.0', id: 1, result: { status: 'idle', exchange: null } }, + }); + + expect(report.success).toBe(false); + expect(report.approval).toEqual({ attempted: false }); + expect(report.friction).toContain( + 'Public RPC did not observe a pending review exchange after the agent turn.', + ); + expect(report.friction).toContain('Review approval was not attempted through public RPC.'); + expect(report.friction).toContain( + 'Graph LSN did not advance for the selected spec after review approval.', + ); + }); + + it('writes session, transcript, report, and graph snapshot artifacts', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'brunch-project-graph-review-artifacts-')); + const report: ProjectGraphReviewCycleReport = summarizeProjectGraphReviewCycleProof({ + runId: 'artifact-run', + generatedAt: '2026-06-06T00:00:00.000Z', + cwd: fixtureRoot, + seedSlug: 'macro-view-grounded-intent', + specId: 7, + sessionId: 'session-1', + prompt: 'Present a review set.', + runtimeState, + sessionText: [presentReviewSetEntry(), requestReviewEntry()].join('\n'), + baseOverview, + finalOverview: approvedOverview, + pendingResponse: pendingReviewResponse(), + approvalResponse: approvedResponse(), + }); + + const artifacts = await writeProjectGraphReviewCycleArtifacts({ + fixtureRoot, + runId: report.runId, + sessionText: [presentReviewSetEntry(), requestReviewEntry()].join('\n'), + report, + graphSnapshot: approvedOverview, + }); + + expect(artifacts.runDir).toBe(join(fixtureRoot, 'runs', 'project-graph-review-cycle', 'artifact-run')); + await expect(readFile(artifacts.sessionJsonl, 'utf8')).resolves.toContain('present_review_set'); + await expect(readFile(artifacts.transcriptMarkdown, 'utf8')).resolves.toContain('## Raw session JSONL'); + await expect(readFile(artifacts.reportJson, 'utf8')).resolves.toContain('project-graph-review-cycle'); + await expect(readFile(artifacts.graphSnapshotJson, 'utf8')).resolves.toContain( + '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 new file mode 100644 index 000000000..8e89c41f0 --- /dev/null +++ b/src/probes/project-graph-review-cycle-proof.ts @@ -0,0 +1,610 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +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 { formatGraphNodeCode } from '../graph/schema/nodes.js'; +import { seedFixture, type SeedFixture } from '../graph/seed-fixtures.js'; +import { createRpcHandlers } from '../rpc/handlers.js'; +import { createProductUpdatePublisher, type ProductUpdate } from '../rpc/product-updates.js'; +import type { JsonRpcResponse } from '../rpc/protocol.js'; +import { renderSessionTranscript } from '../session/session-transcript.js'; +import { createWorkspaceSessionCoordinator } from '../session/workspace-session-coordinator.js'; + +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 { + readonly operationalMode: 'elicit'; + readonly agentStrategy: 'project-graph'; + readonly agentLens: 'intent'; + readonly agentGoal: 'commit-converge'; +} + +export interface ProjectGraphReviewCycleProofOptions { + readonly cwd?: string; + readonly fixtureRoot?: string; + readonly seedSet?: string; + readonly seedSlug?: string; + readonly runId?: string; + readonly prompt?: string; + readonly agentDir?: string; +} + +export interface ProjectGraphReviewCycleArtifacts { + readonly runDir: string; + readonly sessionJsonl: string; + readonly transcriptMarkdown: string; + readonly reportJson: string; + readonly graphSnapshotJson: string; +} + +export interface ReviewCycleToolEvidence { + readonly presentReviewSetCount: number; + readonly requestReviewCount: number; + readonly successfulPresentReviewSetCount: number; + readonly structuralIllegalPresentReviewSetCount: number; +} + +export interface ReviewCycleApprovalEvidence { + readonly attempted: boolean; + readonly status?: + | 'approved' + | 'request_changes' + | 'rejected' + | 'structural_illegal' + | 'rpc_error' + | 'unexpected'; + readonly lsn?: number; + readonly createdNodeRefs?: Record; + readonly diagnostics?: readonly Record[]; + readonly error?: string; +} + +export interface ProjectGraphReviewCycleCreatedNode { + readonly id: number; + readonly code: string; + readonly plane: GraphNode['plane']; + readonly kind: GraphNode['kind']; + readonly title: string; + readonly basis: 'explicit'; +} + +export interface ProjectGraphReviewCycleReport { + readonly schemaVersion: 1; + readonly probeId: typeof PROBE_ID; + readonly runId: string; + readonly generatedAt: string; + readonly mission: string; + readonly evaluationFocus: string; + readonly seedSet: string; + readonly seedSlug: string; + readonly cwd: string; + readonly specId: number; + readonly sessionId: string; + readonly prompt: string; + readonly runtimeState: ProjectGraphReviewRuntimeStateReport; + readonly model?: string; + readonly success: boolean; + readonly baseGraph: { + readonly nodeCount: number; + readonly edgeCount: number; + readonly lsn: number; + }; + readonly finalGraph: { + readonly nodeCount: number; + readonly edgeCount: number; + readonly lsn: number; + readonly explicitNodeCount: number; + readonly explicitEdgeCount: number; + readonly implicitNodeCount: number; + readonly implicitEdgeCount: number; + }; + readonly graphDelta: { + readonly lsnAdvanced: boolean; + readonly nodeDelta: number; + readonly edgeDelta: number; + }; + readonly toolEvidence: ReviewCycleToolEvidence; + readonly pendingReview: { + readonly observed: boolean; + readonly exchangeId?: string; + readonly nodeDraftCount?: number; + readonly edgeDraftCount?: number; + }; + readonly approval: ReviewCycleApprovalEvidence; + readonly createdNodes: readonly ProjectGraphReviewCycleCreatedNode[]; + readonly productUpdates: readonly ProductUpdate[]; + readonly friction: readonly string[]; + readonly artifacts?: ProjectGraphReviewCycleArtifacts; +} + +export interface ProjectGraphReviewCycleSummaryInput { + readonly runId: string; + readonly generatedAt: string; + readonly cwd: string; + readonly seedSet?: string; + readonly seedSlug: string; + readonly specId: number; + readonly sessionId: string; + readonly prompt: string; + readonly runtimeState: ProjectGraphReviewRuntimeStateReport; + readonly model?: string; + readonly sessionText: string; + readonly baseOverview: GraphOverview; + readonly finalOverview: GraphOverview; + readonly pendingResponse?: JsonRpcResponse; + readonly approvalResponse?: JsonRpcResponse; + readonly productUpdates?: readonly ProductUpdate[]; + readonly friction?: readonly string[]; +} + +interface PendingExchangeResult { + readonly status: 'pending' | 'idle'; + readonly exchange: null | { + readonly exchangeId?: unknown; + readonly mode?: unknown; + readonly reviewSet?: unknown; + }; +} + +interface SubmitExchangeResponseResult { + readonly status: 'accepted'; + readonly review?: { + readonly status?: unknown; + readonly lsn?: unknown; + readonly createdNodes?: unknown; + readonly diagnostics?: unknown; + }; +} + +export async function runProjectGraphReviewCycleProof( + options: ProjectGraphReviewCycleProofOptions = {}, +): Promise { + const cwd = resolve(options.cwd ?? (await mkdtemp(join(tmpdir(), 'brunch-project-graph-review-')))); + const fixtureRoot = resolve( + options.fixtureRoot ?? join(dirname(fileURLToPath(import.meta.url)), '../../.fixtures'), + ); + const seedSet = options.seedSet ?? DEFAULT_SEED_SET; + const seedSlug = options.seedSlug ?? DEFAULT_SEED_SLUG; + const runId = options.runId ?? defaultRunId(); + const prompt = options.prompt ?? defaultProjectGraphPrompt(); + const generatedAt = new Date().toISOString(); + const fixture = await readSeedFixture(join(fixtureRoot, 'seeds', seedSet, `${seedSlug}.json`)); + const graph = await openWorkspaceGraphRuntime(cwd); + const seedResult = seedFixture(graph.commandExecutor, fixture); + const gradeResult = graph.commandExecutor.updateReadinessGrade({ + specId: seedResult.specId, + readinessGrade: 'commitments_ready', + }); + if (gradeResult.status !== 'success') { + throw new Error('failed to advance probe spec to commitments_ready'); + } + const baseOverview = graph.forSpec(seedResult.specId).getGraphOverview(); + const coordinator = createWorkspaceSessionCoordinator({ cwd }); + await coordinator.openDefaultWorkspace(); + await selectSpecForSetupSession(cwd, seedResult.specId); + const activated = await coordinator.activateWorkspace({ action: 'newSession', specId: seedResult.specId }); + if (activated.status !== 'ready') { + throw new Error(`project-graph probe could not activate seeded spec: ${activated.status}`); + } + + const runtimeState: BrunchAgentState = { + schemaVersion: 1, + operationalMode: 'elicit', + agentStrategy: 'project-graph', + agentLens: 'intent', + agentGoal: 'commit-converge', + }; + const runtimeStateReport: ProjectGraphReviewRuntimeStateReport = { + operationalMode: 'elicit', + agentStrategy: 'project-graph', + agentLens: 'intent', + agentGoal: 'commit-converge', + }; + appendBrunchAgentRuntimeSwitch(activated.session.manager, runtimeState, 'extension'); + const productUpdates = createProductUpdatePublisher(); + const observedUpdates: ProductUpdate[] = []; + const unsubscribe = productUpdates.subscribe((updates) => observedUpdates.push(...updates)); + const createRuntime = createBrunchAgentSessionRuntimeFactory({ + workspace: activated, + coordinator, + productUpdates, + }); + const created = await createRuntime({ + cwd, + agentDir: options.agentDir ?? getAgentDir(), + sessionManager: activated.session.manager, + }); + const session = created.session; + const friction = created.diagnostics.map((diagnostic) => `${diagnostic.type}: ${diagnostic.message}`); + + try { + await session.sendUserMessage(prompt); + await session.agent.waitForIdle(); + + const handlers = createRpcHandlers({ coordinator, cwd, productUpdates }); + const pendingResponse = await handlers.handle({ + jsonrpc: '2.0', + id: 1, + method: 'session.pendingExchange', + }); + const pending = pendingReviewFromResponse(pendingResponse); + const approvalResponse = pending + ? await handlers.handle({ + jsonrpc: '2.0', + id: 2, + method: 'session.submitExchangeResponse', + params: { + exchangeId: pending.exchangeId, + answer: { review: { decision: 'approve', comment: 'Probe approval.' } }, + }, + }) + : undefined; + + const sessionText = await readFile(activated.session.file, 'utf8'); + const finalOverview = graph.forSpec(seedResult.specId).getGraphOverview(); + let report = summarizeProjectGraphReviewCycleProof({ + runId, + generatedAt, + cwd, + seedSet, + seedSlug, + specId: seedResult.specId, + sessionId: activated.session.id, + prompt, + runtimeState: runtimeStateReport, + ...(session.model?.id !== undefined ? { model: session.model.id } : {}), + sessionText, + baseOverview, + finalOverview, + ...(pendingResponse !== undefined ? { pendingResponse } : {}), + ...(approvalResponse !== undefined ? { approvalResponse } : {}), + productUpdates: observedUpdates, + friction, + }); + + report = { + ...report, + artifacts: await writeProjectGraphReviewCycleArtifacts({ + fixtureRoot, + runId, + sessionText, + report, + graphSnapshot: finalOverview, + }), + }; + return report; + } finally { + unsubscribe(); + session.dispose(); + } +} + +export function summarizeProjectGraphReviewCycleProof( + input: ProjectGraphReviewCycleSummaryInput, +): ProjectGraphReviewCycleReport { + const toolEvidence = reviewCycleToolEvidence(input.sessionText); + const pendingReview = pendingReviewSummary(input.pendingResponse); + const approval = approvalEvidence(input.approvalResponse); + const explicitNodeCount = input.finalOverview.nodes.filter((node) => node.basis === 'explicit').length; + const explicitEdgeCount = input.finalOverview.edges.filter((edge) => edge.basis === 'explicit').length; + const implicitNodeCount = input.finalOverview.nodes.filter((node) => node.basis === 'implicit').length; + const implicitEdgeCount = input.finalOverview.edges.filter((edge) => edge.basis === 'implicit').length; + const createdNodes = input.finalOverview.nodes.flatMap((node): ProjectGraphReviewCycleCreatedNode[] => { + if (node.basis !== 'explicit' || node.createdAtLsn <= input.baseOverview.lsn) return []; + return [ + { + id: node.id, + code: formatGraphNodeCode(node.kind, node.kindOrdinal), + plane: node.plane, + kind: node.kind, + title: node.title, + basis: 'explicit', + }, + ]; + }); + const graphDelta = { + lsnAdvanced: input.finalOverview.lsn > input.baseOverview.lsn, + nodeDelta: input.finalOverview.nodeCount - input.baseOverview.nodeCount, + edgeDelta: input.finalOverview.edgeCount - input.baseOverview.edgeCount, + }; + const friction = [...(input.friction ?? [])]; + + if (toolEvidence.presentReviewSetCount === 0) { + friction.push('No present_review_set tool result was recorded in the session transcript.'); + } + if (toolEvidence.successfulPresentReviewSetCount === 0) { + friction.push('No successful present_review_set details were recorded in the session transcript.'); + } + if (!pendingReview.observed) { + friction.push('Public RPC did not observe a pending review exchange after the agent turn.'); + } + if (!approval.attempted) { + friction.push('Review approval was not attempted through public RPC.'); + } else if (approval.status !== 'approved') { + friction.push(`Public RPC review approval did not succeed; status was ${approval.status ?? 'unknown'}.`); + } + if (!graphDelta.lsnAdvanced) { + friction.push('Graph LSN did not advance for the selected spec after review approval.'); + } + if (graphDelta.nodeDelta <= 0) { + friction.push('Graph node count did not increase after review approval.'); + } + if (createdNodes.length === 0) { + friction.push('No explicit nodes created after the base fixture LSN were present in graph readback.'); + } + + const success = + toolEvidence.successfulPresentReviewSetCount > 0 && + pendingReview.observed && + approval.status === 'approved' && + graphDelta.lsnAdvanced && + graphDelta.nodeDelta > 0 && + createdNodes.length > 0; + + return { + schemaVersion: 1, + probeId: PROBE_ID, + runId: input.runId, + generatedAt: input.generatedAt, + mission: + 'Prove the project-graph strategy can present an exact review set and approve it through public RPC.', + evaluationFocus: + 'FE-809 real agent proposal → present_review_set → session.submitExchangeResponse approval → explicit graph readback.', + seedSet: input.seedSet ?? DEFAULT_SEED_SET, + seedSlug: input.seedSlug, + cwd: input.cwd, + specId: input.specId, + sessionId: input.sessionId, + prompt: input.prompt, + runtimeState: input.runtimeState, + ...(input.model !== undefined ? { model: input.model } : {}), + success, + baseGraph: { + nodeCount: input.baseOverview.nodeCount, + edgeCount: input.baseOverview.edgeCount, + lsn: input.baseOverview.lsn, + }, + finalGraph: { + nodeCount: input.finalOverview.nodeCount, + edgeCount: input.finalOverview.edgeCount, + lsn: input.finalOverview.lsn, + explicitNodeCount, + explicitEdgeCount, + implicitNodeCount, + implicitEdgeCount, + }, + graphDelta, + toolEvidence, + pendingReview, + approval, + createdNodes, + productUpdates: input.productUpdates ?? [], + friction, + }; +} + +export async function writeProjectGraphReviewCycleArtifacts(options: { + readonly fixtureRoot: string; + readonly runId: string; + readonly sessionText: string; + readonly report: ProjectGraphReviewCycleReport; + readonly graphSnapshot: GraphOverview; +}): Promise { + const runDir = join(options.fixtureRoot, 'runs', PROBE_ID, options.runId); + const artifacts: ProjectGraphReviewCycleArtifacts = { + runDir, + sessionJsonl: join(runDir, 'session.jsonl'), + transcriptMarkdown: join(runDir, 'transcript.md'), + reportJson: join(runDir, 'report.json'), + graphSnapshotJson: join(runDir, 'graph-snapshot.json'), + }; + const report = { ...options.report, artifacts }; + + await mkdir(runDir, { recursive: true }); + await writeFile(artifacts.sessionJsonl, options.sessionText, 'utf8'); + await writeFile( + artifacts.transcriptMarkdown, + `${renderSessionTranscript(options.sessionText, { title: 'session.jsonl' })}\n\n## Raw session JSONL\n\n\`\`\`jsonl\n${options.sessionText.trimEnd()}\n\`\`\`\n`, + 'utf8', + ); + await writeFile(artifacts.reportJson, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + await writeFile(artifacts.graphSnapshotJson, `${JSON.stringify(options.graphSnapshot, null, 2)}\n`, 'utf8'); + + return artifacts; +} + +function reviewCycleToolEvidence(sessionText: string): ReviewCycleToolEvidence { + let presentReviewSetCount = 0; + let requestReviewCount = 0; + let successfulPresentReviewSetCount = 0; + let structuralIllegalPresentReviewSetCount = 0; + + for (const message of toolResultMessages(sessionText)) { + if (message.toolName === 'present_review_set') { + presentReviewSetCount += 1; + const details = isRecord(message.details) ? message.details : undefined; + if (details?.status === 'structural_illegal') { + structuralIllegalPresentReviewSetCount += 1; + } else if (details?.schema === 'brunch.structured_exchange.present' && 'review_set' in details) { + successfulPresentReviewSetCount += 1; + } + } + if (message.toolName === 'request_review') { + requestReviewCount += 1; + } + } + + return { + presentReviewSetCount, + requestReviewCount, + successfulPresentReviewSetCount, + structuralIllegalPresentReviewSetCount, + }; +} + +function toolResultMessages(sessionText: string): Record[] { + const messages: Record[] = []; + for (const line of sessionText.split('\n')) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const entry = parseJson(trimmed); + if (!isRecord(entry) || entry.type !== 'message') continue; + const message = entry.message; + if (!isRecord(message) || message.role !== 'toolResult') continue; + messages.push(message); + } + return messages; +} + +function pendingReviewSummary( + response: JsonRpcResponse | undefined, +): ProjectGraphReviewCycleReport['pendingReview'] { + if (!response || !('result' in response)) return { observed: false }; + const result = response.result as PendingExchangeResult; + if (result.status !== 'pending' || !result.exchange || result.exchange.mode !== 'review') { + return { observed: false }; + } + const reviewSet = isRecord(result.exchange.reviewSet) ? result.exchange.reviewSet : undefined; + return { + observed: typeof result.exchange.exchangeId === 'string', + ...(typeof result.exchange.exchangeId === 'string' ? { exchangeId: result.exchange.exchangeId } : {}), + ...(Array.isArray(reviewSet?.nodes) ? { nodeDraftCount: reviewSet.nodes.length } : {}), + ...(Array.isArray(reviewSet?.edges) ? { edgeDraftCount: reviewSet.edges.length } : {}), + }; +} + +function approvalEvidence(response: JsonRpcResponse | undefined): ReviewCycleApprovalEvidence { + if (!response) return { attempted: false }; + if ('error' in response) { + return { attempted: true, status: 'rpc_error', error: response.error.message }; + } + const result = response.result as SubmitExchangeResponseResult; + const review = result.review; + if (!review || typeof review.status !== 'string') { + return { attempted: true, status: 'unexpected' }; + } + if ( + review.status !== 'approved' && + review.status !== 'request_changes' && + review.status !== 'rejected' && + review.status !== 'structural_illegal' + ) { + return { attempted: true, status: 'unexpected' }; + } + return { + attempted: true, + status: review.status, + ...(typeof review.lsn === 'number' ? { lsn: review.lsn } : {}), + ...(isRecord(review.createdNodes) ? { createdNodeRefs: review.createdNodes } : {}), + ...(isRecordArray(review.diagnostics) ? { diagnostics: review.diagnostics } : {}), + }; +} + +function pendingReviewFromResponse(response: JsonRpcResponse): { exchangeId: string } | undefined { + const summary = pendingReviewSummary(response); + return summary.observed && summary.exchangeId ? { exchangeId: summary.exchangeId } : undefined; +} + +async function readSeedFixture(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as SeedFixture; +} + +async function selectSpecForSetupSession(cwd: string, specId: number): Promise { + const path = join(cwd, '.brunch', 'workspace.json'); + const state = JSON.parse(await readFile(path, 'utf8')) as Record; + await writeFile( + path, + `${JSON.stringify( + { + ...state, + current: { specId, sessionId: '' }, + }, + null, + 2, + )}\n`, + 'utf8', + ); +} + +function defaultProjectGraphPrompt(): string { + return `Brunch FE-809 project-graph proof. The selected spec is seeded from "${DEFAULT_SEED_SLUG}" and already has explicit intent-plane graph truth. + +Use read_graph in overview mode to inspect existing node codes. Then use present_review_set exactly once to propose a small exact review set derived from the existing macro-view intent graph. + +Proposal constraints: +- Create one or two new intent-plane requirement or criterion nodes. +- Include at least one edge using category "support" with stance "for" or category "realization". +- When referencing existing graph truth, use existingCode strings from read_graph output, never raw ids. +- Use schemaVersion 1, lens "intent", epistemicStatus "inferred", non-empty grounding.summary, grounding.support, pitch.title, and pitch.narrative. +- Do not call commit_graph. +- Do not call request_review; stop after a successful present_review_set so the external Brunch RPC reviewer can approve it.`; +} + +function defaultRunId(): string { + return new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-'); +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isRecordArray(value: unknown): value is Record[] { + return Array.isArray(value) && value.every(isRecord); +} + +function parseCliArgs(argv: readonly string[]): ProjectGraphReviewCycleProofOptions { + const options: Record = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg !== undefined && arg.startsWith('--')) { + options[arg.slice(2)] = requiredValue(argv, (index += 1), arg); + } + } + return { + ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), + ...(options['fixture-root'] !== undefined ? { fixtureRoot: options['fixture-root'] } : {}), + ...(options['seed-set'] !== undefined ? { seedSet: options['seed-set'] } : {}), + ...(options['seed-slug'] !== undefined ? { seedSlug: options['seed-slug'] } : {}), + ...(options['run-id'] !== undefined ? { runId: options['run-id'] } : {}), + ...(options.prompt !== undefined ? { prompt: options.prompt } : {}), + ...(options['agent-dir'] !== undefined ? { agentDir: options['agent-dir'] } : {}), + }; +} + +function requiredValue(argv: readonly string[], index: number, flag: string): string { + const value = argv[index]; + if (value === undefined) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +async function main(): Promise { + const report = await runProjectGraphReviewCycleProof(parseCliArgs(process.argv.slice(2))); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.exitCode = report.success ? 0 : 1; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/src/probes/propose-graph-commit-proof.ts b/src/probes/propose-graph-commit-proof.ts index c0845b81f..054780cd9 100644 --- a/src/probes/propose-graph-commit-proof.ts +++ b/src/probes/propose-graph-commit-proof.ts @@ -6,8 +6,8 @@ import { fileURLToPath } from 'node:url'; import { getAgentDir } from '@earendil-works/pi-coding-agent'; -import { appendBrunchAgentRuntimeSwitch, type BrunchAgentState } from '../.pi/extensions/operational-mode.js'; -import { createBrunchAgentSessionRuntimeFactory } from '../brunch-tui.js'; +import { appendBrunchAgentRuntimeSwitch, type BrunchAgentState } from '../.pi/extensions/runtime/index.js'; +import { createBrunchAgentSessionRuntimeFactory } from '../app/brunch-tui.js'; import { openWorkspaceGraphRuntime, type CommitGraphSuccess, diff --git a/src/probes/public-rpc-parity-proof.ts b/src/probes/public-rpc-parity-proof.ts index c7b6b338e..e1c6decbf 100644 --- a/src/probes/public-rpc-parity-proof.ts +++ b/src/probes/public-rpc-parity-proof.ts @@ -92,11 +92,9 @@ interface ToolResultOptionDetails { } interface ToolResultDetails { - exchangeId?: string; + exchange_id?: string; schema?: string; - requestTool?: string; - presentTool?: string; - prompt?: string; + display?: { heading?: string }; options?: ToolResultOptionDetails[]; } @@ -237,9 +235,6 @@ export async function runPublicRpcParityProof( if (!richOption) { throw new Error(`Turn ${turn + 1}: pending options dropped content/rationale`); } - if (richOption.content === richOption.label) { - throw new Error(`Turn ${turn + 1}: pending option content collapsed into label`); - } } exchangeIds.push(started.exchange.exchangeId); const response = responseFor(started.exchange); @@ -295,7 +290,7 @@ export async function runPublicRpcParityProof( const presentPrompts = tools .filter((entry) => entry.details?.schema === 'brunch.structured_exchange.present') - .map((entry) => entry.details?.prompt) + .map((entry) => entry.details?.display?.heading) .filter((prompt): prompt is string => prompt !== undefined); if (new Set(presentPrompts).size !== presentPrompts.length) { throw new Error('Public RPC parity proof repeated deterministic prompts'); @@ -308,24 +303,24 @@ export async function runPublicRpcParityProof( ); if (!richOption) { throw new Error( - `Exchange ${entry.details?.exchangeId ?? 'unknown'} JSONL option details dropped content/rationale`, + `Exchange ${entry.details?.exchange_id ?? 'unknown'} JSONL option details dropped content/rationale`, ); } const optionContent = richOption.content; const optionRationale = richOption.rationale; if (optionContent === undefined || optionRationale === undefined) { throw new Error( - `Exchange ${entry.details?.exchangeId ?? 'unknown'} JSONL option details dropped content/rationale`, + `Exchange ${entry.details?.exchange_id ?? 'unknown'} JSONL option details dropped content/rationale`, ); } if (optionContent === richOption.label) { throw new Error( - `Exchange ${entry.details?.exchangeId ?? 'unknown'} JSONL option content collapsed into label`, + `Exchange ${entry.details?.exchange_id ?? 'unknown'} JSONL option content collapsed into label`, ); } if (!entry.content.includes(optionContent) || !entry.content.includes(optionRationale)) { throw new Error( - `Exchange ${entry.details?.exchangeId ?? 'unknown'} transcript markdown dropped option artifacts`, + `Exchange ${entry.details?.exchange_id ?? 'unknown'} transcript markdown dropped option artifacts`, ); } } @@ -333,12 +328,12 @@ export async function runPublicRpcParityProof( for (const exchangeId of exchangeIds) { const presentIndex = tools.findIndex( (entry) => - entry.details?.exchangeId === exchangeId && + entry.details?.exchange_id === exchangeId && entry.details.schema === 'brunch.structured_exchange.present', ); const requestIndex = tools.findIndex( (entry) => - entry.details?.exchangeId === exchangeId && + entry.details?.exchange_id === exchangeId && entry.details.schema === 'brunch.structured_exchange.request', ); if (presentIndex < 0 || requestIndex < 0 || presentIndex > requestIndex) { diff --git a/src/probes/scripts/verify-startup-no-resume.sh b/src/probes/scripts/verify-startup-no-resume.sh index 2cf43fe62..952856261 100755 --- a/src/probes/scripts/verify-startup-no-resume.sh +++ b/src/probes/scripts/verify-startup-no-resume.sh @@ -34,7 +34,7 @@ workspace.session.manager.appendMessage({ console.log(`Seeded stale transcript: ${workspace.session.file}`) NODE -BRUNCH_CMD="cd '$WORK_DIR' && (stty rows 50 cols 100 2>/dev/null || true) && PI_OFFLINE=1 node '$ROOT_DIR/dist/brunch.js' --mode tui" +BRUNCH_CMD="cd '$WORK_DIR' && (stty rows 50 cols 100 2>/dev/null || true) && PI_OFFLINE=1 node '$ROOT_DIR/dist/app/brunch.js' --mode tui" set +e if script --version >/dev/null 2>&1; then diff --git a/src/probes/structured-exchange-ordering-proof.test.ts b/src/probes/structured-exchange-ordering-proof.test.ts index 5116fb136..b420a2556 100644 --- a/src/probes/structured-exchange-ordering-proof.test.ts +++ b/src/probes/structured-exchange-ordering-proof.test.ts @@ -26,21 +26,21 @@ describe('structured-exchange ordering proof', () => { expect(proof.jsonlToolResultOrder).toEqual(['present_options', 'request_choice']); expect(proof.presentDetails).toMatchObject({ schema: 'brunch.structured_exchange.present', - exchangeId: 'ordering-proof', - presentTool: 'present_options', - expectedRequest: { tool: 'request_choice', required: true }, + exchange_id: 'ordering-proof', + tool_meta: { curr: 'present_options', next: 'request_choice' }, + options: [ + { id: 'root', content: 'Keep src/pi-extensions.ts' }, + { id: 'tui', content: 'Move under src/tui-client' }, + ], }); expect(proof.requestDetails).toMatchObject({ schema: 'brunch.structured_exchange.request', - exchangeId: 'ordering-proof', - requestTool: 'request_choice', - status: 'answered', - respondsTo: { - exchangeId: 'ordering-proof', - presentTool: 'present_options', + exchange_id: 'ordering-proof', + tool_meta: { prev: 'present_options', curr: 'request_choice' }, + answered: { + choice: { id: 'tui', label: 'Move under src/tui-client', kind: 'listed' }, + comment: 'Sequential ordering looks safe for the next parity proof.', }, - choice: { id: 'tui', label: 'Move under src/tui-client' }, - comment: 'Sequential ordering looks safe for the next parity proof.', }); }, 20_000); }); diff --git a/src/probes/structured-exchange-ordering-proof.ts b/src/probes/structured-exchange-ordering-proof.ts index b7730735a..e26615821 100644 --- a/src/probes/structured-exchange-ordering-proof.ts +++ b/src/probes/structured-exchange-ordering-proof.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'; import type { StructuredExchangePresentDetails, StructuredExchangeRequestDetails, -} from '../.pi/extensions/structured-exchange/index.js'; +} from '../.pi/extensions/exchanges/index.js'; interface OrderingScenario { mission: string; @@ -150,7 +150,7 @@ export async function runStructuredExchangeOrderingProof( async function writeOrderingExtension(cwd: string): Promise { const extensionPath = join(cwd, 'structured-exchange-ordering-extension.ts'); - const adapterPath = resolve('src/.pi/extensions/structured-exchange/index.ts'); + const adapterPath = resolve('src/.pi/extensions/exchanges/index.ts'); const content = ` import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { diff --git a/src/probes/structured-exchange-rpc-proof.test.ts b/src/probes/structured-exchange-rpc-proof.test.ts index 378f991df..c633eb9f0 100644 --- a/src/probes/structured-exchange-rpc-proof.test.ts +++ b/src/probes/structured-exchange-rpc-proof.test.ts @@ -28,29 +28,13 @@ describe('structured-exchange RPC proof', () => { ], }); expect(proof.terminalDetails).toMatchObject({ - status: 'answered', - mode: 'multi-select', - question: 'Which implementation path should the evaluator choose?', - context: 'Scenario: prove option answers plus notes over Pi RPC.', - options: [ - { label: 'Ship RPC fallback', value: 'rpc-fallback' }, - { label: 'Wait for web relay', value: 'wait-web' }, - { label: 'Escalate blocker', value: 'blocker' }, - ], - answers: [ - { - type: 'option', - label: 'Ship RPC fallback', - value: 'rpc-fallback', - index: 1, - }, - ], - rejectedOptions: [ - { label: 'Wait for web relay', value: 'wait-web' }, - { label: 'Escalate blocker', value: 'blocker' }, - ], - note: 'Proceed, but report any relay friction separately.', - transport: { surface: 'rpc-editor' }, + schema: 'brunch.structured_exchange.request', + v: 1, + tool_meta: { prev: 'present_options', curr: 'request_choices', next: 'capture_choices' }, + answered: { + choices: [{ id: 'rpc-fallback', label: 'Ship RPC fallback', kind: 'listed' }], + comment: 'Proceed, but report any relay friction separately.', + }, probe: { name: 'structured-exchange-rpc-proof', transport: 'pi-rpc-editor', diff --git a/src/probes/structured-exchange-rpc-proof.ts b/src/probes/structured-exchange-rpc-proof.ts index 90c2a1c39..3ae5600dd 100644 --- a/src/probes/structured-exchange-rpc-proof.ts +++ b/src/probes/structured-exchange-rpc-proof.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { StructuredExchangeToolResultDetails } from '../.pi/extensions/structured-exchange/index.js'; +import type { StructuredExchangeToolResultDetails } from '../.pi/extensions/exchanges/index.js'; interface ProbeMetadata { name: string; @@ -16,10 +16,10 @@ interface FrictionReport { frictions: string[]; } -interface TerminalDetails extends StructuredExchangeToolResultDetails { +type TerminalDetails = StructuredExchangeToolResultDetails & { probe: ProbeMetadata; frictionReport: FrictionReport; -} +}; interface ProofResultEntry { customType: string; @@ -135,7 +135,7 @@ export async function runStructuredExchangeRpcProof( async function writeProofExtension(cwd: string): Promise { const extensionPath = join(cwd, 'structured-exchange-rpc-proof-extension.ts'); - const adapterPath = resolve('src/.pi/extensions/structured-exchange/index.ts'); + const adapterPath = resolve('src/.pi/extensions/exchanges/index.ts'); const content = ` import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { diff --git a/src/projections/README.md b/src/projections/README.md new file mode 100644 index 000000000..e97dafd94 --- /dev/null +++ b/src/projections/README.md @@ -0,0 +1,31 @@ +# projections/ — reusable DTO boundaries + +SPEC decisions: D52-L + +## Owns + +Structured DTOs derived from graph, session, workspace, or tool facts when the shape is reused across adapters, renderers, RPC, web, probes, or agent context assembly. + +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. + +## Directory layout + +```pseudo +projections/ + graph/ graph read/command DTO projection + session/ transcript-context and runtime-state DTO projection + structured-exchange/ canonical toolResult.details construction and transcript details → domain DTO adapters + workspace/ workspace/session snapshot DTO projection +``` + +## Dependency direction + +```pseudo +projections/* -> graph/, session/, workspace/ [domain inputs] +projections/ x> .pi/, rpc/, app/, web/ +``` + +Current migration notes: + +- `projections/structured-exchange/*` imports Zod schemas from `.pi/extensions/exchanges/schemas/` because D37-L/D41-L currently place the structured-exchange schema lock at that Pi transcript seam. That is an explicit temporary exception, not a general adapter dependency permission. +- `projections/session/runtime-state.ts` owns flattened runtime-state DTO projection while `session/runtime-state.ts` owns transcript entry facts and append helpers. diff --git a/src/graph/project/commit-result.ts b/src/projections/graph/commit-result.ts similarity index 90% rename from src/graph/project/commit-result.ts rename to src/projections/graph/commit-result.ts index b2f0c22bb..eb3ea7605 100644 --- a/src/graph/project/commit-result.ts +++ b/src/projections/graph/commit-result.ts @@ -9,7 +9,7 @@ * - created refs, diagnostic ordering, and omission policy * * Used by: - * - graph/format/commit-result.ts + * - renderers/graph/commit-result.ts * - .pi/extensions/graph/index.ts via commit_graph tool results */ diff --git a/src/graph/project/neighborhood.ts b/src/projections/graph/neighborhood.ts similarity index 93% rename from src/graph/project/neighborhood.ts rename to src/projections/graph/neighborhood.ts index 4f72899b3..3ee42219c 100644 --- a/src/graph/project/neighborhood.ts +++ b/src/projections/graph/neighborhood.ts @@ -9,13 +9,13 @@ * - omission counts, truncation policy, and not_found normalization * * Used by: - * - graph/format/neighborhood.ts + * - renderers/graph/neighborhood.ts * - .pi/extensions/graph/index.ts via read_graph neighborhood results */ -import { formatGraphNodeCode } from '../schema/nodes.js'; -import type { GraphNode } from '../schema/nodes.js'; -import type { NeighborhoodResult } from '../snapshot.js'; +import { formatGraphNodeCode } from '../../graph/schema/nodes.js'; +import type { GraphNode } from '../../graph/schema/nodes.js'; +import type { NeighborhoodResult } from '../../graph/snapshot.js'; export interface ProjectNeighborhoodOptions { readonly maxNeighbors?: number; diff --git a/src/graph/project/overview.ts b/src/projections/graph/overview.ts similarity index 92% rename from src/graph/project/overview.ts rename to src/projections/graph/overview.ts index 5db3c6d92..217d446d9 100644 --- a/src/graph/project/overview.ts +++ b/src/projections/graph/overview.ts @@ -9,7 +9,7 @@ * - ordered nodes/edges, omission counts, and truncation policy decisions * * Used by: - * - graph/format/overview.ts + * - renderers/graph/overview.ts * - .pi/extensions/graph/index.ts via graph overview tool results * - .pi/extensions/prompting.ts via pushed graph snapshot context */ diff --git a/src/graph/project/reconciliation-needs.ts b/src/projections/graph/reconciliation-needs.ts similarity index 88% rename from src/graph/project/reconciliation-needs.ts rename to src/projections/graph/reconciliation-needs.ts index 9a9eae76f..16fd2ef26 100644 --- a/src/graph/project/reconciliation-needs.ts +++ b/src/projections/graph/reconciliation-needs.ts @@ -9,7 +9,7 @@ * - normalized target references and omission policy * * Future users: - * - graph/format/reconciliation-needs.ts + * - renderers/graph/reconciliation-needs.ts * - pushed prompt snapshots and/or future read tools */ diff --git a/src/projections/session/runtime-policy.ts b/src/projections/session/runtime-policy.ts new file mode 100644 index 000000000..d95d5bc30 --- /dev/null +++ b/src/projections/session/runtime-policy.ts @@ -0,0 +1,94 @@ +import type { + AgentGoalId, + AgentGoalSelection, + AgentLensId, + AgentLensSelection, + AgentRoleId, + AgentStrategyId, + AgentStrategySelection, + BrunchAgentState, + ModelPreference, + OperationalModeId, + PromptPackId, + ThinkingLevel, + ToolPolicyId, +} from '../../session/runtime-state.js'; + +export interface ToolPolicyDefinition { + id: ToolPolicyId; + baseAllowedToolNames: readonly string[]; + blockedToolNames: readonly string[]; +} + +export interface OperationalModeDefinition { + id: OperationalModeId; + defaultRole: AgentRoleId; + allowedRoles: readonly AgentRoleId[]; + toolPolicyId: ToolPolicyId; + promptPackIds: readonly PromptPackId[]; +} + +export interface AgentRoleDefinition { + id: AgentRoleId; + operationalMode: OperationalModeId; + defaultStrategy: AgentStrategySelection; + allowedStrategies: readonly AgentStrategyId[]; + defaultLens: AgentLensSelection; + allowedLenses: readonly AgentLensId[]; + defaultGoal: AgentGoalSelection; + allowedGoals: readonly AgentGoalId[]; + promptPackIds: readonly PromptPackId[]; + modelPreference?: ModelPreference; + thinkingLevel?: ThinkingLevel; +} + +export interface ResolvedBrunchAgentState extends BrunchAgentState { + agentRole: AgentRoleId; + operationalModeDefinition: OperationalModeDefinition; + agentRoleDefinition: AgentRoleDefinition; +} + +export const OPERATIONAL_MODE_DEFINITIONS: Record = { + elicit: { + id: 'elicit', + defaultRole: 'elicitor', + allowedRoles: ['elicitor'], + toolPolicyId: 'elicit-read-only', + promptPackIds: ['brunch-base', 'elicit'], + }, +}; + +export const AGENT_ROLE_DEFINITIONS: Record = { + elicitor: { + id: 'elicitor', + operationalMode: 'elicit', + defaultStrategy: 'auto', + allowedStrategies: [ + 'step-wise-decision-tree', + 'step-wise-disambiguate', + 'propose-graph', + 'project-graph', + ], + defaultLens: 'auto', + allowedLenses: ['intent', 'design', 'oracle'], + defaultGoal: 'grounding-advance', + allowedGoals: ['grounding-advance', 'elicit-expand', 'commit-converge', 'capture-posture'], + promptPackIds: ['elicitor'], + }, +}; + +export const TOOL_POLICY_DEFINITIONS: Record = { + 'elicit-read-only': { + id: 'elicit-read-only', + baseAllowedToolNames: ['read', 'grep', 'find', 'ls'], + blockedToolNames: ['bash', 'edit', 'write'], + }, +}; + +export function toolPolicyForRuntimeState(state: ResolvedBrunchAgentState): ToolPolicyDefinition { + return TOOL_POLICY_DEFINITIONS[state.operationalModeDefinition.toolPolicyId]; +} + +export function isToolBlockedForRuntimeState(state: ResolvedBrunchAgentState, toolName: string): boolean { + return toolPolicyForRuntimeState(state).blockedToolNames.includes(toolName); +} diff --git a/src/projections/session/runtime-state.ts b/src/projections/session/runtime-state.ts new file mode 100644 index 000000000..4e61b9613 --- /dev/null +++ b/src/projections/session/runtime-state.ts @@ -0,0 +1,216 @@ +import type { FileEntry } from '@earendil-works/pi-coding-agent'; + +import { + assertLinearBrunchSessionEnvelope, + type BrunchSessionEnvelope, +} from '../../session/brunch-session-envelope.js'; +import { + DEFAULT_BRUNCH_AGENT_STATE, + latestValidBrunchAgentStateEntryData, + type AgentGoalSelection, + type AgentLensSelection, + type AgentStrategySelection, + type BrunchAgentState, + type FileMention, + type GraphNodeMention, + type OperationalModeId, +} from '../../session/runtime-state.js'; +import { + AGENT_ROLE_DEFINITIONS, + OPERATIONAL_MODE_DEFINITIONS, + type ResolvedBrunchAgentState, +} from './runtime-policy.js'; + +export type { ResolvedBrunchAgentState } from './runtime-policy.js'; +export { AGENT_ROLE_DEFINITIONS, OPERATIONAL_MODE_DEFINITIONS } from './runtime-policy.js'; +export { DEFAULT_BRUNCH_AGENT_STATE } from '../../session/runtime-state.js'; + +export interface RuntimeStateProjection { + status: 'ready'; + specId: number; + sessionId: string; + agent: { + operationalMode: OperationalModeId; + role: ResolvedBrunchAgentState['agentRole']; + strategy: AgentStrategySelection; + lens: AgentLensSelection; + goal: AgentGoalSelection; + }; + mentions: { + graphNodes: GraphNodeMention[]; + files: FileMention[]; + }; + world: { + graph: { + latestLsn: number | null; + }; + git: { + head: string | null; + }; + }; + lifecycle: { + specOrigin: 'new' | 'existing' | null; + sessionOrigin: 'new' | 'resumed' | null; + sessionIndexInSpec: number | null; + isFirstSessionForSpec: boolean | null; + isTenthSessionForSpec: boolean | null; + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isOneOf(value: unknown, allowed: readonly T[]): value is T { + return typeof value === 'string' && allowed.includes(value as T); +} + +export function resolveBrunchAgentState(state: BrunchAgentState): ResolvedBrunchAgentState { + const operationalModeDefinition = OPERATIONAL_MODE_DEFINITIONS[state.operationalMode]; + const agentRole = operationalModeDefinition.defaultRole; + return { + ...state, + agentRole, + operationalModeDefinition, + agentRoleDefinition: AGENT_ROLE_DEFINITIONS[agentRole], + }; +} + +export function projectBrunchAgentState( + entries: readonly { type?: unknown; customType?: unknown; data?: unknown }[], +): ResolvedBrunchAgentState { + return resolveBrunchAgentState( + latestValidBrunchAgentStateEntryData(entries)?.state ?? DEFAULT_BRUNCH_AGENT_STATE, + ); +} + +export function projectSessionRuntimeState(envelope: BrunchSessionEnvelope): RuntimeStateProjection { + assertLinearBrunchSessionEnvelope(envelope); + const agentState = projectBrunchAgentState(envelope.entries); + + return { + status: 'ready', + specId: envelope.binding.specId, + sessionId: envelope.header.id, + agent: { + operationalMode: agentState.operationalMode, + role: agentState.agentRole, + strategy: agentState.agentStrategy, + lens: agentState.agentLens, + goal: agentState.agentGoal, + }, + mentions: projectMentions(envelope.entries), + world: projectWorld(envelope.entries), + lifecycle: projectLifecycle(envelope.entries), + }; +} + +function projectMentions(entries: readonly FileEntry[]): RuntimeStateProjection['mentions'] { + const graphNodes: GraphNodeMention[] = []; + const files: FileMention[] = []; + + for (const entry of entries) { + if (!isRecord(entry) || entry.type !== 'custom') continue; + const customType = entry.customType; + const data = isRecord(entry.data) ? entry.data : undefined; + if (customType === 'brunch.mention' && data) { + const id = stringField(data.entityId) ?? stringField(data.nodeId) ?? stringField(data.id); + if (id) { + const handle = stringField(data.handle); + const title = stringField(data.title); + const seenLsn = integerField(data.snapshottedLsn); + graphNodes.push({ + id, + ...(handle === undefined ? {} : { handle }), + ...(title === undefined ? {} : { title }), + ...(seenLsn === undefined ? {} : { seenLsn }), + }); + } + } + if (customType === 'brunch.file_mention' && data) { + const path = stringField(data.path); + if (path) { + const seenGitHead = stringField(data.gitHead); + files.push({ + path, + ...(seenGitHead === undefined ? {} : { seenGitHead }), + }); + } + } + } + + return { graphNodes, files }; +} + +function projectWorld(entries: readonly FileEntry[]): RuntimeStateProjection['world'] { + let latestGraph: RuntimeStateProjection['world']['graph'] = { + latestLsn: null, + }; + let gitHead: string | null = null; + + for (const entry of entries) { + if (!isRecord(entry) || entry.type !== 'custom') continue; + if (entry.customType !== 'worldUpdate') continue; + const details = isRecord(entry.details) ? entry.details : isRecord(entry.data) ? entry.data : undefined; + if (!details) continue; + + const lsn = integerField(details.currentLsn) ?? integerField(details.changedSinceLsn) ?? null; + latestGraph = { + latestLsn: lsn, + }; + gitHead = stringField(details.gitHead) ?? gitHead; + } + + return { + graph: latestGraph, + git: { head: gitHead }, + }; +} + +function projectLifecycle(entries: readonly FileEntry[]): RuntimeStateProjection['lifecycle'] { + let lifecycle: RuntimeStateProjection['lifecycle'] = { + specOrigin: null, + sessionOrigin: null, + sessionIndexInSpec: null, + isFirstSessionForSpec: null, + isTenthSessionForSpec: null, + }; + + for (const entry of entries) { + if (!isRecord(entry) || entry.type !== 'custom') continue; + if (entry.customType !== 'brunch.session_lifecycle') continue; + const data = isRecord(entry.data) ? entry.data : undefined; + if (!data) continue; + const index = integerField(data.sessionIndexInSpec) ?? lifecycle.sessionIndexInSpec; + const specOrigin = originField(data.specOrigin, ['new', 'existing'] as const) ?? lifecycle.specOrigin; + const sessionOrigin = + originField(data.sessionOrigin, ['new', 'resumed'] as const) ?? lifecycle.sessionOrigin; + lifecycle = { + specOrigin, + sessionOrigin, + sessionIndexInSpec: index, + isFirstSessionForSpec: + booleanField(data.isFirstSessionForSpec) ?? (index === null ? null : index === 1), + isTenthSessionForSpec: + booleanField(data.isTenthSessionForSpec) ?? (index === null ? null : index === 10), + }; + } + + return lifecycle; +} + +function stringField(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function integerField(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) ? value : undefined; +} + +function booleanField(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +function originField(value: unknown, allowed: readonly T[]): T | undefined { + return isOneOf(value, allowed) ? value : undefined; +} diff --git a/src/session/project/transcript-context.ts b/src/projections/session/transcript-context.ts similarity index 98% rename from src/session/project/transcript-context.ts rename to src/projections/session/transcript-context.ts index 52d6e9a99..0a4595054 100644 --- a/src/session/project/transcript-context.ts +++ b/src/projections/session/transcript-context.ts @@ -9,7 +9,7 @@ * - probe-specific filtering policy for which derived messages are worth rendering * * Used by: - * - session/format/transcript.ts + * - renderers/session/transcript.ts * - any future transcript artifact or transcript-equivalence probes */ diff --git a/src/structured-exchange/project/capture-answer.ts b/src/projections/structured-exchange/capture-answer.ts similarity index 80% rename from src/structured-exchange/project/capture-answer.ts rename to src/projections/structured-exchange/capture-answer.ts index 1e7984659..a4c21c87d 100644 --- a/src/structured-exchange/project/capture-answer.ts +++ b/src/projections/structured-exchange/capture-answer.ts @@ -8,7 +8,7 @@ * - normalized captured semantic summary * * Future users: - * - structured-exchange/format/capture-answer.ts + * - renderers/structured-exchange/capture-answer.ts */ export {}; diff --git a/src/structured-exchange/project/capture-candidate.ts b/src/projections/structured-exchange/capture-candidate.ts similarity index 80% rename from src/structured-exchange/project/capture-candidate.ts rename to src/projections/structured-exchange/capture-candidate.ts index f477c6225..c0ff375c4 100644 --- a/src/structured-exchange/project/capture-candidate.ts +++ b/src/projections/structured-exchange/capture-candidate.ts @@ -8,7 +8,7 @@ * - normalized captured semantic summary * * Future users: - * - structured-exchange/format/capture-candidate.ts + * - renderers/structured-exchange/capture-candidate.ts */ export {}; diff --git a/src/structured-exchange/project/capture-choice.ts b/src/projections/structured-exchange/capture-choice.ts similarity index 80% rename from src/structured-exchange/project/capture-choice.ts rename to src/projections/structured-exchange/capture-choice.ts index 055575b64..5b47bd788 100644 --- a/src/structured-exchange/project/capture-choice.ts +++ b/src/projections/structured-exchange/capture-choice.ts @@ -8,7 +8,7 @@ * - normalized captured semantic summary * * Future users: - * - structured-exchange/format/capture-choice.ts + * - renderers/structured-exchange/capture-choice.ts */ export {}; diff --git a/src/structured-exchange/project/capture-choices.ts b/src/projections/structured-exchange/capture-choices.ts similarity index 80% rename from src/structured-exchange/project/capture-choices.ts rename to src/projections/structured-exchange/capture-choices.ts index 5efc55867..3cefb07e3 100644 --- a/src/structured-exchange/project/capture-choices.ts +++ b/src/projections/structured-exchange/capture-choices.ts @@ -8,7 +8,7 @@ * - normalized captured semantic summary * * Future users: - * - structured-exchange/format/capture-choices.ts + * - renderers/structured-exchange/capture-choices.ts */ export {}; diff --git a/src/structured-exchange/project/capture-review.ts b/src/projections/structured-exchange/capture-review.ts similarity index 80% rename from src/structured-exchange/project/capture-review.ts rename to src/projections/structured-exchange/capture-review.ts index 129459928..e62ba841a 100644 --- a/src/structured-exchange/project/capture-review.ts +++ b/src/projections/structured-exchange/capture-review.ts @@ -8,7 +8,7 @@ * - normalized captured semantic summary * * Future users: - * - structured-exchange/format/capture-review.ts + * - renderers/structured-exchange/capture-review.ts */ export {}; diff --git a/src/structured-exchange/project/present-candidates.ts b/src/projections/structured-exchange/present-candidates.ts similarity index 69% rename from src/structured-exchange/project/present-candidates.ts rename to src/projections/structured-exchange/present-candidates.ts index 4bcbde38b..4d14509c6 100644 --- a/src/structured-exchange/project/present-candidates.ts +++ b/src/projections/structured-exchange/present-candidates.ts @@ -8,8 +8,8 @@ * - normalized candidate comparison projection, recommendation cues, and rubric traces * * Future users: - * - structured-exchange/format/present-candidates.ts - * - .pi/extensions/structured-exchange/present-candidates.ts + * - renderers/structured-exchange/present-candidates.ts + * - .pi/extensions/exchanges/present-candidates.ts */ export {}; diff --git a/src/projections/structured-exchange/present-options.ts b/src/projections/structured-exchange/present-options.ts new file mode 100644 index 000000000..544555e14 --- /dev/null +++ b/src/projections/structured-exchange/present-options.ts @@ -0,0 +1,41 @@ +import type { PresentOptionsDetails } from '../../.pi/extensions/exchanges/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA, + zPresentOptionsDetails, + type PresentOptionsParams, +} from '../../.pi/extensions/exchanges/schemas/index.js'; + +export interface PresentOptionsProjection { + readonly heading: string; + readonly body?: string; + readonly details: PresentOptionsDetails; +} + +export function projectPresentOptions(input: PresentOptionsParams): PresentOptionsProjection { + const heading = input.heading.trim(); + const body = normalizeOptionalText(input.body); + const details = zPresentOptionsDetails.parse({ + schema: STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA, + v: 1, + exchange_id: input.exchangeId, + tool_meta: { + curr: 'present_options', + next: input.expectedRequestTool ?? 'request_choice', + }, + display: { + heading, + ...(body ? { body } : {}), + }, + options: input.options.map((option) => ({ + id: option.id, + content: option.content, + ...(option.rationale !== undefined ? { rationale: option.rationale } : {}), + })), + }); + return { heading, ...(body ? { body } : {}), details }; +} + +function normalizeOptionalText(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/src/projections/structured-exchange/present-question.ts b/src/projections/structured-exchange/present-question.ts new file mode 100644 index 000000000..b04d396c3 --- /dev/null +++ b/src/projections/structured-exchange/present-question.ts @@ -0,0 +1,60 @@ +/** + * Canonical projection for `present_question` content. + * + * Input: + * - domain prompt state for a Brunch structured question + * + * Output: + * - normalized heading/body projection plus canonical Zod-authored details + * + * Used by: + * - renderers/structured-exchange/present-question.ts + * - session/structured-exchange-loop.ts + * - .pi/extensions/exchanges/present-question.ts + */ + +import type { PresentQuestionDetails } from '../../.pi/extensions/exchanges/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA, + zPresentQuestionDetails, +} from '../../.pi/extensions/exchanges/schemas/index.js'; + +export interface PresentQuestionProjection { + readonly heading: string; + readonly body?: string; + readonly details: PresentQuestionDetails; +} + +export interface ProjectPresentQuestionInput { + readonly exchangeId: string; + readonly heading: string; + readonly body?: string | undefined; +} + +export function projectPresentQuestion(input: ProjectPresentQuestionInput): PresentQuestionProjection { + const heading = input.heading.trim(); + const body = normalizeOptionalText(input.body); + const details = zPresentQuestionDetails.parse({ + schema: STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA, + v: 1, + exchange_id: input.exchangeId, + tool_meta: { + curr: 'present_question', + next: 'request_answer', + }, + display: { + heading, + ...(body ? { body } : {}), + }, + }); + return { + heading, + ...(body ? { body } : {}), + details, + }; +} + +function normalizeOptionalText(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/src/projections/structured-exchange/present-review-set.ts b/src/projections/structured-exchange/present-review-set.ts new file mode 100644 index 000000000..b7af0aae3 --- /dev/null +++ b/src/projections/structured-exchange/present-review-set.ts @@ -0,0 +1,66 @@ +import type { + PresentReviewSetDetails, + ReviewSetDetailsPayload, +} from '../../.pi/extensions/exchanges/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA, + zPresentReviewSetDetails, +} from '../../.pi/extensions/exchanges/schemas/index.js'; +import type { ReviewSetProposalPayload } from '../../graph/review-set.js'; + +export interface PresentReviewSetProjection { + readonly details: PresentReviewSetDetails; + readonly payload: ReviewSetProposalPayload; +} + +export function projectPresentReviewSet(input: { + readonly exchangeId: string; + readonly payload: ReviewSetProposalPayload; +}): PresentReviewSetProjection { + const body = input.payload.pitch.narrative.trim(); + const details = zPresentReviewSetDetails.parse({ + schema: STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA, + v: 1, + exchange_id: input.exchangeId, + tool_meta: { + curr: 'present_review_set', + next: 'request_review', + }, + display: { + heading: input.payload.pitch.title.trim(), + ...(body ? { body } : {}), + }, + review_set: reviewSetDetailsPayload(input.payload), + }); + return { + details, + payload: input.payload, + }; +} + +function reviewSetDetailsPayload(payload: ReviewSetProposalPayload): ReviewSetDetailsPayload { + return { + nodes: payload.entityDrafts.map((draft) => ({ + draft_id: draft.draftId, + plane: draft.plane, + kind: draft.kind, + title: draft.title, + ...(draft.body !== undefined ? { body: draft.body } : {}), + ...(draft.detail !== undefined ? { detail: draft.detail } : {}), + })), + edges: payload.edgeDrafts.map((draft) => ({ + category: draft.category, + source: endpointRefDetails(draft.source), + target: endpointRefDetails(draft.target), + ...(draft.stance === 'for' || draft.stance === 'against' ? { stance: draft.stance } : {}), + ...(draft.rationale !== undefined ? { rationale: draft.rationale } : {}), + })), + }; +} + +function endpointRefDetails( + value: ReviewSetProposalPayload['edgeDrafts'][number]['source'], +): ReviewSetDetailsPayload['edges'][number]['source'] { + if ('draftId' in value) return { draft_id: value.draftId }; + return { existing_code: value.existingCode }; +} diff --git a/src/projections/structured-exchange/request-answer.ts b/src/projections/structured-exchange/request-answer.ts new file mode 100644 index 000000000..ecc36a756 --- /dev/null +++ b/src/projections/structured-exchange/request-answer.ts @@ -0,0 +1,37 @@ +import type { RequestAnswerDetails } from '../../.pi/extensions/exchanges/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA, + zRequestAnswerDetails, +} from '../../.pi/extensions/exchanges/schemas/index.js'; + +export type { RequestAnswerDetails }; +export function projectRequestAnswer(input: { + readonly exchangeId: string; + readonly status: 'answered' | 'cancelled' | 'unavailable'; + readonly answer?: string | undefined; + readonly message?: string | undefined; +}): RequestAnswerDetails { + const base = { + schema: STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA, + v: 1, + exchange_id: input.exchangeId, + tool_meta: { + prev: 'present_question' as const, + curr: 'request_answer' as const, + }, + }; + if (input.status === 'answered') { + return zRequestAnswerDetails.parse({ + ...base, + tool_meta: { ...base.tool_meta, next: 'capture_answer' as const }, + answered: { text: input.answer?.trim() ?? '' }, + }); + } + if (input.status === 'cancelled') { + return zRequestAnswerDetails.parse({ ...base, cancelled: {} }); + } + return zRequestAnswerDetails.parse({ + ...base, + unavailable: { message: input.message ?? 'request_answer unavailable' }, + }); +} diff --git a/src/projections/structured-exchange/request-choice.ts b/src/projections/structured-exchange/request-choice.ts new file mode 100644 index 000000000..50fe65304 --- /dev/null +++ b/src/projections/structured-exchange/request-choice.ts @@ -0,0 +1,49 @@ +import type { RequestChoiceDetails, SelectedChoice } from '../../.pi/extensions/exchanges/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA, + zRequestChoiceDetails, +} from '../../.pi/extensions/exchanges/schemas/index.js'; + +export type { RequestChoiceDetails, SelectedChoice }; +export type RequestChoicePresentTool = 'present_options' | 'present_candidates'; + +export function projectRequestChoice(input: { + readonly exchangeId: string; + readonly respondsToPresentTool: RequestChoicePresentTool; + readonly status: 'answered' | 'cancelled' | 'unavailable'; + readonly choice?: SelectedChoice | undefined; + readonly comment?: string | undefined; + readonly message?: string | undefined; +}): RequestChoiceDetails { + const base = { + schema: STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA, + v: 1, + exchange_id: input.exchangeId, + tool_meta: { + prev: input.respondsToPresentTool, + curr: 'request_choice' as const, + }, + }; + if (input.status === 'answered') { + const comment = normalizeOptionalText(input.comment); + return zRequestChoiceDetails.parse({ + ...base, + answered: { + choice: input.choice, + ...(comment !== undefined ? { comment } : {}), + }, + }); + } + if (input.status === 'cancelled') { + return zRequestChoiceDetails.parse({ ...base, cancelled: {} }); + } + return zRequestChoiceDetails.parse({ + ...base, + unavailable: { message: input.message ?? 'request_choice unavailable' }, + }); +} + +function normalizeOptionalText(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/src/projections/structured-exchange/request-choices.ts b/src/projections/structured-exchange/request-choices.ts new file mode 100644 index 000000000..b02b8be89 --- /dev/null +++ b/src/projections/structured-exchange/request-choices.ts @@ -0,0 +1,47 @@ +import type { RequestChoicesDetails, SelectedChoice } from '../../.pi/extensions/exchanges/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA, + zRequestChoicesDetails, +} from '../../.pi/extensions/exchanges/schemas/index.js'; + +export type { RequestChoicesDetails, SelectedChoice }; +export function projectRequestChoices(input: { + readonly exchangeId: string; + readonly status: 'answered' | 'cancelled' | 'unavailable'; + readonly choices?: readonly SelectedChoice[] | undefined; + readonly comment?: string | undefined; + readonly message?: string | undefined; +}): RequestChoicesDetails { + const base = { + schema: STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA, + v: 1, + exchange_id: input.exchangeId, + tool_meta: { + prev: 'present_options' as const, + curr: 'request_choices' as const, + }, + }; + if (input.status === 'answered') { + const comment = normalizeOptionalText(input.comment); + return zRequestChoicesDetails.parse({ + ...base, + tool_meta: { ...base.tool_meta, next: 'capture_choices' as const }, + answered: { + choices: input.choices ?? [], + ...(comment !== undefined ? { comment } : {}), + }, + }); + } + if (input.status === 'cancelled') { + return zRequestChoicesDetails.parse({ ...base, cancelled: {} }); + } + return zRequestChoicesDetails.parse({ + ...base, + unavailable: { message: input.message ?? 'request_choices unavailable' }, + }); +} + +function normalizeOptionalText(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/src/projections/structured-exchange/request-review.ts b/src/projections/structured-exchange/request-review.ts new file mode 100644 index 000000000..beba469a9 --- /dev/null +++ b/src/projections/structured-exchange/request-review.ts @@ -0,0 +1,49 @@ +import type { RequestReviewDetails } from '../../.pi/extensions/exchanges/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA, + zRequestReviewDetails, +} from '../../.pi/extensions/exchanges/schemas/index.js'; + +export type { RequestReviewDetails }; +export type ReviewDecision = 'approve' | 'request_changes' | 'reject'; + +export function projectRequestReview(input: { + readonly exchangeId: string; + readonly status: 'answered' | 'cancelled' | 'unavailable'; + readonly review?: ReviewDecision | undefined; + readonly comment?: string | undefined; + readonly message?: string | undefined; +}): RequestReviewDetails { + const base = { + schema: STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA, + v: 1 as const, + exchange_id: input.exchangeId, + tool_meta: { + prev: 'present_review_set' as const, + curr: 'request_review' as const, + }, + }; + if (input.status === 'cancelled') return zRequestReviewDetails.parse({ ...base, cancelled: {} }); + if (input.status === 'unavailable') { + return zRequestReviewDetails.parse({ + ...base, + unavailable: { message: input.message ?? 'request_review requires interactive UI' }, + }); + } + const review = input.review ?? 'reject'; + if (review === 'request_changes') { + return zRequestReviewDetails.parse({ + ...base, + tool_meta: { ...base.tool_meta, next: 'capture_review' }, + answered: { decision: review, comment: input.comment ?? '' }, + }); + } + return zRequestReviewDetails.parse({ + ...base, + tool_meta: { ...base.tool_meta, next: 'capture_review' }, + answered: { + decision: review, + ...(input.comment !== undefined ? { comment: input.comment } : {}), + }, + }); +} diff --git a/src/projections/structured-exchange/review-set-payload.ts b/src/projections/structured-exchange/review-set-payload.ts new file mode 100644 index 000000000..c9df47e58 --- /dev/null +++ b/src/projections/structured-exchange/review-set-payload.ts @@ -0,0 +1,66 @@ +import type { ReviewSetProposalPayload } from '../../graph/review-set.js'; +type ReviewSetDetailsPlane = 'intent' | 'oracle' | 'design' | 'plan'; + +interface ReviewSetDetailsPayload { + readonly nodes: readonly { + readonly draft_id: string; + readonly plane: ReviewSetDetailsPlane; + readonly kind: string; + readonly title: string; + readonly body?: string | undefined; + readonly detail?: unknown; + }[]; + readonly edges: readonly { + readonly category: string; + readonly source: ReviewSetDetailsEndpointRef; + readonly target: ReviewSetDetailsEndpointRef; + readonly stance?: 'for' | 'against' | undefined; + readonly rationale?: string | undefined; + }[]; +} + +type ReviewSetDetailsEndpointRef = { readonly draft_id: string } | { readonly existing_code: string }; + +export function reviewSetProposalPayloadFromDetails(input: { + readonly exchangeId: string; + readonly heading: string; + readonly body?: string | undefined; + readonly reviewSet: ReviewSetDetailsPayload; +}): ReviewSetProposalPayload { + const narrative = input.body?.trim() || input.heading.trim(); + return { + schemaVersion: 1, + lens: 'intent', + epistemicStatus: 'asserted', + grounding: { + summary: narrative, + support: [`present_review_set:${input.exchangeId}`], + }, + pitch: { + title: input.heading.trim(), + narrative, + }, + entityDrafts: input.reviewSet.nodes.map((draft) => ({ + draftId: draft.draft_id, + plane: draft.plane, + kind: draft.kind, + title: draft.title, + ...(draft.body !== undefined ? { body: draft.body } : {}), + ...(draft.detail !== undefined ? { detail: draft.detail } : {}), + })), + edgeDrafts: input.reviewSet.edges.map((draft) => ({ + category: draft.category, + source: endpointRefFromDetails(draft.source), + target: endpointRefFromDetails(draft.target), + ...(draft.stance !== undefined ? { stance: draft.stance } : {}), + ...(draft.rationale !== undefined ? { rationale: draft.rationale } : {}), + })), + }; +} + +function endpointRefFromDetails( + value: ReviewSetDetailsPayload['edges'][number]['source'], +): ReviewSetProposalPayload['edgeDrafts'][number]['source'] { + if ('draft_id' in value) return { draftId: value.draft_id }; + return { existingCode: value.existing_code }; +} diff --git a/src/projections/topology-boundaries.test.ts b/src/projections/topology-boundaries.test.ts new file mode 100644 index 000000000..a654aa975 --- /dev/null +++ b/src/projections/topology-boundaries.test.ts @@ -0,0 +1,80 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const ROOT = process.cwd(); +const SOURCE_ROOT = 'src'; +const PROJECTIONS_ROOT = 'src/projections'; +const RENDERERS_ROOT = 'src/renderers'; +const ADAPTER_IMPORT_SEGMENTS = ['/.pi/', '/rpc/', '/app/', '/web/']; +const PROJECTION_ADAPTER_EXCEPTIONS: Record = { + 'src/projections/structured-exchange/present-options.ts': true, + 'src/projections/structured-exchange/present-question.ts': true, + 'src/projections/structured-exchange/present-review-set.ts': true, + 'src/projections/structured-exchange/request-answer.ts': true, + 'src/projections/structured-exchange/request-choice.ts': true, + 'src/projections/structured-exchange/request-choices.ts': true, + 'src/projections/structured-exchange/request-review.ts': true, +}; + +function sourceFilesUnder(path: string): string[] { + const full = join(ROOT, path); + const entries = readdirSync(full); + const files: string[] = []; + for (const entry of entries) { + const absolute = join(full, entry); + const relativePath = relative(ROOT, absolute); + if (statSync(absolute).isDirectory()) { + files.push(...sourceFilesUnder(relativePath)); + } else if (entry.endsWith('.ts') || entry.endsWith('.tsx')) { + files.push(relativePath); + } + } + return files.sort(); +} + +function importedSourcePaths(file: string): string[] { + const source = readFileSync(join(ROOT, file), 'utf8'); + const directory = file.slice(0, file.lastIndexOf('/')); + const imports = [...source.matchAll(/from ['"](\.{1,2}\/[^'"]+)['"]/g)].flatMap((match) => + match[1] ? [match[1]] : [], + ); + return imports + .map((specifier) => join(directory, specifier.replace(/\.js$/, '.ts'))) + .map((path) => relative(ROOT, join(ROOT, path)).replaceAll('\\', '/')) + .filter((path) => path.startsWith(SOURCE_ROOT)); +} + +describe('projection and renderer topology boundaries', () => { + it('keeps reusable projections out of adapter and transport layers', () => { + const offenders = sourceFilesUnder(PROJECTIONS_ROOT).flatMap((file) => { + if (PROJECTION_ADAPTER_EXCEPTIONS[file]) return []; + const imports = importedSourcePaths(file).filter((path) => + ADAPTER_IMPORT_SEGMENTS.some((segment) => `/${path}`.includes(segment)), + ); + return imports.map((path) => `${file} -> ${path}`); + }); + + expect(offenders).toEqual([]); + }); + + it('keeps reusable renderers out of adapter and transport layers', () => { + const offenders = sourceFilesUnder(RENDERERS_ROOT).flatMap((file) => { + const imports = importedSourcePaths(file).filter((path) => + ADAPTER_IMPORT_SEGMENTS.some((segment) => `/${path}`.includes(segment)), + ); + return imports.map((path) => `${file} -> ${path}`); + }); + + expect(offenders).toEqual([]); + }); + it('keeps runtime-state transcript facts from importing reusable runtime projections', () => { + expect(importedSourcePaths('src/session/runtime-state.ts')).not.toContain( + 'src/projections/session/runtime-state.ts', + ); + expect(importedSourcePaths('src/session/runtime-state.ts')).not.toContain( + 'src/projections/session/runtime-policy.ts', + ); + }); +}); diff --git a/src/print-snapshot.ts b/src/projections/workspace/workspace-snapshot.ts similarity index 56% rename from src/print-snapshot.ts rename to src/projections/workspace/workspace-snapshot.ts index 0356a3453..00b0e030f 100644 --- a/src/print-snapshot.ts +++ b/src/projections/workspace/workspace-snapshot.ts @@ -1,4 +1,4 @@ -import type { WorkspaceSessionState } from './session/workspace-session-coordinator.js'; +import type { WorkspaceSessionState } from '../../session/workspace-session-coordinator.js'; export interface WorkspaceSnapshot { status: WorkspaceSessionState['status']; @@ -43,23 +43,3 @@ export function workspaceSnapshotFromState(state: WorkspaceSessionState): Worksp return base; } - -export function renderWorkspaceSnapshot(snapshot: WorkspaceSnapshot): string { - const lines = [ - 'Brunch workspace snapshot', - `status: ${snapshot.status}`, - `cwd: ${snapshot.cwd}`, - `spec: ${snapshot.spec ? `${snapshot.spec.title} (${snapshot.spec.id})` : ''}`, - `phase: ${snapshot.chrome.phase}`, - `chatMode: ${snapshot.chrome.chatMode}`, - ]; - - if (snapshot.session) { - lines.push(`session: ${snapshot.session.id}`, `sessionFile: ${snapshot.session.file}`); - } - if (snapshot.reason) { - lines.push(`reason: ${snapshot.reason}`); - } - - return `${lines.join('\n')}\n`; -} diff --git a/src/renderers/README.md b/src/renderers/README.md new file mode 100644 index 000000000..69778fe29 --- /dev/null +++ b/src/renderers/README.md @@ -0,0 +1,28 @@ +# renderers/ — reusable lossy text rendering + +SPEC decisions: D52-L + +## Owns + +Reusable lossy renderers that turn domain or projection inputs into markdown, compact text, TOON-like summaries, or toolResult content text. + +Renderers may import input types from `projections/`, `graph/`, or `session/`, but they do not construct canonical DTOs, register Pi tools, handle RPC, or import web/app adapters. + +## Directory layout + +```pseudo +renderers/ + markdown.ts shared markdown helpers + toon.ts compact structured-data rendering stub + graph/ graph overview/neighborhood/command markdown + session/ transcript markdown + structured-exchange/ durable structured-exchange markdown + workspace/ print-mode workspace snapshot text +``` + +## Dependency direction + +```pseudo +renderers/* -> projections/, graph/, session/ [input types/data] +renderers/ x> .pi/, rpc/, app/, web/ +``` diff --git a/src/graph/format/commit-result.ts b/src/renderers/graph/commit-result.ts similarity index 81% rename from src/graph/format/commit-result.ts rename to src/renderers/graph/commit-result.ts index 20f5f20f3..c580d01ad 100644 --- a/src/graph/format/commit-result.ts +++ b/src/renderers/graph/commit-result.ts @@ -2,7 +2,7 @@ * Formats projected commit_graph mutation results into model-facing text. * * Input: - * - projected output from graph/project/commit-result.ts + * - projected output from projections/graph/commit-result.ts * * Output: * - markdown summary for success or structural diagnostics diff --git a/src/graph/format/neighborhood.ts b/src/renderers/graph/neighborhood.ts similarity index 87% rename from src/graph/format/neighborhood.ts rename to src/renderers/graph/neighborhood.ts index 94d3300c6..b12280165 100644 --- a/src/graph/format/neighborhood.ts +++ b/src/renderers/graph/neighborhood.ts @@ -2,18 +2,18 @@ * Formats projected node neighborhood snapshots into model-facing text. * * Input: - * - projected output from graph/project/neighborhood.ts + * - projected output from projections/graph/neighborhood.ts * * Output: * - markdown-framed TOON or equivalent compact text for LLM consumption * * Replaces/adapts: - * - agents/contexts/node.ts + * - .pi/agents/contexts/node.ts * - .pi/extensions/graph/index.ts neighborhood result formatting */ -import { markdownBullet } from '../../render/markdown.js'; -import type { ProjectedNeighborhood } from '../project/neighborhood.js'; +import type { ProjectedNeighborhood } from '../../projections/graph/neighborhood.js'; +import { markdownBullet } from '../markdown.js'; export function formatNeighborhood(projection: ProjectedNeighborhood): string { if (projection.status === 'not_found') { diff --git a/src/graph/format/overview.ts b/src/renderers/graph/overview.ts similarity index 75% rename from src/graph/format/overview.ts rename to src/renderers/graph/overview.ts index bb1ba990e..5ae6baa04 100644 --- a/src/graph/format/overview.ts +++ b/src/renderers/graph/overview.ts @@ -2,13 +2,13 @@ * Formats projected graph overview snapshots into model-facing text. * * Input: - * - projected output from graph/project/overview.ts + * - projected output from projections/graph/overview.ts * * Output: * - markdown-framed TOON or equivalent compact text for LLM consumption * * Replaces/adapts: - * - agents/contexts/graph.ts + * - .pi/agents/contexts/graph.ts * - .pi/extensions/graph/command-adapter.ts overview formatting */ diff --git a/src/graph/format/reconciliation-needs.ts b/src/renderers/graph/reconciliation-needs.ts similarity index 81% rename from src/graph/format/reconciliation-needs.ts rename to src/renderers/graph/reconciliation-needs.ts index d3833d6ff..4a90e6a54 100644 --- a/src/graph/format/reconciliation-needs.ts +++ b/src/renderers/graph/reconciliation-needs.ts @@ -2,7 +2,7 @@ * Formats projected reconciliation-need snapshots into model-facing text. * * Input: - * - projected output from graph/project/reconciliation-needs.ts + * - projected output from projections/graph/reconciliation-needs.ts * * Output: * - markdown-framed TOON or equivalent compact text for LLM consumption diff --git a/src/render/markdown.ts b/src/renderers/markdown.ts similarity index 91% rename from src/render/markdown.ts rename to src/renderers/markdown.ts index c6dabb079..a164c6077 100644 --- a/src/render/markdown.ts +++ b/src/renderers/markdown.ts @@ -7,9 +7,9 @@ * - no graph/session/exchange domain semantics * * Future callers: - * - graph/format/* - * - session/format/* - * - structured-exchange/format/* + * - renderers/graph/* + * - renderers/session/* + * - renderers/structured-exchange/* */ export function markdownHeading(level: number, text: string): string { diff --git a/src/session/format/transcript.ts b/src/renderers/session/transcript.ts similarity index 92% rename from src/session/format/transcript.ts rename to src/renderers/session/transcript.ts index 78d543e44..6cda13b38 100644 --- a/src/session/format/transcript.ts +++ b/src/renderers/session/transcript.ts @@ -2,7 +2,7 @@ * Formats projected transcript context into probe transcript markdown. * * Input: - * - projected output from session/project/transcript-context.ts + * - projected output from projections/session/transcript-context.ts * * Output: * - transcript.md artifact aligned with Pi-derived LLM-visible content @@ -21,7 +21,7 @@ import type { UserMessage, } from '@earendil-works/pi-ai'; -import type { ProjectedTranscriptContext } from '../project/transcript-context.js'; +import type { ProjectedTranscriptContext } from '../../projections/session/transcript-context.js'; export function formatTranscript( context: ProjectedTranscriptContext, diff --git a/src/structured-exchange/format/capture-answer.ts b/src/renderers/structured-exchange/capture-answer.ts similarity index 68% rename from src/structured-exchange/format/capture-answer.ts rename to src/renderers/structured-exchange/capture-answer.ts index 790178238..57038232b 100644 --- a/src/structured-exchange/format/capture-answer.ts +++ b/src/renderers/structured-exchange/capture-answer.ts @@ -2,7 +2,7 @@ * Formats projected `capture_answer` analysis into durable markdown. * * Input: - * - projected output from structured-exchange/project/capture-answer.ts + * - projected output from projections/structured-exchange/capture-answer.ts * * Output: * - capture-side markdown for toolResult.content diff --git a/src/structured-exchange/format/capture-candidate.ts b/src/renderers/structured-exchange/capture-candidate.ts similarity index 68% rename from src/structured-exchange/format/capture-candidate.ts rename to src/renderers/structured-exchange/capture-candidate.ts index 8ebaad476..2c4da00e3 100644 --- a/src/structured-exchange/format/capture-candidate.ts +++ b/src/renderers/structured-exchange/capture-candidate.ts @@ -2,7 +2,7 @@ * Formats projected `capture_candidate` analysis into durable markdown. * * Input: - * - projected output from structured-exchange/project/capture-candidate.ts + * - projected output from projections/structured-exchange/capture-candidate.ts * * Output: * - capture-side markdown for toolResult.content diff --git a/src/structured-exchange/format/capture-choice.ts b/src/renderers/structured-exchange/capture-choice.ts similarity index 68% rename from src/structured-exchange/format/capture-choice.ts rename to src/renderers/structured-exchange/capture-choice.ts index f073493c7..c1bc0a246 100644 --- a/src/structured-exchange/format/capture-choice.ts +++ b/src/renderers/structured-exchange/capture-choice.ts @@ -2,7 +2,7 @@ * Formats projected `capture_choice` analysis into durable markdown. * * Input: - * - projected output from structured-exchange/project/capture-choice.ts + * - projected output from projections/structured-exchange/capture-choice.ts * * Output: * - capture-side markdown for toolResult.content diff --git a/src/structured-exchange/format/capture-choices.ts b/src/renderers/structured-exchange/capture-choices.ts similarity index 68% rename from src/structured-exchange/format/capture-choices.ts rename to src/renderers/structured-exchange/capture-choices.ts index aaf55ed59..0eb763cb7 100644 --- a/src/structured-exchange/format/capture-choices.ts +++ b/src/renderers/structured-exchange/capture-choices.ts @@ -2,7 +2,7 @@ * Formats projected `capture_choices` analysis into durable markdown. * * Input: - * - projected output from structured-exchange/project/capture-choices.ts + * - projected output from projections/structured-exchange/capture-choices.ts * * Output: * - capture-side markdown for toolResult.content diff --git a/src/structured-exchange/format/capture-review.ts b/src/renderers/structured-exchange/capture-review.ts similarity index 68% rename from src/structured-exchange/format/capture-review.ts rename to src/renderers/structured-exchange/capture-review.ts index 957c50396..79cdfbc9b 100644 --- a/src/structured-exchange/format/capture-review.ts +++ b/src/renderers/structured-exchange/capture-review.ts @@ -2,7 +2,7 @@ * Formats projected `capture_review` analysis into durable markdown. * * Input: - * - projected output from structured-exchange/project/capture-review.ts + * - projected output from projections/structured-exchange/capture-review.ts * * Output: * - capture-side markdown for toolResult.content diff --git a/src/structured-exchange/format/present-candidates.ts b/src/renderers/structured-exchange/present-candidates.ts similarity index 69% rename from src/structured-exchange/format/present-candidates.ts rename to src/renderers/structured-exchange/present-candidates.ts index 659cdc537..c1d24b510 100644 --- a/src/structured-exchange/format/present-candidates.ts +++ b/src/renderers/structured-exchange/present-candidates.ts @@ -2,7 +2,7 @@ * Formats projected `present_candidates` data into durable markdown. * * Input: - * - projected output from structured-exchange/project/present-candidates.ts + * - projected output from projections/structured-exchange/present-candidates.ts * * Output: * - durable candidate-comparison markdown for toolResult.content diff --git a/src/renderers/structured-exchange/present-options.ts b/src/renderers/structured-exchange/present-options.ts new file mode 100644 index 000000000..338fd5616 --- /dev/null +++ b/src/renderers/structured-exchange/present-options.ts @@ -0,0 +1,18 @@ +import type { PresentOptionsProjection } from '../../projections/structured-exchange/present-options.js'; + +function markdownEscape(text: string): string { + return text.replace(/([\\`*_{}[\]()#+\-.!|>])/g, '\\$1'); +} + +export function formatPresentOptions(projection: PresentOptionsProjection): string { + const lines = [`## ${projection.heading.trim()}`]; + const body = projection.body?.trim(); + if (body) lines.push('', body); + projection.details.options.forEach((option, index) => { + lines.push('', `### ${index + 1}. ${option.content.trim()}`); + const rationale = option.rationale?.trim(); + if (rationale) lines.push('', `**Rationale:** ${rationale}`); + lines.push('', ``); + }); + return lines.join('\n'); +} diff --git a/src/structured-exchange/format/present-question.ts b/src/renderers/structured-exchange/present-question.ts similarity index 56% rename from src/structured-exchange/format/present-question.ts rename to src/renderers/structured-exchange/present-question.ts index ea3d99346..e80d23b18 100644 --- a/src/structured-exchange/format/present-question.ts +++ b/src/renderers/structured-exchange/present-question.ts @@ -2,14 +2,14 @@ * Formats projected `present_question` data into durable markdown. * * Input: - * - projected output from structured-exchange/project/present-question.ts + * - projected output from projections/structured-exchange/present-question.ts * * Output: * - durable prompt-side markdown for toolResult.content */ -import { joinMarkdownBlocks, markdownHeading } from '../../render/markdown.js'; -import type { PresentQuestionProjection } from '../project/present-question.js'; +import type { PresentQuestionProjection } from '../../projections/structured-exchange/present-question.js'; +import { joinMarkdownBlocks, markdownHeading } from '../markdown.js'; export function formatPresentQuestion(projection: PresentQuestionProjection): string { return joinMarkdownBlocks(markdownHeading(2, projection.heading), projection.body); diff --git a/src/renderers/structured-exchange/present-review-set.ts b/src/renderers/structured-exchange/present-review-set.ts new file mode 100644 index 000000000..73eb5dbef --- /dev/null +++ b/src/renderers/structured-exchange/present-review-set.ts @@ -0,0 +1,38 @@ +import type { PresentReviewSetProjection } from '../../projections/structured-exchange/present-review-set.js'; + +export function formatPresentReviewSet(projection: PresentReviewSetProjection): string { + const payload = projection.payload; + const lines = [ + `## ${payload.pitch.title}`, + '', + payload.pitch.narrative, + '', + `Lens: ${payload.lens}`, + '', + `Epistemic status: ${payload.epistemicStatus}`, + '', + '### Grounding', + '', + payload.grounding.summary, + '', + ...payload.grounding.support.map((support) => `- ${support}`), + '', + '### Entity drafts', + ]; + + payload.entityDrafts.forEach((draft) => { + lines.push('', `- **${draft.draftId}** (${draft.plane}/${draft.kind}): ${draft.title}`); + if (draft.body) lines.push(` ${draft.body}`); + }); + + lines.push('', '### Edge drafts'); + payload.edgeDrafts.forEach((draft) => { + const source = 'draftId' in draft.source ? draft.source.draftId : draft.source.existingCode; + const target = 'draftId' in draft.target ? draft.target.draftId : draft.target.existingCode; + const stance = draft.stance ? ` [${draft.stance}]` : ''; + lines.push('', `- ${source} —${draft.category}${stance}→ ${target}`); + if (draft.rationale) lines.push(` ${draft.rationale}`); + }); + + return lines.join('\n'); +} diff --git a/src/renderers/structured-exchange/request-answer.ts b/src/renderers/structured-exchange/request-answer.ts new file mode 100644 index 000000000..b0a9e24c9 --- /dev/null +++ b/src/renderers/structured-exchange/request-answer.ts @@ -0,0 +1,7 @@ +import type { RequestAnswerDetails } from '../../projections/structured-exchange/request-answer.js'; + +export function formatRequestAnswer(details: RequestAnswerDetails): string { + if ('cancelled' in details) return '### Response\n\n_User cancelled the request._'; + if ('unavailable' in details) return `### Response\n\n_${details.unavailable.message}_`; + return ['### Response', '', details.answered.text].join('\n'); +} diff --git a/src/renderers/structured-exchange/request-choice.ts b/src/renderers/structured-exchange/request-choice.ts new file mode 100644 index 000000000..ee139ac71 --- /dev/null +++ b/src/renderers/structured-exchange/request-choice.ts @@ -0,0 +1,9 @@ +import type { RequestChoiceDetails } from '../../projections/structured-exchange/request-choice.js'; + +export function formatRequestChoice(details: RequestChoiceDetails): string { + if ('cancelled' in details) return '### Response\n\n_User cancelled the request._'; + if ('unavailable' in details) return `### Response\n\n_${details.unavailable.message}_`; + const lines = ['### Response', '', `Selected: **${details.answered.choice.label}**`]; + if (details.answered.comment) lines.push('', 'Comment:', '', `> ${details.answered.comment}`); + return lines.join('\n'); +} diff --git a/src/renderers/structured-exchange/request-choices.ts b/src/renderers/structured-exchange/request-choices.ts new file mode 100644 index 000000000..550da6211 --- /dev/null +++ b/src/renderers/structured-exchange/request-choices.ts @@ -0,0 +1,17 @@ +import type { RequestChoicesDetails } from '../../projections/structured-exchange/request-choices.js'; + +function markdownEscape(text: string): string { + return text.replace(/([\\`*_{}[\]()#+\-.!|>])/g, '\\$1'); +} + +export function formatRequestChoices(details: RequestChoicesDetails): string { + if ('cancelled' in details) return '### Response\n\n_User cancelled the request._'; + if ('unavailable' in details) return `### Response\n\n_${details.unavailable.message}_`; + + const lines = ['### Response']; + if (details.answered.choices.length > 0) { + lines.push('', ...details.answered.choices.map((choice) => `- ${markdownEscape(choice.label)}`)); + } + if (details.answered.comment) lines.push('', 'Comment:', '', `> ${details.answered.comment}`); + return lines.join('\n'); +} diff --git a/src/renderers/structured-exchange/request-review.ts b/src/renderers/structured-exchange/request-review.ts new file mode 100644 index 000000000..18ecb8392 --- /dev/null +++ b/src/renderers/structured-exchange/request-review.ts @@ -0,0 +1,16 @@ +import type { RequestReviewDetails } from '../../projections/structured-exchange/request-review.js'; + +export function formatRequestReview(details: RequestReviewDetails): string { + if ('cancelled' in details) return '### Review decision\n\n_User cancelled the review request._'; + if ('unavailable' in details) return `### Review decision\n\n_${details.unavailable.message}_`; + + const label = + details.answered.decision === 'approve' + ? 'Approved' + : details.answered.decision === 'request_changes' + ? 'Changes requested' + : 'Rejected'; + const lines = ['### Review decision', '', label]; + if (details.answered.comment) lines.push('', 'Comment:', '', `> ${details.answered.comment}`); + return lines.join('\n'); +} diff --git a/src/render/toon.ts b/src/renderers/toon.ts similarity index 94% rename from src/render/toon.ts rename to src/renderers/toon.ts index 639ff6bc5..3a711c68c 100644 --- a/src/render/toon.ts +++ b/src/renderers/toon.ts @@ -7,7 +7,7 @@ * - no graph/session/exchange domain semantics * * Future callers: - * - graph/format/* + * - renderers/graph/* * - any later snapshot formatter that needs compact structured data */ diff --git a/src/print-snapshot.test.ts b/src/renderers/workspace/workspace-snapshot.test.ts similarity index 88% rename from src/print-snapshot.test.ts rename to src/renderers/workspace/workspace-snapshot.test.ts index 5cd0debe8..78ce254d3 100644 --- a/src/print-snapshot.test.ts +++ b/src/renderers/workspace/workspace-snapshot.test.ts @@ -1,8 +1,9 @@ import type { SessionManager } from '@earendil-works/pi-coding-agent'; import { describe, expect, it } from 'vitest'; -import { renderWorkspaceSnapshot, workspaceSnapshotFromState } from './print-snapshot.js'; -import type { WorkspaceSessionState } from './session/workspace-session-coordinator.js'; +import { workspaceSnapshotFromState } from '../../projections/workspace/workspace-snapshot.js'; +import type { WorkspaceSessionState } from '../../session/workspace-session-coordinator.js'; +import { renderWorkspaceSnapshot } from './workspace-snapshot.js'; const cwd = '/tmp/brunch-project'; diff --git a/src/renderers/workspace/workspace-snapshot.ts b/src/renderers/workspace/workspace-snapshot.ts new file mode 100644 index 000000000..7854d159a --- /dev/null +++ b/src/renderers/workspace/workspace-snapshot.ts @@ -0,0 +1,21 @@ +import type { WorkspaceSnapshot } from '../../projections/workspace/workspace-snapshot.js'; + +export function renderWorkspaceSnapshot(snapshot: WorkspaceSnapshot): string { + const lines = [ + 'Brunch workspace snapshot', + `status: ${snapshot.status}`, + `cwd: ${snapshot.cwd}`, + `spec: ${snapshot.spec ? `${snapshot.spec.title} (${snapshot.spec.id})` : ''}`, + `phase: ${snapshot.chrome.phase}`, + `chatMode: ${snapshot.chrome.chatMode}`, + ]; + + if (snapshot.session) { + lines.push(`session: ${snapshot.session.id}`, `sessionFile: ${snapshot.session.file}`); + } + if (snapshot.reason) { + lines.push(`reason: ${snapshot.reason}`); + } + + return `${lines.join('\n')}\n`; +} diff --git a/src/rpc/README.md b/src/rpc/README.md index 3183e334f..cd2faf15a 100644 --- a/src/rpc/README.md +++ b/src/rpc/README.md @@ -44,7 +44,7 @@ canonical stores: worldUpdate entries ``` -RPC handlers must not become a generic records API, REST read model, or canonical view store. Reads are named projections over the store that owns the fact. Mutations route through the owning product seam: session transcript operations through `session.*`, synchronous high-confidence response capture through `session.submitExchangeResponse` → `graph/capture` → `CommandExecutor`, and other graph mutations through the agent/tool or `CommandExecutor` path that owns them. `dev.*` is the only exception family: methods in that namespace are explicitly gated local harnesses, absent from default discovery and absent from the read-only sidecar. +RPC handlers must not become a generic records API, REST read model, or canonical view store. Reads are named projections over the store that owns the fact. Mutations route through the owning product seam: session transcript operations through `session.*`, synchronous high-confidence response capture through `session.submitExchangeResponse` → `graph/capture` → `CommandExecutor`, review-set approval through `session.submitExchangeResponse` → `CommandExecutor.acceptReviewSet`, and other graph mutations through the agent/tool or `CommandExecutor` path that owns them. `dev.*` is the only exception family: methods in that namespace are explicitly gated local harnesses, absent from default discovery and absent from the read-only sidecar. ## Method registry @@ -169,14 +169,19 @@ session.submitExchangeResponse access: write params: exchangeId - answer: {text} | {optionId} | {optionIds} + answer: {text} | {optionId} | {optionIds} | {review:{decision, comment?}} note? - result: accepted terminal response plus capture outcome + result: accepted terminal response plus capture/review outcome capture: captured(lsn, nodeCount, createdNodes) | no_capture(reason) | structural_illegal(diagnostics) - effects: appends request_* toolResult response, publishes selected-session invalidations, and when captured publishes graph.overview / graph.nodeNeighborhood invalidations for the transcript-bound spec + review: + approved(lsn, createdNodes) + | request_changes + | rejected + | structural_illegal(diagnostics) + effects: appends request_* toolResult response, publishes selected-session invalidations, and when captured or approved publishes graph.overview / graph.nodeNeighborhood invalidations for the transcript-bound spec graph.overview access: read diff --git a/src/rpc/handlers.test.ts b/src/rpc/handlers.test.ts index ef5259a26..3db23ac65 100644 --- a/src/rpc/handlers.test.ts +++ b/src/rpc/handlers.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest'; import { openWorkspaceGraphRuntime } from '../graph/workspace-store.js'; import { assistantMessage, userMessage } from '../probes/test-helpers.js'; +import { projectPresentReviewSet } from '../projections/structured-exchange/present-review-set.js'; import { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, type BrunchAgentState } from '../session/runtime-state.js'; import { createSessionBindingData } from '../session/session-binding.js'; import { createWorkspaceSessionCoordinator } from '../session/workspace-session-coordinator.js'; @@ -163,7 +164,8 @@ async function createGraphRpcFixture(): Promise<{ specBId: number; specANodeId: number; specBNodeId: number; - finalLsn: number; + specALsn: number; + specBLsn: number; }> { const cwd = await mkdtemp(join(tmpdir(), 'brunch-rpc-graph-')); const graph = await openWorkspaceGraphRuntime(cwd); @@ -196,7 +198,8 @@ async function createGraphRpcFixture(): Promise<{ specBId: specB.specId, specANodeId: commitA.createdNodes.requirement!.id, specBNodeId: commitB.createdNodes.goal!.id, - finalLsn: commitB.lsn, + specALsn: commitA.lsn, + specBLsn: commitB.lsn, }; } @@ -212,13 +215,10 @@ function presentQuestionEntry() { content: [{ type: 'text', text: '## Domain?\n\nWhat are we specifying?' }], details: { schema: 'brunch.structured_exchange.present', - schemaVersion: 1, - exchangeId: 'domain', - presentTool: 'present_question', - kind: 'question', - status: 'presented', - expectedRequest: { tool: 'request_answer', required: true }, - createdAtToolCallId: 'present-call-1', + v: 1, + exchange_id: 'domain', + tool_meta: { curr: 'present_question', next: 'request_answer' }, + display: { heading: 'Domain?', body: 'What are we specifying?' }, }, isError: false, }, @@ -237,16 +237,10 @@ function requestAnswerEntry(parentId = 'present-question-1') { content: [{ type: 'text', text: '### Response\n\nDeveloper tooling' }], details: { schema: 'brunch.structured_exchange.request', - schemaVersion: 1, - exchangeId: 'domain', - requestTool: 'request_answer', - status: 'answered', - respondsTo: { - exchangeId: 'domain', - presentTool: 'present_question', - }, - answer: 'Developer tooling', - createdAtToolCallId: 'request-call-1', + v: 1, + exchange_id: 'domain', + tool_meta: { prev: 'present_question', curr: 'request_answer' }, + answered: { text: 'Developer tooling' }, }, isError: false, }, @@ -738,7 +732,7 @@ describe('JSON-RPC handlers', () => { options: expect.arrayContaining([ expect.objectContaining({ id: 'new-from-scratch', - label: 'Yes — this is new from scratch', + label: 'Start a new spec workspace from a blank slate.', content: 'Start a new spec workspace from a blank slate.', rationale: 'This keeps the parity run focused on initial grounding.', }), @@ -768,7 +762,6 @@ describe('JSON-RPC handlers', () => { expect(sessionText).toContain('brunch.structured_exchange.present'); expect(sessionText).toContain('present_options'); expect(sessionText).toContain(exchangeId); - expect(sessionText).toContain('"lens":"intent"'); }); it('reads the selected pending structured exchange from transcript truth', async () => { @@ -964,9 +957,11 @@ describe('JSON-RPC handlers', () => { message: { ...requestAnswerEntry().message, details: { - ...requestAnswerEntry().message.details, - status: 'unavailable', - message: 'Editor unavailable.', + schema: 'brunch.structured_exchange.request', + v: 1, + exchange_id: 'domain', + tool_meta: { prev: 'present_question', curr: 'request_answer' }, + unavailable: { message: 'Editor unavailable.' }, }, }, }, @@ -1009,10 +1004,12 @@ describe('JSON-RPC handlers', () => { ...presentQuestionEntry().message, toolName: 'present_options', details: { - ...presentQuestionEntry().message.details, - presentTool: 'present_options', - kind: 'options', - expectedRequest: { tool: 'request_choices', required: true }, + schema: 'brunch.structured_exchange.present', + v: 1, + exchange_id: 'domain', + tool_meta: { curr: 'present_options', next: 'request_choices' }, + display: { heading: 'Choose priorities' }, + options: [{ id: 'speed', content: 'Move quickly' }], }, }, }, @@ -1027,16 +1024,10 @@ describe('JSON-RPC handlers', () => { content: [{ type: 'text', text: '### Response\n\nCancelled.' }], details: { schema: 'brunch.structured_exchange.request', - schemaVersion: 1, - exchangeId: 'domain', - requestTool: 'request_choices', - status: 'cancelled', - respondsTo: { - exchangeId: 'domain', - presentTool: 'present_options', - }, - message: 'User cancelled the selection.', - createdAtToolCallId: 'request-call-choices-cancelled', + v: 1, + exchange_id: 'domain', + tool_meta: { prev: 'present_options', curr: 'request_choices' }, + cancelled: { message: 'User cancelled the selection.' }, }, isError: false, }, @@ -1142,7 +1133,7 @@ describe('JSON-RPC handlers', () => { exchangeId, answer: { optionId: 'new-from-scratch', - label: 'Yes — this is new from scratch', + label: 'Start a new spec workspace from a blank slate.', }, note: 'This is a greenfield product.', }, @@ -1290,6 +1281,109 @@ describe('JSON-RPC handlers', () => { expect(sessionText).not.toContain('brunch.elicitation_response'); }); + it('approves a pending review exchange into the selected spec graph and publishes graph invalidations', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-rpc-review-approve-')); + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }); + const workspace = await coordinatorInstance.createSetupSession({ specTitle: 'Review approval spec' }); + const graph = await openWorkspaceGraphRuntime(cwd); + const existing = graph.commandExecutor.commitGraph({ + specId: workspace.spec.id, + nodes: [{ ref: 'goal', plane: 'intent', kind: 'goal', title: 'Existing selected-spec goal' }], + edges: [], + }); + if (existing.status !== 'success') throw new Error('failed to create existing graph node'); + const payload = { + schemaVersion: 1, + lens: 'intent', + epistemicStatus: 'asserted', + grounding: { summary: 'User asked to approve requirement graph facts.', support: ['Transcript'] }, + pitch: { title: 'Approve reviewed graph facts', narrative: 'Creates one reviewed requirement.' }, + entityDrafts: [ + { + draftId: 'requirement-draft', + plane: 'intent', + kind: 'requirement', + title: 'Reviewed requirement', + body: 'This exact reviewed node should be accepted.', + }, + ], + edgeDrafts: [ + { + category: 'support', + stance: 'for', + source: { draftId: 'requirement-draft' }, + target: { existingCode: 'G1' }, + rationale: 'The reviewed requirement supports the selected-spec goal.', + }, + ], + } as const; + const projection = projectPresentReviewSet({ exchangeId: 'review-cycle', payload }); + workspace.session.manager.appendMessage({ + role: 'toolResult', + toolCallId: 'present-review-call-1', + toolName: 'present_review_set', + content: [{ type: 'text', text: '## Approve reviewed graph facts' }], + details: projection.details, + isError: false, + timestamp: 0, + }); + (workspace.session.manager as unknown as { _rewriteFile(): void })._rewriteFile(); + const productUpdates = createProductUpdatePublisher(); + const updates: unknown[] = []; + productUpdates.subscribe((batch) => updates.push(...batch)); + const handlers = createRpcHandlers({ coordinator: coordinatorInstance, cwd, productUpdates }); + + const response = await handlers.handle({ + jsonrpc: '2.0', + id: 276, + method: 'session.submitExchangeResponse', + params: { + exchangeId: 'review-cycle', + answer: { review: { decision: 'approve', comment: 'Looks correct.' } }, + }, + }); + + expect(response).toMatchObject({ + jsonrpc: '2.0', + id: 276, + result: { + status: 'accepted', + exchangeId: 'review-cycle', + answer: { review: { decision: 'approve', comment: 'Looks correct.' } }, + review: { + status: 'approved', + lsn: expect.any(Number), + createdNodes: { 'requirement-draft': { id: expect.any(Number), code: 'R1' } }, + }, + capture: { status: 'no_capture' }, + }, + }); + if (!('result' in response)) throw new Error('expected review approval response'); + const lsn = (response.result as { review: { lsn: number } }).review.lsn; + expect(updates).toContainEqual({ topic: 'graph.overview', specId: workspace.spec.id, lsn }); + expect(updates).toContainEqual({ topic: 'graph.nodeNeighborhood', specId: workspace.spec.id, lsn }); + + const overview = await handlers.handle({ + jsonrpc: '2.0', + id: 277, + method: 'graph.overview', + params: { specId: workspace.spec.id }, + }); + expect(overview).toMatchObject({ + result: { + nodeCount: 2, + nodes: expect.arrayContaining([ + expect.objectContaining({ + kind: 'requirement', + title: 'Reviewed requirement', + basis: 'explicit', + }), + ]), + }, + }); + await expect(readFile(workspace.session.file, 'utf8')).resolves.toContain('request_review'); + }); + it('captures explicit labeled text answers into the transcript-bound spec graph and publishes graph invalidations', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-rpc-response-capture-')); const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }); @@ -2056,7 +2150,7 @@ describe('JSON-RPC handlers', () => { result: { nodeCount: 2, edgeCount: 1, - lsn: fixture.finalLsn, + lsn: fixture.specALsn, }, }); if (!('result' in overviewA)) throw new Error('expected graph overview'); @@ -2072,7 +2166,7 @@ describe('JSON-RPC handlers', () => { expect(overviewB).toMatchObject({ jsonrpc: '2.0', id: 51, - result: { nodeCount: 1, edgeCount: 0, lsn: fixture.finalLsn }, + result: { nodeCount: 1, edgeCount: 0, lsn: fixture.specBLsn }, }); const crossSpecNeighborhood = await handlers.handle({ @@ -2241,6 +2335,22 @@ describe('JSON-RPC handlers', () => { edges: expect.arrayContaining([expect.objectContaining({ category: 'support', stance: 'for' })]), }, }); + + const siblingOverview = await handlers.handle({ + jsonrpc: '2.0', + id: 62, + method: 'graph.overview', + params: { specId: fixture.specBId }, + }); + expect(siblingOverview).toMatchObject({ + jsonrpc: '2.0', + id: 62, + result: { + nodeCount: 1, + edgeCount: 0, + lsn: fixture.specBLsn, + }, + }); }); it('rejects invalid dev graph commits without partial persistence', async () => { @@ -2304,7 +2414,7 @@ describe('JSON-RPC handlers', () => { result: { nodeCount: 2, edgeCount: 1, - lsn: fixture.finalLsn, + lsn: fixture.specALsn, }, }); if (!('result' in overview)) throw new Error('expected overview success'); diff --git a/src/rpc/methods/session.ts b/src/rpc/methods/session.ts index 616a68564..40df50191 100644 --- a/src/rpc/methods/session.ts +++ b/src/rpc/methods/session.ts @@ -4,13 +4,14 @@ import { Value } from 'typebox/value'; import { captureStructuredResponseFacts } from '../../graph/capture/structured-response.js'; import type { StructuredResponseCaptureOutcome } from '../../graph/capture/structured-response.js'; import type { WorkspaceGraphRuntime } from '../../graph/workspace-store.js'; +import { projectSessionRuntimeState } from '../../projections/session/runtime-state.js'; +import { reviewSetProposalPayloadFromDetails } from '../../projections/structured-exchange/review-set-payload.js'; import { readBrunchSessionEnvelope, NonLinearTranscriptError, type BrunchSessionEnvelope, } from '../../session/brunch-session-envelope.js'; import { projectLinearSessionExchangeProjection } from '../../session/exchange-projection.js'; -import { projectSessionRuntimeState } from '../../session/runtime-state.js'; import { resolveExplicitSessionProjectionTarget, type ExplicitSessionProjectionParams, @@ -204,6 +205,22 @@ const ExchangeResponseParamsSchema = Type.Object( { optionIds: Type.Array(NonBlankStringSchema, { minItems: 1 }) }, { additionalProperties: false }, ), + Type.Object( + { + review: Type.Object( + { + decision: Type.Union([ + Type.Literal('approve'), + Type.Literal('request_changes'), + Type.Literal('reject'), + ]), + comment: Type.Optional(Type.String()), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, + ), ]), note: Type.Optional(Type.String()), }, @@ -236,12 +253,37 @@ const ExchangeResponseCaptureResultSchema = Type.Union([ ), ]); +const ExchangeResponseReviewResultSchema = Type.Union([ + Type.Object( + { + status: Type.Literal('approved'), + lsn: PositiveIntegerSchema, + createdNodes: Type.Object({}, { additionalProperties: true }), + }, + { additionalProperties: false }, + ), + Type.Object( + { + status: Type.Union([Type.Literal('request_changes'), Type.Literal('rejected')]), + }, + { additionalProperties: false }, + ), + Type.Object( + { + status: Type.Literal('structural_illegal'), + diagnostics: Type.Array(Type.Object({}, { additionalProperties: true })), + }, + { additionalProperties: false }, + ), +]); + const ExchangeResponseResultSchema = Type.Object( { status: Type.Literal('accepted'), exchangeId: NonBlankStringSchema, answer: Type.Object({}, { additionalProperties: true }), capture: ExchangeResponseCaptureResultSchema, + review: Type.Optional(ExchangeResponseReviewResultSchema), note: Type.Optional(Type.String()), }, { additionalProperties: false }, @@ -495,18 +537,41 @@ async function handleSubmitExchangeResponse( } const graph = await options.getGraphRuntime(); - const capture = captureStructuredResponseFacts({ + const review = reviewResultForAcceptedResponse({ + pending, + acceptedAnswer: accepted.answer, specId: target.envelope.binding.specId, - exchangeId: pending.exchangeId, - answer: accepted.answer, + proposalEntryId: projectLinearSessionExchangeProjection(target.envelope).openPrompt?.promptEntryIds[0], commandExecutor: graph.commandExecutor, }); + if (review?.status === 'structural_illegal') { + const result: ExchangeResponseResult = { + status: 'accepted', + exchangeId: pending.exchangeId, + answer: accepted.answer, + capture: { status: 'no_capture', reason: 'review responses do not run synchronous capture' }, + review, + ...(params.note === undefined ? {} : { note: params.note }), + }; + return createJsonRpcSuccess(requestId, result); + } + + const capture = + review === undefined + ? captureStructuredResponseFacts({ + specId: target.envelope.binding.specId, + exchangeId: pending.exchangeId, + answer: accepted.answer, + commandExecutor: graph.commandExecutor, + }) + : { status: 'no_capture' as const, reason: 'review responses do not run synchronous capture' }; const result: ExchangeResponseResult = { status: 'accepted', exchangeId: pending.exchangeId, answer: accepted.answer, capture, + ...(review === undefined ? {} : { review }), ...(params.note === undefined ? {} : { note: params.note }), }; @@ -514,9 +579,11 @@ async function handleSubmitExchangeResponse( flushSessionEntries(state.session.manager, state.session.file); publishSelectedSessionUpdates(options.productUpdates, state, target.envelope.binding.specId); - if (capture.status === 'captured') { + const mutationLsn = + review?.status === 'approved' ? review.lsn : capture.status === 'captured' ? capture.lsn : null; + if (mutationLsn !== null) { options.productUpdates?.publish( - graphMutationProductUpdates({ specId: target.envelope.binding.specId, lsn: capture.lsn }), + graphMutationProductUpdates({ specId: target.envelope.binding.specId, lsn: mutationLsn }), ); } return createJsonRpcSuccess(requestId, result); @@ -540,6 +607,63 @@ type SessionProjectionParamsParseResult = } | { ok: false }; +function reviewResultForAcceptedResponse(options: { + readonly pending: PendingStructuredExchange; + readonly acceptedAnswer: Record; + readonly specId: number; + readonly proposalEntryId?: string | undefined; + readonly commandExecutor: WorkspaceGraphRuntime['commandExecutor']; +}): + | { + readonly status: 'approved'; + readonly lsn: number; + readonly createdNodes: Record; + } + | { readonly status: 'request_changes' | 'rejected' } + | { readonly status: 'structural_illegal'; readonly diagnostics: Record[] } + | undefined { + const review = (options.acceptedAnswer as { review?: unknown }).review; + if (typeof review !== 'object' || review === null) return undefined; + if (options.pending.mode !== 'review' || options.pending.reviewSet === undefined) { + return { + status: 'structural_illegal', + diagnostics: [{ field: 'review', message: 'no pending review set' }], + }; + } + + const decision = (review as { decision?: unknown }).decision; + if (decision === 'request_changes') return { status: 'request_changes' }; + if (decision === 'reject') return { status: 'rejected' }; + if (decision !== 'approve') { + return { + status: 'structural_illegal', + diagnostics: [{ field: 'review.decision', message: 'invalid review decision' }], + }; + } + + const accepted = options.commandExecutor.acceptReviewSet({ + specId: options.specId, + proposalEntryId: options.proposalEntryId, + payload: reviewSetProposalPayloadFromDetails({ + exchangeId: options.pending.exchangeId, + heading: options.pending.prompt, + body: options.pending.details, + reviewSet: options.pending.reviewSet as never, + }), + }); + if (accepted.status === 'structural_illegal') { + return { + status: 'structural_illegal', + diagnostics: accepted.diagnostics.map((diagnostic) => ({ ...diagnostic })), + }; + } + return { + status: 'approved', + lsn: accepted.lsn, + createdNodes: accepted.createdNodes, + }; +} + function parseSessionProjectionParams(value: unknown): SessionProjectionParamsParseResult { if (value === undefined) { return { ok: true, value: null }; diff --git a/src/rpc/methods/workspace.ts b/src/rpc/methods/workspace.ts index 391761dd7..41cd47e80 100644 --- a/src/rpc/methods/workspace.ts +++ b/src/rpc/methods/workspace.ts @@ -1,7 +1,7 @@ import { Type, type Static } from 'typebox'; import { Value } from 'typebox/value'; -import { workspaceSnapshotFromState } from '../../print-snapshot.js'; +import { workspaceSnapshotFromState } from '../../projections/workspace/workspace-snapshot.js'; import type { SpecSessionActivationDecision, WorkspaceActivationState, diff --git a/src/scripts/README.md b/src/scripts/README.md new file mode 100644 index 000000000..00fff5c13 --- /dev/null +++ b/src/scripts/README.md @@ -0,0 +1,19 @@ +# scripts/ + +SPEC decisions: D52-L + +## Owns + +Local executable utilities and script-facing helpers that are not product domain layers. + +Current utilities: none. Print-mode snapshot projection/rendering moved to `projections/workspace/` and `renderers/workspace/`; `app/` now calls those shared seams directly. + +## Does not own + +- Durable graph or session semantics. +- Product host lifecycle and mode dispatch — `app/`. +- Reusable DTO projection — `projections/`. +- Reusable text renderers intended for multiple layers — `renderers/`. +## Dependency direction + +`scripts/` may import domain/session types needed to produce utility output. Domain layers, adapters, RPC, and web must not import `scripts/`. diff --git a/src/session/README.md b/src/session/README.md index 36dec0f6b..6ff2f124d 100644 --- a/src/session/README.md +++ b/src/session/README.md @@ -13,10 +13,10 @@ plus the coordination logic for workspace/spec/session lifecycle. - **Exchange extraction** — session exchange projection: prompt-side span + response-side span, per D13-L. -- **Runtime-state projection** — flattened transcript-backed agent posture, - mention, world-watermark, and lifecycle slots from linear Brunch session - envelopes. `.pi` may append operational-mode entries, but the pure projection - lives here. +- **Runtime-state transcript facts** — `brunch.agent_runtime_state` entry type, + parser, and append helpers. Reusable runtime-state projection/policy lives in + `projections/session/`; `.pi` may append operational-mode entries but does not + own hidden runtime memory. - **Structured-exchange loop helpers** — deterministic POC exchange generation, pending prompt reconstruction from structured transcript tuples, and response @@ -42,14 +42,16 @@ plus the coordination logic for workspace/spec/session lifecycle. ## Does NOT own - Graph state, CommandExecutor, graph snapshots — those live in `graph/`. -- Prompt composition, context building — those live in `agents/`. +- Prompt composition, context building — those live in `.pi/agents/`. - Pi extension registration — those live in `.pi/extensions/`. ## Imported by -- `agents/contexts/` — for session/transcript snapshots -- `rpc/` — for session.* and workspace.* RPC handlers -- `.pi/extensions/` — for session lifecycle hooks +- `.pi/agents/contexts/` — for session/transcript snapshots. +- `projections/session/` — for reusable transcript-context DTO projection. +- `renderers/session/` — for reusable transcript markdown rendering. +- `rpc/` — for session.* and workspace.* RPC handlers. +- `.pi/extensions/` — for session lifecycle hooks. ## Moved from src/ root @@ -63,7 +65,7 @@ These files migrated here on 2026-06-02: | `session-projection-reader.ts` | JSONL projection target resolution | | `session-transcript.ts` | transcript row projection | | `exchange-projection.ts` | exchange extraction | -| `runtime-state.ts` | runtime state projection | +| `runtime-state.ts` | runtime-state transcript entries | | `structured-exchange.ts` | structured exchange schemas/types | | `structured-exchange-loop.ts` | deterministic exchange loop helpers| | `project-identity.ts` | workspace identity (cwd discovery) | diff --git a/src/session/exchange-projection.test.ts b/src/session/exchange-projection.test.ts index 4110acd53..2e2463568 100644 --- a/src/session/exchange-projection.test.ts +++ b/src/session/exchange-projection.test.ts @@ -15,7 +15,6 @@ import { projectTranscriptDisplay, } from './exchange-projection.js'; import { createSessionBindingData } from './session-binding.js'; -import { STRUCTURED_EXCHANGE_RESULT_SCHEMA } from './structured-exchange.js'; const assistant = { id: 'a1', @@ -50,13 +49,13 @@ const presentQuestionToolResult = { content: [{ type: 'text', text: '## Domain?\n\nWhat are we specifying?' }], details: { schema: 'brunch.structured_exchange.present', - schemaVersion: 1, - exchangeId: 'domain', - presentTool: 'present_question', - kind: 'question', - status: 'presented', - expectedRequest: { tool: 'request_answer', required: true }, - createdAtToolCallId: 'present-call-1', + v: 1, + exchange_id: 'domain', + tool_meta: { curr: 'present_question', next: 'request_answer' }, + display: { + heading: 'Domain?', + body: 'What are we specifying?', + }, }, isError: false, }, @@ -72,13 +71,10 @@ const requestAnswerToolResult = { content: [{ type: 'text', text: '### Response\n\nDeveloper tooling' }], details: { schema: 'brunch.structured_exchange.request', - schemaVersion: 1, - exchangeId: 'domain', - requestTool: 'request_answer', - status: 'answered', - respondsTo: { exchangeId: 'domain', presentTool: 'present_question' }, - answer: 'Developer tooling', - createdAtToolCallId: 'request-call-1', + v: 1, + exchange_id: 'domain', + tool_meta: { prev: 'present_question', curr: 'request_answer' }, + answered: { text: 'Developer tooling' }, }, isError: false, }, @@ -90,12 +86,53 @@ const mismatchedRequestAnswerToolResult = { ...requestAnswerToolResult.message, details: { ...requestAnswerToolResult.message.details, - exchangeId: 'other-domain', - respondsTo: { - exchangeId: 'other-domain', - presentTool: 'present_question', + exchange_id: 'other-domain', + }, + }, +}; +const presentReviewSetToolResult = { + id: 'present-review-set-1', + type: 'message', + parentId: null, + message: { + role: 'toolResult', + toolCallId: 'present-review-call-1', + toolName: 'present_review_set', + content: [{ type: 'text', text: '## Review cycle wiring\n\nReview this graph proposal.' }], + details: { + schema: 'brunch.structured_exchange.present', + v: 1, + exchange_id: 'review-cycle', + tool_meta: { curr: 'present_review_set', next: 'request_review' }, + display: { + heading: 'Review cycle wiring', + body: 'Review this graph proposal.', }, + review_set: { + nodes: [{ draft_id: 'goal-review', plane: 'intent', kind: 'goal', title: 'Review graph proposals' }], + edges: [], + }, + }, + isError: false, + }, +}; +const requestReviewToolResult = { + id: 'request-review-1', + type: 'message', + parentId: 'present-review-set-1', + message: { + role: 'toolResult', + toolCallId: 'request-review-call-1', + toolName: 'request_review', + content: [{ type: 'text', text: '### Review decision\n\nApproved.' }], + details: { + schema: 'brunch.structured_exchange.request', + v: 1, + exchange_id: 'review-cycle', + tool_meta: { prev: 'present_review_set', curr: 'request_review' }, + answered: { decision: 'approve' }, }, + isError: false, }, }; const requestChoicesToolResult = { @@ -114,17 +151,16 @@ const requestChoicesToolResult = { ], details: { schema: 'brunch.structured_exchange.request', - schemaVersion: 1, - exchangeId: 'domain', - requestTool: 'request_choices', - status: 'answered', - respondsTo: { exchangeId: 'domain', presentTool: 'present_options' }, - choices: [ - { id: 'speed', label: 'Move quickly' }, - { id: 'other', label: 'Other' }, - ], - comment: 'Keep it deterministic.', - createdAtToolCallId: 'request-call-choices-1', + v: 1, + exchange_id: 'domain', + tool_meta: { prev: 'present_options', curr: 'request_choices' }, + answered: { + choices: [ + { id: 'speed', label: 'Move quickly', kind: 'listed' }, + { id: 'other', label: 'Other', kind: 'other' }, + ], + comment: 'Keep it deterministic.', + }, }, isError: false, }, @@ -135,22 +171,14 @@ const structuredExchangeToolResult = { message: { role: 'toolResult', toolCallId: 'call-exchange-1', - toolName: 'structured_exchange', + toolName: 'request_answer', content: [{ type: 'text', text: 'User answered: Developer tooling' }], details: { - schema: STRUCTURED_EXCHANGE_RESULT_SCHEMA, - schemaVersion: 1, - status: 'answered', - question: 'Domain?', - mode: 'text', - answers: [ - { - type: 'text', - label: 'Developer tooling', - value: 'Developer tooling', - }, - ], - transport: { surface: 'rpc-editor' }, + schema: 'brunch.structured_exchange.request', + v: 1, + exchange_id: 'domain', + tool_meta: { prev: 'present_question', curr: 'request_answer', next: 'capture_answer' }, + answered: { text: 'Developer tooling' }, }, isError: false, }, @@ -161,17 +189,14 @@ const unavailableStructuredExchangeToolResult = { message: { role: 'toolResult', toolCallId: 'call-exchange-2', - toolName: 'structured_exchange', + toolName: 'request_answer', content: [{ type: 'text', text: 'Structured exchange unavailable.' }], details: { - schema: STRUCTURED_EXCHANGE_RESULT_SCHEMA, - schemaVersion: 1, - status: 'unavailable', - question: 'Domain?', - mode: 'text', - answers: [], - transport: { surface: 'headless' }, - message: 'Structured exchange UI is unavailable.', + schema: 'brunch.structured_exchange.request', + v: 1, + exchange_id: 'domain', + tool_meta: { prev: 'present_question', curr: 'request_answer' }, + unavailable: { message: 'Structured exchange UI is unavailable.' }, }, isError: false, }, @@ -319,6 +344,21 @@ describe('session exchange projection', () => { }); }); + it('closes present_review_set only with the matching terminal request_review result', () => { + const projection = projectSessionExchanges([presentReviewSetToolResult, requestReviewToolResult]); + + expect(projection).toMatchObject({ + status: 'ready', + exchanges: [ + { + promptEntryIds: ['present-review-set-1'], + responseEntryIds: ['request-review-1'], + }, + ], + openPrompt: null, + }); + }); + it('does not close an open present with a mismatched request tuple', () => { const projection = projectSessionExchanges([ presentQuestionToolResult, @@ -339,10 +379,15 @@ describe('session exchange projection', () => { ...presentQuestionToolResult.message, toolName: 'present_options', details: { - ...presentQuestionToolResult.message.details, - presentTool: 'present_options', - kind: 'options', - expectedRequest: { tool: 'request_choices', required: true }, + schema: 'brunch.structured_exchange.present', + v: 1, + exchange_id: 'domain', + tool_meta: { curr: 'present_options', next: 'request_choices' }, + display: { heading: 'Choose priorities' }, + options: [ + { id: 'speed', content: 'Move quickly' }, + { id: 'other', content: 'Other' }, + ], }, }, }; @@ -351,10 +396,16 @@ describe('session exchange projection', () => { id: `request-choices-${status}`, message: { ...requestChoicesToolResult.message, - details: { - ...requestChoicesToolResult.message.details, - status, - }, + details: + status === 'answered' + ? requestChoicesToolResult.message.details + : { + schema: 'brunch.structured_exchange.request', + v: 1, + exchange_id: 'domain', + tool_meta: { prev: 'present_options', curr: 'request_choices' }, + [status]: status === 'cancelled' ? {} : { message: 'request_choices unavailable' }, + }, }, }; @@ -373,7 +424,7 @@ describe('session exchange projection', () => { ...requestAnswerToolResult.message, details: { ...requestAnswerToolResult.message.details, - respondsTo: { exchangeId: 'domain', presentTool: 'present_options' }, + exchange_id: 'other-domain', }, }, }; @@ -382,14 +433,7 @@ describe('session exchange projection', () => { id: 'request-choices-unexpected-tool', message: { ...requestChoicesToolResult.message, - details: { - ...requestChoicesToolResult.message.details, - exchangeId: 'domain', - respondsTo: { - exchangeId: 'domain', - presentTool: 'present_question', - }, - }, + details: requestChoicesToolResult.message.details, }, }; @@ -419,9 +463,9 @@ describe('session exchange projection', () => { }); it('classifies terminal structured-exchange tool results as response-side entries', () => { - const projection = projectSessionExchanges([assistant, structuredExchangeToolResult]); + const projection = projectSessionExchanges([presentQuestionToolResult, structuredExchangeToolResult]); - expect(projection.exchanges[0]?.promptEntryIds).toEqual(['a1']); + expect(projection.exchanges[0]?.promptEntryIds).toEqual(['present-question-1']); expect(projection.exchanges[0]?.responseEntryIds).toEqual(['sq1']); expect(projection.exchanges[0]?.responseRange).toEqual({ start: 'sq1', @@ -430,11 +474,14 @@ describe('session exchange projection', () => { expect(projection.openPrompt).toBeNull(); }); - it('keeps non-terminal structured-exchange tool results on the prompt side', () => { - const projection = projectSessionExchanges([assistant, unavailableStructuredExchangeToolResult]); + it('classifies unavailable canonical request results as response-side entries', () => { + const projection = projectSessionExchanges([ + presentQuestionToolResult, + unavailableStructuredExchangeToolResult, + ]); - expect(projection.exchanges).toEqual([]); - expect(projection.openPrompt?.promptEntryIds).toEqual(['a1', 'sq-unavailable']); + expect(projection.exchanges[0]?.promptEntryIds).toEqual(['present-question-1']); + expect(projection.exchanges[0]?.responseEntryIds).toEqual(['sq-unavailable']); }); it('returns an explicit empty/open shape for incomplete transcripts', () => { @@ -494,25 +541,32 @@ describe('session exchange projection', () => { const manager = SessionManager.create(cwd, join(cwd, '.brunch/sessions')); appendBinding(manager); manager.appendMessage(assistantMessage('Please answer the structured exchange.')); + manager.appendMessage({ + role: 'toolResult', + toolCallId: 'present-jsonl', + toolName: 'present_question', + content: [{ type: 'text', text: '## Domain?' }], + details: { + schema: 'brunch.structured_exchange.present', + v: 1, + exchange_id: 'jsonl-text', + tool_meta: { curr: 'present_question', next: 'request_answer' }, + display: { heading: 'Domain?' }, + }, + isError: false, + timestamp: 0, + }); manager.appendMessage({ role: 'toolResult', toolCallId: 'call-exchange-jsonl', - toolName: 'structured_exchange', + toolName: 'request_answer', content: [{ type: 'text', text: 'User answered: Developer tooling' }], details: { - schema: STRUCTURED_EXCHANGE_RESULT_SCHEMA, - schemaVersion: 1, - status: 'answered', - question: 'Domain?', - mode: 'text', - answers: [ - { - type: 'text', - label: 'Developer tooling', - value: 'Developer tooling', - }, - ], - transport: { surface: 'rpc-editor' }, + schema: 'brunch.structured_exchange.request', + v: 1, + exchange_id: 'jsonl-text', + tool_meta: { prev: 'present_question', curr: 'request_answer', next: 'capture_answer' }, + answered: { text: 'Developer tooling' }, }, isError: false, timestamp: 0, @@ -522,7 +576,7 @@ describe('session exchange projection', () => { expect(projection.status).toBe('ready'); expect(projection.exchanges).toHaveLength(1); - expect(projection.exchanges[0]?.promptEntryIds).toHaveLength(1); + expect(projection.exchanges[0]?.promptEntryIds).toHaveLength(2); expect(projection.exchanges[0]?.responseEntryIds).toHaveLength(1); }); diff --git a/src/session/exchange-projection.ts b/src/session/exchange-projection.ts index b2dabff04..cc067f799 100644 --- a/src/session/exchange-projection.ts +++ b/src/session/exchange-projection.ts @@ -5,14 +5,11 @@ import { type SessionMessageEntry, } from '@earendil-works/pi-coding-agent'; -import type { - StructuredExchangePresentDetails, - StructuredExchangeRequestDetails, -} from '../.pi/extensions/structured-exchange/shared/model.js'; +import type { PresentDetails, RequestDetails } from '../.pi/extensions/exchanges/schemas/index.js'; import { isStructuredExchangePresentDetails, isStructuredExchangeRequestDetails, -} from '../.pi/extensions/structured-exchange/shared/recovery.js'; +} from '../.pi/extensions/exchanges/shared/recovery.js'; import { assertLinearBrunchSessionEnvelope, loadJsonlTranscriptEntries, @@ -22,7 +19,6 @@ import { readBrunchSessionEnvelope, type BrunchSessionEnvelope, } from './brunch-session-envelope.js'; -import { isTerminalStructuredExchangeResultDetails } from './structured-exchange.js'; const PROMPT_SIDE_CUSTOM_TYPES = new Set([ 'brunch.elicitation_prompt', @@ -151,7 +147,7 @@ export function projectSessionExchanges(entries: readonly unknown[]): SessionExc const exchanges: SessionExchange[] = []; let promptIds: string[] = []; let responseIds: string[] = []; - let openStructuredExchange: StructuredExchangePresentDetails | undefined; + let openStructuredExchange: PresentDetails | undefined; for (const entry of entries) { if (!isTranscriptEntry(entry)) { @@ -229,27 +225,22 @@ function rangeFor(ids: string[]): EntryRange { return { start: ids[0]!, end: ids[ids.length - 1]! }; } -function requestClosesPresent( - request: StructuredExchangeRequestDetails, - present: StructuredExchangePresentDetails, -): boolean { +function requestClosesPresent(request: RequestDetails, present: PresentDetails): boolean { return ( - (request.status === 'answered' || request.status === 'cancelled' || request.status === 'unavailable') && - request.exchangeId === present.exchangeId && - request.respondsTo.exchangeId === present.exchangeId && - request.respondsTo.presentTool === present.presentTool && - (present.expectedRequest === undefined || present.expectedRequest.tool === request.requestTool) + request.exchange_id === present.exchange_id && + request.tool_meta.prev === present.tool_meta.curr && + request.tool_meta.curr === present.tool_meta.next ); } -function structuredExchangePresentDetails(entry: SessionEntry): StructuredExchangePresentDetails | undefined { +function structuredExchangePresentDetails(entry: SessionEntry): PresentDetails | undefined { if (!isStructuredExchangePresentToolResult(entry)) return undefined; - return (entry.message as { details?: unknown }).details as StructuredExchangePresentDetails; + return (entry.message as { details?: unknown }).details as PresentDetails; } -function structuredExchangeRequestDetails(entry: SessionEntry): StructuredExchangeRequestDetails | undefined { +function structuredExchangeRequestDetails(entry: SessionEntry): RequestDetails | undefined { if (!isStructuredExchangeRequestToolResult(entry)) return undefined; - return (entry.message as { details?: unknown }).details as StructuredExchangeRequestDetails; + return (entry.message as { details?: unknown }).details as RequestDetails; } function isStructuredExchangePresentToolResult(entry: SessionEntry): entry is SessionMessageEntry & { @@ -295,11 +286,7 @@ function isResponseSideEntry(entry: SessionEntry): boolean { } function isTerminalStructuredExchangeToolResult(entry: SessionEntry): boolean { - return ( - isMessageEntry(entry) && - entry.message.role === 'toolResult' && - isTerminalStructuredExchangeResultDetails((entry.message as { details?: unknown }).details) - ); + return isStructuredExchangeRequestToolResult(entry); } function isCustomTranscriptEntry(entry: SessionEntry): entry is CustomEntry | CustomMessageEntry { diff --git a/src/session/runtime-state.test.ts b/src/session/runtime-state.test.ts index 894384c5d..bae176774 100644 --- a/src/session/runtime-state.test.ts +++ b/src/session/runtime-state.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; +import { projectSessionRuntimeState } from '../projections/session/runtime-state.js'; import { NonLinearTranscriptError, type BrunchSessionEnvelope } from './brunch-session-envelope.js'; import { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, - projectSessionRuntimeState, type BrunchAgentState, } from './runtime-state.js'; import { createSessionBindingData } from './session-binding.js'; diff --git a/src/session/runtime-state.ts b/src/session/runtime-state.ts index 92ca693c3..1851639cc 100644 --- a/src/session/runtime-state.ts +++ b/src/session/runtime-state.ts @@ -1,7 +1,3 @@ -import type { FileEntry } from '@earendil-works/pi-coding-agent'; - -import { assertLinearBrunchSessionEnvelope, type BrunchSessionEnvelope } from './brunch-session-envelope.js'; - export const BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE = 'brunch.agent_runtime_state'; export type OperationalModeId = 'elicit'; @@ -30,34 +26,6 @@ export interface BrunchAgentState { agentGoal: AgentGoalSelection; } -export interface OperationalModeDefinition { - id: OperationalModeId; - defaultRole: AgentRoleId; - allowedRoles: readonly AgentRoleId[]; - toolPolicyId: ToolPolicyId; - promptPackIds: readonly PromptPackId[]; -} - -export interface AgentRoleDefinition { - id: AgentRoleId; - operationalMode: OperationalModeId; - defaultStrategy: AgentStrategySelection; - allowedStrategies: readonly AgentStrategyId[]; - defaultLens: AgentLensSelection; - allowedLenses: readonly AgentLensId[]; - defaultGoal: AgentGoalSelection; - allowedGoals: readonly AgentGoalId[]; - promptPackIds: readonly PromptPackId[]; - modelPreference?: ModelPreference; - thinkingLevel?: ThinkingLevel; -} - -export interface ResolvedBrunchAgentState extends BrunchAgentState { - agentRole: AgentRoleId; - operationalModeDefinition: OperationalModeDefinition; - agentRoleDefinition: AgentRoleDefinition; -} - export interface BrunchAgentStateEntryData { schemaVersion: 1; reason: 'init' | 'switch'; @@ -66,38 +34,6 @@ export interface BrunchAgentStateEntryData { source: 'system' | 'user' | 'agent' | 'extension'; } -export interface RuntimeStateProjection { - status: 'ready'; - specId: number; - sessionId: string; - agent: { - operationalMode: OperationalModeId; - role: AgentRoleId; - strategy: AgentStrategySelection; - lens: AgentLensSelection; - goal: AgentGoalSelection; - }; - mentions: { - graphNodes: GraphNodeMention[]; - files: FileMention[]; - }; - world: { - graph: { - latestLsn: number | null; - }; - git: { - head: string | null; - }; - }; - lifecycle: { - specOrigin: 'new' | 'existing' | null; - sessionOrigin: 'new' | 'resumed' | null; - sessionIndexInSpec: number | null; - isFirstSessionForSpec: boolean | null; - isTenthSessionForSpec: boolean | null; - }; -} - export interface GraphNodeMention { id: string; handle?: string; @@ -118,40 +54,25 @@ export const DEFAULT_BRUNCH_AGENT_STATE: BrunchAgentState = { agentGoal: 'grounding-advance', }; -export const OPERATIONAL_MODE_DEFINITIONS: Record = { - elicit: { - id: 'elicit', - defaultRole: 'elicitor', - allowedRoles: ['elicitor'], - toolPolicyId: 'elicit-read-only', - promptPackIds: ['brunch-base', 'elicit'], - }, -}; - -export const AGENT_ROLE_DEFINITIONS: Record = { - elicitor: { - id: 'elicitor', - operationalMode: 'elicit', - defaultStrategy: 'auto', - allowedStrategies: [ - 'step-wise-decision-tree', - 'step-wise-disambiguate', - 'propose-graph', - 'project-graph', - ], - defaultLens: 'auto', - allowedLenses: ['intent', 'design', 'oracle'], - defaultGoal: 'grounding-advance', - allowedGoals: ['grounding-advance', 'elicit-expand', 'commit-converge', 'capture-posture'], - promptPackIds: ['elicitor'], - }, -}; +const OPERATIONAL_MODE_IDS: readonly OperationalModeId[] = ['elicit']; +const AGENT_STRATEGY_IDS: readonly AgentStrategyId[] = [ + 'step-wise-decision-tree', + 'step-wise-disambiguate', + 'propose-graph', + 'project-graph', +]; +const AGENT_LENS_IDS: readonly AgentLensId[] = ['intent', 'design', 'oracle']; +const AGENT_GOAL_IDS: readonly AgentGoalId[] = [ + 'grounding-advance', + 'elicit-expand', + 'commit-converge', + 'capture-posture', +]; interface CustomEntryLike { type?: unknown; customType?: unknown; data?: unknown; - details?: unknown; } function isRecord(value: unknown): value is Record { @@ -169,19 +90,14 @@ function isAxisSelection( return value === 'auto' || isOneOf(value, allowed); } -function parseBrunchAgentState(value: unknown): BrunchAgentState | undefined { +export function parseBrunchAgentState(value: unknown): BrunchAgentState | undefined { if (!isRecord(value)) return undefined; - const operationalModes = Object.keys(OPERATIONAL_MODE_DEFINITIONS) as OperationalModeId[]; - if (value.schemaVersion !== 1) return undefined; - if (!isOneOf(value.operationalMode, operationalModes)) return undefined; + if (!isOneOf(value.operationalMode, OPERATIONAL_MODE_IDS)) return undefined; if ('agentRole' in value) return undefined; - - const mode = OPERATIONAL_MODE_DEFINITIONS[value.operationalMode]; - const role = AGENT_ROLE_DEFINITIONS[mode.defaultRole]; - if (!isAxisSelection(value.agentStrategy, role.allowedStrategies)) return undefined; - if (!isAxisSelection(value.agentLens, role.allowedLenses)) return undefined; - if (!isAxisSelection(value.agentGoal, role.allowedGoals)) return undefined; + if (!isAxisSelection(value.agentStrategy, AGENT_STRATEGY_IDS)) return undefined; + if (!isAxisSelection(value.agentLens, AGENT_LENS_IDS)) return undefined; + if (!isAxisSelection(value.agentGoal, AGENT_GOAL_IDS)) return undefined; return { schemaVersion: 1, @@ -192,7 +108,7 @@ function parseBrunchAgentState(value: unknown): BrunchAgentState | undefined { }; } -function parseBrunchAgentStateEntryData(value: unknown): BrunchAgentStateEntryData | undefined { +export function parseBrunchAgentStateEntryData(value: unknown): BrunchAgentStateEntryData | undefined { if (!isRecord(value)) return undefined; if (value.schemaVersion !== 1) return undefined; if (value.reason !== 'init' && value.reason !== 'switch') return undefined; @@ -218,17 +134,6 @@ function parseBrunchAgentStateEntryData(value: unknown): BrunchAgentStateEntryDa }; } -function resolveBrunchAgentState(state: BrunchAgentState): ResolvedBrunchAgentState { - const operationalModeDefinition = OPERATIONAL_MODE_DEFINITIONS[state.operationalMode]; - const agentRole = operationalModeDefinition.defaultRole; - return { - ...state, - agentRole, - operationalModeDefinition, - agentRoleDefinition: AGENT_ROLE_DEFINITIONS[agentRole], - }; -} - export function latestValidBrunchAgentStateEntryData( entries: readonly CustomEntryLike[], ): BrunchAgentStateEntryData | undefined { @@ -245,12 +150,6 @@ export function latestValidBrunchAgentStateEntryData( return latest; } -export function projectBrunchAgentState(entries: readonly CustomEntryLike[]): ResolvedBrunchAgentState { - return resolveBrunchAgentState( - latestValidBrunchAgentStateEntryData(entries)?.state ?? DEFAULT_BRUNCH_AGENT_STATE, - ); -} - export interface BrunchAgentStateEntrySessionManager { getEntries(): readonly CustomEntryLike[]; appendCustomEntry(customType: string, data: BrunchAgentStateEntryData): string; @@ -286,150 +185,14 @@ export function appendBrunchAgentRuntimeSwitch( source: BrunchAgentStateEntryData['source'] = 'user', ): string { const validState = requireValidBrunchAgentState(state); - const previous = projectBrunchAgentState(sessionManager.getEntries()); + const previous = + latestValidBrunchAgentStateEntryData(sessionManager.getEntries())?.state ?? DEFAULT_BRUNCH_AGENT_STATE; return sessionManager.appendCustomEntry(BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, { schemaVersion: 1, reason: 'switch', state: validState, - previous: { - schemaVersion: previous.schemaVersion, - operationalMode: previous.operationalMode, - agentStrategy: previous.agentStrategy, - agentLens: previous.agentLens, - agentGoal: previous.agentGoal, - }, + previous, source, }); } - -export function projectSessionRuntimeState(envelope: BrunchSessionEnvelope): RuntimeStateProjection { - assertLinearBrunchSessionEnvelope(envelope); - const agentState = projectBrunchAgentState(envelope.entries); - - return { - status: 'ready', - specId: envelope.binding.specId, - sessionId: envelope.header.id, - agent: { - operationalMode: agentState.operationalMode, - role: agentState.agentRole, - strategy: agentState.agentStrategy, - lens: agentState.agentLens, - goal: agentState.agentGoal, - }, - mentions: projectMentions(envelope.entries), - world: projectWorld(envelope.entries), - lifecycle: projectLifecycle(envelope.entries), - }; -} - -function projectMentions(entries: readonly FileEntry[]): RuntimeStateProjection['mentions'] { - const graphNodes: GraphNodeMention[] = []; - const files: FileMention[] = []; - - for (const entry of entries) { - if (!isRecord(entry) || entry.type !== 'custom') continue; - const customType = entry.customType; - const data = isRecord(entry.data) ? entry.data : undefined; - if (customType === 'brunch.mention' && data) { - const id = stringField(data.entityId) ?? stringField(data.nodeId) ?? stringField(data.id); - if (id) { - const handle = stringField(data.handle); - const title = stringField(data.title); - const seenLsn = integerField(data.snapshottedLsn); - graphNodes.push({ - id, - ...(handle === undefined ? {} : { handle }), - ...(title === undefined ? {} : { title }), - ...(seenLsn === undefined ? {} : { seenLsn }), - }); - } - } - if (customType === 'brunch.file_mention' && data) { - const path = stringField(data.path); - if (path) { - const seenGitHead = stringField(data.gitHead); - files.push({ - path, - ...(seenGitHead === undefined ? {} : { seenGitHead }), - }); - } - } - } - - return { graphNodes, files }; -} - -function projectWorld(entries: readonly FileEntry[]): RuntimeStateProjection['world'] { - let latestGraph: RuntimeStateProjection['world']['graph'] = { - latestLsn: null, - }; - let gitHead: string | null = null; - - for (const entry of entries) { - if (!isRecord(entry) || entry.type !== 'custom') continue; - if (entry.customType !== 'worldUpdate') continue; - const details = isRecord(entry.details) ? entry.details : isRecord(entry.data) ? entry.data : undefined; - if (!details) continue; - - const lsn = integerField(details.currentLsn) ?? integerField(details.changedSinceLsn) ?? null; - latestGraph = { - latestLsn: lsn, - }; - gitHead = stringField(details.gitHead) ?? gitHead; - } - - return { - graph: latestGraph, - git: { head: gitHead }, - }; -} - -function projectLifecycle(entries: readonly FileEntry[]): RuntimeStateProjection['lifecycle'] { - let lifecycle: RuntimeStateProjection['lifecycle'] = { - specOrigin: null, - sessionOrigin: null, - sessionIndexInSpec: null, - isFirstSessionForSpec: null, - isTenthSessionForSpec: null, - }; - - for (const entry of entries) { - if (!isRecord(entry) || entry.type !== 'custom') continue; - if (entry.customType !== 'brunch.session_lifecycle') continue; - const data = isRecord(entry.data) ? entry.data : undefined; - if (!data) continue; - const index = integerField(data.sessionIndexInSpec) ?? lifecycle.sessionIndexInSpec; - const specOrigin = originField(data.specOrigin, ['new', 'existing'] as const) ?? lifecycle.specOrigin; - const sessionOrigin = - originField(data.sessionOrigin, ['new', 'resumed'] as const) ?? lifecycle.sessionOrigin; - lifecycle = { - specOrigin, - sessionOrigin, - sessionIndexInSpec: index, - isFirstSessionForSpec: - booleanField(data.isFirstSessionForSpec) ?? (index === null ? null : index === 1), - isTenthSessionForSpec: - booleanField(data.isTenthSessionForSpec) ?? (index === null ? null : index === 10), - }; - } - - return lifecycle; -} - -function stringField(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - -function integerField(value: unknown): number | undefined { - return typeof value === 'number' && Number.isInteger(value) ? value : undefined; -} - -function booleanField(value: unknown): boolean | undefined { - return typeof value === 'boolean' ? value : undefined; -} - -function originField(value: unknown, allowed: readonly T[]): T | undefined { - return isOneOf(value, allowed) ? value : undefined; -} diff --git a/src/session/session-transcript.test.ts b/src/session/session-transcript.test.ts index 3ac0538b4..96d7c6105 100644 --- a/src/session/session-transcript.test.ts +++ b/src/session/session-transcript.test.ts @@ -60,13 +60,11 @@ describe('session transcript renderer', () => { ], details: { schema: 'brunch.structured_exchange.present', - schemaVersion: 1, - exchangeId: 'turn-1', - presentTool: 'present_options', - kind: 'options', - status: 'presented', - expectedRequest: { tool: 'request_choice', required: true }, - createdAtToolCallId: 'present-call-1', + v: 1, + exchange_id: 'turn-1', + tool_meta: { curr: 'present_options', next: 'request_choice' }, + display: { heading: 'Which direction?' }, + options: [{ id: 'fast', content: 'Fast', rationale: 'validates the seam.' }], }, isError: false, timestamp: 3, @@ -89,17 +87,13 @@ describe('session transcript renderer', () => { ], details: { schema: 'brunch.structured_exchange.request', - schemaVersion: 1, - exchangeId: 'turn-1', - requestTool: 'request_choice', - status: 'answered', - respondsTo: { - exchangeId: 'turn-1', - presentTool: 'present_options', + v: 1, + exchange_id: 'turn-1', + tool_meta: { prev: 'present_options', curr: 'request_choice' }, + answered: { + choice: { id: 'fast', label: 'Fast', kind: 'listed' }, + comment: 'Keep it deterministic.', }, - choice: { id: 'fast', label: 'Fast' }, - comment: 'Keep it deterministic.', - createdAtToolCallId: 'request-call-1', }, isError: false, timestamp: 4, diff --git a/src/session/session-transcript.ts b/src/session/session-transcript.ts index 204ed3b44..a5ea758dd 100644 --- a/src/session/session-transcript.ts +++ b/src/session/session-transcript.ts @@ -4,8 +4,8 @@ import { fileURLToPath } from 'node:url'; import type { FileEntry } from '@earendil-works/pi-coding-agent'; -import { formatTranscript } from './format/transcript.js'; -import { projectTranscriptContext } from './project/transcript-context.js'; +import { projectTranscriptContext } from '../projections/session/transcript-context.js'; +import { formatTranscript } from '../renderers/session/transcript.js'; type TranscriptEntry = FileEntry; diff --git a/src/session/structured-exchange-loop.test.ts b/src/session/structured-exchange-loop.test.ts index bf3927d32..c2abbc636 100644 --- a/src/session/structured-exchange-loop.test.ts +++ b/src/session/structured-exchange-loop.test.ts @@ -6,6 +6,7 @@ import { acceptedResponseFromParams, nextDeterministicStructuredExchange, pendingExchangeFromEnvelope, + type PendingStructuredExchange, } from './structured-exchange-loop.js'; const header = { type: 'session', id: 'session-1', cwd: '/tmp/brunch-project', timestamp: 0 } as const; @@ -37,10 +38,9 @@ describe('structured exchange loop helpers', () => { content: [{ text: '### Response\n\nA local product specification workspace.' }], details: { schema: 'brunch.structured_exchange.request', - exchangeId: pending.exchangeId, - requestTool: 'request_answer', - status: 'answered', - answer: 'A local product specification workspace.', + exchange_id: pending.exchangeId, + tool_meta: { curr: 'request_answer' }, + answered: { text: 'A local product specification workspace.' }, }, }, }); @@ -62,9 +62,11 @@ describe('structured exchange loop helpers', () => { toolName: 'request_choice', content: [{ text: expect.stringContaining('> This is greenfield.') }], details: { - requestTool: 'request_choice', - comment: 'This is greenfield.', - choice: { id: 'new-from-scratch' }, + tool_meta: { curr: 'request_choice' }, + answered: { + comment: 'This is greenfield.', + choice: { id: 'new-from-scratch', kind: 'listed' }, + }, }, }, }); @@ -96,9 +98,11 @@ describe('structured exchange loop helpers', () => { toolName: 'request_choices', content: [{ text: expect.stringContaining('> Also verify friction reporting.') }], details: { - requestTool: 'request_choices', - comment: 'Also verify friction reporting.', - choices: [{ id: 'transcript' }, { id: 'other' }], + tool_meta: { curr: 'request_choices' }, + answered: { + comment: 'Also verify friction reporting.', + choices: [{ id: 'transcript' }, { id: 'other', kind: 'other' }], + }, }, }, }); @@ -124,7 +128,89 @@ describe('structured exchange loop helpers', () => { ).toEqual({ ok: false, message: 'Invalid elicitation option' }); }); - it('reconstructs pending options from structured present markdown when details omit options', () => { + it('reconstructs a review-mode pending exchange from present_review_set details', () => { + const reviewSet = { + nodes: [{ draft_id: 'g1', plane: 'intent', kind: 'goal', title: 'Review graph proposals' }], + edges: [], + }; + const envelope: BrunchSessionEnvelope = { + header: header as unknown as BrunchSessionEnvelope['header'], + binding, + entries: [ + header, + bindingEntry, + { + id: 'present-review-set-1', + type: 'message', + parentId: 'binding-1', + timestamp: 0, + message: { + role: 'toolResult', + toolCallId: 'present-review-call-1', + toolName: 'present_review_set', + content: [{ type: 'text', text: '## Review cycle wiring\n\nReview this graph proposal.' }], + details: { + schema: 'brunch.structured_exchange.present', + v: 1, + exchange_id: 'review-cycle', + tool_meta: { curr: 'present_review_set', next: 'request_review' }, + display: { heading: 'Review cycle wiring', body: 'Review this graph proposal.' }, + review_set: reviewSet, + }, + isError: false, + }, + }, + ] as unknown as BrunchSessionEnvelope['entries'], + }; + + expect(pendingExchangeFromEnvelope(envelope)).toMatchObject({ + exchangeId: 'review-cycle', + mode: 'review', + prompt: 'Review cycle wiring', + reviewSet, + }); + }); + + it('materializes review decisions as request_review tool results and requires change comments', () => { + const pending = { + exchangeId: 'review-cycle', + lens: 'intent', + mode: 'review', + prompt: 'Review cycle wiring', + options: [], + note: { allowed: true }, + reviewSet: { + nodes: [{ draft_id: 'g1', plane: 'intent', kind: 'goal', title: 'Review graph proposals' }], + edges: [], + }, + } satisfies PendingStructuredExchange; + + expect( + acceptedResponseFromParams(pending, { + exchangeId: 'review-cycle', + answer: { review: { decision: 'request_changes' } }, + }), + ).toEqual({ ok: false, message: 'Review request_changes requires a comment' }); + + expect( + acceptedResponseFromParams(pending, { + exchangeId: 'review-cycle', + answer: { review: { decision: 'reject', comment: 'Not this batch.' } }, + }), + ).toMatchObject({ + ok: true, + answer: { review: { decision: 'reject', comment: 'Not this batch.' } }, + toolResultMessage: { + toolName: 'request_review', + details: { + tool_meta: { prev: 'present_review_set', curr: 'request_review' }, + answered: { decision: 'reject', comment: 'Not this batch.' }, + }, + }, + }); + }); + + it('reconstructs pending options from canonical structured present details', () => { const envelope: BrunchSessionEnvelope = { header: header as unknown as BrunchSessionEnvelope['header'], binding, @@ -156,13 +242,17 @@ describe('structured exchange loop helpers', () => { ], details: { schema: 'brunch.structured_exchange.present', - schemaVersion: 1, - exchangeId: 'quality', - presentTool: 'present_options', - kind: 'options', - status: 'presented', - expectedRequest: { tool: 'request_choice', required: true }, - createdAtToolCallId: 'present-call-1', + v: 1, + exchange_id: 'quality', + tool_meta: { curr: 'present_options', next: 'request_choice' }, + display: { heading: 'Choose proof quality' }, + options: [ + { + id: 'transcript', + content: 'Transcript fidelity', + rationale: 'Pi JSONL keeps truth recoverable.', + }, + ], }, isError: false, }, diff --git a/src/session/structured-exchange-loop.ts b/src/session/structured-exchange-loop.ts index 5173727f1..a446d86f1 100644 --- a/src/session/structured-exchange-loop.ts +++ b/src/session/structured-exchange-loop.ts @@ -1,40 +1,44 @@ -import { Type, type Static } from 'typebox'; -import { Value } from 'typebox/value'; - -import type { StructuredExchangePresentDetails } from '../.pi/extensions/structured-exchange/shared/model.js'; -import { isStructuredExchangePresentDetails } from '../.pi/extensions/structured-exchange/shared/recovery.js'; +import * as z from 'zod'; + +import type { PresentDetails } from '../.pi/extensions/exchanges/schemas/index.js'; +import { isStructuredExchangePresentDetails } from '../.pi/extensions/exchanges/shared/recovery.js'; +import { projectPresentOptions } from '../projections/structured-exchange/present-options.js'; +import { projectPresentQuestion } from '../projections/structured-exchange/present-question.js'; +import { projectRequestAnswer } from '../projections/structured-exchange/request-answer.js'; +import { projectRequestChoice } from '../projections/structured-exchange/request-choice.js'; +import { projectRequestChoices } from '../projections/structured-exchange/request-choices.js'; +import { projectRequestReview } from '../projections/structured-exchange/request-review.js'; +import { formatPresentOptions } from '../renderers/structured-exchange/present-options.js'; +import { formatPresentQuestion } from '../renderers/structured-exchange/present-question.js'; import type { BrunchSessionEnvelope } from './brunch-session-envelope.js'; import { projectLinearSessionExchangeProjection } from './exchange-projection.js'; -const NonBlankStringSchema = Type.String({ minLength: 1 }); - -export const PendingStructuredExchangeSchema = Type.Object( - { - exchangeId: NonBlankStringSchema, - lens: Type.Literal('intent'), - mode: Type.Union([Type.Literal('text'), Type.Literal('single-select'), Type.Literal('multi-select')]), - prompt: NonBlankStringSchema, - details: Type.Optional(NonBlankStringSchema), - options: Type.Array( - Type.Object( - { - id: NonBlankStringSchema, - label: NonBlankStringSchema, - content: NonBlankStringSchema, - rationale: Type.Optional(NonBlankStringSchema), - }, - { additionalProperties: false }, - ), +const zNonBlankString = z.string().min(1); + +export const zPendingStructuredExchange = z + .object({ + exchangeId: zNonBlankString, + lens: z.literal('intent'), + mode: z.enum(['text', 'single-select', 'multi-select', 'review']), + prompt: zNonBlankString, + details: zNonBlankString.optional(), + options: z.array( + z + .object({ + id: zNonBlankString, + label: zNonBlankString, + content: zNonBlankString, + rationale: zNonBlankString.optional(), + }) + .strict(), ), - note: Type.Object( - { allowed: Type.Boolean() }, - { - additionalProperties: false, - }, - ), - }, - { additionalProperties: false }, -); + note: z.object({ allowed: z.boolean() }).strict(), + reviewSet: z.record(z.string(), z.unknown()).optional(), + }) + .strict(); +export const PendingStructuredExchangeSchema = z.toJSONSchema(zPendingStructuredExchange, { + unrepresentable: 'throw', +}); export interface StructuredExchangeTextResponseInput { exchangeId: string; @@ -54,10 +58,17 @@ export interface StructuredExchangeMultiChoiceResponseInput { note?: string | undefined; } +export interface StructuredExchangeReviewResponseInput { + exchangeId: string; + answer: { review: { decision: 'approve' | 'request_changes' | 'reject'; comment?: string | undefined } }; + note?: string | undefined; +} + export type StructuredExchangeResponseInput = | StructuredExchangeTextResponseInput | StructuredExchangeSingleChoiceResponseInput - | StructuredExchangeMultiChoiceResponseInput; + | StructuredExchangeMultiChoiceResponseInput + | StructuredExchangeReviewResponseInput; export interface AcceptedToolTextContent { type: 'text'; @@ -74,7 +85,7 @@ export interface AcceptedToolResultMessage { timestamp: 0; } -export type PendingStructuredExchange = Static; +export type PendingStructuredExchange = z.infer; export type AcceptedStructuredExchangeResponse = | { @@ -175,38 +186,50 @@ export function nextDeterministicStructuredExchange(completedCount: number): Pen } export function presentToolResultMessage(exchange: PendingStructuredExchange) { - const presentTool = exchange.mode === 'text' ? 'present_question' : 'present_options'; - const requestTool = - exchange.mode === 'text' - ? 'request_answer' - : exchange.mode === 'multi-select' - ? 'request_choices' - : 'request_choice'; - const toolCallId = `${exchange.exchangeId}:${presentTool}`; + const projection = presentProjection(exchange); return { role: 'toolResult' as const, - toolCallId, - toolName: presentTool, - content: [{ type: 'text' as const, text: presentMarkdown(exchange) }], - details: { - schema: 'brunch.structured_exchange.present', - schemaVersion: 1, - exchangeId: exchange.exchangeId, - presentTool, - kind: exchange.mode === 'text' ? 'question' : 'options', - status: 'presented', - expectedRequest: { tool: requestTool, required: true }, - createdAtToolCallId: toolCallId, - prompt: exchange.prompt, - details: exchange.details, - lens: exchange.lens, - options: exchange.options, - }, + toolCallId: `${exchange.exchangeId}:${projection.toolName}`, + toolName: projection.toolName, + content: [{ type: 'text' as const, text: projection.markdown }], + details: projection.details, isError: false as const, timestamp: 0 as const, }; } +function presentProjection(exchange: PendingStructuredExchange): { + toolName: 'present_question' | 'present_options'; + markdown: string; + details: PresentDetails; +} { + if (exchange.mode === 'text') { + const projection = projectPresentQuestion({ + exchangeId: exchange.exchangeId, + heading: exchange.prompt, + body: exchange.details, + }); + return { + toolName: 'present_question', + markdown: formatPresentQuestion(projection), + details: projection.details, + }; + } + + const projection = projectPresentOptions({ + exchangeId: exchange.exchangeId, + heading: exchange.prompt, + body: exchange.details, + options: exchange.options, + expectedRequestTool: exchange.mode === 'multi-select' ? 'request_choices' : 'request_choice', + }); + return { + toolName: 'present_options', + markdown: formatPresentOptions(projection), + details: projection.details, + }; +} + export function pendingExchangeFromEnvelope( envelope: BrunchSessionEnvelope, ): PendingStructuredExchange | null { @@ -221,10 +244,10 @@ export function pendingExchangeFromEnvelope( candidate.type === 'custom_message' && candidate.id === entryId && candidate.customType === 'brunch.elicitation_prompt' && - Value.Check(PendingStructuredExchangeSchema, candidate.details), + zPendingStructuredExchange.safeParse(candidate.details).success, ); if (entry?.type === 'custom_message') { - return Value.Parse(PendingStructuredExchangeSchema, entry.details); + return zPendingStructuredExchange.parse(entry.details); } } @@ -257,14 +280,17 @@ export function acceptedResponseFromParams( ): AcceptedStructuredExchangeResponse { if ('text' in params.answer) { if (pending.mode !== 'text') return invalidResponseMode(); - const details = requestDetailsBase(pending, 'request_answer'); return { ok: true, answer: { text: params.answer.text }, toolResultMessage: { ...toolResultMessageBase(pending, 'request_answer'), content: [{ type: 'text', text: `### Response\n\n${params.answer.text}` }], - details: { ...details, answer: params.answer.text }, + details: projectRequestAnswer({ + exchangeId: pending.exchangeId, + status: 'answered', + answer: params.answer.text, + }), }, }; } @@ -274,17 +300,48 @@ export function acceptedResponseFromParams( const optionId = params.answer.optionId; const choice = pending.options.find((option) => option.id === optionId); if (!choice) return { ok: false, message: 'Invalid elicitation option' }; - const details = requestDetailsBase(pending, 'request_choice'); - if (params.note !== undefined && params.note.trim().length > 0) { - details.comment = params.note.trim(); - } + const comment = params.note?.trim(); return { ok: true, answer: { optionId: choice.id, label: choice.label }, toolResultMessage: { ...toolResultMessageBase(pending, 'request_choice'), content: [{ type: 'text', text: choiceResponseMarkdown([choice], params.note) }], - details: { ...details, choice }, + details: projectRequestChoice({ + exchangeId: pending.exchangeId, + respondsToPresentTool: 'present_options', + status: 'answered', + choice: { id: choice.id, label: choice.label, kind: choiceKind(choice.id) }, + comment, + }), + }, + }; + } + + if ('review' in params.answer) { + if (pending.mode !== 'review') return invalidResponseMode(); + const review = params.answer.review; + const comment = review.comment?.trim(); + if (review.decision === 'request_changes' && (comment === undefined || comment.length === 0)) { + return { ok: false, message: 'Review request_changes requires a comment' }; + } + return { + ok: true, + answer: { + review: { + decision: review.decision, + ...(comment !== undefined ? { comment } : {}), + }, + }, + toolResultMessage: { + ...toolResultMessageBase(pending, 'request_review'), + content: [{ type: 'text', text: reviewResponseMarkdown(review.decision, comment) }], + details: projectRequestReview({ + exchangeId: pending.exchangeId, + status: 'answered', + review: review.decision, + comment, + }), }, }; } @@ -304,17 +361,23 @@ export function acceptedResponseFromParams( message: 'Elicitation response requires a comment for Other or None selections', }; } - const details = requestDetailsBase(pending, 'request_choices'); - if (params.note !== undefined && params.note.trim().length > 0) { - details.comment = params.note.trim(); - } + const comment = params.note?.trim(); return { ok: true, answer: { optionIds: choices.map((choice) => choice.id), choices }, toolResultMessage: { ...toolResultMessageBase(pending, 'request_choices'), content: [{ type: 'text', text: choiceResponseMarkdown(choices, params.note) }], - details: { ...details, choices }, + details: projectRequestChoices({ + exchangeId: pending.exchangeId, + status: 'answered', + choices: choices.map((choice) => ({ + id: choice.id, + label: choice.label, + kind: choiceKind(choice.id), + })), + comment, + }), }, }; } @@ -326,27 +389,15 @@ function invalidResponseMode(): AcceptedStructuredExchangeResponse { }; } -function requestDetailsBase( - pending: PendingStructuredExchange, - requestTool: 'request_answer' | 'request_choice' | 'request_choices', -): Record { - return { - schema: 'brunch.structured_exchange.request', - schemaVersion: 1, - exchangeId: pending.exchangeId, - requestTool, - status: 'answered', - respondsTo: { - exchangeId: pending.exchangeId, - presentTool: pending.mode === 'text' ? 'present_question' : 'present_options', - }, - createdAtToolCallId: `${pending.exchangeId}:${requestTool}`, - }; +function choiceKind(id: string): 'listed' | 'other' | 'none' { + if (id === 'other') return 'other'; + if (id === 'none') return 'none'; + return 'listed'; } function toolResultMessageBase( pending: PendingStructuredExchange, - requestTool: 'request_answer' | 'request_choice' | 'request_choices', + requestTool: 'request_answer' | 'request_choice' | 'request_choices' | 'request_review', ) { return { role: 'toolResult' as const, @@ -365,52 +416,65 @@ function choiceResponseMarkdown(choices: Array<{ label: string }>, comment: stri return lines.join('\n'); } -function presentMarkdown(exchange: PendingStructuredExchange): string { - if (exchange.mode === 'text') { - return [`## ${exchange.prompt}`, exchange.details].filter(Boolean).join('\n\n'); +function reviewResponseMarkdown( + decision: 'approve' | 'request_changes' | 'reject', + comment: string | undefined, +): string { + const label = + decision === 'approve' ? 'Approved' : decision === 'request_changes' ? 'Requested changes' : 'Rejected'; + const lines = ['### Review decision', '', label]; + if (comment !== undefined && comment.length > 0) { + lines.push('', 'Comment:', '', `> ${comment}`); } - const lines = [`## ${exchange.prompt}`]; - if (exchange.details) lines.push('', exchange.details); - exchange.options.forEach((option, index) => { - lines.push('', `### ${index + 1}. ${option.content}`); - if (option.rationale) { - lines.push('', `**Rationale:** ${option.rationale}`); - } - lines.push('', ``); - }); return lines.join('\n'); } function pendingExchangeFromStructuredPresent( - details: StructuredExchangePresentDetails, + details: PresentDetails, markdown: string, ): PendingStructuredExchange { - const richDetails = details as StructuredExchangePresentDetails & { - prompt?: unknown; - details?: unknown; - options?: unknown; - }; - const prompt = - typeof richDetails.prompt === 'string' - ? richDetails.prompt - : (firstNonEmptyMarkdownLine(markdown) ?? markdown); - const detailsText = typeof richDetails.details === 'string' ? richDetails.details : markdown; + const prompt = details.display.heading; + const detailsText = presentDetailsText(details, markdown); + if ('review_set' in details) { + return { + exchangeId: details.exchange_id, + lens: 'intent', + mode: 'review', + prompt, + ...(detailsText.length > 0 ? { details: detailsText } : {}), + options: [], + note: { allowed: true }, + reviewSet: details.review_set, + }; + } + return { - exchangeId: details.exchangeId, + exchangeId: details.exchange_id, lens: 'intent', mode: - details.expectedRequest?.tool === 'request_choices' + details.tool_meta.next === 'request_choices' ? 'multi-select' - : details.presentTool === 'present_question' + : details.tool_meta.curr === 'present_question' ? 'text' : 'single-select', prompt, ...(detailsText.length > 0 ? { details: detailsText } : {}), - options: parsePendingOptions(richDetails.options, markdown), + options: + 'options' in details + ? parsePendingOptions(details.options, markdown) + : parsePendingOptions(undefined, markdown), note: { allowed: true }, }; } +function presentDetailsText(details: PresentDetails, markdown: string): string { + if ('preface' in details.display && details.display.preface && details.display.body) { + return `${details.display.preface}\n\n${details.display.body}`; + } + if ('preface' in details.display && details.display.preface) return details.display.preface; + return details.display.body ?? markdown; +} + function parsePendingOptions(value: unknown, markdown: string = ''): PendingChoice[] { if (!Array.isArray(value)) return parseMarkdownPendingOptions(markdown); const options = value.flatMap((option) => { @@ -473,7 +537,7 @@ function parseMarkdownPendingOptions(markdown: string): PendingChoice[] { return options; } -function structuredExchangePresentDetails(entry: unknown): StructuredExchangePresentDetails | undefined { +function structuredExchangePresentDetails(entry: unknown): PresentDetails | undefined { if (typeof entry !== 'object' || entry === null || (entry as { type?: unknown }).type !== 'message') { return undefined; } @@ -486,16 +550,7 @@ function structuredExchangePresentDetails(entry: unknown): StructuredExchangePre return undefined; } const details = (message as { details?: unknown }).details; - return isStructuredExchangePresentDetails(details) - ? (details as StructuredExchangePresentDetails) - : undefined; -} - -function firstNonEmptyMarkdownLine(markdown: string): string | undefined { - return markdown - .split('\n') - .map((line) => line.replace(/^#+\s*/, '').trim()) - .find((line) => line.length > 0); + return isStructuredExchangePresentDetails(details) ? (details as PresentDetails) : undefined; } function textContent(content: unknown): string { diff --git a/src/session/structured-exchange.ts b/src/session/structured-exchange.ts deleted file mode 100644 index 03d3f14a6..000000000 --- a/src/session/structured-exchange.ts +++ /dev/null @@ -1,67 +0,0 @@ -export const STRUCTURED_EXCHANGE_RESULT_SCHEMA = 'brunch.structured_exchange.result' as const; - -export type StructuredExchangeStatus = 'answered' | 'cancelled' | 'unavailable'; -export type StructuredExchangeMode = 'text' | 'single-select' | 'multi-select'; - -export interface StructuredExchangeOption { - label: string; - value: string; - description?: string; -} - -export type StructuredExchangeAnswer = - | { - type: 'text'; - label: string; - value: string; - } - | { - type: 'option'; - label: string; - value: string; - index: number; - } - | { - type: 'other'; - label: string; - value: string; - }; - -export interface StructuredExchangeResultDetails { - schema: typeof STRUCTURED_EXCHANGE_RESULT_SCHEMA; - schemaVersion: 1; - status: StructuredExchangeStatus; - question: string; - context?: string; - mode: StructuredExchangeMode; - options?: StructuredExchangeOption[]; - answers: StructuredExchangeAnswer[]; - rejectedOptions?: StructuredExchangeOption[]; - note?: string; - transport?: { surface: 'tui-custom' | 'rpc-editor' | 'headless' }; - message?: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -export function isStructuredExchangeResultDetails(value: unknown): value is StructuredExchangeResultDetails { - if (!isRecord(value)) return false; - return ( - value.schema === STRUCTURED_EXCHANGE_RESULT_SCHEMA && - value.schemaVersion === 1 && - (value.status === 'answered' || value.status === 'cancelled' || value.status === 'unavailable') && - typeof value.question === 'string' && - (value.mode === 'text' || value.mode === 'single-select' || value.mode === 'multi-select') && - Array.isArray(value.answers) - ); -} - -export function isTerminalStructuredExchangeResultDetails( - value: unknown, -): value is StructuredExchangeResultDetails { - return ( - isStructuredExchangeResultDetails(value) && (value.status === 'answered' || value.status === 'cancelled') - ); -} diff --git a/src/session/workspace-session-coordinator.test.ts b/src/session/workspace-session-coordinator.test.ts index 86df805e7..cb8536a60 100644 --- a/src/session/workspace-session-coordinator.test.ts +++ b/src/session/workspace-session-coordinator.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { SessionManager, type SessionEntry } from '@earendil-works/pi-coding-agent'; import { describe, expect, it } from 'vitest'; +import { openWorkspaceCommandExecutor } from '../graph/index.js'; import { assistantMessage, userMessage, isCustomEntry } from '../probes/test-helpers.js'; import { projectSessionExchanges } from './exchange-projection.js'; import { SESSION_BINDING_TYPE } from './session-binding.js'; @@ -235,7 +236,7 @@ describe('WorkspaceSessionCoordinator', () => { expect(oracle.sessions.every((session) => session.bindingCount === 1)).toBe(true); }); - it('inspects current defaults, bound specs, and sessions without activation writes', async () => { + it('inspects workspace defaults, DB specs, and sessions without activation writes', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-ws-')); const coordinator = createWorkspaceSessionCoordinator({ cwd }); @@ -250,6 +251,7 @@ describe('WorkspaceSessionCoordinator', () => { const beforeSecond = await readFile(second.session.file, 'utf8'); const inventory = await coordinator.inspectWorkspace(); + const oracle = await verifyWorkspaceSessionStores({ cwd, expectedSessionCount: 2 }); expect(inventory.cwd).toBe(cwd); expect(inventory.needsNewSpec).toBe(false); @@ -275,6 +277,12 @@ describe('WorkspaceSessionCoordinator', () => { }), ]); expect(inventory.unavailableSessions).toEqual([]); + expect(oracle.ok).toBe(true); + if (!oracle.ok) return; + expect(oracle.sessions.map((session) => session.binding.specId).sort((a, b) => a - b)).toEqual([ + first.spec.id, + second.spec.id, + ]); await expect(readFile(join(cwd, '.brunch', 'workspace.json'), 'utf8')).resolves.toBe(beforeState); await expect(readFile(first.session.file, 'utf8')).resolves.toBe(beforeFirst); await expect(readFile(second.session.file, 'utf8')).resolves.toBe(beforeSecond); @@ -299,6 +307,37 @@ describe('WorkspaceSessionCoordinator', () => { }); }); + it('lists database specs even when no sessions are bound yet', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-ws-')); + const executor = await openWorkspaceCommandExecutor(cwd); + const alpha = executor.createSpec({ name: 'Alpha', slug: 'alpha' }); + const beta = executor.createSpec({ name: 'Beta', slug: 'beta' }); + expect(alpha.status).toBe('success'); + expect(beta.status).toBe('success'); + if (alpha.status !== 'success' || beta.status !== 'success') return; + + const coordinator = createWorkspaceSessionCoordinator({ cwd }); + const inventory = await coordinator.inspectWorkspace(); + + expect(inventory).toMatchObject({ + cwd, + currentSpec: null, + currentSessionFile: null, + needsNewSpec: false, + unavailableSessions: [], + }); + expect(inventory.specs).toEqual([ + { spec: { id: alpha.specId, title: 'Alpha' }, sessions: [] }, + { spec: { id: beta.specId, title: 'Beta' }, sessions: [] }, + ]); + + const activated = await coordinator.activateWorkspace({ action: 'newSession', specId: beta.specId }); + + expect(activated.status).toBe('ready'); + if (activated.status !== 'ready') return; + expect(activated.spec).toEqual({ id: beta.specId, title: 'Beta' }); + }); + it('marks unbound or incompatible sessions unavailable during inventory', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-ws-')); const coordinator = createWorkspaceSessionCoordinator({ cwd }); @@ -378,7 +417,7 @@ describe('WorkspaceSessionCoordinator', () => { } }); - it('activates explicit open and continue decisions as the current workspace', async () => { + it('activates explicit open and continue decisions as workspace defaults', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-ws-')); const coordinator = createWorkspaceSessionCoordinator({ cwd }); const first = await coordinator.createSetupSession({ specTitle: 'Alpha' }); @@ -415,7 +454,7 @@ describe('WorkspaceSessionCoordinator', () => { expect(continued.spec).toEqual(second.spec); expect(continued.session.id).toBe(second.session.id); expect(JSON.parse(await readFile(join(cwd, '.brunch', 'workspace.json'), 'utf8'))).toMatchObject({ - current: { specId: second.spec.id, sessionId: second.session.id }, + defaults: { specId: second.spec.id, sessionId: second.session.id }, }); }); @@ -448,7 +487,7 @@ describe('WorkspaceSessionCoordinator', () => { expect(oracle.ok).toBe(true); }); - it('activates a new spec decision by creating a bound current session', async () => { + it('activates a new spec decision by creating a bound default session', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-ws-')); const coordinator = createWorkspaceSessionCoordinator({ cwd }); @@ -510,7 +549,7 @@ describe('WorkspaceSessionCoordinator', () => { expect(mismatched.status).toBe('needs_human'); }); - it('scaffolds workspace.json and data.db when no current spec exists', async () => { + it('scaffolds workspace.json and data.db when no default spec exists', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-ws-')); const coordinator = createWorkspaceSessionCoordinator({ cwd }); @@ -522,7 +561,7 @@ describe('WorkspaceSessionCoordinator', () => { await expect(stat(join(cwd, '.brunch', 'data.db'))).resolves.toMatchObject({}); expect(JSON.parse(await readFile(join(cwd, '.brunch', 'workspace.json'), 'utf8'))).toMatchObject({ project: expect.objectContaining({ name: expect.any(String), slug: expect.any(String) }), - current: null, + defaults: null, posture: { certainty: '', stakes: '', diff --git a/src/session/workspace-session-coordinator.ts b/src/session/workspace-session-coordinator.ts index 1d9dddec8..c08a9d2e7 100644 --- a/src/session/workspace-session-coordinator.ts +++ b/src/session/workspace-session-coordinator.ts @@ -4,7 +4,7 @@ import { join, resolve } from 'node:path'; import { SessionManager } from '@earendil-works/pi-coding-agent'; import { openWorkspaceCommandExecutor, type SpecRecord } from '../graph/index.js'; -import { discoverProjectIdentity } from './project-identity.js'; +import { discoverProjectIdentity, slugify } from './project-identity.js'; import { createSessionBindingData, isSessionBindingEntry, @@ -26,7 +26,7 @@ export interface WorkspaceSpecState { title: string; } -interface WorkspaceProjectState { +export interface WorkspaceProjectState { name: string; slug: string; } @@ -40,7 +40,7 @@ export interface WorkspacePostureState { sourcing: string; } -interface WorkspaceCurrentState { +interface WorkspaceDefaultState { specId: number; sessionId: string; } @@ -48,12 +48,13 @@ interface WorkspaceCurrentState { interface WorkspaceStateFile { schemaVersion: 1; project: WorkspaceProjectState; - current: WorkspaceCurrentState | null; + defaults: WorkspaceDefaultState | null; posture: WorkspacePostureState; } export interface WorkspaceSessionChromeState { cwd: string; + project?: WorkspaceProjectState; spec: WorkspaceSpecState | null; phase: 'select_spec' | 'elicitation'; chatMode: 'select-spec' | 'responding-to-elicitation'; @@ -162,6 +163,7 @@ export interface WorkspaceUnavailableSession { export interface WorkspaceLaunchInventory { cwd: string; + project?: WorkspaceProjectState; currentSpec: WorkspaceSpecState | null; currentSessionFile: string | null; needsNewSpec: boolean; @@ -221,11 +223,11 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { async activateWorkspace(decision: SpecSessionActivationDecision): Promise { if (decision.action === 'cancel') { const state = await readWorkspaceState(this.#cwd); - const spec = state ? await currentSpecFromState(this.#cwd, state) : null; + const spec = state ? await defaultSpecFromState(this.#cwd, state) : null; return { status: 'cancelled', cwd: this.#cwd, - chrome: chromeState(this.#cwd, spec), + chrome: chromeState(this.#cwd, spec, state?.project), }; } @@ -244,13 +246,14 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { this.#cwd, inventory.currentSpec, 'Selected spec is not available in this workspace.', + inventory.project, ); } if (decision.action === 'newSession') { const session = await createBoundSession(this.#cwd, spec.spec); - await writeCurrentWorkspaceState(this.#cwd, spec.spec, session.id); - return readyState(this.#cwd, spec.spec, session); + await writeWorkspaceDefaults(this.#cwd, spec.spec, session.id); + return readyState(this.#cwd, spec.spec, session, inventory.project); } const session = spec.sessions.find((candidate) => candidate.file === decision.sessionFile); @@ -259,37 +262,43 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { this.#cwd, inventory.currentSpec, 'Selected session is not available for the selected spec.', + inventory.project, ); } const manager = SessionManager.open(session.file, sessionDir(this.#cwd), this.#cwd); const opened = bindSessionToSpec(manager, spec.spec); - await writeCurrentWorkspaceState(this.#cwd, spec.spec, opened.id); - return readyState(this.#cwd, spec.spec, opened); + await writeWorkspaceDefaults(this.#cwd, spec.spec, opened.id); + return readyState(this.#cwd, spec.spec, opened, inventory.project); } async openDefaultWorkspace(): Promise { const state = await readOrCreateWorkspaceState(this.#cwd); - const current = state.current; - if (!current) { + const defaults = state.defaults; + if (!defaults) { return { status: 'select_spec', cwd: this.#cwd, - chrome: chromeState(this.#cwd, null), + chrome: chromeState(this.#cwd, null, state.project), }; } - const spec = await getSpecState(this.#cwd, current.specId); + const spec = await getSpecState(this.#cwd, defaults.specId); if (!spec) { - return needsHumanState(this.#cwd, null, 'Current spec is missing from the workspace database.'); + return needsHumanState( + this.#cwd, + null, + 'Default spec is missing from the workspace database.', + state.project, + ); } - const session = await openCurrentSession(this.#cwd, spec, current.sessionId); + const session = await openDefaultSession(this.#cwd, spec, defaults.sessionId); if (!session) { - return needsHumanState(this.#cwd, spec, 'Current session is missing or stale.'); + return needsHumanState(this.#cwd, spec, 'Default session is missing or stale.', state.project); } - await writeCurrentWorkspaceState(this.#cwd, spec, session.id); - return readyState(this.#cwd, spec, session); + await writeWorkspaceDefaults(this.#cwd, spec, session.id); + return readyState(this.#cwd, spec, session, state.project); } async createSetupSession(options?: { @@ -298,46 +307,46 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { }): Promise { const state = await readOrCreateWorkspaceState(this.#cwd); const existing = - state.current && !options?.createNewSpec ? await getSpecState(this.#cwd, state.current.specId) : null; + state.defaults && !options?.createNewSpec ? await getSpecState(this.#cwd, state.defaults.specId) : null; const spec = existing ?? (await createSpec(this.#cwd, options?.specTitle)); const session = await createBoundSession(this.#cwd, spec); - await writeCurrentWorkspaceState(this.#cwd, spec, session.id); - return readyState(this.#cwd, spec, session); + await writeWorkspaceDefaults(this.#cwd, spec, session.id); + return readyState(this.#cwd, spec, session, state.project); } async createSetupSessionForCurrentSpec(): Promise { const state = await readWorkspaceState(this.#cwd); - const spec = state ? await currentSpecFromState(this.#cwd, state) : null; + const spec = state ? await defaultSpecFromState(this.#cwd, state) : null; if (!spec) { return { status: 'needs_human', cwd: this.#cwd, - reason: 'No current spec is selected for this workspace.', - chrome: chromeState(this.#cwd, null), + reason: 'No default spec is selected for this workspace.', + chrome: chromeState(this.#cwd, null, state?.project), }; } const session = await createBoundSession(this.#cwd, spec); - await writeCurrentWorkspaceState(this.#cwd, spec, session.id); - return readyState(this.#cwd, spec, session); + await writeWorkspaceDefaults(this.#cwd, spec, session.id); + return readyState(this.#cwd, spec, session, state?.project); } async bindCurrentSpecToReplacementSession(manager: SessionManager): Promise { const state = await readWorkspaceState(this.#cwd); - const spec = state ? await currentSpecFromState(this.#cwd, state) : null; + const spec = state ? await defaultSpecFromState(this.#cwd, state) : null; if (!spec) { - throw new Error('No current spec is selected for this workspace.'); + throw new Error('No default spec is selected for this workspace.'); } const session = bindSessionToSpec(manager, spec); - await writeCurrentWorkspaceState(this.#cwd, spec, session.id); - return readyState(this.#cwd, spec, session); + await writeWorkspaceDefaults(this.#cwd, spec, session.id); + return readyState(this.#cwd, spec, session, state?.project); } async deriveDefaultChromeState(): Promise { const state = await readWorkspaceState(this.#cwd); - const spec = state ? await currentSpecFromState(this.#cwd, state) : null; - return chromeState(this.#cwd, spec); + const spec = state ? await defaultSpecFromState(this.#cwd, state) : null; + return chromeState(this.#cwd, spec, state?.project); } } @@ -356,6 +365,11 @@ async function getSpecState(cwd: string, specId: number): Promise { + const executor = await openWorkspaceCommandExecutor(cwd); + return executor.listSpecs().map(specStateFromRecord); +} + function specStateFromRecord(spec: SpecRecord): WorkspaceSpecState { return { id: spec.id, title: spec.name }; } @@ -387,15 +401,15 @@ async function countSessionsForSpec(cwd: string, specId: number): Promise session.available && session.specId === specId).length; } -async function openCurrentSession( +async function openDefaultSession( cwd: string, spec: WorkspaceSpecState, - currentSessionId: string, + defaultSessionId: string, ): Promise { await ensureWorkspaceDirs(cwd); const sessions = await inspectCanonicalSessionFiles(cwd); for (const session of sessions) { - if (session.available && session.id === currentSessionId && session.specId === spec.id) { + if (session.available && session.id === defaultSessionId && session.specId === spec.id) { const manager = SessionManager.open(session.file, sessionDir(cwd), cwd); return bindSessionToSpec(manager, spec); } @@ -478,7 +492,7 @@ async function readWorkspaceState(cwd: string): Promise { - return state.current ? getSpecState(cwd, state.current.specId) : null; + return state.defaults ? getSpecState(cwd, state.defaults.specId) : null; } function isProjectState(value: unknown): value is WorkspaceProjectState { @@ -523,7 +537,7 @@ function isProjectState(value: unknown): value is WorkspaceProjectState { ); } -function isCurrentState(value: unknown): value is WorkspaceCurrentState { +function isDefaultState(value: unknown): value is WorkspaceDefaultState { return ( typeof value === 'object' && value !== null && @@ -555,11 +569,11 @@ async function inspectWorkspaceInventory(cwd: string): Promise(); const unavailableSessions: WorkspaceUnavailableSession[] = []; - const currentSpec = await currentSpecFromState(cwd, state); + const [currentSpec, dbSpecs] = await Promise.all([defaultSpecFromState(cwd, state), listSpecStates(cwd)]); - if (currentSpec) { - specsById.set(currentSpec.id, { - spec: currentSpec, + for (const dbSpec of dbSpecs) { + specsById.set(dbSpec.id, { + spec: dbSpec, sessions: [], }); } @@ -585,13 +599,14 @@ async function inspectWorkspaceInventory(cwd: string): Promise left.spec.title.localeCompare(right.spec.title)); - const currentSessionFile = state.current - ? (specs.flatMap((spec) => spec.sessions).find((session) => session.id === state.current?.sessionId) + const currentSessionFile = state.defaults + ? (specs.flatMap((spec) => spec.sessions).find((session) => session.id === state.defaults?.sessionId) ?.file ?? null) : null; return { cwd, + project: state.project, currentSpec, currentSessionFile, needsNewSpec: specs.length === 0, @@ -618,15 +633,15 @@ async function writeWorkspaceState(cwd: string, state: WorkspaceStateFile): Prom await writeFile(statePath(cwd), `${JSON.stringify(state, null, 2)}\n`, 'utf8'); } -async function writeCurrentWorkspaceState( +async function writeWorkspaceDefaults( cwd: string, spec: WorkspaceSpecState, - currentSessionId: string, + defaultSessionId: string, ): Promise { const existing = await readOrCreateWorkspaceState(cwd); await writeWorkspaceState(cwd, { ...existing, - current: { specId: spec.id, sessionId: currentSessionId }, + defaults: { specId: spec.id, sessionId: defaultSessionId }, }); } @@ -634,13 +649,14 @@ function readyState( cwd: string, spec: WorkspaceSpecState, session: WorkspaceSessionReadyState['session'], + project?: WorkspaceProjectState, ): WorkspaceSessionReadyState { return { status: 'ready', cwd, spec, session, - chrome: chromeState(cwd, spec), + chrome: chromeState(cwd, spec, project), }; } @@ -648,24 +664,35 @@ function needsHumanState( cwd: string, spec: WorkspaceSpecState | null, reason: string, + project?: WorkspaceProjectState, ): WorkspaceSessionNeedsHumanState { return { status: 'needs_human', cwd, reason, - chrome: chromeState(cwd, spec), + chrome: chromeState(cwd, spec, project), }; } -function chromeState(cwd: string, spec: WorkspaceSpecState | null): WorkspaceSessionChromeState { +function chromeState( + cwd: string, + spec: WorkspaceSpecState | null, + project?: WorkspaceProjectState, +): WorkspaceSessionChromeState { return { cwd, + project: project ?? projectStateFromCwd(cwd), spec, phase: spec ? 'elicitation' : 'select_spec', chatMode: spec ? 'responding-to-elicitation' : 'select-spec', }; } +function projectStateFromCwd(cwd: string): WorkspaceProjectState { + const name = cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? 'project'; + return { name, slug: slugify(name) }; +} + export interface WorkspaceStoreOracleOptions { cwd: string; expectedSessionCount?: number; @@ -701,6 +728,6 @@ export async function verifyWorkspaceSessionStores( return verifyCanonicalSessionStore({ cwd, expectedSessionCount: options.expectedSessionCount, - currentSpecId: state.current?.specId ?? null, + defaultSpecId: state.defaults?.specId ?? null, }); } diff --git a/src/session/workspace-session-coordinator/boot-session-store.ts b/src/session/workspace-session-coordinator/boot-session-store.ts index 4d915d9e9..3f01a8f34 100644 --- a/src/session/workspace-session-coordinator/boot-session-store.ts +++ b/src/session/workspace-session-coordinator/boot-session-store.ts @@ -36,7 +36,7 @@ export async function inspectCanonicalSessionFiles(cwd: string): Promise { const classifiedSessions = await inspectCanonicalSessionFiles(options.cwd); const errors: string[] = []; @@ -62,11 +62,6 @@ export async function verifyCanonicalSessionStore(options: { errors.push(formatUnavailableSessionError(session)); continue; } - if (options.currentSpecId !== null && session.specId !== options.currentSpecId) { - errors.push( - `${session.file} binding spec ${session.specId} does not match state ${options.currentSpecId}`, - ); - } sessions.push({ file: session.file, sessionId: session.id, @@ -75,7 +70,7 @@ export async function verifyCanonicalSessionStore(options: { }); } - return errors.length === 0 ? { ok: true, specId: options.currentSpecId, sessions } : { ok: false, errors }; + return errors.length === 0 ? { ok: true, specId: options.defaultSpecId, sessions } : { ok: false, errors }; } async function inspectCanonicalSessionFile(file: string): Promise { diff --git a/src/structured-exchange/format/present-options.ts b/src/structured-exchange/format/present-options.ts deleted file mode 100644 index 65ebcb3a9..000000000 --- a/src/structured-exchange/format/present-options.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Formats projected `present_options` data into durable markdown. - * - * Input: - * - projected output from structured-exchange/project/present-options.ts - * - * Output: - * - durable prompt-side markdown for toolResult.content - */ - -export {}; diff --git a/src/structured-exchange/format/present-review-set.ts b/src/structured-exchange/format/present-review-set.ts deleted file mode 100644 index e2fcd98a7..000000000 --- a/src/structured-exchange/format/present-review-set.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Formats projected `present_review_set` data into durable markdown. - * - * Input: - * - projected output from structured-exchange/project/present-review-set.ts - * - * Output: - * - durable review-set markdown for toolResult.content - */ - -export {}; diff --git a/src/structured-exchange/format/request-answer.ts b/src/structured-exchange/format/request-answer.ts deleted file mode 100644 index f6541fdc8..000000000 --- a/src/structured-exchange/format/request-answer.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Formats projected `request_answer` response data into durable markdown. - * - * Input: - * - projected output from structured-exchange/project/request-answer.ts - * - * Output: - * - durable response markdown for toolResult.content - */ - -export {}; diff --git a/src/structured-exchange/format/request-choice.ts b/src/structured-exchange/format/request-choice.ts deleted file mode 100644 index 9d42a4f75..000000000 --- a/src/structured-exchange/format/request-choice.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Formats projected `request_choice` response data into durable markdown. - * - * Input: - * - projected output from structured-exchange/project/request-choice.ts - * - * Output: - * - durable response markdown for toolResult.content - */ - -export {}; diff --git a/src/structured-exchange/format/request-choices.ts b/src/structured-exchange/format/request-choices.ts deleted file mode 100644 index 856a27097..000000000 --- a/src/structured-exchange/format/request-choices.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Formats projected `request_choices` response data into durable markdown. - * - * Input: - * - projected output from structured-exchange/project/request-choices.ts - * - * Output: - * - durable response markdown for toolResult.content - */ - -export {}; diff --git a/src/structured-exchange/format/request-review.ts b/src/structured-exchange/format/request-review.ts deleted file mode 100644 index bcd3c32b5..000000000 --- a/src/structured-exchange/format/request-review.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Formats projected `request_review` response data into durable markdown. - * - * Input: - * - projected output from structured-exchange/project/request-review.ts - * - * Output: - * - durable response markdown for toolResult.content - */ - -export {}; diff --git a/src/structured-exchange/project/present-options.ts b/src/structured-exchange/project/present-options.ts deleted file mode 100644 index b4e92bc1e..000000000 --- a/src/structured-exchange/project/present-options.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Canonical projection for `present_options` content. - * - * Input: - * - StructuredExchangePresentDetails or equivalent domain prompt state - * - * Output: - * - normalized heading/body/options/rationale projection - * - * Used by: - * - structured-exchange/format/present-options.ts - * - session/structured-exchange-loop.ts - * - .pi/extensions/structured-exchange/present-options.ts - */ - -export {}; diff --git a/src/structured-exchange/project/present-question.ts b/src/structured-exchange/project/present-question.ts deleted file mode 100644 index 345e8de26..000000000 --- a/src/structured-exchange/project/present-question.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Canonical projection for `present_question` content. - * - * Input: - * - StructuredExchangePresentDetails or equivalent domain prompt state - * - * Output: - * - normalized heading/body projection for durable prompt-side content - * - * Used by: - * - structured-exchange/format/present-question.ts - * - session/structured-exchange-loop.ts - * - .pi/extensions/structured-exchange/present-question.ts - */ - -import { - STRUCTURED_EXCHANGE_PRESENT_SCHEMA, - type StructuredExchangePresentDetails, -} from '../../.pi/extensions/structured-exchange/shared/model.js'; - -export interface PresentQuestionProjection { - readonly heading: string; - readonly body?: string; - readonly details: StructuredExchangePresentDetails; -} - -export interface ProjectPresentQuestionInput { - readonly toolCallId: string; - readonly exchangeId: string; - readonly heading: string; - readonly body?: string | undefined; - readonly expectedRequestTool?: 'request_answer' | undefined; -} - -export function projectPresentQuestion(input: ProjectPresentQuestionInput): PresentQuestionProjection { - const heading = input.heading.trim(); - const body = normalizeOptionalText(input.body); - return { - heading, - ...(body ? { body } : {}), - details: { - schema: STRUCTURED_EXCHANGE_PRESENT_SCHEMA, - schemaVersion: 1, - exchangeId: input.exchangeId, - presentTool: 'present_question', - kind: 'question', - status: 'presented', - expectedRequest: { - tool: input.expectedRequestTool ?? 'request_answer', - required: true, - }, - createdAtToolCallId: input.toolCallId, - }, - }; -} - -function normalizeOptionalText(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} diff --git a/src/structured-exchange/project/present-review-set.ts b/src/structured-exchange/project/present-review-set.ts deleted file mode 100644 index c7f282ad0..000000000 --- a/src/structured-exchange/project/present-review-set.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Canonical projection for `present_review_set` content. - * - * Input: - * - review-set presentation details once the tool lands - * - * Output: - * - normalized reviewable artifact projection - * - * Future users: - * - structured-exchange/format/present-review-set.ts - * - .pi/extensions/structured-exchange/present-review-set.ts - */ - -export {}; diff --git a/src/structured-exchange/project/request-answer.ts b/src/structured-exchange/project/request-answer.ts deleted file mode 100644 index ecfa2064a..000000000 --- a/src/structured-exchange/project/request-answer.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Canonical projection for `request_answer` responses. - * - * Input: - * - StructuredExchangeRequestDetails for answered/cancelled/unavailable cases - * - * Output: - * - normalized answer/comment/status projection for durable response content - * - * Used by: - * - structured-exchange/format/request-answer.ts - * - session/structured-exchange-loop.ts - * - .pi/extensions/structured-exchange/request-answer.ts - */ - -export {}; diff --git a/src/structured-exchange/project/request-choice.ts b/src/structured-exchange/project/request-choice.ts deleted file mode 100644 index 61b98f75e..000000000 --- a/src/structured-exchange/project/request-choice.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Canonical projection for `request_choice` responses. - * - * Input: - * - StructuredExchangeRequestDetails for answered/cancelled/unavailable cases - * - * Output: - * - normalized selected-choice/comment/status projection - * - * Used by: - * - structured-exchange/format/request-choice.ts - * - session/structured-exchange-loop.ts - * - .pi/extensions/structured-exchange/request-choice.ts - */ - -export {}; diff --git a/src/structured-exchange/project/request-choices.ts b/src/structured-exchange/project/request-choices.ts deleted file mode 100644 index dc4a59edb..000000000 --- a/src/structured-exchange/project/request-choices.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Canonical projection for `request_choices` responses. - * - * Input: - * - StructuredExchangeRequestDetails for answered/cancelled/unavailable cases - * - * Output: - * - normalized multi-choice/comment/status projection - * - * Used by: - * - structured-exchange/format/request-choices.ts - * - session/structured-exchange-loop.ts - * - .pi/extensions/structured-exchange/request-choices.ts - */ - -export {}; diff --git a/src/structured-exchange/project/request-review.ts b/src/structured-exchange/project/request-review.ts deleted file mode 100644 index fadc4d8eb..000000000 --- a/src/structured-exchange/project/request-review.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Canonical projection for `request_review` responses. - * - * Input: - * - review-response details once the tool lands - * - * Output: - * - normalized review decision/comment/status projection - * - * Future users: - * - structured-exchange/format/request-review.ts - * - .pi/extensions/structured-exchange/request-review.ts - */ - -export {}; diff --git a/src/web/app.test.tsx b/src/web/app.test.tsx index 870bb0cf8..cc922b309 100644 --- a/src/web/app.test.tsx +++ b/src/web/app.test.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { WorkspaceSnapshot } from '../print-snapshot.js'; +import type { WorkspaceSnapshot } from '../projections/workspace/workspace-snapshot.js'; import { BrunchWebApp, createBrunchWebRuntime } from './app.js'; import type { WebSocketRpcClient, WebSocketRpcNotificationListener } from './rpc-client.js'; diff --git a/src/web/features/graph/GraphOverview.tsx b/src/web/features/graph/GraphOverview.tsx index 7cf0dd86d..8713ccc5c 100644 --- a/src/web/features/graph/GraphOverview.tsx +++ b/src/web/features/graph/GraphOverview.tsx @@ -11,50 +11,78 @@ export function GraphOverviewPanel(options: { overview: GraphOverview }) { focusedNodeId === null ? undefined : overview.nodes.find((node) => node.id === focusedNodeId); return ( -
-

Graph overview

-
+
+
-
Nodes
-
{overview.nodeCount}
+

Selected spec

+

Graph overview

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

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

+

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

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

Edge categories

+
+
+

+ Edge categories +

{edgeSummary.length === 0 ? ( -

No edges yet.

+

No edges yet.

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

{group.label}

-
    +
    +

    + {group.label} +

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

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

      - {node.body ?

      {node.body}

      : null} -
      @@ -63,10 +91,12 @@ export function GraphOverviewPanel(options: { overview: GraphOverview }) {
    ))} - +
) : null} {focusedNode ? ( -

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

+

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

) : null}
); diff --git a/src/web/main.tsx b/src/web/main.tsx index c3a41b414..ada14f796 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -4,6 +4,8 @@ import { createRoot } from 'react-dom/client'; import { BrunchWebApp, createBrunchWebRuntime } from './app.js'; import { createWebSocketRpcClient } from './rpc-client.js'; +import './styles.css'; + const rootElement = document.getElementById('root'); if (!rootElement) { throw new Error('Brunch web shell requires a #root element'); diff --git a/src/web/queries/session.ts b/src/web/queries/session.ts index 4ca6bae49..052c8785b 100644 --- a/src/web/queries/session.ts +++ b/src/web/queries/session.ts @@ -1,6 +1,6 @@ import type { QueryObserverOptions } from '@tanstack/react-query'; -import type { RuntimeStateProjection } from '../../session/runtime-state.js'; +import type { RuntimeStateProjection } from '../../projections/session/runtime-state.js'; import { queryKeys } from '../query-keys.js'; import type { WebSocketRpcClient } from '../rpc-client.js'; diff --git a/src/web/queries/workspace.ts b/src/web/queries/workspace.ts index 48ee59995..e0423d44e 100644 --- a/src/web/queries/workspace.ts +++ b/src/web/queries/workspace.ts @@ -1,6 +1,6 @@ import { queryOptions } from '@tanstack/react-query'; -import type { WorkspaceSnapshot } from '../../print-snapshot.js'; +import type { WorkspaceSnapshot } from '../../projections/workspace/workspace-snapshot.js'; import { queryKeys } from '../query-keys.js'; import type { WebSocketRpcClient } from '../rpc-client.js'; diff --git a/src/web/routes/root.tsx b/src/web/routes/root.tsx index d213df9d2..33bab0b63 100644 --- a/src/web/routes/root.tsx +++ b/src/web/routes/root.tsx @@ -1,7 +1,8 @@ import { useSuspenseQuery, type QueryClient } from '@tanstack/react-query'; import { Outlet, createRootRouteWithContext, createRoute } from '@tanstack/react-router'; +import type { ReactNode } from 'react'; -import type { WorkspaceSnapshot } from '../../print-snapshot.js'; +import type { WorkspaceSnapshot } from '../../projections/workspace/workspace-snapshot.js'; import { workspaceSnapshotQueryOptions } from '../queries/workspace.js'; import type { WebSocketRpcClient } from '../rpc-client.js'; import { useBrunchUpdateSubscription } from '../subscriptions/brunch-updates.js'; @@ -51,8 +52,8 @@ function WorkspaceSnapshotPage() { const { data: snapshot } = useSuspenseQuery(workspaceSnapshotQueryOptions(rpcClient)); return ( -
-

Brunch workspace

+
+

Brunch workspace

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

Session

-

No Brunch session selected.

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

Session

+ content =

No Brunch session selected.

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

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

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

-
+ + ); + } else { + content = ( + <> +

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

+

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

+ ); } return ( -
-

Session

-

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

-

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

+
+

Session

+
{content}
); } diff --git a/src/web/routes/spec.tsx b/src/web/routes/spec.tsx index 6e94943d4..3a9fbd55c 100644 --- a/src/web/routes/spec.tsx +++ b/src/web/routes/spec.tsx @@ -33,10 +33,12 @@ function InvalidSpecRoutePage() { const { rpcClient } = specRoute.useRouteContext(); const { data: snapshot } = useSuspenseQuery(workspaceSnapshotQueryOptions(rpcClient)); return ( -
-

Brunch workspace

+
+

Brunch workspace

-

Invalid spec id.

+

+ Invalid spec id. +

); } @@ -47,8 +49,8 @@ function ValidSpecRoutePage({ specId }: { specId: number }) { const { data: overview } = useSuspenseQuery(graphOverviewQueryOptions(rpcClient, specId)); return ( -
-

Brunch workspace

+
+

Brunch workspace

diff --git a/src/web/styles.css b/src/web/styles.css new file mode 100644 index 000000000..8961eea92 --- /dev/null +++ b/src/web/styles.css @@ -0,0 +1,47 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: 'IBM Plex Sans', 'Aptos', 'Segoe UI', system-ui, sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + --color-brunch-ink: #17130d; + --color-brunch-paper: #f8f2e7; + --color-brunch-card: #fffaf0; + --color-brunch-rule: #d8c6a6; + --color-brunch-muted: #756852; + --color-brunch-accent: #d35c2f; + --color-brunch-graph: #246a73; +} + +@layer base { + html { + color-scheme: light; + font-family: var(--font-sans); + background: var(--color-brunch-paper); + } + + body { + min-width: 320px; + margin: 0; + background: + radial-gradient(circle at top left, rgb(211 92 47 / 0.16), transparent 32rem), + linear-gradient(135deg, #fbf4e8 0%, #f3e6cf 48%, #eee0c8 100%); + color: var(--color-brunch-ink); + } + + button, + input, + textarea, + select { + font: inherit; + } + + button:focus-visible, + a:focus-visible { + outline: 2px solid var(--color-brunch-graph); + outline-offset: 3px; + } + + #root { + min-height: 100vh; + } +} diff --git a/src/web/vite-env.d.ts b/src/web/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/src/web/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/workspace/README.md b/src/workspace/README.md new file mode 100644 index 000000000..37423859b --- /dev/null +++ b/src/workspace/README.md @@ -0,0 +1,22 @@ +# workspace/ + +SPEC decisions: D52-L + +## Owns + +Cwd/package/workspace identity helpers and their tests. + +Current state: + +- `package-identity.test.ts` protects package-level CLI identity (`brunch-cli`, version floor, executable bin shim). +- No reusable workspace identity source module has been extracted yet; add one here only when current code needs it. + +## Does not own + +- Spec/session selection and binding lifecycle — `session/`. +- Product host mode dispatch — `app/`. +- Graph truth or persistence. + +## Dependency direction + +`workspace/` may provide cwd/package identity facts to `app/`, `projections/`, `rpc/`, and `.pi` once source helpers exist. It must not depend on adapters, web code, or product entrypoints. diff --git a/src/package-identity.test.ts b/src/workspace/package-identity.test.ts similarity index 96% rename from src/package-identity.test.ts rename to src/workspace/package-identity.test.ts index 73a4b365d..b0c92b900 100644 --- a/src/package-identity.test.ts +++ b/src/workspace/package-identity.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = fileURLToPath(new URL('../..', import.meta.url)); interface PackageJson { name: string; diff --git a/vite.config.ts b/vite.config.ts index 7e4ffa328..43e30a9c7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,9 @@ +import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], build: { outDir: 'dist-web', emptyOutDir: true,