diff --git a/.gitignore b/.gitignore index 84ab885b..1ed6282a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ dist-ssr bun.lock .notes.md .brunch/ +.cook/ brunch.db* todo.txt @@ -52,3 +53,6 @@ tmp/ # skill quarantine .agents/_quarantine + +# antigravity cli +.antigravitycli/ diff --git a/config/vite-server-runtime.ts b/config/vite-server-runtime.ts index 255aa99b..db3c875f 100644 --- a/config/vite-server-runtime.ts +++ b/config/vite-server-runtime.ts @@ -54,6 +54,10 @@ export const createServerRuntimeConfig = ({ }, closeBundle() { copyServerPromptAssets(resolve(rootDir, 'src/server/prompts'), promptAssetsDestinationDir); + copyServerPromptAssets( + resolve(rootDir, 'src/orchestrator/prompts'), + resolve(promptAssetsDestinationDir, '..', 'orchestrator-prompts'), + ); }, }, ], diff --git a/docs/design/orchestrator.md b/docs/design/orchestrator.md new file mode 100644 index 00000000..1f42acb5 --- /dev/null +++ b/docs/design/orchestrator.md @@ -0,0 +1,318 @@ +# Orchestrator POC — Design Proposal + +> Status: **landed POC** — CLI orchestrator that consumes a brunch-shaped execution plan (epics → slices) and dispatches agents and deterministic checks to drive the plan to completion. Canonical decisions in `memory/SPEC.md` (R46–50, D155-K–D159-K, I121-K–I123-K). Tracked as FE-730; umbrella H-6476. +> +> Scope is intentionally narrow: two interchangeable execution engines behind a shared seam, plan-as-YAML, an append-only event log as the communication medium, and an isolated worktree per run. The 15-step build sequence, fixture definitions, and pi-agent invocation details are operational scaffolding kept separate from this doc. Code lives under `src/orchestrator/` in the brunch repo; `cook` is only the CLI subcommand name. +> +> **Full design vs POC implementation:** this doc describes the design as it should land if/when the orchestrator productizes. The POC implements a deliberate subset to avoid premature abstraction — see [§POC scope and deferrals](#12-poc-scope-and-deferrals) for the explicit map of designed-but-deferred items. + +## 1. Concept & problem + +Brunch elicits specs and (eventually) projects them into execution plans. The orchestrator closes the loop: it takes such a plan, walks its work units, and produces real code + verification results. + +Two pressures shaped the design: + +- The team explicitly wants to **test the Petri-net substrate as a hypothesis** rather than commit to it on faith. Running it side-by-side with a hand-coded baseline is the only way to get empirical signal on whether the abstraction earns its complexity. +- The plan model is **provisional**. Brunch does not yet emit execution plans; canonical fixtures are forthcoming. The orchestrator should be forward-compatible (room for intent/design/oracle pointers, status semantics, milestone-level structure) without invalidating the engine seam when the plan model sharpens. + +The orchestrator is not productized brunch. It is an experiment that should produce: (a) one working CLI built end-to-end from a plan, (b) two engines reaching the same outcome, (c) enough qualitative comparison to justify the next architectural commitment. + +## 2. Architecture + +``` + brunch cook + │ + ▼ + ┌──────────────────────┐ + │ Orchestrator │ <-- shared seam + │ .run(input) │ + └──────────┬───────────┘ + │ + ┌────────────┴────────────┐ + ▼ ▼ + petrinet engine procedural engine + (interpreter + (walks epics, then + net + tokens) slices within each) + │ │ + └────────────┬────────────┘ + ▼ + ┌──────────────────────┐ + │ ActionRegistry │ <-- name-keyed dispatch + └──────────┬───────────┘ + │ + ┌──────────────────────┐ + │ AgentDispatch │ + │ ReportSink (jsonl) │ + │ TestRunner (det.) │ + │ Worktree (fs) │ + └──────────────────────┘ +``` + +### The seam + +```ts +interface Orchestrator { + run(input: OrchestratorInput): Promise; +} + +type OrchestratorInput = { + plan: Plan; // { epics, slices } + worktreeDir: string; // cwd-scoped isolated run directory + actions: ActionHandlers; // Record — inline dispatch (POC); ActionRegistry when productized (§12) + reports: ReportSink; // append-only jsonl + testRunner: TestRunner; // deterministic exec + policy: RunPolicy; // { maxRetries } +}; + +type OrchestratorResult = { + status: 'completed' | 'halted'; + reason?: string; + reports: ReportRef[]; + epics: EpicOutcome[]; + slices: SliceOutcome[]; +}; +``` + +Every dependency is injected. Contract tests swap in fakes — a fake `ActionRegistry` returns canned report refs without invoking any real agent or test runner. This is what makes the two-engine experiment cheap. + +### How each engine handles the hierarchy + +- **procedural:** `topoOrder(epics)` → for each ready epic, `topoOrder(epic.slices)` → for each ready slice, run inner loop → after all slices done, run epic-level verifications → if fail, halt. +- **petrinet:** Epic and slice readiness states are places in the net. Slice completion produces tokens that feed into an epic-completion transition. Epic verification is itself a transition. Epic dependencies become input arcs into the first slices' ready-places. + +Both produce identical observable behavior on the contract test suite. That's the non-negotiable. + +## 3. ActionRegistry — name-keyed dispatch + +> **POC note:** The POC uses inline `ActionHandlers` (a record of handler functions) instead of a formal registry class. The `ActionRegistry` interface below is the productized target — see [§12 POC scope and deferrals](#12-poc-scope-and-deferrals). + +The TDD inner loop's transitions (`write-tests`, `write-code`, `run-tests`, `evaluate-done`, `verify-epic`) are not hardcoded inside the engines. They are registered handlers the engines look up by name: + +```ts +interface ActionRegistry { + register(name: ActionName, handler: ActionHandler): void; + get(name: ActionName): ActionHandler; // throws on unknown + has(name: ActionName): boolean; +} + +type ActionHandler = (ctx: ActionContext) => Promise; +``` + +Engines orchestrate **which** action fires when (the state machine). The registry owns **how**. Adding `lint`, `human-review`, or `research` later is a registration, not engine surgery. This satisfies the PRD's "actions looked up by name, extensible without restructuring" intent without changing the plan schema — slices still trigger the fixed TDD loop, but the loop's primitives are pluggable. + +## 4. Plan model: epics → slices + +Two levels. **Slices** are the execution unit; **epics** are organizational groupings that can carry their own integration-level verification. No milestones in POC. + +```yaml +epics: + - id: scaffolding + summary: "CLI scaffolding" + depends_on: [] + verification: + - kind: integration-test + target: "tests/cli.integration.test.ts" + +slices: + - id: version-flag + epic_id: scaffolding + definition: "Add `--version` flag printing version from package.json" + depends_on: [] + verification: + - kind: unit-test + target: "tests/version.test.ts" +``` + +### Readiness rules + +- An epic is **ready** when every epic in its `depends_on` is **done**. +- A slice is **ready** when (a) its parent epic is ready and (b) every slice in its `depends_on` is done. +- An epic is **done** when (a) every slice with that `epic_id` is done and (b) the epic's own verifications all pass. +- A failed epic-level verification halts the run. POC does not scope remediation slices. + +### Slicing principle + +Slice **vertically** through layers, not horizontally. Each slice produces a thin end-to-end increment; epics carry the cross-slice integration checks. This mirrors the walking-skeleton posture: keep all layers moving together at minimum increments rather than building one layer at a time. + +### Schema provenance + +The schema is **provisional**. The PRD says plans are "based on a brunch produced plan's speculative schema," but brunch (the elicitation tool) does not yet emit execution plans. The design here is intentionally minimal and forward-compatible: as canonical fixtures land, the schema may grow new fields (intent/design/oracle pointers, status semantics, milestone level) without invalidating the engine seam. + +## 5. Reports as communication medium + +This is the load-bearing communication discipline: + +> **Tokens carry only pointers. All event content lives in `reports.jsonl`. Transitions communicate by appending lines and reading prior lines by `reportId` — never by passing data through the net.** + +The log isn't a side-effect of the run; it's *the* communication medium. The net stays narrow (tiny token shape) precisely because the log carries everything else. + +### Discipline + +- Tokens carry exactly `{ reportId, sliceId, epicId }`. Nothing else. +- Every transition appends one line per event. Each line has a fresh `reportId` (UID). +- When a downstream transition needs prior context (e.g. `write-code` needs the test files from `write-tests`), it reads the prior line by `reportId` from the log. +- The whole log is also the post-run audit trail. + +### Line schema + +```json +{ + "id": "rpt_01J...", + "ts": "2026-05-20T14:23:00Z", + "epicId": "epic-1", + "sliceId": "slice-1", + "actor": "test-writer | code-writer | test-runner | evaluator | orchestrator", + "event": "tests-written | code-written | tests-run | eval-done | epic-verified | halt", + "payload": { /* event-specific */ } +} +``` + +### Resumability-readiness + +POC runs are not resumable per PRD, but the architecture preserves the affordance: `reports.jsonl` is sufficient to reconstruct epic/slice state at any point. A future `brunch cook resume ` could replay the log to the last consistent transition and continue without changing the engine seam. + +## 6. Per-slice inner loop + +The execution of one slice is the same state machine in both engines. The procedural engine implements it as a hand-coded loop; the petrinet engine compiles it into a generic net and runs a solver. + +### Places (states) + +- `slice spec ready` — slice received; ready to evaluate +- `testing agent ready` / `coding agent ready` — agent resources (single-token discipline in POC; pool later) +- `failing tests exist` — tests written and a deterministic run failed (or have just been written, awaiting first run) +- `untested code ready` — code written; needs deterministic re-run +- `NO spec needs more` — evaluator says spec isn't satisfied yet +- `YES spec is done` — evaluator says spec satisfied; slice can terminate + +### Transitions (actions) + +- `evaluate done state` (testing agent) — reads slice spec + prior reports; emits `NO/YES`; returns testing-agent token +- `write tests` (testing agent) — consumes `NO spec needs more` + testing-agent token; emits `failing tests exist`; appends a report line +- `write code` (coding agent) — consumes `failing tests exist` + coding-agent token; emits `untested code ready`; appends a report line +- `run latest tests` (deterministic, orchestrator-owned) — consumes `untested code ready`; emits either `failing tests exist` (loop) or `slice spec ready` (re-evaluate); appends a report line +- `return DONE` — consumes `YES spec is done` + +### Loop pattern + +``` +slice spec ready → evaluate + ├─ needs more → write tests → write code → run tests + │ ├─ fail → write code → ... (up to maxRetries) + │ └─ pass → slice spec ready (re-evaluate) + └─ done → return DONE +``` + +The "run latest tests → slice spec ready" arc is what makes the orchestrator handle multi-criterion slices: a passing run doesn't end the slice, it triggers another `evaluate done state` to check whether the spec is fully satisfied. + +### Why the orchestrator owns the deterministic test run + +Agents can be wrong about whether their own tests passed. The orchestrator re-runs tests itself as an outside check, so the coding agent's claim of success is verified independently. This isn't anti-gaming (the deeper anti-gaming move would be ensuring test quality); it's anti-lying — the agent can't accidentally or sloppily claim a pass that didn't happen. + +## 7. Dual-mode CLI resolver + +The CLI takes a single directory argument: + +``` +brunch cook +``` + +Cook decides between **fixture mode** (greenfield) and **codebase mode** (brownfield) by where it finds the plan: + +| Plan location | Mode | Worktree behavior | POC status | +|---|---|---|---| +| `/plan.yaml` | Fixture (greenfield) | Empty worktree | Implemented | +| `/.cook/plan.yaml` | Codebase (brownfield) | Worktree seeded from `` | Reserved; seed implementation deferred | + +Naming intuition: a **fixture** *is* a plan with supporting artifacts (`plan.yaml` at root, like a manifest); a **codebase** *has* a plan as configuration (`.cook/plan.yaml`, like `.eslintrc` or `.github/`). + +The plan may declare `mode: greenfield | brownfield` to override the default inferred from location. + +POC implements fixture mode end-to-end; codebase mode returns a structured "not yet implemented" error on the reserved resolver branch. The seed step (likely `git worktree add` when `.git` exists; filtered copy fallback otherwise) is the only meaningful added work to enable brownfield — engine, registry, agents, and reports are mode-agnostic. + +## 8. Worktree isolation + +Each run gets an isolated worktree at `/.cook/runs//worktree/`, where `` is the directory the user invoked `brunch cook` from (not the fixture/plan directory). Reports land alongside at `/.cook/runs//reports.jsonl`. Agents write freely inside the worktree; the fixture directory (``) and the invoking repo are never mutated. No commits, no pushes. Recovery = throw the worktree away and start a new run. + +The run location is cwd-scoped rather than fixture-scoped so that: + +- **Fixtures stay pristine.** Checked-in fixture directories (e.g. `fixtures/txt/`) contain only `plan.yaml` and are byte-identical before and after a run. +- **No path traversal.** Because the worktree is not a descendant of the fixture dir, agents cannot accidentally read or write fixture-level files. +- **Easy cleanup.** `rm -rf .cook/runs/` in the invoking directory clears all run history. `.cook/` is gitignored at the repo level. + +`--worktree ` overrides the default location for explicit pinning. + +## 9. Verification stance + +Three tiers, each with a distinct purpose: + +| Tier | Real or fake | Purpose | +|---|---|---| +| **Engine contract tests** | Fake agents, fake test runner | Both engines must produce identical observable behavior. This is the experiment. | +| **Adapter tests** | N/A (per-engine internals) | Petri-net compilation, solver step semantics, transition firing for the petrinet engine. Topo sort, inner-loop state, retry counter for the procedural engine. | +| **Integration fixture run** | Real pi-agent, real test runner | One greenfield CLI fixture executed end-to-end. Manual inspection of outcomes and `reports.jsonl` legibility. | + +The contract tier is where the two-engine experiment is decided. Both engines must pass the same suite; any divergence is a bug in one of them, not a "different design." The adapter tier covers per-engine internals that don't have a meaningful equivalent in the other engine. The integration tier is what gets demoed. + +## 10. PRD reconciliation + +| PRD claim | Design posture | +|---|---| +| "Plan can be based on both greenfield and brownfield projects." | Dual-mode resolver makes the brownfield slot explicit and reachable. POC implements greenfield only; seed-copy/git-worktree step is the only added work to enable brownfield. PRD intent satisfied structurally. | +| "Actions looked up by name; extensible without restructuring." | Internal `ActionRegistry`. Plan schema unchanged — slices don't declare actions, they trigger the fixed TDD loop. New action types (lint, research, human-review) register without engine surgery. | +| "Live progress stream the user can watch." | Per-event streaming is the default UX, not opt-in. Verbose mode adds raw agent stdout. | +| "Architecture should allow future resumability." | Append-only `reports.jsonl` is the substrate; sufficient to reconstruct epic/slice state. Implementation deferred. | +| "Realistic fixture run all the way through." | One greenfield CLI fixture (TypeScript + Bun), two epics, five slices. Exercises happy paths, intra/inter-epic deps, epic-level integration verification, and the retry loop. | + +## 11. Out of scope + +- Milestones (third level above epics) +- Remediation slices when epic-level verification fails +- Dynamic replanning during a run +- Resumability implementation (architecture supports it) +- Parallel slice or epic execution +- Brownfield seed implementation (resolver branch reserved) +- Halt-and-continue across independent slices (halt-all on any failure for POC) +- Multiple test-runner backends (let fixture pick one) +- Human-review checkpoint (PRD stretch goal) +- Plan generation from spec (separate concern) +- Petrinaut / brunch UI integration + +## 12. POC scope and deferrals + +The design above is the target shape. The POC builds a deliberate subset and defers the rest as architectural slots — designed in the doc, not in the code. The full design is preserved here so future iterations have somewhere to start from rather than re-deriving it. + +| Design element | Full design | POC posture | +|---|---|---| +| **Action dispatch** | `ActionRegistry` registers handlers by name; engines look up by name; new actions (e.g. `lint`, `human-review`, `research`) register without engine surgery. | Inline handler dispatch per engine (e.g. a record literal or switch). Promote to a real registry when a 3rd action type lands. | +| **Plan resolver** | Dual-mode by plan location: `/plan.yaml` → fixture (greenfield); `/.cook/plan.yaml` → codebase (brownfield). | Fixture mode only. CLI takes `` directly; codebase branch is documented here, not coded. | +| **Brownfield seed** | When codebase mode is used and `/.git` exists, prefer `git worktree add`; otherwise filtered copy (`rsync` excluding `.git`, `node_modules`, `dist`, `.cook/runs/`). | Not implemented. Greenfield-only execution; `mkdir` creates an empty worktree. | +| **Token-pointer discipline** | Universal rule: tokens between transitions carry only `{ reportId, sliceId, epicId }` pointers; all event content lives in `reports.jsonl`. Applied across both engines. | Petrinet engine enforces this internally (it's a hard constraint of the substrate). Procedural engine is free to pass data through normal function calls — each engine handles its own state shape, the shared seam is just inputs and outputs. | +| **Layer 2 adapter tests** | Per-engine internal tests (net compilation / solver / transition firing for petrinet; topo sort / inner-loop state transitions / retry counter for procedural). | Optional. Defer until a debugging need surfaces. Layer 1 (contract) + Layer 3 (integration) are mandatory; Layer 2 is added if and when it pays for itself. | +| **Streaming UX formatting** | Compact per-event lines like `[slice-1 ▸ test-writer] tests-written → 3 files`. | Implemented: elapsed timing, icons (▸/✓/✗/●/○), structured header/footer, `--verbose` for raw pi output. JSON stays in `reports.jsonl` only. | + +Rationale for deferring: each item above is "right" for the productized version and "premature" for the POC. The experiment we actually need to run is whether the Petri-net substrate earns its complexity — none of the deferred items affect that experiment's signal. Adding them now would inflate the LOC count and make the comparison muddier, not crisper. + +When the experiment concludes and the orchestrator productizes (or merges into something else), the deferrals become the natural follow-up backlog: lift inline dispatch into `ActionRegistry`, wire the codebase-mode resolver branch, add the seed step, etc. + +## 13. Two-path experiment results + +Both engines completed Fixture #1 end-to-end. Procedural: 206 LOC, ~9 min, 23 events. Petri-net: 410 LOC, ~13 min, 27 events. Both produced a working `txt` CLI with 154 agent-written tests passing. + +**Verdict:** The procedural engine is half the code, faster to debug (stack traces point to loop lines, not fire() closures), and trivially readable. The Petri-net engine's main advantage is parallelism readiness — independent slices could fire concurrently without restructuring the engine. For serial execution, proc wins. Petri earns its complexity only when parallel execution or dynamic replanning enters scope. + +Full comparison table in the POC summary doc. + +## Lexicon + +| Term | Definition | +|---|---| +| **plan** | YAML file describing epics + slices with definitions, dependencies, and verifications. The orchestrator's input. | +| **epic** | Organizational grouping of slices with cross-slice integration verification. | +| **slice** | The execution unit. A thin vertical increment across all relevant layers with its own definition and verifications. | +| **fixture** | Packaged test scenario for the orchestrator (plan + supporting artifacts). Used to test `cook` itself. | +| **engine** | Implementation of the `Orchestrator` interface. Two engines exist: `petrinet` and `procedural`. | +| **action** | A handler in the `ActionRegistry` (e.g. `write-tests`, `write-code`, `run-tests`, `evaluate-done`, `verify-epic`). Engines look up by name. | +| **report** | One structured event line in `reports.jsonl`. Carries the durable content; tokens carry only pointers to reports. | +| **worktree** | Isolated filesystem location where agents write during a run. Per-run; ephemeral. | +| **fixture mode** | Greenfield execution: plan at `/plan.yaml`, empty worktree. POC default. | +| **codebase mode** | Brownfield execution: plan at `/.cook/plan.yaml`, worktree seeded from ``. Reserved, not implemented in POC. | diff --git a/fixtures/txt/plan.yaml b/fixtures/txt/plan.yaml new file mode 100644 index 00000000..caf85f8b --- /dev/null +++ b/fixtures/txt/plan.yaml @@ -0,0 +1,55 @@ +epics: + - id: scaffolding + summary: "CLI scaffolding" + depends_on: [] + verification: + - kind: integration-test + target: "tests/cli-scaffolding.integration.test.ts" + + - id: text-ops + summary: "Text operations" + depends_on: [scaffolding] + verification: + - kind: integration-test + target: "tests/text-ops-pipe.integration.test.ts" + +slices: + - id: version-flag + epic_id: scaffolding + definition: "Add `--version` flag that prints the version from package.json" + depends_on: [] + verification: + - kind: unit-test + target: "tests/version.test.ts" + + - id: help-flag + epic_id: scaffolding + definition: "Add `--help` flag that lists subcommands: reverse, count, slugify" + depends_on: [version-flag] + verification: + - kind: unit-test + target: "tests/help.test.ts" + + - id: reverse + epic_id: text-ops + definition: "Add `reverse` subcommand. Pure function reverses a string. CLI wires it to argv[2]." + depends_on: [] + verification: + - kind: unit-test + target: "tests/reverse.test.ts" + + - id: count + epic_id: text-ops + definition: "Add `count` subcommand that counts whitespace-separated words. Empty input returns 0." + depends_on: [] + verification: + - kind: unit-test + target: "tests/count.test.ts" + + - id: slugify + epic_id: text-ops + definition: "Add `slugify` subcommand. Lowercase, replace non-alphanumerics with single dash, collapse multiple dashes, trim leading/trailing dashes. Handle unicode by removing diacritics." + depends_on: [] + verification: + - kind: unit-test + target: "tests/slugify.test.ts" diff --git a/memory/CARDS.md b/memory/CARDS.md index 10c1c410..6201ee42 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -1,1014 +1,156 @@ - + -# Cards — `chat-runtime-secondary-chats` (FE-716) +# petri-semantic-lanes — scope cards -Branch: `ka/fe-716-chat-runtime-unified-secondary-chats` -Linear: [FE-716](https://linear.app/hash/issue/FE-716) -Stacked on: `ln/fe-709-reconciliations` (PR #139, awaiting merge to main) +## Card 1: Two-lane subnet with semantic completion gate -## V1 framing +**Status:** next -V1 = "every behavior the current side-chat (V3.1) ships today, surfaced through the elevated unified-workspace shape from `docs/design/UNIFIED_CHAT_UX.md`." Build only what that framing requires; defer the rest of the brief to follow-up frontiers. See PLAN.md `chat-runtime-secondary-chats` § V1 narrowing for the explicit defer list. +### Target Behavior -Vocabulary: **secondary chat** (matches PR #139's lexicon). The `chat.parent_chat_id IS NOT NULL` projection is the sole driver of "render inline as a secondary chat under parent." +The compiled slice subnet enforces a two-lane terminal join: `return-done` is unreachable unless both mechanical verification (`done-spec`) and semantic assessment (`semantic-satisfied`) have produced tokens. -## Card queue +### Boundary Crossings -### C0 — Bring forward `UNIFIED_CHAT_UX.md` design brief +``` +→ types.ts (add 'assess-semantic' to action vocabulary) +→ net-compiler.ts (add semantic places + assess-semantic transition + terminal join) +→ engine-contract.test.ts (update call-order assertions, add semantic-gate scenario) +→ petri-net.ts (no change — interpreter is topology-agnostic) +→ engine-petri.ts / engine-proc.ts (no change — thin wrappers) +``` -- **Status:** **done** (2026-05-15) — Option B chosen (verbatim body + prepended `` translation header mapping `thread` → `secondary chat` and noting D153 substrate deferral). -- **What:** Copy `docs/design/UNIFIED_CHAT_UX.md` verbatim from PR #138 onto this branch. Body preserved unedited; reading-note header added for current readers. Brief stays the canonical UX ceiling for future tracks. -- **Why first:** Zero substrate dependency; gives downstream cards a single in-tree reference. Cheap to land alone. -- **Scope:** doc-only. -- **Verification:** `npm run check` — 0 errors (6 pre-existing warnings unrelated). Body matches PR #138 commit `cd48b49a` byte-for-byte. +### Risks and Assumptions -### C1 — Substrate migration: four columns on `chat`, zero enum changes +``` +- RISK: All existing contract tests check call-order sequences that will gain an + assess-semantic step → every test with call-order assertions needs updating. + → MITIGATION: Mechanical change — add the step to expected sequences. The + fake factory already uses Record so adding a new key + is trivial. -- **Status:** **done** (2026-05-15) — `drizzle/0020_chat_secondary_chat_columns.sql` adds the four nullable integer/text columns + two non-unique indexes; `src/server/schema.ts` chat table promoted to `(table) => […])` form to declare the indexes. Real schema uses `integer` ids (HANDOFF's UUID was illustrative). Resolved: `invoked_in_turn_id` kept (denormalized anchor); `pinned_reconciliation_need_id` deferred; per-turn span-hint not in V1; `parent_chat_id` + `invoked_in_turn_id` indexed. -- **What:** Drizzle migration adding `parent_chat_id integer NULL REFERENCES chat(id)`, `invoked_in_turn_id integer NULL REFERENCES turn(id)`, `pinned_item_id integer NULL REFERENCES knowledge_item(id)`, `pinned_span_hint text NULL` + indexes `chat_parent_chat_id_idx` and `chat_invoked_in_turn_id_idx`. `chat.kind` enum unchanged; `chat.active_turn_id` preserved. -- **Verification:** `npm run verify` — 100 test files / 1272 tests pass; build clean. New tests in `src/server/chat-substrate.test.ts` cover column shape, index presence, FK integrity (parent_chat_id, pinned_item_id, invoked_in_turn_id all reject missing targets), nullable inserts, and `chat.active_turn_id` preservation. -- **Out of scope:** any new enum value; the `thread` table; `turn.thread_id`; `thread_context_item`. +- RISK: Semantic assessment always passes in fakes — the topological constraint + is real but the assessment itself is a no-op until real oracles land. + → MITIGATION: Add one contract test where assess-semantic fails → slice halts. + This proves the gate is load-bearing, not decorative. -### C2 — Server: `createSecondaryChat` + `createKickoffTurn` helpers +- ASSUMPTION: A single assess-semantic action per slice is sufficient for Phase 1. + The spec doc shows multiple semantic transitions (AssessOracleSatisfaction, + AssessDesignExercised, AssessIntentEstablished), but those can be sub-steps + of one assessment action in this slice; the net template can refine later. + → VALIDATE: The terminal join enforces the gate; internal decomposition of + semantic assessment is additive, not structural. +``` -- **Status:** **done** (2026-05-15) — helpers + tests landed; route deferred to C3 to avoid speculative scaffolding (no consumer until UI wires up). -- **What:** Two new public DB helpers exported from [src/server/db.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/server/db.ts): - - `createSecondaryChat(db, specId, { parent_chat_id, invoked_in_turn_id?, pinned_item_id?, pinned_span_hint? })` — inserts a `chat` row with `kind='side_chat'` and the four C1 columns; returns `Chat`. - - `createKickoffTurn(db, chatId, { phase, content })` — inserts a `turn` with `turn_kind='kickoff'`, `chat_id=chatId`, and `assistant_parts=content`; resolves the chat's `specification_id` automatically; returns `Turn`. -- **Verification:** `npm run verify` — 100 test files / 1277 tests pass. New tests in [src/server/chat-substrate.test.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/server/chat-substrate.test.ts) cover happy-path persistence, optional column population, FK rejection, kickoff turn metadata, and error on missing chat. -- **Out of scope (moved to C3):** `POST /api/specifications/:id/secondary-chats` route. Building it without a consumer is speculative; C3 will define the route alongside the UI client that calls it. -- **Harvest reference:** `src/server/side-chat-route.ts`, `src/server/side-chat-prompt.ts`, PR #138's threads endpoint. +### Acceptance Criteria -### C3 — Client: `secondary-chat-collapsible` inline component +``` +✓ semantic-places — Compiled subnet per slice includes `semantic-gate` and + `semantic-satisfied` places. Adapter test confirms updated place count. -C3 has been split into three sub-cards (C3a / C3b / C3c) for verifiable thin slices. Original "What" preserved below for reference. +✓ assess-semantic-transition — New transition `{sliceId}:assess-semantic` + consumes `done-spec` + `semantic-gate` and produces `semantic-satisfied` + (on pass) or routes to `needs-more` (on fail, forcing another TDD cycle). -- **C3 original What:** Build the inline collapsible UI for `chat.parent_chat_id IS NOT NULL` chats, anchored under their `invoked_in_turn_id` in the parent transcript. Driven entirely by the projection rule — no flavor enum needed. Replace `SideChatHost`'s popover plumbing with inline rendering inside `ContinuousWorkspaceView`. -- **Out of scope (across all sub-cards):** popover deletion (C8), Ask/Edit toggle (C4), patch staging (C5), `#` injection (C6). +✓ terminal-join — `return-done` transition consumes `semantic-satisfied` + instead of `done-spec`. PlanDoneAccepted (= `completed` place) is + topologically unreachable without semantic satisfaction. -#### C3a — Server: `listSecondaryChatsForSpecification` + bundle field +✓ assess-semantic-action — `assess-semantic` key added to ActionHandlers. + Fake factory provides a default that always returns { satisfied: true }. -- **Status:** **done** (2026-05-15) — list helper, `SecondaryChatWithKickoff` type, bundle `secondaryChats` field, and Zod schema all landed. -- **What:** New helper `listSecondaryChatsForSpecification(db, specId) → SecondaryChatWithKickoff[]` returns secondary chats (rows with `parent_chat_id IS NOT NULL`) with each chat's first kickoff turn (or null). `readSpecificationStateProjection` includes the projected `secondaryChats` field; `specificationStateSchema` extended with `secondaryChatStateSchema`. -- **Verification:** `npm run verify` — 100 test files / 1283 tests pass. New tests cover empty/single/multi-spec scoping, kickoff turn population, missing-kickoff null fallback, primary-chat exclusion, and bundle inclusion via `getSpecificationState`. +✓ contract-tests-updated — All existing contract test call-order assertions + include the new assess-semantic step. All 26 tests pass. -#### C3b — `` standalone component +✓ semantic-gate-fail-test — New contract test: assess-semantic returns + { satisfied: false } → slice re-enters TDD loop. If it keeps failing, + slice halts. -- **Status:** **done** (2026-05-15) — component + tests landed; mounting deferred to C3c (where there's a real consumer to drive it). -- **What:** New `src/client/components/secondary-chat-collapsible.tsx` renders a Radix-`Collapsible`-backed secondary chat surface. Header always renders; body shows the kickoff turn's `assistant_parts` and is collapsed by default. Supports `kickoffTurn=null` (renders an empty body when expanded). -- **Verification:** `npm run verify` — 101 test files / 1287 tests pass. New tests in `src/client/components/__tests__/secondary-chat-collapsible.test.tsx` cover header presence, collapsed-by-default, expand-on-click reveals content, and empty-body fallback for missing kickoff. -- **Scope adjustment from original C3b:** mounting in `-continuous-workspace-view.tsx` deferred to C3c. Reason: `WorkspaceTranscriptArtifacts` (556 LOC) is the actual turn-render seam; threading the collapsible through it is invasive enough to merit landing alongside the trigger that creates the rows in the first place. Building mounting now without a creation flow would require fixture-seeding side-channels. - -#### C3c-route — Server: `POST /api/specifications/:id/secondary-chats` - -- **Status:** **done** (2026-05-15) — route + handler landed; client wiring + view mounting deferred to C3c-mount and C3c-wire. -- **What:** New `src/server/secondary-chat-route.ts` exports `handleCreateSecondaryChatRequest(db, req, res)`. Body schema: `{ parentChatId, invokedInTurnId, itemKind, itemId, spanHint? }`. Validates spec exists, validates body shape, resolves the item via `getKnowledgeItem` (rejects if missing or wrong kind/spec), calls `createSecondaryChat` + `createKickoffTurn`, returns `{ chatId, kickoffTurnId }`. Kickoff content templated as `Anchored to ''.` (with `, focused on ''` when provided) — minimal V1 wording; richer per-mode templates from UNIFIED_CHAT_UX.md §6 land alongside C4 (Ask/Edit toggle). -- **Verification:** `npm run verify` — 101 test files / 1292 tests pass. New tests in `src/server/app.test.ts` cover happy path with bundle round-trip, span-hint persistence, 400 on bad body, 404 on missing spec, and 404 on missing item. - -#### C3c-mount — View: thread `secondaryChats` through to `` mounting - -- **Status:** **done** (2026-05-15) — controller projects a `secondaryChatsByInvokedTurnId: ReadonlyMap` from `specificationState.secondaryChats`; view threads it into [WorkspaceTranscriptArtifacts](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/%24id/_view/-workspace-transcript-artifacts.tsx); a new `getArtifactAnchorTurnId` helper resolves the anchor turn id for each artifact kind (`answered-turn`, `prefaced-question`, `answered-review-turn`, `answered-revision-review`, `collapsed-review-turn`, `accepted-closure`, `persisted-turn`, `active-prefaced-question`, `phase-summary`); `` instances are rendered in a `data-testid="secondary-chats-for-turn-{id}"` slot beneath each matching artifact. -- **What:** `WorkspaceTranscriptArtifacts` accepts a `secondaryChatsByInvokedTurnId` map prop and renders `` after each turn artifact whose id matches a key. `-continuous-workspace-controller.ts` projects `specificationState.secondaryChats` into the map and threads it through; `-continuous-workspace-view.tsx` passes it to the artifacts renderer. -- **Acceptance:** fixture-seeded secondary chat appears under the right turn; collapsed by default; no orphan render when the parent turn is unrendered. All three covered by tests. -- **Verification:** `npm run verify` — 102 test files / 1295 tests pass; build clean. New tests: - - [`-workspace-transcript-artifacts.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/%24id/_view/__tests__/-workspace-transcript-artifacts.test.tsx) — 4 tests covering inline rendering after the matching turn, collapsed-by-default, no-orphan when the anchor turn isn't in the stream, and multiple chats per turn. - - [`-continuous-workspace-view.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/%24id/_view/__tests__/-continuous-workspace-view.test.tsx) — added prop-threading test asserting the controller's `secondaryChatsByInvokedTurnId` reaches the artifacts renderer by reference. - -#### C3c-wire — Client: trigger that calls the C3c-route POST + invalidates bundle - -- **Status:** **done** (2026-05-15) — `useCreateSecondaryChatMutation` mutation + `SecondaryChatTriggerProvider` context landed in [src/client/components/secondary-chat-trigger.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/secondary-chat-trigger.tsx); provider is mounted in `route.tsx` alongside `SideChatHost`; `ItemActionRail` in [-structured-list-view.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/%24id/-structured-list-view.tsx) gains an `Open inline chat` button (`data-graph-action="open-inline-chat"`, MessagesSquare icon) alongside the existing chat-with popover trigger. `specificationSchema` now exposes `primary_chat_id` (nullable+optional for transition) so the client can resolve the parent chat without a new endpoint. -- **What:** New `useCreateSecondaryChatMutation(specificationId)` hook posts to `/api/specifications/:id/secondary-chats` with `{ parentChatId, invokedInTurnId, itemKind, itemId, spanHint? }` and invalidates the bundle on success. `SecondaryChatTriggerProvider` reads `specificationState.specification.primary_chat_id` (parent) + `active_turn_id` (anchor) and exposes a `create({ kind, id })` callback through `useSecondaryChatTrigger()`. The button is disabled when either is missing or while a create is in flight. -- **Acceptance:** clicking the new trigger creates a secondary chat and reveals an inline collapsible (via C3c-mount) without disturbing the existing popover path. Verified by mutation tests + bundle invalidation; UI button surfaces alongside (not replacing) the chat-with popover trigger. -- **Verification:** `npm run verify` — 103 test files / 1302 tests pass; build clean. New tests in [`secondary-chat-trigger.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/secondary-chat-trigger.test.tsx) cover canCreate=true happy path, canCreate=false when `primary_chat_id` or `active_turn_id` is missing, POST payload shape, bundle invalidation on success, and no-POST when canCreate is false. - -### C4 — Ask / Edit mode toggle on secondary chats - -- **Status:** **done** (2026-05-15) — `mode` column added to `chat` (nullable text enum `explore | edit`); `createSecondaryChat` defaults to `'explore'`; new `setSecondaryChatMode` helper + `PATCH /api/specifications/:id/secondary-chats/:chatId/mode` route; `secondaryChatStateSchema.chat.mode` propagates through the bundle; `SecondaryChatCollapsible` gains an Ask/Edit toggle (sibling to the trigger to avoid nested-button); a thin `SecondaryChatCollapsibleWithMode` wrapper subscribes to `useSetSecondaryChatModeMutation` and bundle invalidation. -- **What:** Mode toggle (Ask = `explore`, Edit = `edit`) with per-mode tool sets via `getSideChatTools(mode)`; persist mode on the chat (column-based, smallest viable storage). The actual streaming-with-tools wiring for secondary chats remains a follow-up — C4 lands persistence + UI selection. `getSideChatTools(mode)` is unchanged and continues to gate edit tools when called with `chat.mode`. -- **Why fifth:** Re-establishes V3.1 functional parity for side-chat editing. -- **Verification:** `npm run verify` — 103 test files / 1317 tests pass; build clean. New tests: - - [`chat-substrate.test.ts`](file:///Users/kostandin/Projects/hashdev/brunch/src/server/chat-substrate.test.ts) — default mode='explore', explicit mode='edit', `setSecondaryChatMode` updates + invariants (rejects non-secondary chats and missing chats). - - [`app.test.ts`](file:///Users/kostandin/Projects/hashdev/brunch/src/server/app.test.ts) — PATCH happy path with bundle round-trip, 400 on invalid mode, 404 on cross-spec chatId, 404 when targeting the primary interview chat. - - [`secondary-chat-collapsible.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/secondary-chat-collapsible.test.tsx) — toggle reflects persisted mode, falls back to explore when null, click invokes `onSetMode`, no-op when clicking active mode, disabled while pending or read-only. -- **Harvest:** `getSideChatTools(mode)` (unchanged), V3.1 mode plumbing pattern (Ask/Edit semantics). -- **Out of scope (deferred to C5):** wiring the persisted mode into the secondary-chat streaming pipeline + edit-tool registration; in-thread patch staging. - -### C5 — In-thread patch staging on secondary chats - -C5 has been split into three sub-cards (C5a / C5b / C5c) for verifiable thin slices. Original "What" preserved below for reference. +✓ adapter-test-updated — Net shape adapter tests updated for new place + and transition counts. +``` -- **C5 original What:** Port #138's in-thread staged-patches strip onto the chat substrate. Patches stay turn artifacts; accepted mutations still flow through Brunch-owned handlers (no new source of semantic truth). -- **Why sixth:** Closes the Edit-mode loop end-to-end. -- **Verification (umbrella):** staging/apply/cancel tests on a secondary chat; regression on the V3.1 side-chat edit flow; `npm run verify` green at C5c. -- **Harvest:** [side-chat-route.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/server/side-chat-route.ts), [side-chat-prompt.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/server/side-chat-prompt.ts), [side-chat-stream.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/client/lib/side-chat-stream.ts), [side-chat-host.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/side-chat-host.tsx) (staging strip render), [patch-list-host.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/patch-list-host.tsx) + [patch-list-reducer.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/patch-list-reducer.ts) (`pendingPatches` plumbing). - -#### Cross-cutting design decision (Shape A — patch-list partition seam) - -C5c needs `PatchListProvider` to keep one global event log while letting each secondary chat see *only its own* staged patches. **Decision (this thread):** add `producerChatId: number | null` to `PatchBase` and expose a new `usePatchListForChat(chatId)` hook that filters the staged slice and scopes apply/discard/editSummary to that chat's patch ids. Existing `usePatchList()` keeps current behavior (popover sees all patches; safe during transition). Reducer logic is unchanged; the partition lives at the selector layer. C8 (popover retirement) deletes the legacy `producerChatId === null` branch. +### Verification Approach -Considered alternatives and rejected: -- **Shape B (one provider per chat):** N reducers + N applier injections; popover and inline use disjoint logs. -- **Shape C (`Map` reducer):** principled but over-engineered for V1's "popover + N inline" reality; large reducer churn. -- **Shape D (no shared abstraction):** inline duplicates the popover machinery until C8. - -Shape A wins on Ousterhout's depth test (one new field + one new hook hides the partitioning concern) and is forward-compatible with A71's future server `appendPatch(spec, patch[])` signature. - -#### C5a — Server: secondary-chat streaming endpoint + edit-tool registration - -- **Status:** **next** -- **What:** New server seam `POST /api/specifications/:id/secondary-chats/:chatId/messages` (or equivalent — confirm naming during build) that resolves the chat by id, validates `chat.parent_chat_id IS NOT NULL`, calls `getSideChatTools(chat.mode)` to gate `propose_edit` / `propose_edge` / `propose_drill_down` on Edit mode, streams an assistant turn under the secondary chat using the existing SSE shape from `side-chat-route.ts`, and persists user/assistant turns under the secondary chat's `chat_id`. Reuse `side-chat-prompt.ts` for system instructions; per-mode kickoff template enrichment (deferred from C4) lands here as a side-effect of touching the prompt path. -- **Boundary crossings:** HTTP route → spec/chat lookup → `getSideChatTools(mode)` → AI SDK stream → `appendTurn(chat_id, role, parts)`. Same shape as `side-chat-route.ts`, scoped to secondary chats. -- **Risks/assumptions:** - - RISK: `side-chat-route.ts` may have popover-specific assumptions baked in (e.g. anchor item lookup from request body) → MITIGATION: read it once before mirroring; lift only the streaming/tools shell, not the request envelope. - - ASSUMPTION: secondary chats stream into `assistant_parts` of a freshly-created turn under the secondary chat (mirrors interview chat shape) → VALIDATE: round-trip oracle (POST a message, GET the bundle, see the new turn under `secondaryChats[i].turns`). May require extending the `SecondaryChatState` bundle to include turns beyond the kickoff — confirm during build. -- **Acceptance:** - - ✓ POST with mode=`explore` streams an assistant turn; bundle round-trip surfaces the new turn under the secondary chat. - - ✓ POST with mode=`edit` registers edit tools; SSE event for `propose_edit` is emitted. - - ✓ POST against a primary chat returns 404 (refuses non-secondary chats — same invariant as PATCH mode route). - - ✓ POST against a missing chat returns 404. - - ✓ Existing `POST /side-chat` (popover) regression unaffected. -- **Verification:** Inner — Vitest integration tests in `app.test.ts` covering happy paths + 404 invariants + tool gating. Middle — round-trip oracle (POST → GET bundle → assert turn presence). No outer-loop verification at this slice. -- **Out of scope:** client composer (C5b); staging strip (C5c); per-chat patch list partition (C5c). - -#### C5b — Client: composer + stream consumer for inline secondary chats - -- **Status:** **next** (after C5a) -- **What:** - 1. Promote a `` component (per the C0–C4 review finding #1) that owns *all* per-chat mutation/streaming hooks and renders `` with the wired props. Replaces the current `SecondaryChatCollapsibleWithMode` wrapper. Wires: - - `useSetSecondaryChatModeMutation(chatId)` (existing) - - `useSecondaryChatStream(chatId)` (new — wraps the C5a SSE response into staged turns + activity) - 2. Add a small composer (text input + Send) inside the collapsible body, posting to C5a and reusing `side-chat-stream.ts` parser. - 3. Render the chat's existing turns under the collapsible body (kickoff first, then user/assistant pairs). -- **Boundary crossings:** `` → `useSecondaryChatStream` → `fetch` POST → SSE parser → derived turn list → `` body. -- **Risks/assumptions:** - - RISK: `SecondaryChatState` bundle currently exposes only `chat` + `kickoffTurn`; rendering subsequent turns needs either a per-chat `turns: Turn[]` field on the bundle or a separate `useSecondaryChatTurns(chatId)` query → MITIGATION: extend the bundle if cheap (preferred), else add a per-chat turn-list query. - - ASSUMPTION: Existing `side-chat-stream.ts` parser is generic enough to consume the C5a response without forking → VALIDATE: read the parser once during build; fork only if the SSE event vocabulary diverges. -- **Acceptance:** - - ✓ Typing in the composer + Send POSTs to C5a and renders the streaming assistant turn live in the collapsible body. - - ✓ After stream completes, bundle invalidation reveals the persisted turn unchanged on next mount. - - ✓ `` replaces `SecondaryChatCollapsibleWithMode` in `-workspace-transcript-artifacts.tsx` with no regression in the C4 mode-toggle tests. - - ✓ Multiple secondary chats can be composed against in parallel without state cross-talk (no shared in-flight ref). -- **Verification:** Inner — happy-dom Vitest covering composer → POST → stream consumption → derived turn list. Middle — bundle round-trip after stream ends. Reuse `secondary-chat-collapsible.test.tsx` patterns for harness. -- **Out of scope:** patch staging strip (C5c); patch list partition (C5c); typing-while-streaming queue. - -#### C5c — Per-chat patch staging strip + partition seam - -- **Status:** **next** (after C5b) -- **What:** Land the Shape A partition seam (above) and surface the staged-patches strip *inside* ``'s collapsible body, scoped to the host's chat id. - 1. **Reducer change:** add `producerChatId: number | null` to `PatchBase` and `StagePatchInput`. Existing call sites (popover, manual tests) pass `null`. - 2. **Provider change:** new `usePatchListForChat(chatId)` hook that returns the filtered staged slice + scoped actions (apply/discard/editSummary auto-filter by chat id; apply uses `patchIds` derived from the slice). - 3. **Stream wire-up:** C5b's `useSecondaryChatStream(chatId)` translates `propose_*` SSE tool calls into `actions.stage({ ...patch, producerChatId: chatId })`. - 4. **UI:** harvest `SideChatPopover`'s staged-patches strip render shape (`stagedPatches`, `onApply`, `onUndo`, `` for `edit` patches, ``) into a `` component mounted inside ``'s collapsible body. -- **Boundary crossings:** SSE stream → `usePatchListForChat(chatId).actions.stage` → reducer event log → `usePatchListForChat(chatId).staged` → strip UI → `actions.apply()` → existing `makeEditApplier` (unchanged). -- **Risks/assumptions:** - - RISK: existing call sites (popover, side-chat-host derived state at lines 578–602) need `producerChatId: null` threaded through without semantic change → MITIGATION: type the field as required-but-nullable on `PatchBase`; let the type system surface every site. - - RISK: undo currently reverses the last apply batch globally; per-chat undo could cross chats if a popover apply followed an inline apply → MITIGATION: for V1 ship per-`apply()`-batch undo (chat scope is implicit because each chat's apply only touches its own patch ids); document the invariant in the reducer header. - - ASSUMPTION: `` and `` are reusable as-is outside the popover → VALIDATE: read both during build; lift to a shared location if needed (no new abstraction unless the second caller forces it). -- **Acceptance:** - - ✓ Staging an `edit` proposal during streaming surfaces it in the host's strip; popover does NOT see it via `usePatchList()` (filter excludes per-chat patches by default — adjust if popover-during-transition wants the full union view). - - ✓ Apply on the strip mutates the anchor item via `makeEditApplier`; undo reverses it; bundle round-trip reflects the change. - - ✓ Popover staging path (V3.1) is unaffected: existing side-chat tests pass with `producerChatId: null`. - - ✓ Two open inline secondary chats can stage edits in parallel; each strip shows only its own patches. -- **Verification:** Inner — reducer/state unit tests for `producerChatId` filtering; per-chat hook unit tests; popover regression in `side-chat-host.test.tsx`. Middle — round-trip: stage → apply → bundle reflects mutation. Outer — manual: open two inline secondary chats, stage edits in each, apply one, verify the other strip is untouched. (Capture in the C10 walkthrough.) -- **Out of scope:** rendering staged patches as turn artifacts (deferred — patches stay UI state, not turn-persisted, until a future card promotes them); cross-chat undo; deletion of `usePatchList()` (waits for C8). - -##### Order discipline - -C5a (server) → C5b (client composer + host) → C5c (partition + strip). Sequential because C5b consumes C5a's response shape; C5c's stream wire-up plugs into C5b's host. None of C5b's interface should change based on C5a build findings beyond response-shape details (those are absorbed in `useSecondaryChatStream`); C5c's interface is independent of either earlier slice. - -### C6 — `#` knowledge-item symbol injection (V1 surface only) - -- **What:** Implement `#REF-CODE` resolution in the secondary-chat composer that inserts an item context snapshot artifact into the next turn. **No** autocomplete chip; **no** `$` secondary-chat mention symbol; **no** snapshot builder lifecycle (those are Track 5 / `chat-context-provision`). Use a server-owned resolver scoped to the specification per `CONVERSATIONAL_WORKSPACE_RUNTIME.md` §3.5. -- **Why seventh:** Provides the V1 structured way to add item context, replacing the ad-hoc V3.1 anchoring path for in-flight mentions. -- **Verification:** resolver unit tests for valid/missing/ambiguous codes; turn-snapshot insertion test; manual walkthrough. - -### C7 — Agent-run inline rendering + `chat.kind` decision - -- **What:** Decide and implement: (a) keep enum at `interview` + `side_chat` and project `agent_run` flavor from `first_turn_role='system'`; (b) add a fifth `chat.kind='agent_run'` enum value. Default posture per HANDOFF: (a). Render agent-run secondary chats inline using the same component from C3. If (b) is chosen, this card carries a follow-up substrate migration. -- **Why eighth:** Agent-run inline is in V1 scope per HANDOFF; deferring to last lets the substrate decision settle after C1–C6 reveal whether projection-only is sufficient. -- **Verification:** agent-run secondary chat renders inline; system-first frontier turn invariant holds; if (b), enum migration applies cleanly. - -### C8 — `SideChatPopover` retirement + `side-chat-host` shrinkage - -- **What:** Delete `SideChatPopover`; shrink `side-chat-host` to its minimal post-popover form (target ~95 LOC per #138's harvest). Remove popover-only routes/state. -- **Why ninth:** Retire only after C3–C7 reach parity over durable secondary chats. -- **Verification:** `npm run verify`; manual regression on side-chat entry from substantive reconciliation rows; ensure no popover code paths remain reachable. - -### C9 — Lightweight reconciliation-element view - -- **Status:** **done** (2026-05-17) — `drizzle/0022_chat_pinned_reconciliation_need.sql` adds the nullable FK column on `chat`; `createSecondaryChat` + the `POST /api/specifications/:id/secondary-chats` payload accept an optional `reconciliationNeedId` (server rejects cross-spec needs with 404); `listSecondaryChatsForSpecification` joins the need + both knowledge items at read time and surfaces a `pinnedReconciliationNeed: { needId, kind, sourceItemId/RefCode/Excerpt, targetItemId/RefCode/Excerpt }` projection on each `SecondaryChat`; `SecondaryChatTriggerItem.reconciliationNeedId` is threaded through `useCreateSecondaryChatMutation`; `PendingReviewSection.handleOpenSideChat` passes `need.id` alongside `target_item_kind`/`target_item_id`; `SecondaryChatCollapsible` renders a small `data-testid="secondary-chat-reconciliation-panel"` band (kind label + per-endpoint ref code + truncated excerpt) when the field is populated. Other trigger paths (StructuredListView, etc.) are unchanged and continue to omit `reconciliationNeedId`. -- **What:** When a secondary chat is opened with a reconciliation context (entry bridge from a substantive reconciliation row), render a minimal "elements being reconciled" panel inside the secondary chat surface. **Not** the full target-grouped / classifier-state UX from the brief — that's Track 3 (`reconciliation-runtime`). `PendingReviewSection` retirement stays Track 3's job. -- **Verification:** `npm run verify` — 104 test files / 1252 tests pass; build clean. New tests: - - [`app.test.ts`](file:///Users/kostandin/Projects/hashdev/brunch/src/server/app.test.ts) — POST persists `pinned_reconciliation_need_id`, bundle round-trip surfaces `pinnedReconciliationNeed` with `kind` + source/target ref-code & excerpt joins; cross-spec need id returns 404. - - [`secondary-chat-collapsible.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/secondary-chat-collapsible.test.tsx) — panel renders kind label + source/target ref codes & excerpts when populated; no panel when `pinnedReconciliationNeed` is null. - - [`pending-review-section.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/pending-review-section.test.tsx) — assertion updated to include `reconciliationNeedId: need.id` in the substantive `Open side-chat` trigger payload. - -### C10 — Substrate verification + initial PR draft - -- **Status:** **done** (2026-05-17). `npm run verify` green at 4dc1083d (104 test files / 1252 tests pass; build clean). The substrate hypothesis behind SPEC.md A94 (durable secondary chats over chat/turn with no `thread` table) is satisfied. PR description drafted (below). -- **Note (2026-05-18):** V1 closure has since been re-scoped to include the unified chat shell (C11–C16); the PLAN.md `V1 done` status set by this card was rolled back. **The verification snapshot and the SPEC.md A94 evidence stay valid** — only the "this closes V1" framing moves to C16, which also rewrites the PR description below. -- **What:** Full `npm run verify`; outer-loop walkthrough of the side-chat V3.1 capability matrix on the new substrate; confirm SPEC.md A94 is satisfied; update PLAN.md frontier status; draft PR description. - -#### PR description (draft) - -**Title:** `FE-716: Walking skeleton chat runtime — inline secondary chats over chat/turn` - -**Body:** - -> **What** -> -> Lands V1 of the Conversational Workspace Runtime Track 2 (`chat-runtime-secondary-chats`): every behavior the V3.1 side-chat ships today, surfaced through the elevated unified-workspace shape from `docs/design/UNIFIED_CHAT_UX.md`, on the typed AI-SDK UIMessage protocol shared with the interview spine. Durable side-chats are now durable secondary chats over the existing `chat`/`turn` substrate; the legacy `SideChatPopover` is retired; lightweight reconciliation entry now renders inline; the bespoke side-chat SSE envelope is retired in favor of `useChat` + `createUIMessageStream`; the `thread` table remains deferred per A94. -> -> **Substrate (no new tables)** -> -> - `chat.parent_chat_id`, `chat.invoked_in_turn_id`, `chat.pinned_item_id`, `chat.pinned_span_hint`, `chat.mode`, `chat.pinned_reconciliation_need_id` (drizzle/0020, 0021, 0022). No enum changes; secondary chats are projected from `parent_chat_id IS NOT NULL`. -> - Shared chat types (`src/shared/chat.ts`) register `propose_edit | propose_edge | propose_drill_down` on `BrunchUITools` and an `edit-impact` data part on `BrunchDataParts` so the secondary-chat surface composes against the same typed-UIMessage substrate as the interview spine. -> -> **Server** -> -> - `createSecondaryChat`, `createKickoffTurn`, `appendSecondaryChatTurn`, `setSecondaryChatMode`, `listSecondaryChatsForSpecification` in `specification-store.ts`. -> - `POST /api/specifications/:id/secondary-chats` (create), `PATCH …/mode` (mode toggle), `POST …/messages` (UIMessage protocol — `validateUIMessages` body, `createUIMessageStream` response, `streamText(...).toUIMessageStream(...)` merged into the writer, `data-edit-impact` written after `await result.finishReason` keyed by `toolCallId`, `#REF-CODE` mention resolution preserved). `getSideChatTools(mode)` still gates edit tools. -> - Bundle hydrates `secondaryChats[*]` with kickoff turn, post-kickoff turns, pinned-item kind, and joined reconciliation-need projection. -> -> **Client** -> -> - `SecondaryChatTriggerProvider` + `useSecondaryChatTrigger()` exposes one `create({ kind, id, spanHint?, reconciliationNeedId? })` callback + an `inlineChatRoute` descriptor so non-transcript callers can navigate to the transcript view. -> - `` mounts `useChat` per chat with a `DefaultChatTransport` pointed at the C24b route; walks `messages` for `tool-propose_*` parts (dedupe by `toolCallId`) and joins `data-edit-impact` via `onData` so edit proposals stage with the correct `impact` tier. The bespoke `src/client/lib/secondary-chat-stream.ts` is deleted. -> - `` renders the kickoff card, ai-elements `` / `` / `` for turn rendering, `` live-state for streaming, `` composer with leading-edge mode chip, turn-zero ``, `#`-mention autocomplete popup via cmdk, staged-patches strip slot, and the C9 "Elements being reconciled" panel. -> - Patch-list partitioning by `producerChatId` (Shape A) — `usePatchListForChat(chatId)` returns a per-chat staged slice while the legacy popover hook keeps the global view; `` mounts inside the collapsible body. -> - Triggers: `PendingReviewSection` substantive row + `StructuredListView` item-action rail both call into `useSecondaryChatTrigger()`; `SideChatPopover` and `SideChatHost` are deleted. -> -> **Verification** -> -> - `npm run verify` — 109 test files / 1299 tests pass; build clean. -> - Coverage spans schema invariants (including `BrunchUITools` / `brunchDataPartSchemas` admission of secondary-chat surfaces), route happy-paths + 404 invariants, UIMessage envelope round-trip + bundle round-trip, partition-seam reducer + per-chat hook tests, popover-regression sweeps, the C9 reconciliation-panel render, and `useChat`-mount isolation across parallel secondary chats. -> -> **Deferred (parking lot — follow-up frontiers)** -> -> `$` mention symbol, snapshot builder family, item-version-gated handle refresh, full target-grouped reconciliation UX, `PendingReviewSection` retirement, QA composer refinements, strategy sub-chat UI, layout-state header control, and C7 agent-run inline rendering (the substrate is ready; no producer exists yet). Persisting secondary-chat assistant turns as `parts: BrunchAssistantPart[]` (currently plain text) is also deferred — the UIMessage protocol carries the parts on the wire today but persistence stays text-only until a future frontier needs the structured shape. -> -> **Stacking** -> -> Stacked on `ln/fe-709-reconciliations` (PR #139). Restack on `main` once #139 lands. - -### C11 — Strip inline-under-turn rendering + retire "Secondary chat" label - -- **Status:** **done** (2026-05-18) — controller no longer projects `secondaryChatsByInvokedTurnId`; `WorkspaceTranscriptArtifacts` drops the projection prop, `getArtifactAnchorTurnId` helper, and `` mounting (no chat surface beneath turn artifacts); `SecondaryChatCollapsible` header renders `` (PencilLine + "Edit" / MessageCircleQuestion + "Ask", `data-testid="secondary-chat-kind-chip"`, `data-kind="edit" | "ask"`) instead of the literal "Secondary chat" label. Tests updated as planned. -- **What:** Tear out the inline-under-turn mounting so the unified shell (C12) can host secondary chats instead: - - Remove the `secondaryChatsByInvokedTurnId` projection from [-continuous-workspace-controller.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts) and stop threading it through [-continuous-workspace-view.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/$id/_view/-continuous-workspace-view.tsx). - - Remove `` rendering and the `getArtifactAnchorTurnId` helper from [-workspace-transcript-artifacts.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/$id/_view/-workspace-transcript-artifacts.tsx); the artifacts renderer drops the `secondaryChatsByInvokedTurnId` prop. - - Replace the literal `"Secondary chat"` header label in `` with a kind chip per `UNIFIED_CHAT_UX.md` §8 (`PencilLine` for Edit, `MessageCircleQuestion` for Ask) — neutral chrome + subtle accent only on the kind chip per §7 dec 3. -- **Tests:** delete `inline rendering after the matching turn` / `no-orphan` / `multiple chats per turn` cases from `-workspace-transcript-artifacts.test.tsx`; drop the controller projection test for the map; update `secondary-chat-collapsible.test.tsx` to assert the kind chip in the header instead of the "Secondary chat" string. -- **Out of scope:** the unified shell itself (C12); layout modes (C13); trigger wire-up (C14); motion (C15). -- **Verification:** `npm run verify` green; no orphan calls into the removed projection; the workspace transcript no longer renders any chat surface beneath turn artifacts. - -### C12 — `` skeleton (Side-docked default) - -- **Status:** **done** (2026-05-18) — `src/client/components/unified-chat-shell.tsx` lands as a peer of `` inside [_view/route.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/$id/_view/route.tsx); the shell reads `useSpecificationBundleData()`, renders a header (spec name spine label + four layout-mode buttons + close affordance) and a body listing every active `secondaryChats[*]` (already returned in `chat.id` ascending order from `listSecondaryChatsForSpecification`) as `` collapsibles. The shell defaults to side-docked at ~50% width; the workspace center (existing Outlet + EntitySidebar) reflows into the left 50%. Spine resolution: the shell is a *lightweight spine indicator + secondary-chats slot*, not a re-mounted transcript — the workspace center remains the canonical transcript + composer surface. Tests in [`unified-chat-shell.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/unified-chat-shell.test.tsx) cover header presence, default mode, empty-state, host order, close↔expand round-trip, and layout-mode callback forwarding. -- **What:** New `src/client/components/unified-chat-shell.tsx` mounted in the specification route as a peer to ``. The shell renders: - - The **interview spine** (the primary chat's transcript) as its always-visible body — sourced from the same bundle the workspace center already reads. - - **Active secondary chats** for the spec as inline collapsibles inside the shell body (ordered by `chat.created_at` ascending — confirm during build), using the existing `` per chat. No "Secondary chat" label; kind chip from C11. - - A **header strip** with a layout-mode toggle (buttons present but inert until C13) and a close affordance that switches the shell to a collapsed bar. -- **Mounting:** default layout state **Side-docked** (~50% width right rail per `UNIFIED_CHAT_UX.md` §4). Workspace center reflows to remaining width. The shell is a sibling of `` inside `route.tsx`'s layout, not a child of it. -- **Out of scope:** localStorage persistence (C13); width/mode transitions (C13); trigger auto-expand (C14); motion (C15). -- **Verification:** shell renders the interview spine + lists all active secondary chats; nothing renders under turn artifacts; existing transcript scrolls in the workspace center pane; build + test green. - -### C13 — Layout modes + header control + localStorage - -- **Status:** **done** (2026-05-18) — new `useChatLayoutMode(specificationId)` hook in [`use-chat-layout-mode.ts`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/use-chat-layout-mode.ts) persists the chosen mode under per-spec localStorage key `brunch:chat-layout-mode:{id}`, defaulting to `side-docked`; document-level Esc keydown decrements one tier via the exported `decrementChatLayoutMode` helper (Full → Maximize → Side-docked → Compact, no-op below). [_view/route.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/$id/_view/route.tsx) gains three layout components: `ResizableLayout` (50/50 for Side-docked, 30/70 for Maximize; `key={mode}` remounts the ResizablePanelGroup on mode change for clean defaultSizes), `CompactLayout` (floating dock 360–420 px bottom-right, workspace center fills), `FullLayout` (chat at 100%, center hidden). 9-test suite in [`use-chat-layout-mode.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/use-chat-layout-mode.test.tsx) covers default, persistence, rehydration, junk rejection, Esc tier walk, defaultPrevented skip, and per-spec switching. **Open question kept open:** default stays Side-docked; revisit Compact-as-default only if walkthrough surfaces friction. -- **What:** Implement the four layout states from `UNIFIED_CHAT_UX.md` §4: - - **Compact** — small floating dock, ~360–420 px. - - **Side-docked** *(default)* — right rail, ~50% width. - - **Maximize** — wide center, ~70% with rails. - - **Full** — 100% workspace. -- New `useChatLayoutMode(specificationId)` hook backed by `localStorage` (key per workspace; default Side-docked). Header strip in the shell renders four mode buttons; current mode highlighted. **Esc** decrements one tier per §10. -- **Out of scope:** motion (C15); mode chip on the composer (deferred); suggestions row (deferred). -- **Verification:** four modes render at correct footprints; workspace center reflows correctly; toggle persists across reload; Esc steps the mode down. -- **Open question (resolve in build):** brief defaults to Side-docked, but Compact is closer to the retired V3.1 popover footprint. Keep Side-docked unless walkthrough surfaces friction — revisit in C16. - -### C14 — Trigger wire-up: open shell + auto-expand new chat - -- **Status:** **done** (2026-05-18) — new `ChatShellPresenceProvider` ([`chat-shell-presence.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/chat-shell-presence.tsx)) supplies `{ isCollapsed, expand, collapse, focusedChatId, focusChat, clearFocus, jumpToAnchor }`; mounted in [parent route.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/$id/route.tsx) above `` so the trigger can `focusChat(response.chatId)` after a successful create (expands shell + sets focused id). `SecondaryChatCollapsible` gained controlled `open`/`onOpenChange` props plus an `onJumpToAnchor` handler that renders a `Crosshair`-iconed "Jump" button (data-testid `secondary-chat-jump-to-anchor`) when the chat carries an `invoked_in_turn_id`. `SecondaryChatHost` watches `focusedChatId === chatId` and auto-opens its collapsible via the controlled open prop. `WorkspaceArtifactRow` accepts `anchorTurnId` and exposes `data-anchor-turn-id`; threaded through `answered-turn`, `prefaced-question`, `answered-review-turn`, `answered-revision-review`, `accepted-closure`, `persisted-turn`, `active-prefaced-question`. `jumpToAnchor` does `document.querySelector('[data-anchor-turn-id="X"]')?.scrollIntoView({ behavior: 'smooth' })` plus a 1.5 s ring highlight. Tests in [`chat-shell-presence.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/chat-shell-presence.test.tsx) cover trigger → expand + focus, Jump button rendering and scroll dispatch, absence when `invoked_in_turn_id` is null, and auto-open on focus. -- **What:** Extend `useSecondaryChatTrigger().create()` (or add a sibling effect inside the shell) so that creating a secondary chat: - 1. Ensures the shell is visible (if user collapsed it to a bar, expand to its last layout mode). - 2. Auto-expands the newly-created chat's collapsible inside the shell. - 3. Adds a "Jump to anchor" link in the collapsible header that scrolls the workspace center pane to `invoked_in_turn_id` (highlight briefly). -- Trigger sites (`PendingReviewSection`, `StructuredListView`) are unchanged externally. -- **Verification:** clicking the trigger from either site opens the shell with the new chat expanded; reconciliation-pinned chats still render the C9 panel inside; jump-to-anchor scrolls correctly; reload keeps the persisted chat (no regression on substrate); per-chat collapse state stays component-local. - -### C15 — Motion + spring transitions - -- **Status:** **done** (2026-05-18) — `motion` v12.38.0 was already a dep; no new install required. New [`use-prefers-reduced-motion.ts`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/use-prefers-reduced-motion.ts) hook + exported `CHAT_SHELL_SPRING` constant (mass 0.6, stiffness 220, damping 30 per §7 dec 5). `SecondaryChatCollapsible` wraps the streaming-assistant text in a `motion.div` that pulses opacity at ~1.4s (per §8 live-state); pulse collapses to `opacity: 1` when reduced-motion is requested. `UnifiedChatShell` switches its root containers to `motion.div` with spring fade-ins and uses `` with `layout` per secondary-chat-host wrapper for smooth add/remove transitions; all transitions short-circuit to `{ duration: 0 }` under reduced-motion. Tests in [`use-prefers-reduced-motion.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/use-prefers-reduced-motion.test.tsx) cover canonical spring config, matchMedia true/false branches, and missing-matchMedia fallback. -- **What:** Wire `motion` (Framer Motion) per `UNIFIED_CHAT_UX.md` §7 dec 5 / §8: - - Spring on collapsible expand/collapse: mass 0.6, stiffness 220, damping 30, ~250 ms. - - Animate shell width across layout-mode changes. - - Streaming live-state pulse on the kickoff card. -- Confirm `framer-motion` dep state before adding; honor `prefers-reduced-motion` to disable springs. -- **Verification:** transitions feel smooth across all four modes; no layout thrash during workspace reflow; reduced-motion preference disables springs. - -### C16 — V1 closure (unified shell) + verification + PR description rewrite - -- **Status:** **done** (2026-05-18) — supersedes C10 as the V1 closeout. `npm run verify` green: 108 test files / 1273 tests pass; build clean; only the 6 pre-existing `rendered is declared but never used` warnings in `InterviewView.test.tsx` (not introduced here). `memory/PLAN.md` frontier `chat-runtime-secondary-chats` status updated to **V1 done** in both the Sequencing list and the Frontier Definition. PR description draft rewritten below to reflect the full V1 surface (substrate + unified shell). PR submits once #139 merges or per Lu's signal. -- **Outer-loop walkthrough (deferred to operator):** the mechanical four-mode walkthrough across Compact ↔ Side-docked ↔ Maximize ↔ Full, with one open secondary chat from each trigger site (`PendingReviewSection` substantive row + `StructuredListView` item-action rail), localStorage round-trip across reload, reconciliation panel rendering inside the C9 band, and staging strip scoped per chat — performed by the human operator before clicking "Ready for review". The unit/integration coverage above asserts each mechanism in isolation; the outer-loop run confirms the integrated UX. - -#### PR description (final draft, supersedes C10) - -**Title:** `FE-716: Walking skeleton chat runtime — durable secondary chats + unified chat shell` - -**Body:** - -> **What** -> -> Lands V1 of Conversational Workspace Runtime Track 2 (`chat-runtime-secondary-chats`): every behavior the V3.1 side-chat shipped, now surfaced through the layoutable unified chat shell from `docs/design/UNIFIED_CHAT_UX.md`. Durable side-chats become durable secondary chats over the existing `chat`/`turn` substrate; the legacy `SideChatPopover` is retired; the inline-under-turn rendering from the earlier substrate slice is replaced by a peer chat surface with Compact / Side-docked / Maximize / Full layout modes. The `thread` table stays deferred per A94. -> -> **Substrate (no new tables)** -> -> - `chat.parent_chat_id`, `chat.invoked_in_turn_id`, `chat.pinned_item_id`, `chat.pinned_span_hint`, `chat.mode`, `chat.pinned_reconciliation_need_id` (drizzle/0020, 0021, 0022). No enum changes; secondary chats are projected from `parent_chat_id IS NOT NULL`. -> -> **Server** -> -> - `createSecondaryChat`, `createKickoffTurn`, `appendSecondaryChatTurn`, `setSecondaryChatMode`, `listSecondaryChatsForSpecification` in `specification-store.ts`. -> - `POST /api/specifications/:id/secondary-chats` (create), `PATCH …/mode` (mode toggle), `POST …/messages` (streaming SSE with `getSideChatTools(mode)` edit-tool gating + `#REF-CODE` mention resolution). -> - Bundle hydrates `secondaryChats[*]` with kickoff turn, post-kickoff turns, pinned-item kind, and joined reconciliation-need projection. -> -> **Client — substrate (C0–C9)** -> -> - `SecondaryChatTriggerProvider` + `useSecondaryChatTrigger()` exposes one `create({ kind, id, spanHint?, reconciliationNeedId? })` callback + an `inlineChatRoute` descriptor. -> - `` wires per-chat mutation/streaming hooks; `` renders the kickoff card, mode toggle, composer, streaming assistant, staged-patches strip slot, and the C9 reconciliation panel. -> - Patch-list partitioning by `producerChatId` (Shape A) — `usePatchListForChat(chatId)` returns a per-chat staged slice; `` mounts inside the collapsible body. -> - Triggers: `PendingReviewSection` substantive row + `StructuredListView` item-action rail both call `useSecondaryChatTrigger()`; `SideChatPopover` and `SideChatHost` are deleted. -> -> **Client — unified shell (C11–C15)** -> -> - C11 — Inline-under-turn rendering retired. `WorkspaceTranscriptArtifacts` no longer mounts secondary chats; the controller no longer projects `secondaryChatsByInvokedTurnId`. `SecondaryChatCollapsible` renders a kind chip (`PencilLine` = Edit, `MessageCircleQuestion` = Ask) instead of the literal "Secondary chat" label. -> - C12 — `` mounts in `_view/route.tsx` as a peer of ``. Header (spec-name spine indicator + four layout-mode buttons + close affordance) + body (active secondary chats as `` collapsibles, id-ascending order). The workspace center remains the canonical transcript+composer surface; the shell is the spine indicator + secondary-chats slot. -> - C13 — `useChatLayoutMode(specificationId)` persists Compact / Side-docked / Maximize / Full under per-spec localStorage; default Side-docked. Esc decrements one tier (Full → Maximize → Side-docked → Compact, no-op below) per §10. Each mode has its own layout component: ResizableLayout (50/50 or 30/70), CompactLayout (floating dock 360–420 px), FullLayout (100%). -> - C14 — `` provides `expand`/`focusChat`/`jumpToAnchor`. The trigger calls `focusChat(response.chatId)` on successful create so the shell expands and the new chat auto-opens. `` renders a Jump-to-anchor button when `invoked_in_turn_id` is set; `WorkspaceArtifactRow` exposes `data-anchor-turn-id` on rendered turn rows so jumps scroll into view with a brief highlight ring. -> - C15 — `motion` springs (mass 0.6 / stiffness 220 / damping 30 per §7 dec 5); streaming live-state pulse on the secondary-chat streaming text per §8; AnimatePresence on the chat list for smooth add/remove. `usePrefersReducedMotion` short-circuits every animation to a duration-0 step per §10. -> -> **Verification** -> -> - `npm run verify` — 108 test files / 1273 tests pass; build clean. -> - Coverage spans schema invariants, route happy-paths + 404 invariants, SSE chunk round-trip + bundle round-trip, partition-seam reducer + per-chat hook tests, popover-regression sweeps, the C9 reconciliation panel render, the unified shell skeleton + layout-mode persistence + Esc decrement + presence-focused auto-expand + jump-to-anchor scroll dispatch, and the prefers-reduced-motion hook. -> -> **Deferred (parking lot — follow-up frontiers)** -> -> `$` mention symbol, mention autocomplete, snapshot builder family, item-version-gated handle refresh, full target-grouped reconciliation UX, `PendingReviewSection` retirement, QA composer refinements, strategy sub-chat UI, mode chip + Shift+Tab toggle on the composer, suggestions row per mode, per-kind kickoff copy variations, item-anchored badge in structured-list / graph view, Ladle prototype, C7 agent-run inline rendering (the substrate is ready; no producer exists yet). -> -> **Stacking** -> -> Stacked on `ln/fe-709-reconciliations` (PR #139). Restack on `main` once #139 lands. - -### C17 — Hide Full + Minimize/Maximize toggle + close vs minimize semantics - -- **Status:** **done** (2026-05-18) — four iterations on the same day per walkthrough feedback. Final shape: - - **Header layout-button row (left → right):** Minimize · Side-docked · Compact↔Maximize-toggle. The toggle is a single button whose icon + click target flip with state (Maximize2 when not maxed → click to Maximize; Minimize2 when maxed → click to Compact). Pressed state lights when current mode is compact or maximize. - - **Full mode hidden** entirely. Substrate type union + `CHAT_LAYOUT_MODE_ORDER` intact; `useChatLayoutMode` clamps any persisted `'full'` to `'maximize'` so older reloads stay safe. - - **Close (X) vs Minimize semantics** lifted into `ChatShellPresenceProvider` as `appearance: 'expanded' | 'minimized' | 'closed'`: - - `X` close (top-right of header) → `presence.close()` → shell renders `null`. The route layout drops the shell's panel slot so the workspace center fills the freed space (no empty pane). - - `Minimize` (first in layout-button row, `Minus` icon) → `presence.minimize()` → fixed bottom-right "Ask Brunch" pill with `Send` icon. Pill click restores via `presence.expand()`. Context persists (the chat substrate is untouched). - - Trigger-driven `focusChat()` always restores `appearance='expanded'` so creating a chat re-opens a closed shell. - - **Route layout:** `_view/route.tsx` consults `presence.isCollapsed` first. When collapsed, the workspace center renders at full width and the shell mounts at root (renders pill or null). When expanded, the layout dispatches per `layoutMode` (compact dock / resizable / full) as before. - - **Comments cleanup:** stripped the FE-716-C17 narrative comments and the C12/C13/C14/C15 docstring sections per "remove unnecessary comments" direction. -- **What:** User direction (2026-05-18): three layout modes (Compact / Side-docked / Maximize) exposed as a Minimize + Side-docked + Compact↔Maximize-toggle row; X closes the shell entirely (workspace reclaims the space, no empty pane); Minimize sends the shell to a bottom-right pill while preserving chat state. - - [`unified-chat-shell.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/unified-chat-shell.tsx) — extend `LAYOUT_MODE_BUTTONS` entries with an optional `disabled: true` flag; mark `'full'` disabled; OR the render's `disabled` prop with the per-button flag; add a `title="Coming soon"` (or similar) hint. - - [`use-chat-layout-mode.ts`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/use-chat-layout-mode.ts) — clamp persisted `'full'` to `'maximize'` on read (and rewrite storage); refuse to write `'full'` (silently clamp). The type stays `'compact' | 'side-docked' | 'maximize' | 'full'` so the substrate can re-enable later without a migration. -- **Tests:** update [`unified-chat-shell.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/unified-chat-shell.test.tsx) — Full button is rendered but always `disabled`; clicking it does not fire `onLayoutModeChange`. Update [`use-chat-layout-mode.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/use-chat-layout-mode.test.tsx) — persisted `'full'` reads back as `'maximize'`; `setLayoutMode('full')` clamps to `'maximize'`; Esc decrement chain stays valid mechanically but starts from `'maximize'`. -- **Out of scope:** removing `'full'` from the union type or `CHAT_LAYOUT_MODE_ORDER`; rewriting motion transitions; re-styling Maximize. -- **Verification:** `npm run verify` green. - -### C18 — Single scratch chat per spec + click-to-anchor injection - -- **Status:** **done** (2026-05-18) — substrate pivot landed. `npm run verify` green: 108 test files / 1278 tests pass; build clean. - - **Migration `0023_chat_anchored_items.sql`:** adds `chat.anchored_item_ids text NOT NULL DEFAULT '[]'` (JSON array). Schema column added; journal updated. - - **Server helpers:** `findScratchSecondaryChat(db, specId, parentChatId)` returns the unique per-spec scratch (one with `parent_chat_id = primary AND pinned_reconciliation_need_id IS NULL`). `appendAnchorToScratchChat(db, specId, input)` is the public entry: find-or-create the scratch (carries `invoked_in_turn_id` from the first call), parse anchored ids, no-op if itemId already present, else push id + append a mode-aware kickoff turn ("Editing ''." for edit-mode, "Anchored to ''." otherwise). Returns `{ chat, kickoffTurnId, anchoredItemIds }`. - - **Route repointed:** `POST /api/specifications/:id/secondary-chats` now branches — when `reconciliationNeedId` is set, falls through to the existing dedicated-chat path (preserves FE-716 C9 reconciliation chat behavior); otherwise calls `appendAnchorToScratchChat`. Response shape: `{ chatId, kickoffTurnId | null, anchoredItemIds }`. - - **Bundle projection:** `SecondaryChatWithKickoff.anchoredItemIds: number[]` derived from the new column; threaded through `core.ts` and the Zod `secondaryChatStateSchema`. The first kickoff turn is what `listSecondaryChatsForSpecification` already returns via `limit(1)`, so subsequent anchor kickoffs are recorded in the substrate but invisible to the UI (Option b from the open question). - - **Shell filter:** [unified-chat-shell.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/unified-chat-shell.tsx) picks the single scratch chat (`pinned_reconciliation_need_id === null`) from `secondaryChats` and renders one `` only. Reconciliation-pinned chats stay in the bundle data but the shell hides them until Track 3 defines their UX. `AnimatePresence` dropped since only one chat renders. - - **Tests:** all existing fixtures gained `anchoredItemIds: []` (bulk perl insert across four test files + manual for the populated reconciliation case). The shell test that asserted "renders one host per chat" now asserts "renders only the scratch chat" with multiple secondary chats in the bundle. Server tests (`app.test.ts`, `secondary-chat-route.test.ts`) continue to pass — the `invoked_in_turn_id` invariant and mode-aware "Editing" kickoff verb both preserved by threading those through to `appendAnchorToScratchChat`. -- **What:** Behavioral pivot away from "one secondary chat per item-click" to "one persistent scratch chat per spec, items injected as anchors over time." - - **Migration `drizzle/0023_chat_anchored_items.sql`** — add `chat.anchored_item_ids text NOT NULL DEFAULT '[]'` (JSON array of knowledge-item ids). Index not required at this volume. - - **Server:** - - `getOrCreateScratchSecondaryChat(db, specId, primaryChatId)` — find-or-create the unique secondary chat for the spec (uniqueness enforced at create time; identified as the one with `parent_chat_id = primary`). First-call also seeds the chat with `pinned_item_id` from the inbound itemId so the existing C9 reconciliation-pin and prompt context paths keep working unchanged. - - `appendAnchorToScratchChat(db, specId, primaryChatId, { itemId, itemKind, spanHint? })` — parses `anchored_item_ids`, pushes the id if absent, persists; appends a kickoff-style turn (`Anchored to ''.`) but the UI filters post-first kickoffs (see Open question below). - - Repoint `POST /api/specifications/:id/secondary-chats` from "create new chat per click" to call `appendAnchorToScratchChat`. Response shape extends to `{ chatId, kickoffTurnId, anchoredItemIds }`. - - Bundle: `secondaryChatStateSchema.chat.anchoredItemIds: number[]` projected from the new column. - - **Client:** - - `useSecondaryChatTrigger().create()` keeps its public signature; just hits the rebound route. Call sites (`StructuredListView`, `PendingReviewSection`) need no change. - - [`unified-chat-shell.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/unified-chat-shell.tsx) line 69 — filter `secondaryChats` to the scratch chat (the one whose `parent_chat_id = primary_chat_id`; substrate uniqueness guarantees ≤1). The `AnimatePresence` list renders one host max. - - Composer stays. Ask/Edit toggle stays. No mention chip; no expansion popout. -- **Tests:** - - Server: `chat-substrate.test.ts` — `getOrCreateScratchSecondaryChat` is idempotent; `appendAnchorToScratchChat` appends idempotently (no duplicate ids); `anchored_item_ids` survives reload. - - Server: `app.test.ts` — first POST creates the scratch chat + seeds anchor; second POST against a different item appends to the existing scratch chat (no new row in `chat`); response carries `anchoredItemIds`. - - Client: `unified-chat-shell.test.tsx` — given two secondary chats in the bundle (legacy + scratch), only the scratch chat renders. - - Client: existing `secondary-chat-trigger.test.tsx` — POST payload + bundle invalidation still pass (the public signature is unchanged); add an assertion that two `create` calls against different items produce one chat row. -- **Out of scope:** workspace selection styling (C19); deleting legacy per-item chats from existing local DBs (pre-release posture per `CLAUDE.md` — operator nukes `.brunch/brunch.db`); `chat_anchor` join table (not needed for V1). -- **Verification:** `npm run verify` green; manual probe: open spec, click dash on item A → scratch chat appears; click dash on item B → same scratch chat, both items in `anchoredItemIds`; reload → state persists. -- **Open question (resolve in build):** does the per-anchor kickoff-style turn render in the transcript or stay hidden? - - **(a)** Visible — self-documenting context history; slightly noisier. - - **(b)** Hidden — filter out post-first kickoffs at render time; cleaner UI. - - **Default per user direction:** (b). Substrate still records the turns; UI just doesn't show post-first kickoffs. Easy to flip later by removing the filter. - -### C19 — Workspace selection styling for anchored items - -- **Status:** **done** (2026-05-18) — promotes C27's left-border foundation to the full selection state: rows whose ids appear in the focused chat's `pinned_item_id` or `anchoredItemIds` render with a 2px left-border + ~10% kind-accent background tint (`${kindAccentHex[item.kind]}1A`), both colours resolved per row against the item's own kind per C19's "matching the item's kind" directive. `ItemActionRail` flips its `open-inline-chat` trigger's `aria-label` between `"Anchored to active chat"` and `"Open inline chat about this item"` (plus `aria-pressed` + `data-chat-anchored` for snapshot/test access); the click handler stays a no-op-equivalent — C26 server-side dedupe makes re-triggering on the anchored item return the existing chat id. Tests: 4 new cases in [`structured-list-view.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/%24id/__tests__/structured-list-view.test.tsx) — no chat focused → no `data-graph-row-chat-anchored`; pinned item gets selection styling while siblings don't; `anchoredItemIds` selection mirrors the pinned-item selection; aria-label flip. The bundle + presence mocks were extended with mutable `mockSecondaryChats` / `mockFocusedChatId` so each test can name an active chat without a full QueryClient/Presence harness. `npm run verify` green: 110 test files / 1306 tests pass; build clean. -- **Status (historical):** **next** (after C18) -- **What:** Items in `StructuredListView` whose ids appear in `scratchChat.anchoredItemIds` render with a selected/anchored visual state using `kindAccentHex` from [`knowledge-card.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/knowledge-card.tsx) — subtle background tint + accent border matching the item's kind. Clicking the dash icon on an already-anchored item is idempotent. -- **Optional polish:** a small "Anchored: A12, GOAL3" mini-band in the chat shell's header strip listing current anchor ref-codes. Defer until walkthrough surfaces a need. -- **Tests:** `structured-list-view.test.tsx` — items not in `anchoredItemIds` render without selection class; items in it render with the kind-accented selected class; the dash button label flips between `aria-label="Anchor to chat"` and `aria-label="Anchored"` (or similar). -- **Out of scope:** anchor-removal UX (defer until walkthrough demands it); selection in graph view (defer to a follow-up frontier per the parking lot's item-anchored badge entry). -- **Verification:** `npm run verify` green; outer-loop walkthrough confirming the selection styling matches the chat's anchor state across click + reload + mode toggle. - -## V1 re-narrowing (proposed, 2026-05-18) - -V1 was originally "every behavior the V3.1 side-chat ships today, surfaced through the elevated unified-workspace shape." C20–C25 propose absorbing **ai-elements adoption for the secondary-chat surface** into the same frontier and PR, on the basis that the design brief (`docs/design/UNIFIED_CHAT_UX.md` §Constraints) names ai-elements composition as non-negotiable for the terminal state. The Ladle prototype phases A–D in §13 are explicitly skipped; visual decisions are tested against real workspace state instead. If accepted, V1 = "V3.1 parity through unified shell **+ ai-elements parity with the interview spine**." PLAN.md frontier description for `chat-runtime-secondary-chats` updates to match. Cards land sequentially after C18 / C19 so they target the post-scratch-chat-pivot shape. - -### C20 — Adopt `` + `` for turn rendering - -- **Status:** **done** (2026-05-18) — `npm run verify` green: 1280 tests pass; build clean. - - **Client:** `secondary-chat-collapsible.tsx` now wraps persisted turns + the streaming-assistant pulse in `` → ``. `SecondaryChatTurnRow` renders `` + ``. Assistant text routes through `` (→ `MarkdownRenderer`); user text stays plain `whitespace-pre-wrap`. - - **Tests:** `secondary-chat-collapsible.test.tsx`, `secondary-chat-host.test.tsx`, and `chat-shell-presence.test.tsx` add `vi.mock` shims for `@/client/components/ai-elements/conversation.js` + `message.js` (matching the `InterviewView.test.tsx` pattern). New test in collapsible suite asserts that an assistant turn with `**bold**` renders `bold` (markdown shim in the mock makes it deterministic in happy-dom). - - **Note:** Cards described `` / `` / `` as "already used by `question-cards.tsx` / the interview spine." Reality: those primitives were vendored but unused; only `Reasoning` + `Task` had real consumers. C20 introduces the first real consumer of `` + `` + `` in production code. -- **What:** Replace the bespoke `SecondaryChatTurnRow` (in [`secondary-chat-collapsible.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/secondary-chat-collapsible.tsx) lines 228–244) with the vendored ai-elements `` shell and `` rows already used by `question-cards.tsx` / the interview spine. Wire `streamdown` markdown rendering for assistant `assistant_parts`; user `user_parts` stay plain-text. Keep the existing kickoff-content rendering as-is for one card so the diff stays scoped. -- **Why first:** Smallest delta from the current shape; proves the pattern is portable from interview to secondary chat without a streaming or composer refit. -- **Boundary crossings:** `` body → `` → `` × turns. No new server work; no bundle shape change; no test-mock surface change beyond importing the ai-elements mocks already used in `InterviewView.test.tsx`. -- **Risks / assumptions:** - - ASSUMPTION: `secondary-chat-collapsible.test.tsx` can mock `@/client/components/ai-elements/*` the way `InterviewView.test.tsx` does → VALIDATE: copy the mock pattern; expect a 5–10 line bump in setup. - - RISK: `streamdown` may render trailing whitespace differently than the current `whitespace-pre-wrap` div → MITIGATION: validate the existing `secondary-chat-collapsible.test.tsx` expectations and adjust string assertions to `toContain` rather than `toEqual` if needed. -- **Tests:** existing collapsible tests adapt to new harness; add one test asserting that an assistant turn with markdown (`**bold**`) renders strong instead of literal asterisks. -- **Out of scope:** composer refit (C21); streaming live-state (C22); suggestions (C23); `useChat` (C24); mentions (C25). -- **Verification:** `npm run verify` green; manual walkthrough confirms transcripts render identically to today on a real spec for plain-text turns, with markdown rendered for assistant turns. - -### C21 — Replace composer with `` + leading-edge mode chip - -- **Status:** **done** (2026-05-18) — `npm run verify` green: 1282 tests pass; build clean. - - **Client:** `SecondaryChatComposer` in `secondary-chat-collapsible.tsx` rebuilt on `` + `` + `` + `` + `` + ``. The mode toggle moved from the collapsible header into the composer footer (leading-edge tools slot); `Shift+Tab` inside the textarea flips Ask↔Edit via the textarea's `onKeyDown` (preventDefault). The header retains a read-only `SecondaryChatKindChip` so collapsed state still surfaces kind. Testids preserved (`secondary-chat-composer`, `secondary-chat-composer-input`, `secondary-chat-composer-send`). - - **Tests:** `secondary-chat-collapsible.test.tsx` mode-toggle tests now expand the collapsible and pass `onSubmitMessage` so the composer (and its toggle) mounts; new tests assert (a) toggle is absent without a composer, (b) `Shift+Tab` calls `onSetMode('edit')` from `'explore'`. Submit test switched to `fireEvent.submit` on the form + microtask flush because `PromptInput.onSubmit` `await`s `Promise.all([])` before invoking the user callback. - - **Note:** This is the first real production consumer of `` (the vendored primitives were previously unused outside `InterviewView.test.tsx` mocks). -- **What:** Retire the hand-rolled `SecondaryChatComposer` (`
` + `` + `