diff --git a/.pi/extensions/worktree/index.ts b/.pi/extensions/worktree/index.ts index b385ddf1..8c2eb523 100644 --- a/.pi/extensions/worktree/index.ts +++ b/.pi/extensions/worktree/index.ts @@ -110,6 +110,18 @@ export type CreateSiblingWorktreeResultDetails = readonly stderr?: string; }; +export interface ListedWorktree { + readonly path: string; + readonly head?: string; + readonly branch?: string; + readonly detached: boolean; +} + +export interface SwitchableWorktree { + readonly path: string; + readonly label: string; +} + interface GitProbeResult { readonly ok: boolean; readonly stdout: string; @@ -163,6 +175,81 @@ export async function validateGitWorktree(targetPath: string): Promise entry.path !== callerRoot) + .map((entry) => { + let labelSuffix: string; + if (entry.detached) { + labelSuffix = `detached ${entry.head ?? 'unknown HEAD'}`; + } else if (entry.branch !== undefined) { + labelSuffix = `branch ${entry.branch}`; + } else { + labelSuffix = `HEAD ${entry.head ?? 'unknown'}`; + } + return { path: entry.path, label: `${entry.path} (${labelSuffix})` }; + }); +} + 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.'); @@ -320,12 +407,13 @@ export async function cleanForkedSessionHeader(sessionFile: string): 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 selectedTarget = await selectSwitchWorktreeTarget(ctx); + if (selectedTarget.status !== 'selected') return selectedTarget; + return runSwitchWorktree(selectedTarget.targetPath, ctx, switchOptions); } const validation = await validateGitWorktree(resolvedTarget); @@ -356,7 +444,7 @@ export async function runSwitchWorktree( const relocatedSessionFile = await createRelocatedSession( sourceSessionFile, validation.cwd, - options.sessionDir, + switchOptions.sessionDir, ); const continuation = continuationPrompt(validation.cwd); const result = await ctx.switchSession(relocatedSessionFile, { @@ -476,6 +564,56 @@ export default function registerWorktreeExtension(pi: ExtensionAPI): void { }); } +interface SelectedSwitchTarget { + readonly status: 'selected'; + readonly targetPath: string; +} + +async function selectSwitchWorktreeTarget( + ctx: ExtensionCommandContext, +): Promise { + const rootProbe = await gitProbe(ctx.cwd, 'rev-parse', '--show-toplevel'); + if (!rootProbe.ok) { + const reason = gitFailureReason('Could not list git worktrees.', rootProbe); + ctx.ui.notify(reason, 'error'); + return { status: 'failed', targetPath: '', reason }; + } + + const listProbe = await gitProbe(ctx.cwd, 'worktree', 'list', '--porcelain'); + if (!listProbe.ok) { + const reason = gitFailureReason('Could not list git worktrees.', listProbe); + ctx.ui.notify(reason, 'error'); + return { status: 'failed', targetPath: '', reason }; + } + + const options = selectableSwitchWorktrees( + parseWorktreePorcelain(listProbe.stdout), + rootProbe.stdout.trim(), + ); + if (options.length === 0) { + const reason = 'no other git worktrees available'; + ctx.ui.notify('No other git worktrees are available for this repository.', 'info'); + return { status: 'failed', targetPath: '', reason }; + } + + const selectedLabel = await ctx.ui.select( + 'Switch Pi worktree', + options.map((option) => option.label), + ); + if (selectedLabel === undefined) { + return { status: 'cancelled', targetPath: '', reason: 'worktree selection cancelled' }; + } + + const selected = options.find((option) => option.label === selectedLabel); + if (selected === undefined) { + const reason = 'selected worktree is no longer available'; + ctx.ui.notify(reason, 'error'); + return { status: 'failed', targetPath: '', reason }; + } + + return { status: 'selected', targetPath: selected.path }; +} + function continuationPrompt(targetCwd: string): string { return `Continue in the relocated Pi session from cwd: ${targetCwd}`; } diff --git a/docs/architecture/pi-seam-extensions.md b/docs/architecture/pi-seam-extensions.md index 5613572a..3afbc776 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/testing/seeded-dev-rpc.md b/docs/testing/seeded-dev-rpc.md index fa1a0b76..9988f97e 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,7 +52,7 @@ 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 ) @@ -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,7 +127,7 @@ 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 00000000..15d871cf --- /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 00000000..57c38e48 --- /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 431874df..13c7dcac 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 dd751033..42db4395 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -143,7 +143,7 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple - **Kind:** structural / bounded feature - **Status:** active - **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. @@ -156,9 +156,9 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple - **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. - **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:** Structured-exchange schema/emission lock complete and structurally guarded: active details/params are Zod-authored; the only Pi `TSchema` accommodation is `src/.pi/extensions/structured-exchange/pi-schema.ts`; active Pi tools, session-triggered present/request emissions, and the intentional RPC/editor relay route details through canonical projectors and durable markdown through `src/structured-exchange/format/*`; source-boundary tests guard against inline detail construction, scattered TypeBox, duplicate `tool_meta`, and missing projector parse validation. Remaining FE-809 work is approval-to-`acceptReviewSet` product wiring and the real `project-graph` proposal probe. ### minimal-authority-shell @@ -246,22 +246,24 @@ 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:** Spec-scoped graph-clock hardening landed; no active scope file for `dev-seed-fixtures`. Product-driven fixture-curation tracer remains the next 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. 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, 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-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: 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`; then tightened the invariant so `createSpec` creates exactly one clock row, later mutations use an update-only bump that fails loud on missing rows, and legacy migrations backfill one clock row for every spec from change-log/graph/reconciliation history. Updated graph snapshots, RPC expectations, prompt context wording, migrations, seed-fixture assertions, and canonical docs so sibling-spec mutations no longer make an unchanged spec appear stale and spec-only legacy histories migrate coherently. Verified: `src/graph/command-executor.test.ts`, `src/graph/command-executor/commit-graph-batch.test.ts`, `src/db/connection.test.ts`, `src/graph/snapshot.test.ts`, `src/graph/seed-fixtures.test.ts`, `src/rpc/handlers.test.ts`, `src/agents/contexts/graph.test.ts`, `npm test`, seeded-dev-rpc smoke. + - 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/`. diff --git a/memory/SPEC.md b/memory/SPEC.md index 51af90f9..6de7f48f 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. | @@ -127,16 +127,16 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. - **D39-L — Brunch owns 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/`. +- **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. 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/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. +- **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 is `src/{.pi, agents, db, graph, session, rpc, web}` with directed layer dependencies.** `graph/` is the domain layer: CommandExecutor, readers, policy, validators, snapshot bucketing, change-log replay, reconciliation-need substrate; it imports from `db/` (Drizzle schema, migrations, connection lifecycle) and no other layer imports `db/` directly. `session/` owns transcript projection, exchange extraction, workspace coordination, session binding, runtime-state projection, and LSN staleness tracking over Pi JSONL. `agents/` is organized by registry/resource family (`definitions/`, `goals/`, `strategies/`, `lenses/`, `methods/`, `contexts/`) and imports snapshot/state projection functions from `graph/` and `session/`; it owns prompt composition, context building, prompt resources, and future agent registry definitions that drive `op_mode`/goal/strategy/lens selection. `.pi/extensions/` houses Pi adapter registrars (agent tools, TUI commands, TUI enhancements) and may re-export/use session-owned runtime-state helpers; `.pi/components/` houses reusable TUI components. `rpc/` owns Brunch JSON-RPC handlers. `web/` owns the React client. Dependency direction: `.pi/extensions/` and `rpc/` may import from `graph/`, `session/`, and `agents/`; `agents/` imports from `graph/` and `session/`; `graph/` imports from `db/`; `web/` is a standalone build target. Depends on: D2-L, D4-L, D39-L, D40-L. Supersedes: scattering session domain files at `src/` root; nesting prompt composition exclusively under `src/.pi/context/`. #### Data model & vocabulary - **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,7 +210,7 @@ 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. @@ -219,7 +219,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### 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/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, session-triggered present/request emissions, and the intentional RPC/editor relay now construct details through canonical `src/structured-exchange/project/*` projectors and render durable markdown through `src/structured-exchange/format/*` formatters 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,8 +244,8 @@ 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. +- **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.** `agents/compose(agentId, sessionState, spec, workspace, snapshots)` runs before Pi provider requests through Brunch's prompt extension and emits: **(1) agent control header** — keyed agent identity, model/thinking expectation, foreground role derived from `op_mode`, and mode/tool-authority summary; **(2) runtime-state header** — current pinned/AUTO `goal`, `strategy`, and `lens`, `spec.readiness_grade`, and workspace posture; **(3) resource manifests** — XML-style ``, ``, ``, and `` entries filtered by `agents/state.ts` legal tuples, grade, `op_mode`, and the agent allow-list, each carrying `{name, description, location}` for a Brunch-owned markdown resource under `src/agents/`; the `{name, description, location}` triples are code-owned in `agents/state.ts`, not filesystem-discovered, honoring D39-L sealing; **(4) compact pushed context** — only the minimal snapshot summary/handles needed to orient the turn, with detailed snapshot content still governed by D60-L. Detailed goal/strategy/lens/method instructions live in Brunch prompt resources and are loaded by the agent with `read` when needed, following the same simple mechanism Pi uses for skills. Method resources are the prompt-level home for Brunch tool-routing and sequencing guidance; tool definitions remain boundary schemas/execution hooks, not the whole Brunch guide to when or how tools should be composed. `AUTO` means the axis is unpinned: the manifest lists legal choices and router instructions tell the agent to choose only from the current manifest, reading the selected resource before applying it when detail matters. Pinned axes point to the pinned resource; code enforces legality and tool gating but does not choose or concatenate large semantic packs on the agent's behalf. Pi-native skills may still carry startup-scoped capabilities, but runtime-state-gated availability is Brunch's manifest, not ambient Pi discovery. `agents/` is a keyed resource registry (`definitions/`, `goals/`, `strategies/`, `lenses/`, `methods/`, `contexts/`); `agents/contexts/` is the D60-L snapshot render layer (code), not a manifest resource family; composition is projection, not a behavioral state machine. Depends on: D23-L, D25-L, D39-L, D40-L, D52-L, D59-L, D60-L. Supersedes: the flat "base + mode + role + strategy + lens + grade + …" layering; the fixed all-packs concatenation in `compose-brunch-prompt.ts`; "role preset / runtime bundle" as the composition unit; direct Layer-2 eager prompt-pack injection as the default mechanism; and `capability` as a parallel name for `method` / ``. - **D59-L — `goal` is a grade-derived, AUTO-able objective axis, distinct from strategy.** A *goal* is what the session agent currently pursues; a *strategy* is the reusable interaction shape used to pursue it — a goal is pursued *via* a strategy *through* a lens (three orthogonal axes). The goal set is derived/gated by `spec.readiness_grade`: `grounding-advance` (fill grounding and advance the grade), `elicit-expand` (expand the elicited specification graph while ambiguity remains productive), `commit-converge` (reduce / lock down reviewable commitments), plus an always-on `capture-posture` (capture or confirm dev `posture`, D45-L). `goal` defaults to the grade-derived objective, may be pinned, or left `AUTO`; in either case D58-L manifests advertise the legal resource(s) rather than injecting the whole goal body. `elicit-expand` and `commit-converge` intentionally form the diverge/converge pair for the elicitation diamond; `elicit-I` / `elicit-II` are retired because they were phase-like labels, not objectives. "Advance the grade" is a goal, not a strategy — though the `grounding-advance` goal may carry a dedicated default interaction pattern. Depends on: D45-L, D57-L, D58-L. Supersedes: conflating the elicit-lifecycle objective with strategy selection. - **D60-L — "Snapshot" splits into pull / render / surface, 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. @@ -254,12 +254,12 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | # | 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,19 +268,19 @@ 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 at graph layer (`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 tests now prove approve/request-changes/reject terminal `request_review` outcomes, while approval-to-`acceptReviewSet` commit wiring remains planned | 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. | partially covered (`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` now calls `CommandExecutor.dryRunAcceptReviewSet` through injected selected-spec graph deps before emitting recoverable present details, and invalid proposals return non-reviewable `structural_illegal` diagnostics; real agent-generated `project-graph` proposal fixtures remain planned) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; FE-744 web live-update tests prove WebSocket notifications only invalidate/refetch canonical projection handlers after RPC-originated structured-exchange mutations; 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 | +| 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 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 | +| 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/structured-exchange/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/structured-exchange/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/structured-exchange/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/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/auto-compaction-anchors.json) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor config) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | | I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec readiness-grade updates; low-confidence implications remain in structured-exchange preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | 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 | @@ -377,7 +377,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 @@ -417,7 +417,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 +429,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 +456,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. | @@ -568,7 +568,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 +593,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. | 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 00000000..a181f754 --- /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 00000000..973983c5 --- /dev/null +++ b/memory/cards/poc-live-ship-gate--live-mention-autocomplete.md @@ -0,0 +1,82 @@ +# 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`; diagnosis proved 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/pi-extension-shell.ts` is currently modified by adjacent FE-809 work; coordinate before building this card on the same worktree. + +## 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 `graph/project/*` projection module are introduced. + +### Verification Approach + +- Inner: `npm test -- src/.pi/__tests__/mention-autocomplete.test.ts src/brunch-tui.test.ts -t mention` — proves provider mechanics and shell wiring 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 ~ +├── extensions/ +│ └── mention-autocomplete.ts ~ +└── pi-extension-shell.ts ~ ! concurrent FE-809 edits present + +src/ +├── 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/tooling--worktree-command-ux.md b/memory/cards/tooling--worktree-command-ux.md deleted file mode 100644 index 323fcbb9..00000000 --- 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/memory/cards/topology-readmes-and-boundaries--projection-format-boundaries.md b/memory/cards/topology-readmes-and-boundaries--projection-format-boundaries.md new file mode 100644 index 00000000..583d0ea7 --- /dev/null +++ b/memory/cards/topology-readmes-and-boundaries--projection-format-boundaries.md @@ -0,0 +1,335 @@ +# Top-level projections and formatters topology + +Frontier: topology-readmes-and-boundaries +Status: active +Mode: chain +Created: 2026-06-05 + +## Orientation + +- Containing seam: source topology after FE-809 made projection and formatting real cross-cutting seams rather than local helper folders. +- Relevant frontier item: `topology-readmes-and-boundaries`; this supersedes the earlier local cleanup card that framed `project/` vs `format/` mostly as hollow-layer deletion. +- Volatile handoff state: `HANDOFF.md` remains untracked review context; FE-809 schema/emission closure has landed, so this card no longer needs to defer around dirty structured-exchange work. +- Main open risk: top-level `projections/` and `formatters/` could become vague utility buckets. The migration must define them as narrow boundary layers with import rules, not as places to put any reusable function. + +Posture: proving (inherited from `topology-readmes-and-boundaries`). + +Frontier-level cross-cutting obligations this slice carries: + +- Preserve D52-L dependency direction: graph owns graph truth and may import `db/`; session owns Pi JSONL/session semantics; `.pi`, `rpc`, and app entrypoints adapt product seams rather than owning domain logic. +- Preserve D37-L/D41-L structured-exchange schema lock: details construction remains Zod/projector-owned; markdown remains formatter-owned; `.pi` remains adapter/UI registration. +- Preserve topology README authority: every moved directory with a README gets updated in the same commit that changes its ownership or layout. +- Preserve free-rewrite posture: move imports directly and delete old paths; do not leave compatibility barrels for old `project/` / `format/` locations unless removed in the same slice. +- Preserve overlap discipline: this card touches broad topology and is not parallel-safe with other cards moving `src/{graph,session,structured-exchange,.pi}` files. + +## Target topology sketch + +```pseudo +src/ +├── app/ [product entrypoints and host wiring] +├── workspace/ [cwd/package/workspace identity] +├── scripts/ [local executable utilities] +├── graph/ [graph truth, mutation, readers, policy] +├── session/ [Pi JSONL/session semantics] +├── projections/ [structured DTOs derived from domain/session/tool facts] +│ ├── graph/ +│ ├── session/ +│ ├── workspace/ +│ └── structured-exchange/ +├── formatters/ [lossy text/markdown/toon/tool content] +│ ├── markdown.ts +│ ├── toon.ts ? [only if current code already needs it] +│ ├── graph/ +│ ├── session/ +│ └── structured-exchange/ +├── .pi/ [Pi adapters] +├── rpc/ [JSON-RPC transport/method handlers] +├── web/ [React client] +├── agents/ [prompt/resource composition] +├── probes/ [product-path probes] +└── db/ [persistence substrate] +``` + +Layer rules to prove during build: + +```pseudo +rules: + graph/ -> db/ [allowed] + projections/* -> graph/, session/ [read/domain imports allowed] + formatters/* -> projections/, graph/, session/ as needed for input types + .pi/, rpc/, app/ -> graph/, session/, projections/, formatters/, agents/ + graph/, session/ x> .pi/, rpc/, app/, web/ + projections/ x> .pi/, rpc/, app/, web/ + formatters/ x> .pi/, rpc/, app/, web/ +``` + +## Card 1 — Lock the top-level topology decision + +Status: next +Weight: full + +### Target Behavior + +The canonical topology documentation names `app/`, `workspace/`, `scripts/`, `projections/`, and `formatters/` as first-class source layers. + +### Boundary Crossings + +```pseudo +→ current D52-L / src README topology +→ new top-level layer contract and import rules +→ stale local projection/format cleanup card language +→ architecture/topology tests if they assert old root layout +``` + +### Risks and Assumptions + +- RISK: Documentation gets ahead of code and creates a false topology claim. + → MITIGATION: mark the migration state explicitly: layers are canonical target, with later cards materializing files. +- RISK: `projectors` vs `projections` naming remains unsettled. + → MITIGATION: choose `projections/` unless the build uncovers an existing convention that makes `projectors/` materially clearer; update docs once, not both. +- ASSUMPTION: Top-level projection/formatter layers improve navigation more than domain-local `project/` / `format/` subtrees now that multiple domains share the pattern. + → IMPACT IF FALSE: later cards should stop after docs and keep local folders, leaving only root-entrypoint cleanup. + → VALIDATE: import/call-site audit included in this card's build report before moving code. + +### Posture check + +This proving slice establishes the target topology and makes the migration auditable before moving files. It scores on invariants and uncertainty: if the import audit shows the top-level layers would be bucket-like rather than boundary-like, the chain stops here. + +### Acceptance Criteria + +✓ `memory/SPEC.md` D52-L (or the current topology decision) describes `app/`, `workspace/`, `scripts/`, `projections/`, and `formatters/` with dependency direction. +✓ `src/README.md` matches the new target topology and names any not-yet-moved directories as migration state rather than current truth. +✓ This scope file no longer asks builders to merely collapse `project/` layers; it scopes a top-level topology migration. +✓ A call-site audit in the build summary identifies which current `project/` and `format/` files will move, collapse, or remain intentionally local. + +### Verification Approach + +- Inner: docs/readme review — proves topology claims are precise and do not overclaim completed moves. +- Inner: grep/import audit — proves the proposed moved sets are finite and not mixed with adapter-only code. +- Middle: `npm run check` — catches formatting/lint drift from documentation edits. + +### Cross-cutting obligations + +- Do not move source files in Card 1 except to satisfy a failing topology test. +- Do not introduce compatibility aliases. +- Do not rename the graph/session/.pi authority layers themselves. + +### Expected touched paths (tentative) + +```pseudo +memory/ +├── SPEC.md ~ +└── PLAN.md ? + +src/ +└── README.md ~ + +memory/cards/ +└── topology-readmes-and-boundaries--projection-format-boundaries.md ~ +``` + +## Card 2 — Move root entrypoints into app/workspace/scripts + +Status: next +Weight: full + +### Target Behavior + +No product entrypoint, workspace identity helper, or local executable utility source file remains directly under `src/` root. + +### Boundary Crossings + +```pseudo +→ src root entrypoint files +→ app/ product host module imports +→ workspace/ identity helper imports +→ scripts/ print snapshot imports +→ package/bin/test import paths +→ topology READMEs +``` + +### Risks and Assumptions + +- RISK: Build/package entrypoints assume `src/brunch.ts` or `src/brunch-tui.ts` paths. + → MITIGATION: follow TypeScript/test failures and update package scripts or bin imports directly; no root aliases. +- RISK: `package-identity` is actually session-owned rather than workspace-owned. + → MITIGATION: move it to `workspace/` only if call sites use it as cwd/package identity; otherwise stop and rescope that file. +- ASSUMPTION: Root-level `brunch*`, `print-snapshot*`, and `package-identity*` files are entrypoint/workspace/script concerns, not domain modules. + → IMPACT IF FALSE: affected file remains in its domain owner and the acceptance criterion is narrowed in the card update. + → VALIDATE: call-site audit before file moves. + +### Posture check + +This slice materializes the `app/`, `workspace/`, and `scripts/` layer names without touching graph/session semantics. It scores on topology and deletion: root-level source ambiguity disappears. + +### Acceptance Criteria + +✓ `src/app/` owns Brunch product entrypoints and their tests. +✓ `src/workspace/` owns package/workspace identity source and tests if call-site audit confirms that ownership. +✓ `src/scripts/` owns print-snapshot utility source and tests. +✓ `src/README.md` and any new directory README accurately describe these layers. +✓ No root-level `src/brunch*`, `src/print-snapshot*`, or `src/package-identity*` source/test file remains. + +### Verification Approach + +- Inner: moved file tests — proves import-path repair preserved entrypoint behavior. +- Middle: `npm run check` and targeted tests for brunch/print/package identity. +- Gate: `npm run verify` if build scripts or package entrypoints changed. + +### Cross-cutting obligations + +- Keep `app/` as wiring/entrypoint code, not a new domain layer. +- Keep `workspace/` scoped to cwd/package/workspace identity; session/spec selection remains in `session/` unless a separate design changes it. +- Delete old root paths directly; do not create compatibility barrels. + +### Expected touched paths (tentative) + +```pseudo +src/ +├── brunch.ts - +├── brunch.test.ts - +├── brunch.smoke.test.ts - +├── brunch-tui.ts - +├── brunch-tui.test.ts - +├── print-snapshot.ts - +├── print-snapshot.test.ts - +├── package-identity.test.ts - +├── app/ + +│ ├── README.md + +│ ├── brunch.ts + +│ ├── brunch.test.ts + +│ ├── brunch.smoke.test.ts + +│ ├── brunch-tui.ts + +│ └── brunch-tui.test.ts + +├── workspace/ + +│ ├── README.md + +│ ├── package-identity.ts +? +│ └── package-identity.test.ts + +├── scripts/ + +│ ├── README.md + +│ ├── print-snapshot.ts + +│ └── print-snapshot.test.ts + +└── README.md ~ + +package.json ? +bin/ ? +tsconfig*.json ? +``` + +## Card 3 — Hoist reusable projections and formatters + +Status: next +Weight: full + +### Target Behavior + +Reusable projection and formatting modules live under top-level `src/projections/` and `src/formatters/` instead of domain-local `project/` and `format/` folders. + +### Boundary Crossings + +```pseudo +→ graph/session/structured-exchange project/format modules +→ top-level projections/formatters import rules +→ .pi/rpc/agents/probes call sites +→ topology READMEs +→ architecture/source-boundary tests +``` + +### Risks and Assumptions + +- RISK: Some domain-local files are not reusable boundary projections and should collapse into their only caller instead of moving. + → MITIGATION: apply the same test as the old card: a file moves only if it has a named DTO/text boundary; otherwise collapse it in place. +- RISK: Structured-exchange `project/*` imports `.pi` schema types, which may make `projections/structured-exchange` look adapter-dependent. + → MITIGATION: preserve the current schema-lock direction until a separate schema-ownership card changes it; document this as current migration state if needed. +- RISK: Large import churn hides behavior changes. + → MITIGATION: move one family at a time inside the card and run focused tests after each family. +- ASSUMPTION: Current `graph/project`, `session/project`, and `structured-exchange/project` modules are projection-layer concerns, while current `*/format` modules are formatter-layer concerns. + → IMPACT IF FALSE: move only the confirmed subset and mark the remainder as stale for rescoping. + → VALIDATE: call-site audit from Card 1 and compiler/test failures during breakage-driven repair. + +### Posture check + +This slice materializes the projection/formatter boundary in code. It scores on invariants, topology, and deletion: old local `project/` / `format/` folders disappear unless a local owner still has a current reason to keep one. + +### Acceptance Criteria + +✓ `src/projections/{graph,session,structured-exchange}/` owns every surviving reusable non-text projection module. +✓ `src/formatters/{graph,session,structured-exchange}/` owns every surviving text/markdown formatter module, and `src/formatters/markdown.ts` replaces `src/render/markdown.ts` if that helper is still needed. +✓ Old `src/**/project/` and `src/**/format/` folders touched by this card are deleted unless a README names why a local folder remains. +✓ `.pi`, `rpc`, `agents`, `session`, `graph`, and probes import from the new top-level layers without compatibility barrels. +✓ Boundary tests or README rules make it clear that projections/formatters must not import adapters, app entrypoints, web, or RPC handlers. + +### Verification Approach + +- Inner: TypeScript import repair — proves no stale old paths remain. +- Inner: focused tests for graph formatting, session transcript formatting, structured-exchange formatting, and boundary tests. +- Middle: `npm run check` plus targeted `npm run test -- structured-exchange graph session` as applicable. +- Gate: `npm run verify` before commit. + +### Cross-cutting obligations + +- Do not change structured-exchange details schemas, graph command semantics, or session exchange projection semantics while moving files. +- Do not use barrels to preserve old import paths. +- Do not move web-specific formatting into top-level formatters unless it is shared outside web. + +### Expected touched paths (tentative) + +```pseudo +src/ +├── projections/ + +│ ├── README.md + +│ ├── graph/ + +│ │ ├── commit-result.ts +? +│ │ ├── neighborhood.ts +? +│ │ ├── overview.ts +? +│ │ └── reconciliation-needs.ts +? +│ ├── session/ + +│ │ └── transcript-context.ts +? +│ └── structured-exchange/ + +│ ├── present-options.ts + +│ ├── present-question.ts + +│ ├── present-review-set.ts + +│ ├── request-answer.ts + +│ ├── request-choice.ts + +│ ├── request-choices.ts + +│ └── request-review.ts + +├── formatters/ + +│ ├── README.md + +│ ├── markdown.ts +? +│ ├── graph/ + +│ │ ├── commit-result.ts +? +│ │ ├── neighborhood.ts +? +│ │ ├── overview.ts +? +│ │ └── reconciliation-needs.ts +? +│ ├── session/ + +│ │ └── transcript.ts +? +│ └── structured-exchange/ + +│ ├── present-options.ts + +│ ├── present-question.ts + +│ ├── present-review-set.ts + +│ ├── request-answer.ts + +│ ├── request-choice.ts + +│ ├── request-choices.ts + +│ └── request-review.ts + +├── render/ -? +├── graph/ ~ +│ ├── README.md ~ +│ ├── project/ -? +│ └── format/ -? +├── session/ ~ +│ ├── README.md ~ +│ ├── project/ -? +│ └── format/ -? +├── structured-exchange/ -? +│ ├── project/ -? +│ └── format/ -? +├── .pi/ ~ +├── rpc/ ~ +├── agents/ ~ +└── probes/ ~ + +src/.pi/__tests__/structured-exchange-boundaries.test.ts ~ +src/README.md ~ +memory/SPEC.md ~ +memory/PLAN.md ? +``` diff --git a/package-lock.json b/package-lock.json index 52edaa0a..9f7aeead 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 582c3b2e..d8673b1b 100644 --- a/package.json +++ b/package.json @@ -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/__tests__/chrome.test.ts b/src/.pi/__tests__/chrome.test.ts index b958f96a..728ef211 100644 --- a/src/.pi/__tests__/chrome.test.ts +++ b/src/.pi/__tests__/chrome.test.ts @@ -4,8 +4,6 @@ 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'; @@ -14,7 +12,7 @@ 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 703431b5..cf1c2751 100644 --- a/src/.pi/__tests__/extension-registry.test.ts +++ b/src/.pi/__tests__/extension-registry.test.ts @@ -21,9 +21,11 @@ import sessionLifecycle from '../extensions/session-lifecycle.js'; import structuredExchange, { PRESENT_OPTIONS_TOOL, PRESENT_QUESTION_TOOL, + PRESENT_REVIEW_SET_TOOL, REQUEST_ANSWER_TOOL, REQUEST_CHOICE_TOOL, REQUEST_CHOICES_TOOL, + REQUEST_REVIEW_TOOL, } from '../extensions/structured-exchange/index.js'; import { createBrunchPiExtensionShell } from '../pi-extension-shell.js'; @@ -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', diff --git a/src/.pi/__tests__/graph-tools.test.ts b/src/.pi/__tests__/graph-tools.test.ts index ee53a27c..14b556cb 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__/project-worktree-extension.test.ts b/src/.pi/__tests__/project-worktree-extension.test.ts index ab1ff88d..c2d94478 100644 --- a/src/.pi/__tests__/project-worktree-extension.test.ts +++ b/src/.pi/__tests__/project-worktree-extension.test.ts @@ -11,9 +11,11 @@ import worktreeExtension, { cleanForkedSessionHeader, createRelocatedSession, createSiblingWorktree, + parseWorktreePorcelain, planSiblingWorktree, resolveSwitchTarget, runSwitchWorktree, + selectableSwitchWorktrees, validateGitWorktree, WORKTREE_CREATE_COMMAND, WORKTREE_CREATE_TOOL, @@ -164,6 +166,142 @@ describe('project-local worktree Pi extension', () => { }); }); + it('parses git worktree porcelain entries and filters the caller worktree from switch choices', () => { + const entries = parseWorktreePorcelain( + [ + 'worktree /tmp/repo', + 'HEAD abc123', + 'branch refs/heads/main', + '', + 'worktree /tmp/repo-linked', + 'HEAD def456', + 'branch refs/heads/feature/demo', + '', + 'worktree /tmp/repo-detached', + 'HEAD fed789', + 'detached', + '', + ].join('\n'), + ); + + expect(entries).toEqual([ + { path: '/tmp/repo', head: 'abc123', branch: 'main', detached: false }, + { path: '/tmp/repo-linked', head: 'def456', branch: 'feature/demo', detached: false }, + { path: '/tmp/repo-detached', head: 'fed789', detached: true }, + ]); + expect(selectableSwitchWorktrees(entries, '/tmp/repo')).toEqual([ + { + path: '/tmp/repo-linked', + label: '/tmp/repo-linked (branch feature/demo)', + }, + { + path: '/tmp/repo-detached', + label: '/tmp/repo-detached (detached fed789)', + }, + ]); + }); + + it('cancels a no-arg switch cleanly when the selector is dismissed', async () => { + await withTempDir(async (dir) => { + const main = join(dir, 'repo'); + await initRepo(main); + const linked = join(dir, 'repo-linked'); + await git(main, 'worktree', 'add', '-b', 'linked', linked, 'HEAD'); + const ctx = createSwitchContext({ + cwd: main, + sourceSession: join(dir, 'source.jsonl'), + sessionDir: join(dir, 'sessions'), + confirm: true, + select: undefined, + }); + + const result = await runSwitchWorktree('', ctx); + + expect(result).toEqual({ + status: 'cancelled', + targetPath: '', + reason: 'worktree selection cancelled', + }); + expect(ctx.selections).toHaveLength(1); + expect(ctx.confirmations).toHaveLength(0); + expect(ctx.switchedSessionFile).toBeUndefined(); + }); + }); + + it('uses no-arg switch selection before the existing validated and confirmed relocation path', async () => { + await withTempDir(async (dir) => { + const main = join(dir, 'repo'); + await initRepo(main); + const linked = join(dir, 'repo-linked'); + await git(main, 'worktree', 'add', '-b', 'linked', linked, 'HEAD'); + const linkedRoot = await gitOutput(linked, 'rev-parse', '--show-toplevel'); + const sourceSession = join(dir, 'source.jsonl'); + const sessionDir = join(dir, 'sessions'); + await writeFile( + sourceSession, + `${JSON.stringify({ type: 'session', version: 3, id: 's1', timestamp: '2026-06-05T00:00:00.000Z', cwd: main })}\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: main, + sourceSession, + sessionDir, + confirm: true, + select: 'first', + }); + + const result = await runSwitchWorktree('', ctx, { sessionDir }); + + expect(result).toMatchObject({ status: 'switched', targetPath: linkedRoot }); + expect(ctx.selections).toEqual([ + { + title: 'Switch Pi worktree', + options: [`${linkedRoot} (branch linked)`], + }, + ]); + expect(ctx.confirmations).toHaveLength(1); + expect(ctx.switchedSessionFile).toContain(sessionDir); + expect(ctx.replacementMessages).toEqual([ + `Continue in the relocated Pi session from cwd: ${linkedRoot}`, + ]); + }); + }); + + it('notifies when no-arg switch is run outside a git repository or without other worktrees', async () => { + await withTempDir(async (dir) => { + const outside = createSwitchContext({ + cwd: dir, + sourceSession: join(dir, 'source.jsonl'), + sessionDir: join(dir, 'sessions'), + confirm: true, + select: 'first', + }); + await expect(runSwitchWorktree('', outside)).resolves.toMatchObject({ + status: 'failed', + targetPath: '', + }); + expect(outside.notifications.at(-1)?.message).toContain('Could not list git worktrees.'); + + const repo = join(dir, 'repo'); + await initRepo(repo); + const only = createSwitchContext({ + cwd: repo, + sourceSession: join(dir, 'source.jsonl'), + sessionDir: join(dir, 'sessions'), + confirm: true, + select: 'first', + }); + await expect(runSwitchWorktree('', only)).resolves.toEqual({ + status: 'failed', + targetPath: '', + reason: 'no other git worktrees available', + }); + expect(only.notifications.at(-1)).toEqual({ + message: 'No other git worktrees are available for this repository.', + type: 'info', + }); + }); + }); + 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'); @@ -300,15 +438,18 @@ function createSwitchContext({ sourceSession, sessionDir, confirm, + select, }: { cwd: string; sourceSession: string; sessionDir: string; confirm: boolean; + select?: 'first' | undefined; }) { const confirmations: string[] = []; const notifications: Array<{ message: string; type: 'info' | 'warning' | 'error' | undefined }> = []; const replacementMessages: string[] = []; + const selections: Array<{ title: string; options: string[] }> = []; const ctx = { cwd, hasUI: true, @@ -321,6 +462,10 @@ function createSwitchContext({ notifications.push({ message, type }); }, setEditorText() {}, + select: async (title: string, options: string[]) => { + selections.push({ title, options }); + return select === 'first' ? options[0] : undefined; + }, }, sessionManager: { getSessionFile: () => sourceSession, @@ -348,6 +493,7 @@ function createSwitchContext({ confirmations, notifications, replacementMessages, + selections, }; return ctx as typeof ctx & ExtensionCommandContext; } 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 24e278ea..00000000 --- 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 00000000..cee53966 --- /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/structured-exchange'; +const STRUCTURED_EXCHANGE_PROJECT = 'src/structured-exchange/project'; +const STRUCTURED_EXCHANGE_SCHEMAS = 'src/.pi/extensions/structured-exchange/schemas'; +const STRUCTURED_EXCHANGE_EMISSION_BOUNDARIES = [ + STRUCTURED_EXCHANGE_EXTENSION, + 'src/session/structured-exchange-loop.ts', +]; +const ACTIVE_PROJECTORS = new Set([ + 'src/structured-exchange/project/present-options.ts', + 'src/structured-exchange/project/present-question.ts', + 'src/structured-exchange/project/present-review-set.ts', + 'src/structured-exchange/project/request-answer.ts', + 'src/structured-exchange/project/request-choice.ts', + 'src/structured-exchange/project/request-choices.ts', + 'src/structured-exchange/project/request-review.ts', +]); +const ALLOWED_TYPEBOX_FILES = new Set(['src/.pi/extensions/structured-exchange/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-present-request.test.ts b/src/.pi/__tests__/structured-exchange-present-request.test.ts index 2c458b72..d4bc480c 100644 --- a/src/.pi/__tests__/structured-exchange-present-request.test.ts +++ b/src/.pi/__tests__/structured-exchange-present-request.test.ts @@ -1,9 +1,13 @@ 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, + REQUEST_REVIEW_TOOL, } from '../extensions/structured-exchange/index.js'; import { findIncompleteStructuredExchangePresents, @@ -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 1da2c9b1..b8baa5ab 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'; @@ -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/structured-exchange'); + 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 b6440633..ad7acd4c 100644 --- a/src/.pi/__tests__/structured-exchange.test.ts +++ b/src/.pi/__tests__/structured-exchange.test.ts @@ -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 98be16ef..6e02f2c6 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/components/workspace-dialog/component.ts b/src/.pi/components/workspace-dialog/component.ts index 9cb6d70e..de192c3d 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/chrome.ts b/src/.pi/extensions/chrome.ts index 6f5de6f7..2bbe77bf 100644 --- a/src/.pi/extensions/chrome.ts +++ b/src/.pi/extensions/chrome.ts @@ -1,11 +1,26 @@ -import type { ExtensionAPI, ExtensionUIContext } from '@earendil-works/pi-coding-agent'; +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 AgentLensSelection, + type AgentStrategySelection, + type OperationalModeId, + type ResolvedBrunchAgentState, +} from '../../session/runtime-state.js'; import type { + WorkspaceProjectState, 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'; @@ -21,7 +36,9 @@ export interface BrunchChromeRuntimeState { role?: string; model?: string; thinking?: string; - lens?: string; + mode?: OperationalModeId; + strategy?: AgentStrategySelection; + lens?: AgentLensSelection; } export interface BrunchChromeBuildState { @@ -29,12 +46,37 @@ export interface BrunchChromeBuildState { 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; @@ -49,65 +91,80 @@ export interface BrunchChromeState extends WorkspaceSessionChromeState { coherence?: BrunchChromeCoherenceVerdict; } -export type BrunchChromeUi = Pick; +export type BrunchChromeUi = Pick; -export function formatBrunchChromeHeaderLines(chrome: BrunchChromeState): string[] { - return [ - ...BRUNCH_COMPACT_WORDMARK, - `runtime: ${formatRuntime(chrome)}`, - `${formatChromeIdentity(chrome)} · phase: ${chrome.phase}`, - ]; -} +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; - 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}`, - ]; -} + const branch = telemetry?.gitBranch ?? 'no branch'; -function formatChromeIdentity(chrome: BrunchChromeState): string { - return `spec: ${formatSpec(chrome)} · session: ${formatSession(chrome)}`; -} + 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, + ); -function formatCompactWordmark(): string { - return BRUNCH_COMPACT_WORDMARK.join(' / '); + 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]) => value.trim()); + .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 { - const available = Math.max(0, width); - const gap = Math.max(1, available - visibleWidth(left) - visibleWidth(right)); - return truncateToWidth(`${left}${' '.repeat(gap)}${right}`, available); + 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 { @@ -120,82 +177,175 @@ export function chromeStateForWorkspace(workspace: WorkspaceSessionReadyState): }; } -export function renderBrunchChrome(ui: BrunchChromeUi, chrome: BrunchChromeState): void { - ui.setHeader(() => ({ - render: () => formatBrunchChromeHeaderLines(chrome), - invalidate: () => {}, - })); - ui.setFooter((tui, _theme, footerData) => { +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, + dispose: () => { + unsubscribe(); + options?.bindFooterRenderRequest?.(null); + }, }; }); - ui.setWidget('brunch.chrome', formatChromeWidgetLines(chrome), { - placement: 'aboveEditor', - }); - ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`); + 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); + 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 formatSession(chrome: BrunchChromeState): string { - return chrome.session.label ?? chrome.session.id; +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 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'; +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/graph/review-set-proposal.ts b/src/.pi/extensions/graph/review-set-proposal.ts deleted file mode 100644 index 27554936..00000000 --- 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/structured-exchange/index.ts b/src/.pi/extensions/structured-exchange/index.ts index 1e3b5339..79fd2abf 100644 --- a/src/.pi/extensions/structured-exchange/index.ts +++ b/src/.pi/extensions/structured-exchange/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/structured-exchange/pi-schema.ts b/src/.pi/extensions/structured-exchange/pi-schema.ts new file mode 100644 index 00000000..2f7879ff --- /dev/null +++ b/src/.pi/extensions/structured-exchange/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-options.ts b/src/.pi/extensions/structured-exchange/present-options.ts index 24526b07..efd25013 100644 --- a/src/.pi/extensions/structured-exchange/present-options.ts +++ b/src/.pi/extensions/structured-exchange/present-options.ts @@ -1,62 +1,13 @@ 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'; +import { formatPresentOptions } from '../../../structured-exchange/format/present-options.js'; +import { projectPresentOptions } from '../../../structured-exchange/project/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; -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', @@ -67,25 +18,16 @@ export const presentOptionsTool = defineTool({ '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, + parameters: piSchema(zPresentOptionsParams), 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, + 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, }; - return { content: [{ type: 'text' as const, text: markdown }], details }; }, renderCall() { diff --git a/src/.pi/extensions/structured-exchange/present-question.ts b/src/.pi/extensions/structured-exchange/present-question.ts index 64b60e32..a4d95aed 100644 --- a/src/.pi/extensions/structured-exchange/present-question.ts +++ b/src/.pi/extensions/structured-exchange/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 { 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/structured-exchange/present-review-set.ts b/src/.pi/extensions/structured-exchange/present-review-set.ts index b30977de..60cdbd5e 100644 --- a/src/.pi/extensions/structured-exchange/present-review-set.ts +++ b/src/.pi/extensions/structured-exchange/present-review-set.ts @@ -1,5 +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 { formatPresentReviewSet } from '../../../structured-exchange/format/present-review-set.js'; +import { projectPresentReviewSet } from '../../../structured-exchange/project/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; -// Stubbed intentionally: review-set presentation semantics are named now, but -// not registered until review-set proposal/acceptance flow lands. -export const presentReviewSetTool = undefined; +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/structured-exchange/request-answer.ts b/src/.pi/extensions/structured-exchange/request-answer.ts index ba65dcb5..a5851fd6 100644 --- a/src/.pi/extensions/structured-exchange/request-answer.ts +++ b/src/.pi/extensions/structured-exchange/request-answer.ts @@ -1,29 +1,13 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; -import { Type } from 'typebox'; +import { formatRequestAnswer } from '../../../structured-exchange/format/request-answer.js'; +import { projectRequestAnswer } from '../../../structured-exchange/project/request-answer.js'; +import { piSchema } from './pi-schema.js'; +import { zRequestAnswerParams, type RequestAnswerParams } from './schemas/index.js'; 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', @@ -34,55 +18,26 @@ export const requestAnswerTool = defineTool({ '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, + parameters: piSchema(zRequestAnswerParams), 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, - }; - + async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) { + const params = zRequestAnswerParams.parse(rawParams) satisfies RequestAnswerParams; if (!ctx.hasUI || typeof ctx.ui.editor !== 'function') { - const details: StructuredExchangeRequestDetails = { - ...base, + const details = projectRequestAnswer({ + exchangeId: params.exchangeId, status: 'unavailable', message: 'request_answer requires interactive UI', - }; - return { - content: [{ type: 'text' as const, text: responseMarkdown(details) }], - details, - }; + }); + return { content: [{ type: 'text' as const, text: formatRequestAnswer(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, - }; + 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() { diff --git a/src/.pi/extensions/structured-exchange/request-choice.ts b/src/.pi/extensions/structured-exchange/request-choice.ts index 1f140ced..139fd7e6 100644 --- a/src/.pi/extensions/structured-exchange/request-choice.ts +++ b/src/.pi/extensions/structured-exchange/request-choice.ts @@ -1,53 +1,19 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; -import { Type } from 'typebox'; -import { normalizeOptionalText, renderMarkdownResult } from './shared/markdown.js'; +import { formatRequestChoice } from '../../../structured-exchange/format/request-choice.js'; +import { projectRequestChoice } from '../../../structured-exchange/project/request-choice.js'; +import { piSchema } from './pi-schema.js'; import { - STRUCTURED_EXCHANGE_REQUEST_SCHEMA, - type StructuredExchangeChoice, - type StructuredExchangeRequestDetails, -} from './shared/model.js'; + 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; -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'); -} +type StructuredExchangeChoice = RequestChoiceParam; function choiceByLabel( choices: readonly StructuredExchangeChoice[], @@ -56,6 +22,10 @@ function choiceByLabel( 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', @@ -66,107 +36,54 @@ export const requestChoiceTool = defineTool({ '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, + parameters: piSchema(zRequestChoiceParams), 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, + 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, - requestTool: REQUEST_CHOICE_TOOL, - status: 'unavailable', - respondsTo: { - exchangeId: params.exchangeId, - presentTool: params.respondsToPresentTool, - }, + respondsToPresentTool: params.respondsToPresentTool, + status, message, - createdAtToolCallId: toolCallId, - }; - return { - content: [{ type: 'text' as const, text: responseMarkdown(details) }], - details, - }; + }); + return { content: [{ type: 'text' as const, text: formatRequestChoice(details) }], details }; }; if (!ctx.hasUI || typeof ctx.ui.select !== 'function') { - return unavailable('request_choice requires interactive UI'); + 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) { - 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, - }; - } + if (selected === undefined) return terminal('cancelled'); const picked = choiceByLabel(choices, selected); - let choice = picked; + let choice: SelectedChoice; let comment = ''; - if (!choice) { + 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) { - 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, - }; + 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')) ?? ''; } - 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, + const details = projectRequestChoice({ exchangeId: params.exchangeId, - requestTool: REQUEST_CHOICE_TOOL, + respondsToPresentTool: params.respondsToPresentTool, 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, - }; + comment: normalizeOptionalText(comment), + }); + return { content: [{ type: 'text' as const, text: formatRequestChoice(details) }], details }; }, renderCall() { diff --git a/src/.pi/extensions/structured-exchange/request-choices.ts b/src/.pi/extensions/structured-exchange/request-choices.ts index b6eb9242..d4be4d83 100644 --- a/src/.pi/extensions/structured-exchange/request-choices.ts +++ b/src/.pi/extensions/structured-exchange/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 { formatRequestChoices } from '../../../structured-exchange/format/request-choices.js'; +import { projectRequestChoices } from '../../../structured-exchange/project/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/structured-exchange/request-review.ts b/src/.pi/extensions/structured-exchange/request-review.ts index 24401b86..56c099e2 100644 --- a/src/.pi/extensions/structured-exchange/request-review.ts +++ b/src/.pi/extensions/structured-exchange/request-review.ts @@ -1,5 +1,80 @@ +import { defineTool } from '@earendil-works/pi-coding-agent'; + +import { formatRequestReview } from '../../../structured-exchange/format/request-review.js'; +import { + projectRequestReview, + type ReviewDecision, +} from '../../../structured-exchange/project/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; -// Stubbed intentionally: review response semantics are named now, but not -// registered until review-set proposal/acceptance flow lands. -export const requestReviewTool = undefined; +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/structured-exchange/schemas/README.md index 55edd97d..5e42abad 100644 --- a/src/.pi/extensions/structured-exchange/schemas/README.md +++ b/src/.pi/extensions/structured-exchange/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 + -> structured-exchange/project/* constructs details + -> relevant details Zod schema parses result + -> structured-exchange/format/* renders durable markdown +``` + +- Active `.pi/extensions/structured-exchange/*.ts` files own Pi registration and UI collection only. +- `../pi-schema.ts` is the only Zod JSON Schema to Pi `TSchema` adapter. +- `structured-exchange/project/*` is the only construction boundary for active present/request `toolResult.details`. +- `structured-exchange/format/*` 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/structured-exchange/schemas/capture.ts index 28ef07fd..4754e27e 100644 --- a/src/.pi/extensions/structured-exchange/schemas/capture.ts +++ b/src/.pi/extensions/structured-exchange/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/structured-exchange/schemas/index.ts index cb6d5e27..a381857c 100644 --- a/src/.pi/extensions/structured-exchange/schemas/index.ts +++ b/src/.pi/extensions/structured-exchange/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/structured-exchange/schemas/params.ts b/src/.pi/extensions/structured-exchange/schemas/params.ts new file mode 100644 index 00000000..a3b14ef6 --- /dev/null +++ b/src/.pi/extensions/structured-exchange/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/structured-exchange/schemas/present.ts index fe195fb8..c8db5ab2 100644 --- a/src/.pi/extensions/structured-exchange/schemas/present.ts +++ b/src/.pi/extensions/structured-exchange/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/structured-exchange/schemas/request.ts index 5c93eb72..a87fdf2b 100644 --- a/src/.pi/extensions/structured-exchange/schemas/request.ts +++ b/src/.pi/extensions/structured-exchange/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/structured-exchange/schemas/shared.ts b/src/.pi/extensions/structured-exchange/schemas/shared.ts index ff833111..4048bb56 100644 --- a/src/.pi/extensions/structured-exchange/schemas/shared.ts +++ b/src/.pi/extensions/structured-exchange/schemas/shared.ts @@ -7,15 +7,11 @@ 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 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 GraphNodeRefSchema = z.toJSONSchema(zGraphNodeRef, { unrepresentable: 'throw' }); export const zPresentToolName = z.enum([ 'present_question', @@ -24,9 +20,7 @@ export const zPresentToolName = z.enum([ 'present_candidates', ]); export type PresentToolName = z.infer; -export const PresentToolNameSchema = z.toJSONSchema(zPresentToolName, { - unrepresentable: 'throw', -}); +export const PresentToolNameSchema = z.toJSONSchema(zPresentToolName, { unrepresentable: 'throw' }); export const zRequestToolName = z.enum([ 'request_answer', @@ -35,9 +29,7 @@ export const zRequestToolName = z.enum([ 'request_review', ]); export type RequestToolName = z.infer; -export const RequestToolNameSchema = z.toJSONSchema(zRequestToolName, { - unrepresentable: 'throw', -}); +export const RequestToolNameSchema = z.toJSONSchema(zRequestToolName, { unrepresentable: 'throw' }); export const zCaptureToolName = z.enum([ 'capture_answer', @@ -47,146 +39,129 @@ export const zCaptureToolName = z.enum([ 'capture_candidate', ]); export type CaptureToolName = z.infer; -export const CaptureToolNameSchema = z.toJSONSchema(zCaptureToolName, { - unrepresentable: 'throw', -}); +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), - v: z.literal(STRUCTURED_EXCHANGE_DETAILS_VERSION), - exchange_id: z.string().min(1), - }) + .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), - v: z.literal(STRUCTURED_EXCHANGE_DETAILS_VERSION), - exchange_id: z.string().min(1), - }) + .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), - v: z.literal(STRUCTURED_EXCHANGE_DETAILS_VERSION), - exchange_id: z.string().min(1), - }) + .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', [ - 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(), + zPresentQuestionToolMeta, + zPresentOptionsToolMeta, + zPresentReviewSetToolMeta, + zPresentCandidatesToolMeta, ]); export type PresentToolMeta = z.infer; -export const PresentToolMetaSchema = z.toJSONSchema(zPresentToolMeta, { - unrepresentable: 'throw', -}); +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([ - 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(), + zRequestAnswerToolMeta, + zRequestChoiceFromOptionsToolMeta, + zRequestChoiceFromCandidatesToolMeta, + zRequestChoicesToolMeta, + zRequestReviewToolMeta, ]); export type RequestToolMeta = z.infer; -export const RequestToolMetaSchema = z.toJSONSchema(zRequestToolMeta, { - unrepresentable: 'throw', -}); +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([ - 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(), + zCaptureAnswerToolMeta, + zCaptureChoiceToolMeta, + zCaptureChoicesToolMeta, + zCaptureReviewToolMeta, + zCaptureCandidateToolMeta, ]); export type CaptureToolMeta = z.infer; -export const CaptureToolMetaSchema = z.toJSONSchema(zCaptureToolMeta, { - unrepresentable: 'throw', -}); +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 index c847d93b..2abf03c8 100644 --- a/src/.pi/extensions/structured-exchange/shared/editor-fallback.ts +++ b/src/.pi/extensions/structured-exchange/shared/editor-fallback.ts @@ -1,15 +1,26 @@ -import { - STRUCTURED_EXCHANGE_RESULT_SCHEMA, - type StructuredExchangeAnswer, - type StructuredExchangeMode, - type StructuredExchangeOption, -} from '../../../../session/structured-exchange.js'; -import { isRecord } from './model.js'; +import { formatRequestChoice } from '../../../../structured-exchange/format/request-choice.js'; +import { formatRequestChoices } from '../../../../structured-exchange/format/request-choices.js'; +import { projectRequestChoice } from '../../../../structured-exchange/project/request-choice.js'; +import { projectRequestChoices } from '../../../../structured-exchange/project/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; - mode: Exclude; + exchangeId?: string; + mode: StructuredExchangeMode; options: StructuredExchangeOption[]; } @@ -19,15 +30,12 @@ interface StructuredExchangeEditorResponse { note: string; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + 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; - } + return answer.type === 'option' ? answer.index : Number.MAX_SAFE_INTEGER - 1; } function sortAnswers(answers: StructuredExchangeAnswer[]): StructuredExchangeAnswer[] { @@ -47,70 +55,20 @@ function parseEditorAnswer(value: unknown): StructuredExchangeAnswer | null { ) { return null; } - return { - type: 'option', - label: value.label, - value: value.value, - index: value.index, - }; + 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; - } + 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 } : {}), - }, - }; +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 { @@ -150,20 +108,16 @@ export function parseStructuredExchangeEditorResponse( 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 === '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; + 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.trim(), + note: response.note, }; } @@ -172,17 +126,60 @@ export function structuredExchangeResultFromEditor( edited: string | undefined, ) { const response = parseStructuredExchangeEditorResponse(edited ?? ''); + const exchangeId = params.exchangeId ?? `rpc-editor:${params.question}`; if (edited === undefined || response?.status === 'cancelled') { - return buildLegacyResult('cancelled', params, [], '', 'User cancelled the question'); + 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) { - return buildLegacyResult( - 'unavailable', - params, - [], - '', - 'structured_exchange editor fallback returned invalid JSON', - ); + + 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 }; } - return buildLegacyResult('answered', params, response.answers, response.note); + + 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/model.ts b/src/.pi/extensions/structured-exchange/shared/model.ts deleted file mode 100644 index bd9850f3..00000000 --- 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 index b1c3a960..b203296e 100644 --- a/src/.pi/extensions/structured-exchange/shared/recovery.ts +++ b/src/.pi/extensions/structured-exchange/shared/recovery.ts @@ -1,80 +1,12 @@ -import { - STRUCTURED_EXCHANGE_PRESENT_SCHEMA, - STRUCTURED_EXCHANGE_REQUEST_SCHEMA, - type PresentToolName, - type RequestToolName, - type StructuredExchangePresentDetails, - type StructuredExchangeRequestDetails, - isRecord, -} from './model.js'; +import type { PresentDetails, RequestDetails } from '../schemas/index.js'; +import { zPresentDetails, zRequestDetails } from '../schemas/index.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 isStructuredExchangePresentDetails(value: unknown): value is PresentDetails { + return zPresentDetails.safeParse(value).success; } -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; +export function isStructuredExchangeRequestDetails(value: unknown): value is RequestDetails { + return zRequestDetails.safeParse(value).success; } interface EntryLike { @@ -91,7 +23,7 @@ function toolResultDetails(entry: EntryLike): unknown { export interface IncompleteStructuredExchangePresent { entry: EntryLike; - details: StructuredExchangePresentDetails; + details: PresentDetails; } export function findIncompleteStructuredExchangePresents( @@ -103,13 +35,11 @@ export function findIncompleteStructuredExchangePresents( for (const entry of entries) { const details = toolResultDetails(entry); if (isStructuredExchangePresentDetails(details)) { - if (details.expectedRequest?.required !== false) { - presents.set(details.exchangeId, { entry, details }); - } + presents.set(details.exchange_id, { entry, details }); } else if (isStructuredExchangeRequestDetails(details)) { - completed.add(details.exchangeId); + completed.add(details.exchange_id); } } - return [...presents.values()].filter((present) => !completed.has(present.details.exchangeId)); + return [...presents.values()].filter((present) => !completed.has(present.details.exchange_id)); } diff --git a/src/.pi/pi-extension-shell.ts b/src/.pi/pi-extension-shell.ts index a44d665c..c52a7d7a 100644 --- a/src/.pi/pi-extension-shell.ts +++ b/src/.pi/pi-extension-shell.ts @@ -112,7 +112,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/agents/compose.test.ts b/src/agents/compose.test.ts index 663f28e5..9093cf93 100644 --- a/src/agents/compose.test.ts +++ b/src/agents/compose.test.ts @@ -40,7 +40,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', () => { diff --git a/src/agents/contexts/graph.test.ts b/src/agents/contexts/graph.test.ts index 8bee6379..61e94a15 100644 --- a/src/agents/contexts/graph.test.ts +++ b/src/agents/contexts/graph.test.ts @@ -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/agents/contexts/graph.ts index d1fb43cc..ed91b3cd 100644 --- a/src/agents/contexts/graph.ts +++ b/src/agents/contexts/graph.ts @@ -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/brunch-tui.test.ts b/src/brunch-tui.test.ts index 7d9e9a0c..bd23e0d7 100644 --- a/src/brunch-tui.test.ts +++ b/src/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, @@ -462,8 +462,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 () => { @@ -499,9 +499,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 +617,7 @@ describe('Brunch TUI boot', () => { 'custom', 'activate:openSession', `switch:${target.session.file}`, - 'replacement:setHeader', 'replacement:setFooter', - 'replacement:setWidget', 'replacement:setTitle', 'replacement:notify', ]); diff --git a/src/db/README.md b/src/db/README.md index 2b0c5c23..ee3cd5d2 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 2f8561ad..997f29f2 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 e207287e..a33f3d5a 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 00000000..b0028f68 --- /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/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/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 6e5958e1..264e917b 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. @@ -67,6 +84,7 @@ graph/ createNode per-kind node ordinal allocation commitGraph / dryRunCommitGraph + acceptReviewSet create/resolve reconciliation need command-executor/ @@ -77,6 +95,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 diff --git a/src/graph/command-executor.test.ts b/src/graph/command-executor.test.ts index bf311039..984cc426 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 919e438b..d97a0bc4 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 00000000..5b493247 --- /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 8c840bf3..19f71fc7 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 00000000..9cb6542f --- /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 00000000..26e0ee64 --- /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 d9c167ed..a788fd30 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 00000000..cc9ab290 --- /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 00000000..8b2e5526 --- /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 61682a3a..2ab69dc3 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 116b10bd..dde0d2b8 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 38406583..4b3736b8 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/public-rpc-parity-proof.ts b/src/probes/public-rpc-parity-proof.ts index c7b6b338..e1c6decb 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/structured-exchange-ordering-proof.test.ts b/src/probes/structured-exchange-ordering-proof.test.ts index 5116fb13..b420a255 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-rpc-proof.test.ts b/src/probes/structured-exchange-rpc-proof.test.ts index 378f991d..c633eb9f 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 90c2a1c3..59593d80 100644 --- a/src/probes/structured-exchange-rpc-proof.ts +++ b/src/probes/structured-exchange-rpc-proof.ts @@ -16,10 +16,10 @@ interface FrictionReport { frictions: string[]; } -interface TerminalDetails extends StructuredExchangeToolResultDetails { +type TerminalDetails = StructuredExchangeToolResultDetails & { probe: ProbeMetadata; frictionReport: FrictionReport; -} +}; interface ProofResultEntry { customType: string; diff --git a/src/rpc/handlers.test.ts b/src/rpc/handlers.test.ts index ef5259a2..57021d00 100644 --- a/src/rpc/handlers.test.ts +++ b/src/rpc/handlers.test.ts @@ -163,7 +163,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 +197,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 +214,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 +236,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 +731,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 +761,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 +956,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 +1003,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 +1023,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 +1132,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.', }, @@ -2056,7 +2046,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 +2062,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 +2231,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 +2310,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/session/exchange-projection.test.ts b/src/session/exchange-projection.test.ts index 4110acd5..2e246356 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 b2dabff0..eaf3235d 100644 --- a/src/session/exchange-projection.ts +++ b/src/session/exchange-projection.ts @@ -5,10 +5,7 @@ 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/structured-exchange/schemas/index.js'; import { isStructuredExchangePresentDetails, isStructuredExchangeRequestDetails, @@ -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/session-transcript.test.ts b/src/session/session-transcript.test.ts index 3ac0538b..96d7c610 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/structured-exchange-loop.test.ts b/src/session/structured-exchange-loop.test.ts index bf3927d3..8958ed72 100644 --- a/src/session/structured-exchange-loop.test.ts +++ b/src/session/structured-exchange-loop.test.ts @@ -37,10 +37,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 +61,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 +97,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 +127,50 @@ 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('reconstructs pending options from canonical structured present details', () => { const envelope: BrunchSessionEnvelope = { header: header as unknown as BrunchSessionEnvelope['header'], binding, @@ -156,13 +202,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 5173727f..ae625f71 100644 --- a/src/session/structured-exchange-loop.ts +++ b/src/session/structured-exchange-loop.ts @@ -1,40 +1,43 @@ -import { Type, type Static } from 'typebox'; -import { Value } from 'typebox/value'; +import * as z from 'zod'; -import type { StructuredExchangePresentDetails } from '../.pi/extensions/structured-exchange/shared/model.js'; +import type { PresentDetails } from '../.pi/extensions/structured-exchange/schemas/index.js'; import { isStructuredExchangePresentDetails } from '../.pi/extensions/structured-exchange/shared/recovery.js'; +import { formatPresentOptions } from '../structured-exchange/format/present-options.js'; +import { formatPresentQuestion } from '../structured-exchange/format/present-question.js'; +import { projectPresentOptions } from '../structured-exchange/project/present-options.js'; +import { projectPresentQuestion } from '../structured-exchange/project/present-question.js'; +import { projectRequestAnswer } from '../structured-exchange/project/request-answer.js'; +import { projectRequestChoice } from '../structured-exchange/project/request-choice.js'; +import { projectRequestChoices } from '../structured-exchange/project/request-choices.js'; import type { BrunchSessionEnvelope } from './brunch-session-envelope.js'; import { projectLinearSessionExchangeProjection } from './exchange-projection.js'; -const NonBlankStringSchema = Type.String({ minLength: 1 }); - -export const PendingStructuredExchangeSchema = Type.Object( - { - exchangeId: NonBlankStringSchema, - lens: Type.Literal('intent'), - mode: Type.Union([Type.Literal('text'), Type.Literal('single-select'), Type.Literal('multi-select')]), - prompt: NonBlankStringSchema, - details: Type.Optional(NonBlankStringSchema), - options: Type.Array( - Type.Object( - { - id: NonBlankStringSchema, - label: NonBlankStringSchema, - content: NonBlankStringSchema, - rationale: Type.Optional(NonBlankStringSchema), - }, - { additionalProperties: false }, - ), - ), - note: Type.Object( - { allowed: Type.Boolean() }, - { - additionalProperties: false, - }, +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(), ), - }, - { 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; @@ -74,7 +77,7 @@ export interface AcceptedToolResultMessage { timestamp: 0; } -export type PendingStructuredExchange = Static; +export type PendingStructuredExchange = z.infer; export type AcceptedStructuredExchangeResponse = | { @@ -175,38 +178,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 +236,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 +272,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 +292,20 @@ 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, + }), }, }; } @@ -304,17 +325,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,22 +353,10 @@ 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( @@ -365,52 +380,52 @@ 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'); - } - 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 +488,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 +501,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 03d3f14a..00000000 --- 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 86df805e..cb8536a6 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 1d9dddec..c08a9d2e 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 4d915d9e..3f01a8f3 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 index 65ebcb3a..c3cb6679 100644 --- a/src/structured-exchange/format/present-options.ts +++ b/src/structured-exchange/format/present-options.ts @@ -1,11 +1,18 @@ -/** - * 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 - */ +import type { PresentOptionsProjection } from '../project/present-options.js'; -export {}; +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-review-set.ts b/src/structured-exchange/format/present-review-set.ts index e2fcd98a..7ba5beca 100644 --- a/src/structured-exchange/format/present-review-set.ts +++ b/src/structured-exchange/format/present-review-set.ts @@ -1,11 +1,38 @@ -/** - * 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 {}; +import type { PresentReviewSetProjection } from '../project/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/structured-exchange/format/request-answer.ts b/src/structured-exchange/format/request-answer.ts index f6541fdc..7e64a1a1 100644 --- a/src/structured-exchange/format/request-answer.ts +++ b/src/structured-exchange/format/request-answer.ts @@ -1,11 +1,7 @@ -/** - * 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 - */ +import type { RequestAnswerDetails } from '../../.pi/extensions/structured-exchange/schemas/index.js'; -export {}; +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/structured-exchange/format/request-choice.ts b/src/structured-exchange/format/request-choice.ts index 9d42a4f7..d4b54b80 100644 --- a/src/structured-exchange/format/request-choice.ts +++ b/src/structured-exchange/format/request-choice.ts @@ -1,11 +1,9 @@ -/** - * 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 - */ +import type { RequestChoiceDetails } from '../../.pi/extensions/structured-exchange/schemas/index.js'; -export {}; +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/structured-exchange/format/request-choices.ts b/src/structured-exchange/format/request-choices.ts index 856a2709..2eb8b162 100644 --- a/src/structured-exchange/format/request-choices.ts +++ b/src/structured-exchange/format/request-choices.ts @@ -1,11 +1,17 @@ -/** - * 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 - */ +import type { RequestChoicesDetails } from '../../.pi/extensions/structured-exchange/schemas/index.js'; -export {}; +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/structured-exchange/format/request-review.ts b/src/structured-exchange/format/request-review.ts index bcd3c32b..eabd7d9c 100644 --- a/src/structured-exchange/format/request-review.ts +++ b/src/structured-exchange/format/request-review.ts @@ -1,11 +1,16 @@ -/** - * 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 - */ +import type { RequestReviewDetails } from '../../.pi/extensions/structured-exchange/schemas/index.js'; -export {}; +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/structured-exchange/project/present-options.ts b/src/structured-exchange/project/present-options.ts index b4e92bc1..0a28b313 100644 --- a/src/structured-exchange/project/present-options.ts +++ b/src/structured-exchange/project/present-options.ts @@ -1,16 +1,41 @@ -/** - * 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 - */ +import type { PresentOptionsDetails } from '../../.pi/extensions/structured-exchange/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA, + zPresentOptionsDetails, + type PresentOptionsParams, +} from '../../.pi/extensions/structured-exchange/schemas/index.js'; -export {}; +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/structured-exchange/project/present-question.ts b/src/structured-exchange/project/present-question.ts index 345e8de2..53bc03fb 100644 --- a/src/structured-exchange/project/present-question.ts +++ b/src/structured-exchange/project/present-question.ts @@ -2,10 +2,10 @@ * Canonical projection for `present_question` content. * * Input: - * - StructuredExchangePresentDetails or equivalent domain prompt state + * - domain prompt state for a Brunch structured question * * Output: - * - normalized heading/body projection for durable prompt-side content + * - normalized heading/body projection plus canonical Zod-authored details * * Used by: * - structured-exchange/format/present-question.ts @@ -13,44 +13,44 @@ * - .pi/extensions/structured-exchange/present-question.ts */ +import type { PresentQuestionDetails } from '../../.pi/extensions/structured-exchange/schemas/index.js'; import { - STRUCTURED_EXCHANGE_PRESENT_SCHEMA, - type StructuredExchangePresentDetails, -} from '../../.pi/extensions/structured-exchange/shared/model.js'; + STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA, + zPresentQuestionDetails, +} from '../../.pi/extensions/structured-exchange/schemas/index.js'; export interface PresentQuestionProjection { readonly heading: string; readonly body?: string; - readonly details: StructuredExchangePresentDetails; + readonly details: PresentQuestionDetails; } 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); + 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: { - 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, - }, + details, }; } diff --git a/src/structured-exchange/project/present-review-set.ts b/src/structured-exchange/project/present-review-set.ts index c7f282ad..ab2574da 100644 --- a/src/structured-exchange/project/present-review-set.ts +++ b/src/structured-exchange/project/present-review-set.ts @@ -1,15 +1,66 @@ -/** - * 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 {}; +import type { + PresentReviewSetDetails, + ReviewSetDetailsPayload, +} from '../../.pi/extensions/structured-exchange/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA, + zPresentReviewSetDetails, +} from '../../.pi/extensions/structured-exchange/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/structured-exchange/project/request-answer.ts b/src/structured-exchange/project/request-answer.ts index ecfa2064..091bbdcb 100644 --- a/src/structured-exchange/project/request-answer.ts +++ b/src/structured-exchange/project/request-answer.ts @@ -1,16 +1,36 @@ -/** - * 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 - */ +import type { RequestAnswerDetails } from '../../.pi/extensions/structured-exchange/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA, + zRequestAnswerDetails, +} from '../../.pi/extensions/structured-exchange/schemas/index.js'; -export {}; +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/structured-exchange/project/request-choice.ts b/src/structured-exchange/project/request-choice.ts index 61b98f75..6fb40dba 100644 --- a/src/structured-exchange/project/request-choice.ts +++ b/src/structured-exchange/project/request-choice.ts @@ -1,16 +1,51 @@ -/** - * 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 - */ +import type { + RequestChoiceDetails, + SelectedChoice, +} from '../../.pi/extensions/structured-exchange/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA, + zRequestChoiceDetails, +} from '../../.pi/extensions/structured-exchange/schemas/index.js'; -export {}; +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/structured-exchange/project/request-choices.ts b/src/structured-exchange/project/request-choices.ts index dc4a59ed..d798a396 100644 --- a/src/structured-exchange/project/request-choices.ts +++ b/src/structured-exchange/project/request-choices.ts @@ -1,16 +1,49 @@ -/** - * 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 - */ +import type { + RequestChoicesDetails, + SelectedChoice, +} from '../../.pi/extensions/structured-exchange/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA, + zRequestChoicesDetails, +} from '../../.pi/extensions/structured-exchange/schemas/index.js'; -export {}; +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/structured-exchange/project/request-review.ts b/src/structured-exchange/project/request-review.ts index fadc4d8e..c358ad7e 100644 --- a/src/structured-exchange/project/request-review.ts +++ b/src/structured-exchange/project/request-review.ts @@ -1,15 +1,48 @@ -/** - * 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 - */ +import type { RequestReviewDetails } from '../../.pi/extensions/structured-exchange/schemas/index.js'; +import { + STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA, + zRequestReviewDetails, +} from '../../.pi/extensions/structured-exchange/schemas/index.js'; -export {}; +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/web/features/graph/GraphOverview.tsx b/src/web/features/graph/GraphOverview.tsx index 7cf0dd86..8713ccc5 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 c3a41b41..ada14f79 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/routes/root.tsx b/src/web/routes/root.tsx index d213df9d..5bfbd8f6 100644 --- a/src/web/routes/root.tsx +++ b/src/web/routes/root.tsx @@ -1,5 +1,6 @@ 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 { workspaceSnapshotQueryOptions } from '../queries/workspace.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 6e94943d..3a9fbd55 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 00000000..8961eea9 --- /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 00000000..11f02fe2 --- /dev/null +++ b/src/web/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vite.config.ts b/vite.config.ts index 7e4ffa32..43e30a9c 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,