From 6c994600339b775322b2df4c6003528539535cbc Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 16:27:51 +0200 Subject: [PATCH 01/10] chore: ln-sync + ln-handoff after slices 17a and 14 Sync: updated dependency graph (14 done), parallelism notes (14a next), coverage table (6 file count corrections + api-types.test.ts), added BrunchProject to lexicon. Handoff: captures session state for resumption. Co-Authored-By: Claude Opus 4.6 (1M context) --- HANDOFF.md | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++ memory/PLAN.md | 16 ++++----- memory/SPEC.md | 12 ++++--- 3 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..aa0799f4 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,95 @@ +# Handoff — 2026-04-12 + +## Phase in flow + +``` +grill ✓ → spec ✓ → plan ✓ → [design ✓] → scope ✓ → build ✓ → review ✓ → [sync] +``` + +- **Last completed skill**: `ln-review` — reviewed slices 17a and 14, no high-impact findings +- **Current skill**: `ln-handoff` + `ln-sync` (in progress) +- **Next action**: `/ln-sync` to refresh SPEC.md and PLAN.md after the implementation burst, then `/ln-scope` for slice 14a + +## Session summary + +Three slices landed plus a sync pass. The session started from the prior handoff which had slice 14's scope card ready. + +1. **ln-sync** — Refreshed PLAN.md and SPEC.md. Trimmed slice 17 completion block to 4 lines, updated parallelism notes (17 done), updated dependency graph. Updated SPEC.md coverage table with actual test counts (interview.test.ts 10→11, InterviewWorkspace.test.tsx 21→22, workspace-loader.test.ts 2→3, export.test.ts 6→9, added manifest.test.ts). + +2. **ln-scope → ln-build** for slice 17a (debug route removal + shiki decoupling): + - Replaced `CodeBlock` in `tool.tsx` with plain `
` for JSON rendering
+   - Removed `preloadRichCodeHighlighter` from `markdown-rendering.tsx`
+   - Removed `/debug` route from router
+   - Deleted `ComponentDebug.tsx` + `debug-surface.tsx`
+   - Created `ai-elements.stories.tsx` Ladle story with full showcase
+   - Updated `build-boundary.test.ts` (no-shiki oracle, 1050KB budget) and `capability-boundaries.test.ts`
+   - Retired invariant I30 (moot), updated I28/I29/I32
+   - Commit: `78f7e89`
+
+3. **ln-scope → ln-build** for slice 14 (local-first storage + npx distribution):
+   - Created `project.ts` — `BrunchProject` interface, `findBrunchProject` (walk-up), `initBrunchProject`, `resolveBrunchProject`
+   - Created `launcher.ts` — Express serving API + static dist/ on one port, opens browser
+   - Created `cli.ts` — bin entry for `npx @hashintel/brunch`
+   - Fixed `db.ts` — migrations path resolved via `import.meta.url` instead of relative `./drizzle`
+   - Updated `index.ts` — dev server uses `resolveBrunchProject` when no `BRUNCH_DB` env var
+   - Added `open` dependency, `bin` entry in `package.json`
+   - 11 new tests (project.test.ts: 8, launcher.test.ts: 3)
+   - New invariant I100 (project resolution + launcher seam)
+   - Commits: `006b9b8`, `6306579`
+
+4. **ln-review** of slices 17a and 14 — clean, no high-impact findings. One medium oracle gap: launcher-serves-static test doesn't create a mock dist/ (deferred to outer-loop manual testing).
+
+## In-flight state
+
+### Review findings (from ln-review, all deferred)
+
+1. **tool.tsx: repeated `
` pattern** — depth/low — three identical pre/code blocks in ToolInput/ToolOutput. Acceptable per YAGNI.
+2. **launcher.test.ts unused import** — coupling/low — `writeFileSync` imported but unused.
+3. **launcher-serves-static oracle gap** — oracle-coverage/medium — test 2 doesn't actually test static serving (no mock dist/). Deferred to outer-loop manual npx walkthrough.
+4. **launcher.ts owns resolution + serving** — coupling/low — `launch(cwd)` does both project resolution and server setup. Fine for single caller.
+5. **db.ts + launcher.ts: duplicated `__dirname` pattern** — coupling/low — standard ESM boilerplate, not worth abstracting.
+6. **index.ts dev/production divergence** — depth/low — intentional, well-bounded.
+7. **All oracle coverage met** except finding #3. Lexicon alignment clean.
+
+### Key design insight preserved from prior session
+
+(Carried forward from prior handoff — still relevant for 14a)
+- Answered question cards are **read-only** — re-answering routes through the revisit model (Phase 8), not a casual UI toggle
+- Primary blue needs adjustment to match Hash logo (lighter sky blue) — not yet resolved via Figma
+- Badges should explore mono font — not yet implemented
+
+## Priority note from user
+
+The first delivery deadline is approaching. Priority order:
+1. **Done**: slice 17 (UI refinement), slice 17a (shiki decoupling), slice 14 (local-first + npx)
+2. **Must-have next**: slice 14a (brownfield/greenfield) — this must land
+3. **Stretch**: slice 15 + 15a (knowledge-graph revisit) — may not land before deadline
+4. **Deferred**: 13a (review lifecycle refinement), 16 (drizzle-kit audit)
+
+## Persisted state
+
+### Git
+- **Branch**: `ln/fe-545-storage-and-distro`
+- **Recent commits**: 6306579 → 006b9b8 → 415deb1 → 78f7e89
+- **Working tree**: clean
+
+### Artifacts
+- `memory/SPEC.md` — **current** (coverage table updated, I30 retired, I28/I29/I32 updated, I100 added)
+- `memory/PLAN.md` — **current** (slices 17a and 14 marked done, parallelism notes updated)
+- `docs/design/LOCAL_STORAGE.md` — **current** (approved design, implemented in slice 14)
+
+### Test status
+264 tests: all pass. `npm run verify` green (0 lint errors, build succeeds).
+
+## Resume prompt
+
+```
+I'm picking up from a build + review session. Read HANDOFF.md, then:
+1. Run /ln-sync to refresh docs after the implementation burst (slices 17a + 14)
+2. Then /ln-scope for slice 14a (greenfield/brownfield first-screen + exploration)
+
+Key context: slice 14a depends on slice 14 which just landed. Branch is
+ln/fe-545-storage-and-distro. The design doc is at docs/design/BROWNFIELD_EXPLORATION.md.
+
+Priority: slice 14a is the last must-have for the first delivery deadline.
+```
diff --git a/memory/PLAN.md b/memory/PLAN.md
index 24ddfe58..c185d84e 100644
--- a/memory/PLAN.md
+++ b/memory/PLAN.md
@@ -228,11 +228,10 @@
 ```
 done ─────────────────────────────────────────────────────────────┐
   Phase 1–6: all complete                                         │
+  Phase 7:   14 done, 17 done, 17a done                          │
 ──────────────────────────────────────────────────────────────────┘
                         │
-Phase 7:  12b ──→ 14 (local-first storage + npx distribution)
-          14 ──→ 14a (greenfield/brownfield + exploration)
-          17 done
+Phase 7:  14 ──→ 14a (greenfield/brownfield + exploration)
 Phase 8:  12a ──→ 15 (edit mode + cascade preview)        [stretch]
           15 ──→ 15a (cascade execution + secondary threads) [stretch]
 Phase 9:  14 ──→ 16 (drizzle-kit audit remediation)
@@ -241,9 +240,8 @@ Deferred: 12a + 12b ──→ 13a (review lifecycle refinement)
 
 ### Parallelism opportunities
 
-- Phase 6 is fully done (11a, 11b, 11c, 12a, 12b all complete).
-- **17 (UI refinement) is done.** 14 (local-first + npx) is the next unblocked slice.
-- 14a (brownfield) depends on 14 landing first (needs the launcher and `.brunch/` resolution).
-- 15 + 15a (knowledge-graph revisit) are stretch goals; they depend on 12a (knowledge workspace) which is done, but may not land before the first deadline.
-- 13a (review lifecycle refinement) is explicitly deferred; it should collect rarer review variants after the revisit model stabilizes.
-- 16 (drizzle-kit audit) should wait until 14 lands.
+- Phases 1–6 fully done. Phase 7: 14, 17, 17a all done.
+- **14a (greenfield/brownfield) is the next unblocked must-have** for the first delivery deadline.
+- 15 + 15a (knowledge-graph revisit) are stretch goals; they depend on 12a (done) but may not land before the first deadline.
+- 16 (drizzle-kit audit) is unblocked by 14 but deferred to post-distribution.
+- 13a (review lifecycle refinement) is explicitly deferred.
diff --git a/memory/SPEC.md b/memory/SPEC.md
index 49bd0b41..f0502284 100644
--- a/memory/SPEC.md
+++ b/memory/SPEC.md
@@ -355,6 +355,7 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`.
 
 | Term                   | Definition                                                                                                                                                                                                                                                                            |
 | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **BrunchProject**      | The resolved `.brunch/` directory struct: `{ root, dbPath, cwd }`. Discovered by `findBrunchProject` (walk-up), created by `initBrunchProject`, or resolved by `resolveBrunchProject`. Represents the local storage location, not the elicitation run. See D81, I100.                  |
 | **project**            | A spec elicitation run within a `.brunch/` directory. Has a name, a HEAD pointer (`active_turn_id`), and workflow/readiness state. Multiple projects can coexist in one `.brunch/` directory (different runs, versions, or feature scopes).                                             |
 | **turn**               | A checkpoint in the interview history. Carries phase provenance plus typed interaction payloads and UI parts. Points to its parent turn. Turns belong to either the primary conversation or a secondary thread.                                                                        |
 | **active path**        | The chain from HEAD to root in the primary conversation. Determines which turns, knowledge items, phase outcomes, and review state are currently trusted. Secondary threads inherit validity from their anchor turn on the active path.                                                |
@@ -570,9 +571,9 @@ This projection difference is a deliberate design choice, not an implementation
 | InterviewWorkspace.test.tsx   | 22    | I23, I24, I44, I48, I54, I72                          |
 | ProjectList.test.tsx          | 3     | I24                                                   |
 | workspace-data.test.ts        | 7     | I24, I48, I72                                         |
-| chat-hydration.test.ts        | 3     | I24                                                   |
-| workspace-controller.test.tsx | 3     | I24, I48                                              |
-| client-mutation.test.ts       | 3     | I24                                                   |
+| chat-hydration.test.ts        | 2     | I24                                                   |
+| workspace-controller.test.tsx | 6     | I24, I48                                              |
+| client-mutation.test.ts       | 6     | I24                                                   |
 | EntitySidebar.test.tsx        | 1     | I87                                                   |
 | code-block.test.tsx           | 4     | I24, I26                                              |
 | markdown-rendering.test.tsx   | 3     | I24, I31                                              |
@@ -580,10 +581,11 @@ This projection difference is a deliberate design choice, not an implementation
 | build-boundary.test.ts        | 1     | I24, I28, I32                                         |
 | capability-boundaries.test.ts | 2     | I24, I29                                              |
 | KnowledgeWorkspace.test.tsx   | 5     | I24, I48                                              |
-| workspace-loader.test.ts      | 3     | I24                                                   |
+| workspace-loader.test.ts      | 7     | I24                                                   |
 | project.test.ts               | 8     | I100                                                  |
 | launcher.test.ts              | 3     | I5, I100                                              |
-| export-loader.test.ts         | 1     | D26, D65, D66, D70                                    |
+| api-types.test.ts             | 5     | —                                                     |
+| export-loader.test.ts         | 3     | D26, D65, D66, D70                                    |
 | ExportPreview.test.tsx        | 2     | D26, D65, D66, D70                                    |
 | export.test.ts                | 9     | D26, D65, D66, D70                                    |
 | manifest.test.ts              | 1     | —                                                     |

From feecb9a1afe49a0b4aa5cd74ecdf5730d5488c8f Mon Sep 17 00:00:00 2001
From: Lu Nelson 
Date: Sun, 12 Apr 2026 16:55:12 +0200
Subject: [PATCH 02/10] feat: greenfield/brownfield first-screen routing and
 codebase exploration

Project creation now routes between greenfield (blank concept) and
brownfield (existing codebase). Brownfield interviewer gets core tools,
an exploration-first system prompt, and a higher step budget (12 vs 4).
Server derives cwd from launcher context, not from the client.

Co-Authored-By: Claude Opus 4.6 (1M context) 
---
 drizzle/0007_project_mode.sql                 |  3 +
 drizzle/meta/_journal.json                    |  7 ++
 memory/PLAN.md                                | 18 ++--
 memory/SPEC.md                                |  9 +-
 src/client/mutations/project-mutations.ts     | 14 ++-
 src/client/routes/InterviewWorkspace.test.tsx |  2 +
 src/client/routes/ProjectList.test.tsx        | 91 ++++++++++++++++--
 src/client/routes/ProjectList.tsx             | 95 +++++++++++++++++--
 .../workspace/workspace-controller.test.tsx   |  2 +
 src/client/workspace/workspace-data.test.ts   |  4 +
 src/client/workspace/workspace-loader.test.ts |  2 +
 src/server/app.test.ts                        | 32 +++++++
 src/server/app.ts                             | 22 ++++-
 src/server/core.ts                            |  7 +-
 src/server/db.test.ts                         | 19 ++++
 src/server/db.ts                              | 17 +++-
 src/server/index.ts                           |  5 +-
 src/server/interview.test.ts                  | 37 ++++++++
 src/server/interview.ts                       | 76 ++++++++++++---
 src/server/launcher.ts                        |  2 +-
 src/server/schema.ts                          |  4 +
 src/shared/api-types.test.ts                  |  4 +
 src/shared/api-types.ts                       |  7 ++
 23 files changed, 425 insertions(+), 54 deletions(-)
 create mode 100644 drizzle/0007_project_mode.sql

diff --git a/drizzle/0007_project_mode.sql b/drizzle/0007_project_mode.sql
new file mode 100644
index 00000000..d5aa2d25
--- /dev/null
+++ b/drizzle/0007_project_mode.sql
@@ -0,0 +1,3 @@
+ALTER TABLE `project` ADD `mode` text NOT NULL DEFAULT 'greenfield';
+--> statement-breakpoint
+ALTER TABLE `project` ADD `cwd` text;
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 398d276c..71f20318 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -50,6 +50,13 @@
       "when": 1775715000000,
       "tag": "0006_phase_outcome_closure_basis",
       "breakpoints": true
+    },
+    {
+      "idx": 7,
+      "version": "7",
+      "when": 1775800000000,
+      "tag": "0007_project_mode",
+      "breakpoints": true
     }
   ]
 }
\ No newline at end of file
diff --git a/memory/PLAN.md b/memory/PLAN.md
index c185d84e..b54b16ac 100644
--- a/memory/PLAN.md
+++ b/memory/PLAN.md
@@ -162,14 +162,11 @@
     - Debt: actual npx publish/distribution testing, `--port` flag, graceful shutdown
     - Unblocks: 14a (greenfield/brownfield), 16 (drizzle-kit audit)
 
-14a. **Greenfield/brownfield first-screen + exploration** — First screen routes between greenfield (blank concept) and brownfield (existing codebase). Project records store `mode` and `cwd`. Brownfield adds core tools to interviewer, brownfield system prompt variant instructs explore-then-interview on first turn. Observer extracts from that turn as usual. `not-started`
-     - Requirements: → SPEC.md §Requirements #2, #3, #16
-     - Assumptions: → SPEC.md §Assumptions A7, A47
-     - Decisions: → SPEC.md §Decisions D32, D82, D83
-     - Candidate invariant goals: brownfield first turn is grounded in discovered codebase context; greenfield path is unchanged; observer extracts from exploration turn normally
-     - Invariants to respect: → SPEC.md §Invariants I16, I22, I24, I54
-     - Acceptance: create brownfield project → agent explores codebase → first scope question is grounded in findings; create greenfield project → existing scope flow unchanged
+14a. **Greenfield/brownfield first-screen + exploration** `done`
+     - Shipped: project table stores `mode` (greenfield/brownfield) and `cwd`; dialog-based first-screen routes between modes; brownfield interviewer gets core tools + exploration system prompt + higher step budget (12 vs 4); server derives cwd from launcher; greenfield path unchanged
+     - Evidence: db.test.ts (5 new assertions), interview.test.ts (3 new), app.test.ts (3 new), ProjectList.test.tsx (updated), 274 tests pass, npm run verify green
      - Design: `docs/design/BROWNFIELD_EXPLORATION.md`
+     - Debt: outer-loop manual brownfield walkthrough (A47 validation), brownfield prompt for non-scope phases
 
 ## Phase 8: Knowledge-Graph Revisit (stretch)
 
@@ -228,10 +225,8 @@
 ```
 done ─────────────────────────────────────────────────────────────┐
   Phase 1–6: all complete                                         │
-  Phase 7:   14 done, 17 done, 17a done                          │
+  Phase 7:   14 done, 17 done, 17a done, 14a done                │
 ──────────────────────────────────────────────────────────────────┘
-                        │
-Phase 7:  14 ──→ 14a (greenfield/brownfield + exploration)
 Phase 8:  12a ──→ 15 (edit mode + cascade preview)        [stretch]
           15 ──→ 15a (cascade execution + secondary threads) [stretch]
 Phase 9:  14 ──→ 16 (drizzle-kit audit remediation)
@@ -240,8 +235,7 @@ Deferred: 12a + 12b ──→ 13a (review lifecycle refinement)
 
 ### Parallelism opportunities
 
-- Phases 1–6 fully done. Phase 7: 14, 17, 17a all done.
-- **14a (greenfield/brownfield) is the next unblocked must-have** for the first delivery deadline.
+- Phases 1–7 fully done (14, 17, 17a, 14a all complete). **All must-haves for the first delivery deadline are shipped.**
 - 15 + 15a (knowledge-graph revisit) are stretch goals; they depend on 12a (done) but may not land before the first deadline.
 - 16 (drizzle-kit audit) is unblocked by 14 but deferred to post-distribution.
 - 13a (review lifecycle refinement) is explicitly deferred.
diff --git a/memory/SPEC.md b/memory/SPEC.md
index f0502284..0bdfc1b1 100644
--- a/memory/SPEC.md
+++ b/memory/SPEC.md
@@ -254,6 +254,7 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`.
 | #    | Invariant                                                  | Established by            | Protected by                         | Proves      |
 | ---- | ---------------------------------------------------------- | ------------------------- | ------------------------------------ | ----------- |
 | I100 | `.brunch/` project resolution with walk-up discovery, init-rejects-existing, and resolve-creates-or-finds semantics; launcher serves API from resolved DB path with drizzle migrations resolving via import.meta.url | Slice 14 | project.test.ts, launcher.test.ts | D10, D81 |
+| I101 | Project mode (greenfield/brownfield) persists through schema, API, and interviewer configuration: brownfield gets core tools + exploration prompt + higher step budget; greenfield path is unchanged; server derives cwd from launcher context | Slice 14a | db.test.ts, interview.test.ts, app.test.ts, ProjectList.test.tsx | D32, D82, D83 |
 
 ### Client characterization
 
@@ -558,18 +559,18 @@ This projection difference is a deliberate design choice, not an implementation
 
 | File                          | Tests | Protects                                              |
 | ----------------------------- | ----- | ----------------------------------------------------- |
-| db.test.ts                    | 43    | I5, I6, I9, I10, I11, I20, I48, I54, I72, I87, I98   |
+| db.test.ts                    | 46    | I5, I6, I9, I10, I11, I20, I48, I54, I72, I87, I98, I101 |
 | knowledge.test.ts             | 1     | I48                                                   |
-| app.test.ts                   | 41    | I1, I2, I3, I7, I14, I21, I23, I44, I48, I54, I72, I87, I98, I99 |
+| app.test.ts                   | 44    | I1, I2, I3, I7, I14, I21, I23, I44, I48, I54, I72, I87, I98, I99, I101 |
 | core.test.ts                  | 10    | I12, I13, I18, I72, I87                               |
-| interview.test.ts             | 11    | I16, I72, I87                                         |
+| interview.test.ts             | 14    | I16, I72, I87, I101                                   |
 | parts.test.ts                 | 15    | I17, I18, I44, I54, I72                               |
 | context.test.ts               | 15    | I19, I44, I48, I54, I87                               |
 | observer.test.ts              | 9     | I20, I21, I44, I48, I54                               |
 | phase-close.test.ts           | 13    | I72                                                   |
 | turn-response.test.ts         | 4     | I44                                                   |
 | InterviewWorkspace.test.tsx   | 22    | I23, I24, I44, I48, I54, I72                          |
-| ProjectList.test.tsx          | 3     | I24                                                   |
+| ProjectList.test.tsx          | 4     | I24, I101                                             |
 | workspace-data.test.ts        | 7     | I24, I48, I72                                         |
 | chat-hydration.test.ts        | 2     | I24                                                   |
 | workspace-controller.test.tsx | 6     | I24, I48                                              |
diff --git a/src/client/mutations/project-mutations.ts b/src/client/mutations/project-mutations.ts
index 60538aa0..a8056246 100644
--- a/src/client/mutations/project-mutations.ts
+++ b/src/client/mutations/project-mutations.ts
@@ -1,11 +1,17 @@
 import { useNavigate } from '@tanstack/react-router';
 
 import { createProjectResponseSchema } from '../../shared/api-types.js';
-import type { CreateProjectRequest, CreateProjectResponse } from '../../shared/api-types.js';
+import type { CreateProjectRequest, CreateProjectResponse, ProjectMode } from '../../shared/api-types.js';
 import { postJsonMutation, useClientMutation } from './client-mutation.js';
 
+export interface CreateProjectInput {
+  name: string;
+  mode?: ProjectMode;
+  cwd?: string;
+}
+
 export interface CreateProjectMutationState {
-  readonly createProject: (name: string) => Promise;
+  readonly createProject: (input: CreateProjectInput) => Promise;
   readonly isPending: boolean;
   readonly errorMessage: string | null;
   readonly clearError: () => void;
@@ -23,8 +29,8 @@ export function useCreateProjectMutation(): CreateProjectMutationState {
   );
 
   return {
-    createProject: async (name: string) => {
-      const project = await mutation.run({ name });
+    createProject: async ({ name, mode, cwd }: CreateProjectInput) => {
+      const project = await mutation.run({ name, ...(mode ? { mode } : {}), ...(cwd ? { cwd } : {}) });
       void navigate({ to: '/project/$id', params: { id: String(project.id) } });
       return project;
     },
diff --git a/src/client/routes/InterviewWorkspace.test.tsx b/src/client/routes/InterviewWorkspace.test.tsx
index 7116d739..4eb37691 100644
--- a/src/client/routes/InterviewWorkspace.test.tsx
+++ b/src/client/routes/InterviewWorkspace.test.tsx
@@ -144,6 +144,8 @@ function createProjectState({
     project: {
       id: projectId,
       name: `Project ${projectId}`,
+      mode: 'greenfield',
+      cwd: null,
       active_turn_id: 1,
       created_at: '2026-04-03 10:00:00',
       updated_at: '2026-04-03 10:00:00',
diff --git a/src/client/routes/ProjectList.test.tsx b/src/client/routes/ProjectList.test.tsx
index 3f0bc353..6c8171ef 100644
--- a/src/client/routes/ProjectList.test.tsx
+++ b/src/client/routes/ProjectList.test.tsx
@@ -31,6 +31,16 @@ vi.mock('@/components/ui/card', () => ({
   CardDescription: ({ children }: { children: React.ReactNode }) => 
{children}
, })); +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + function createQueryClient() { return new QueryClient({ defaultOptions: { @@ -53,10 +63,6 @@ beforeEach(() => { navigateMock.mockReset(); fetchMock.mockReset(); vi.stubGlobal('fetch', fetchMock); - vi.stubGlobal( - 'prompt', - vi.fn(() => 'New project'), - ); }); afterEach(() => { @@ -65,12 +71,14 @@ afterEach(() => { }); describe('ProjectList', () => { - it('creates a project and navigates to its workspace', async () => { + it('creates a greenfield project and navigates to its workspace', async () => { fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ id: 7, name: 'New project', + mode: 'greenfield', + cwd: null, active_turn_id: null, created_at: '2026-04-03 10:00:00', updated_at: '2026-04-03 10:00:00', @@ -85,13 +93,25 @@ describe('ProjectList', () => { renderProjectList(); fireEvent.click(screen.getByRole('button', { name: 'New project' })); + // Enter project name + await waitFor(() => { + expect(screen.getByPlaceholderText('Project name')).toBeDefined(); + }); + fireEvent.change(screen.getByPlaceholderText('Project name'), { target: { value: 'New project' } }); + fireEvent.click(screen.getByText('Next')); + + // Select greenfield mode + await waitFor(() => { + expect(screen.getByText(/from scratch/i)).toBeDefined(); + }); + fireEvent.click(screen.getByText(/from scratch/i)); + await waitFor(() => { expect(fetchMock).toHaveBeenCalledWith( '/api/projects', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'New project' }), }), ); }); @@ -106,6 +126,8 @@ describe('ProjectList', () => { { id: 1, name: 'Active project', + mode: 'greenfield', + cwd: null, active_turn_id: 5, created_at: '2026-04-10 09:00:00', updated_at: '2026-04-10 09:30:00', @@ -125,6 +147,50 @@ describe('ProjectList', () => { expect(screen.getByText('Criteria')).toBeDefined(); }); + it('sends mode when creating a brownfield project', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 8, + name: 'New project', + mode: 'brownfield', + cwd: '/server/path', + active_turn_id: null, + created_at: '2026-04-12 10:00:00', + updated_at: '2026-04-12 10:00:00', + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); + + renderProjectList(); + fireEvent.click(screen.getByRole('button', { name: 'New project' })); + + // Enter project name and proceed to mode step + await waitFor(() => { + expect(screen.getByPlaceholderText('Project name')).toBeDefined(); + }); + fireEvent.change(screen.getByPlaceholderText('Project name'), { target: { value: 'New project' } }); + fireEvent.click(screen.getByText('Next')); + + // Select brownfield mode + await waitFor(() => { + expect(screen.getByText(/existing codebase/i)).toBeDefined(); + }); + fireEvent.click(screen.getByText(/existing codebase/i)); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + const call = fetchMock.mock.calls[0]; + const body = JSON.parse(call[1]?.body as string); + expect(body.mode).toBe('brownfield'); + expect(body.name).toBe('New project'); + }); + }); + it('shows a visible error when project creation fails', async () => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ error: 'Project name already exists' }), { @@ -136,6 +202,19 @@ describe('ProjectList', () => { renderProjectList(); fireEvent.click(screen.getByRole('button', { name: 'New project' })); + // Enter project name and proceed + await waitFor(() => { + expect(screen.getByPlaceholderText('Project name')).toBeDefined(); + }); + fireEvent.change(screen.getByPlaceholderText('Project name'), { target: { value: 'Bad project' } }); + fireEvent.click(screen.getByText('Next')); + + // Select greenfield to trigger fetch + await waitFor(() => { + expect(screen.getByText(/from scratch/i)).toBeDefined(); + }); + fireEvent.click(screen.getByText(/from scratch/i)); + expect((await screen.findByRole('alert')).textContent).toContain('Project name already exists'); expect(navigateMock).not.toHaveBeenCalled(); }); diff --git a/src/client/routes/ProjectList.tsx b/src/client/routes/ProjectList.tsx index 1bf14e46..4d21fc32 100644 --- a/src/client/routes/ProjectList.tsx +++ b/src/client/routes/ProjectList.tsx @@ -1,10 +1,19 @@ import { useLoaderData, useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { useCreateProjectMutation } from '@/mutations/project-mutations'; -import type { ProjectListItem } from '../../shared/api-types.js'; +import type { ProjectListItem, ProjectMode } from '../../shared/api-types.js'; const phaseLabels: Array<{ key: keyof ProjectListItem['workflowSummary']; label: string }> = [ { key: 'scope', label: 'Scope' }, @@ -19,28 +28,45 @@ const statusStyles: Record = { unstarted: 'bg-muted text-muted-foreground', }; +type DialogStep = 'closed' | 'name' | 'mode'; + export function ProjectList() { const projects = useLoaderData({ from: '/' }); const navigate = useNavigate(); const createProjectMutation = useCreateProjectMutation(); - const handleCreate = async () => { - const name = prompt('Project name:'); - if (!name?.trim()) return; + const [dialogStep, setDialogStep] = useState('closed'); + const [projectName, setProjectName] = useState(''); + + const handleOpen = () => { + setProjectName(''); + setDialogStep('name'); + }; + + const handleNameSubmit = () => { + if (!projectName.trim()) return; + setDialogStep('mode'); + }; + const handleModeSelect = async (mode: ProjectMode) => { + setDialogStep('closed'); try { - await createProjectMutation.createProject(name.trim()); + await createProjectMutation.createProject({ name: projectName.trim(), mode }); } catch { // The shared mutation hook surfaces the failure state in the UI. } }; + const handleClose = () => { + setDialogStep('closed'); + }; + return (

Brunch

AI-guided spec elicitation

- @@ -78,6 +104,63 @@ export function ProjectList() { ))}
)} + + !open && handleClose()}> + + {dialogStep === 'name' && ( + <> + + New project + Give your project a name. + + setProjectName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleNameSubmit()} + placeholder="Project name" + autoFocus + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + /> + + + + + )} + {dialogStep === 'mode' && ( + <> + + What kind of project? + Choose how to start your spec elicitation. + +
+ + +
+ + )} +
+
); } diff --git a/src/client/workspace/workspace-controller.test.tsx b/src/client/workspace/workspace-controller.test.tsx index 456ca567..f28d5959 100644 --- a/src/client/workspace/workspace-controller.test.tsx +++ b/src/client/workspace/workspace-controller.test.tsx @@ -101,6 +101,8 @@ function createProjectState({ project: { id: projectId, name: `Project ${projectId}`, + mode: 'greenfield', + cwd: null, active_turn_id: 1, created_at: '2026-04-03 10:00:00', updated_at: '2026-04-03 10:00:00', diff --git a/src/client/workspace/workspace-data.test.ts b/src/client/workspace/workspace-data.test.ts index 3572ced1..2e26e9ec 100644 --- a/src/client/workspace/workspace-data.test.ts +++ b/src/client/workspace/workspace-data.test.ts @@ -35,6 +35,8 @@ function createProjectState({ project: { id: projectId, name: `Project ${projectId}`, + mode: 'greenfield', + cwd: null, active_turn_id: 1, created_at: '2026-04-03 10:00:00', updated_at: '2026-04-03 10:00:00', @@ -409,6 +411,8 @@ describe('workspace controller core', () => { project: { id: 1, name: 'Project 1', + mode: 'greenfield', + cwd: null, active_turn_id: null, created_at: '2026-04-03 10:00:00', updated_at: '2026-04-03 10:00:00', diff --git a/src/client/workspace/workspace-loader.test.ts b/src/client/workspace/workspace-loader.test.ts index a363876e..f7ec4251 100644 --- a/src/client/workspace/workspace-loader.test.ts +++ b/src/client/workspace/workspace-loader.test.ts @@ -11,6 +11,8 @@ const projectState: ProjectState = { project: { id: 7, name: 'Project 7', + mode: 'greenfield', + cwd: null, active_turn_id: null, created_at: '2026-04-03 10:00:00', updated_at: '2026-04-03 10:00:00', diff --git a/src/server/app.test.ts b/src/server/app.test.ts index abaf34a0..ac6b20a0 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -281,6 +281,33 @@ describe('GET /api/projects/:id/export', () => { }); }); +describe('POST /api/projects', () => { + it('creates a greenfield project by default', async () => { + const res = await request(app).post('/api/projects').send({ name: 'Greenfield' }).expect(201); + expect(res.body.mode).toBe('greenfield'); + expect(res.body.cwd).toBeNull(); + }); + + it('creates a brownfield project with mode and server-derived cwd', async () => { + const res = await request(app) + .post('/api/projects') + .send({ name: 'Brownfield', mode: 'brownfield' }) + .expect(201); + expect(res.body.mode).toBe('brownfield'); + expect(res.body.cwd).toBe(process.cwd()); + }); + + it('persists mode in project state', async () => { + const createRes = await request(app) + .post('/api/projects') + .send({ name: 'BF', mode: 'brownfield' }) + .expect(201); + const stateRes = await request(app).get(`/api/projects/${createRes.body.id}`).expect(200); + expect(stateRes.body.project.mode).toBe('brownfield'); + expect(stateRes.body.project.cwd).toBe(process.cwd()); + }); +}); + describe('POST /api/projects/:id/chat', () => { it('requires typed UI messages', async () => { const projectId = await createTestProject(); @@ -966,6 +993,7 @@ describe('phase outcomes + scope closure', () => { expect.any(Array), 'Let us compare SQLite and Postgres', 'design', + undefined, ); expect(mockRunObserver).toHaveBeenLastCalledWith( expect.anything(), @@ -1207,6 +1235,7 @@ describe('phase outcomes + scope closure', () => { expect.any(Array), 'Let us review the must-have capabilities', 'requirements', + undefined, ); expect(mockRunObserver).toHaveBeenLastCalledWith( expect.anything(), @@ -1513,6 +1542,7 @@ describe('phase outcomes + scope closure', () => { expect.any(Array), 'Let us define the first acceptance criterion', 'criteria', + undefined, ); expect(mockRunObserver).toHaveBeenLastCalledWith( expect.anything(), @@ -1576,6 +1606,7 @@ describe('phase outcomes + scope closure', () => { expect.any(Array), expect.any(String), 'criteria', + undefined, ); expect(mockRunObserver).toHaveBeenLastCalledWith( @@ -1971,6 +2002,7 @@ describe('phase outcomes + scope closure', () => { expect.any(Array), 'Let us review the must-have capabilities', 'requirements', + undefined, ); expect(mockRunObserver).toHaveBeenLastCalledWith( expect.anything(), diff --git a/src/server/app.ts b/src/server/app.ts index f5add30b..481ade39 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -54,13 +54,20 @@ import { persistFallbackQuestionText, streamInterviewer } from './interview.js'; import { runObserver } from './observer.js'; import { serializeParts } from './parts.js'; +export interface AppOptions { + readonly dbPath?: string; + readonly projectCwd?: string; +} + export interface AppServices { readonly app: Express; readonly db: DB; } -export function createApp(dbPath?: string): AppServices { - const db = createDb(dbPath); +export function createApp(dbPathOrOptions?: string | AppOptions): AppServices { + const options = typeof dbPathOrOptions === 'string' ? { dbPath: dbPathOrOptions } : (dbPathOrOptions ?? {}); + const db = createDb(options.dbPath); + const projectCwd = options.projectCwd ?? process.cwd(); const app = express(); app.use(express.json()); @@ -76,7 +83,9 @@ export function createApp(dbPath?: string): AppServices { res.status(400).json({ error: 'name is required' } satisfies MutationErrorResponse); return; } - const project = createNewProject(db, name); + const mode = req.body.mode === 'brownfield' ? ('brownfield' as const) : undefined; + const cwd = mode === 'brownfield' ? projectCwd : undefined; + const project = createNewProject(db, name, { mode, cwd }); res.status(201).json(project); }); @@ -290,12 +299,19 @@ export function createApp(dbPath?: string): AppServices { return; } + const project = prepared.project; + const modeOptions = + project.mode === 'brownfield' && project.cwd + ? { mode: 'brownfield' as const, cwd: project.cwd } + : undefined; + const interviewer = await streamInterviewer( db, prepared.turn, prepared.activePath, prompt, prepared.turn.phase, + modeOptions, ); writer.merge( diff --git a/src/server/core.ts b/src/server/core.ts index 7314894c..f9bd7676 100644 --- a/src/server/core.ts +++ b/src/server/core.ts @@ -10,6 +10,7 @@ import { advanceHead, listProjects, createProject, + type CreateProjectOptions, type Option, type Turn, type DB, @@ -86,7 +87,7 @@ export function listProjectStates(db: DB) { }); } -/** Create a new project with the given name. */ -export function createNewProject(db: DB, name: string): Project { - return createProject(db, name); +/** Create a new project with the given name and optional mode/cwd. */ +export function createNewProject(db: DB, name: string, options?: CreateProjectOptions): Project { + return createProject(db, name, options); } diff --git a/src/server/db.test.ts b/src/server/db.test.ts index 5232d3f1..5a3d31bd 100644 --- a/src/server/db.test.ts +++ b/src/server/db.test.ts @@ -77,6 +77,13 @@ describe('createDb', () => { expect(phaseOutcomeColumns.map((column) => column.name)).toContain('closure_basis'); }); + it('project table has mode and cwd columns', () => { + const columns = db.$client.prepare("PRAGMA table_info('project')").all() as Array<{ name: string }>; + const names = columns.map((c) => c.name); + expect(names).toContain('mode'); + expect(names).toContain('cwd'); + }); + it('creates database file on disk when given a path', () => { const dir = join(tmpdir(), `brunch-test-${randomUUID()}`); mkdirSync(dir, { recursive: true }); @@ -1159,6 +1166,18 @@ describe('createProject', () => { const p2 = createProject(db, 'Second'); expect(p1.id).not.toBe(p2.id); }); + + it('defaults to greenfield mode with null cwd', () => { + const project = createProject(db, 'Greenfield'); + expect(project.mode).toBe('greenfield'); + expect(project.cwd).toBeNull(); + }); + + it('creates a brownfield project with mode and cwd', () => { + const project = createProject(db, 'Brownfield', { mode: 'brownfield', cwd: '/path/to/repo' }); + expect(project.mode).toBe('brownfield'); + expect(project.cwd).toBe('/path/to/repo'); + }); }); describe('getProject', () => { diff --git a/src/server/db.ts b/src/server/db.ts index 473a1d40..a4472f74 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -97,8 +97,21 @@ export function listProjects(db: DB): Project[] { return db.select().from(schema.project).orderBy(desc(schema.project.updated_at)).all() as Project[]; } -export function createProject(db: DB, name: string): Project { - const result = db.insert(schema.project).values({ name }).returning().get(); +export interface CreateProjectOptions { + mode?: 'greenfield' | 'brownfield'; + cwd?: string | null; +} + +export function createProject(db: DB, name: string, options?: CreateProjectOptions): Project { + const result = db + .insert(schema.project) + .values({ + name, + ...(options?.mode ? { mode: options.mode } : {}), + ...(options?.cwd ? { cwd: options.cwd } : {}), + }) + .returning() + .get(); return result as Project; } diff --git a/src/server/index.ts b/src/server/index.ts index 1c98f0c9..4371c219 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,9 +5,10 @@ const PORT = process.env.PORT || 3000; const DB_PATH = process.env.BRUNCH_DB; // In dev mode, use BRUNCH_DB env var if set, otherwise resolve .brunch/ project -const dbPath = DB_PATH ?? resolveBrunchProject(process.cwd()).dbPath; +const projectCwd = process.cwd(); +const dbPath = DB_PATH ?? resolveBrunchProject(projectCwd).dbPath; -const { app } = createApp(dbPath); +const { app } = createApp({ dbPath, projectCwd }); app.listen(PORT, () => { console.log(`Brunch server listening on http://localhost:${PORT}`); diff --git a/src/server/interview.test.ts b/src/server/interview.test.ts index ffae6710..477d7c24 100644 --- a/src/server/interview.test.ts +++ b/src/server/interview.test.ts @@ -4,6 +4,8 @@ import { structuredQuestionSchema, type StructuredQuestion } from '../shared/cha import { createDb, createProject, createTurn, getOptionsForTurn, getTurn, type DB } from './db.js'; import { canProposePhaseClosure, + getBrownfieldScopePrompt, + getInterviewerTools, getSystemPrompt, persistFallbackQuestionText, persistStructuredQuestion, @@ -146,6 +148,41 @@ describe('createProposePhaseClosureTool', () => { }); }); +describe('brownfield interviewer configuration', () => { + it('includes core tools when mode is brownfield', () => { + const project = createProject(db, 'BF', { mode: 'brownfield', cwd: '/tmp/repo' }); + const turn = createTurn(db, project.id, { phase: 'scope', question: '', answer: '' }); + const tools = getInterviewerTools(db, turn.id, 'scope', project.id, { + mode: 'brownfield', + cwd: '/tmp/repo', + }); + const toolNames = Object.keys(tools); + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('grep'); + expect(toolNames).toContain('find_files'); + expect(toolNames).toContain('list_directory'); + expect(toolNames).toContain('ask_question'); + }); + + it('excludes core tools when mode is greenfield', () => { + const project = createProject(db, 'GF'); + const turn = createTurn(db, project.id, { phase: 'scope', question: '', answer: '' }); + const tools = getInterviewerTools(db, turn.id, 'scope', project.id); + const toolNames = Object.keys(tools); + expect(toolNames).not.toContain('read_file'); + expect(toolNames).not.toContain('grep'); + expect(toolNames).toContain('ask_question'); + }); + + it('uses a distinct brownfield system prompt for scope phase', () => { + const brownfieldPrompt = getBrownfieldScopePrompt('/tmp/repo'); + const greenfieldPrompt = getSystemPrompt('scope'); + expect(brownfieldPrompt).not.toBe(greenfieldPrompt); + expect(brownfieldPrompt).toContain('explore'); + expect(brownfieldPrompt).toContain('/tmp/repo'); + }); +}); + describe('persistFallbackQuestionText', () => { it('fills the question only when the turn does not already have one', () => { const project = createProject(db, 'Spec'); diff --git a/src/server/interview.ts b/src/server/interview.ts index 720f2c5e..4cca2f2d 100644 --- a/src/server/interview.ts +++ b/src/server/interview.ts @@ -26,6 +26,7 @@ import { type Impact, type Phase, } from './db.js'; +import { createCoreTools } from './tools/index.js'; const SYSTEM_PROMPTS: Record = { scope: `You are a spec elicitation interviewer conducting the SCOPE phase. @@ -74,12 +75,48 @@ Your job is to propose testable acceptance criteria for each confirmed requireme For every turn, you MUST use the ask_question tool. Never respond with plain text.`, }; +/** Brownfield scope system prompt. Instructs the agent to explore the codebase before asking its first scope question. */ +export function getBrownfieldScopePrompt(cwd: string): string { + return `You are a spec elicitation interviewer conducting the SCOPE phase for a feature within an existing codebase. + +The project directory is: ${cwd} + +Before asking your first scope question, use your tools to explore the codebase and build a working understanding of the project. Follow this strategy: +1. Look for README, package.json, Cargo.toml, pyproject.toml, or other project manifest files +2. Explore the directory structure to understand the project layout +3. Read key files that reveal architecture and conventions +4. Look for existing documentation, tests, and configuration + +Spend no more than 5-8 tool calls on exploration before synthesizing. + +Once you have a working understanding, summarize what you found in 2-3 sentences. Then begin the structured scope interview grounded in that context — your questions should reflect what you discovered about the codebase. + +For every turn after the exploration, you MUST use the ask_question tool to generate your question. Never respond with plain text — always use the tool. + +Each question should: +- Be clear and specific, not vague or open-ended +- Include 2-4 options that represent meaningfully different directions +- Mark exactly one option as recommended based on what you know so far +- Include a "why" field explaining why this question matters for the spec +- Include an impact level (high/medium/low) reflecting how much this decision affects downstream choices + +Ask one question at a time. Build on previous answers to go deeper. + +When goals, terms, context, and constraints are sufficiently captured for now, use the propose_phase_closure tool instead of asking another question. The summary should concisely explain what is now understood and why scope can close.`; +} + +export interface InterviewerModeOptions { + mode?: 'greenfield' | 'brownfield'; + cwd?: string; +} + export type AskQuestionTool = Tool; export type ProposePhaseClosureTool = Tool; -export type InterviewerTools = { +export type BaseInterviewerTools = { ask_question: AskQuestionTool; propose_phase_closure?: ProposePhaseClosureTool; }; +export type InterviewerTools = BaseInterviewerTools & Record>; export type InterviewerAgent = ToolLoopAgent; /** Phase-specific system prompts. */ @@ -152,23 +189,39 @@ export function createProposePhaseClosureTool( }); } +/** Build the tool set for the interviewer agent, conditionally including core tools for brownfield mode. */ +export function getInterviewerTools( + db: DB, + turnId: number, + phase: Phase, + projectId: number, + options?: InterviewerModeOptions, +): InterviewerTools { + const closeability = getCurrentWorkflowState(db, projectId).phases[phase].closeability; + return { + ask_question: createAskQuestionTool(db, turnId), + ...(canProposePhaseClosure(phase, closeability) + ? { propose_phase_closure: createProposePhaseClosureTool(db, turnId, phase, projectId) } + : {}), + ...(options?.mode === 'brownfield' && options.cwd ? createCoreTools(options.cwd) : {}), + }; +} + export function createInterviewerAgent( db: DB, turnId: number, phase: Phase, projectId: number, + options?: InterviewerModeOptions, ): InterviewerAgent { - const closeability = getCurrentWorkflowState(db, projectId).phases[phase].closeability; + const tools = getInterviewerTools(db, turnId, phase, projectId, options); + const isBrownfield = options?.mode === 'brownfield' && options.cwd; + const instructions = isBrownfield ? getBrownfieldScopePrompt(options.cwd!) : getSystemPrompt(phase); return new ToolLoopAgent({ model: anthropic(process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514'), - instructions: getSystemPrompt(phase), - tools: { - ask_question: createAskQuestionTool(db, turnId), - ...(canProposePhaseClosure(phase, closeability) - ? { propose_phase_closure: createProposePhaseClosureTool(db, turnId, phase, projectId) } - : {}), - }, + instructions, + tools, providerOptions: { anthropic: { sendReasoning: true, @@ -179,7 +232,7 @@ export function createInterviewerAgent( }, }, maxOutputTokens: 16000, - stopWhen: stepCountIs(4), + stopWhen: stepCountIs(isBrownfield ? 12 : 4), }); } @@ -189,8 +242,9 @@ export async function streamInterviewer( activePath: TurnWithOptions[], userMessage: string, phase: Phase, + modeOptions?: InterviewerModeOptions, ): ReturnType { - const agent = createInterviewerAgent(db, turn.id, phase, turn.project_id); + const agent = createInterviewerAgent(db, turn.id, phase, turn.project_id, modeOptions); const entities = getEntitiesForProject(db, turn.project_id); const fullPrompt = buildInterviewerContext(activePath, userMessage, { phase, diff --git a/src/server/launcher.ts b/src/server/launcher.ts index 48a9c7ba..b175c6df 100644 --- a/src/server/launcher.ts +++ b/src/server/launcher.ts @@ -14,7 +14,7 @@ export async function launch(cwd: string): Promise { const project = resolveBrunchProject(cwd); console.log(`.brunch/ directory: ${project.root}`); - const { app } = createApp(project.dbPath); + const { app } = createApp({ dbPath: project.dbPath, projectCwd: cwd }); // Serve built Vite assets as static files (production mode) if (existsSync(DIST_DIR)) { diff --git a/src/server/schema.ts b/src/server/schema.ts index 229c4002..6cd71be9 100644 --- a/src/server/schema.ts +++ b/src/server/schema.ts @@ -6,6 +6,10 @@ import { sqliteTable, integer, text, primaryKey, uniqueIndex } from 'drizzle-orm export const project = sqliteTable('project', { id: integer().primaryKey({ autoIncrement: true }), name: text().notNull(), + mode: text('mode', { enum: ['greenfield', 'brownfield'] }) + .notNull() + .default('greenfield'), + cwd: text('cwd'), active_turn_id: integer(), created_at: text() .notNull() diff --git a/src/shared/api-types.test.ts b/src/shared/api-types.test.ts index fc999433..37d4fa97 100644 --- a/src/shared/api-types.test.ts +++ b/src/shared/api-types.test.ts @@ -16,6 +16,8 @@ describe('api transport contracts', () => { projectListItemSchema.parse({ id: 1, name: 'Project 1', + mode: 'greenfield', + cwd: null, active_turn_id: 4, created_at: '2026-04-12 10:00:00', updated_at: '2026-04-12 10:00:00', @@ -42,6 +44,8 @@ describe('api transport contracts', () => { project: { id: 1, name: 'Project 1', + mode: 'greenfield', + cwd: null, active_turn_id: 4, created_at: '2026-04-12 10:00:00', updated_at: '2026-04-12 10:00:00', diff --git a/src/shared/api-types.ts b/src/shared/api-types.ts index 3b6cd795..daa25b9a 100644 --- a/src/shared/api-types.ts +++ b/src/shared/api-types.ts @@ -5,9 +5,13 @@ import { phaseClosureBasisSchema, workflowPhaseSchema } from './phase-close.js'; export const workflowPhaseStatusSchema = z.enum(['unstarted', 'in_progress', 'closed']); export const readinessBandSchema = z.enum(['low', 'medium', 'high']); +export const projectModeSchema = z.enum(['greenfield', 'brownfield']); + export const projectSchema = z.object({ id: z.number().int().positive(), name: z.string(), + mode: projectModeSchema, + cwd: z.string().nullable(), active_turn_id: z.number().int().positive().nullable(), created_at: z.string(), updated_at: z.string(), @@ -65,6 +69,8 @@ export const projectStateTurnSchema = z.object({ export const createProjectRequestSchema = z.object({ name: z.string().trim().min(1), + mode: projectModeSchema.optional(), + cwd: z.string().optional(), }); export const createProjectResponseSchema = projectSchema; @@ -197,6 +203,7 @@ export const submitTurnResponseResponseSchema = z.object({ ok: z.literal(true), }); +export type ProjectMode = z.infer; export type WorkflowPhaseStatus = z.infer; export type ReadinessBand = z.infer; export type Project = z.infer; From 7079e9cd7f64a53c3fdf939d5ae9772fb1235a2d Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 17:25:47 +0200 Subject: [PATCH 03/10] chore: ln-review findings from slice 14a appended to REFACTOR.md Four structural findings from the brownfield mode review integrated into the existing type-deduplication refactor plan. Co-Authored-By: Claude Opus 4.6 (1M context) --- memory/REFACTOR.md | 82 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 memory/REFACTOR.md diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md new file mode 100644 index 00000000..b3a7d281 --- /dev/null +++ b/memory/REFACTOR.md @@ -0,0 +1,82 @@ +## Problem Statement + +The codebase currently has more than one source of truth for several domain and transport types. Shared contracts already exist, but parallel local declarations still appear in server projection code, workflow primitives, client-facing status unions, and fixture/seeding definitions. This creates drift risk at the boundaries where consistency matters most: producer/consumer seams, seed data, export projections, and tests. + +The problem is not that local types exist. The problem is that some local types restate an importable or derivable contract. Storage-row types and true local view models are acceptable. Duplicated transport unions and DTO shapes are not. + +Fixture generation and database seeding are part of this same problem. If the canonical seams change while manifests, scenarios, and seed helpers remain hand-authored and wider than the real contracts, the app becomes correct in production code but loose in its development harness. + +## Solution + +Establish one explicit rule in the codebase: + +- shared transport and domain contracts own API-facing and cross-layer DTO shapes +- storage modules own persistence-row and storage-only input shapes +- UI and controller modules may own true local view models +- fixtures, manifests, scenario builders, and seed helpers must derive from the same canonical shared types rather than restating unions by hand + +This refactor stops at the type-boundary level. It is not a behavior change. The target state is: + +- server read models project directly into shared transport types +- shared workflow primitives own phase, status, readiness, impact, and review unions +- tests, fixtures, and seeders import or derive those same types +- no duplicated literal unions remain where a canonical type already exists +- no local DTO re-declarations remain where the shared contract is already authoritative + +## Commits + +1. Add characterization coverage for the contract seams most exposed to this refactor. +2. Extract and stabilize the canonical shared primitive types. +3. Collapse duplicated server workflow DTO types onto the shared contracts. +4. Collapse duplicated entity and relationship DTO types onto the shared contracts. +5. Replace server-side assembled transport-shape aliases with shared turn and project transport types. +6. Align client and shared workflow consumers to the canonical primitives. +7. Narrow review-state consumers to the canonical review-status type. +8. Refactor fixture manifests and scenario builders to derive from shared contracts. +9. Refactor seed helpers to consume only the narrowed shared or derived types. +10. Remove residual duplicate type declarations and simplify tests to assert the shared seam directly. +11. Run the full verification gate and do a final drift sweep focused on fixtures and seeds. + +## Decisions + +- The shared transport and domain layer becomes the authority for cross-layer DTOs. +- The persistence layer continues to own row types and storage-only input types. +- Local controller and view-model types remain allowed when they model a real local abstraction rather than a transport contract. +- The cleanup intentionally distinguishes projection types from persistence types; not every type moves into one module. +- Workflow primitives should be importable from one shared seam rather than recreated across UI, server, tests, and fixtures. +- Review status should become a first-class shared primitive rather than an ad hoc repeated union. +- Fixture manifests and seed helpers are in scope because they are contract producers and can drift if left wider than runtime code. +- The refactor should prefer derivation over re-declaration whenever a type can be indexed from an existing shared type. +- Runtime behavior, schema layout, and endpoint semantics should remain unchanged. + +## Testing Decisions + +- Good tests here prove behavioral compatibility at module boundaries, not the existence of particular type aliases. +- The highest-value tests are shared schema parses for current transport payloads, server projection tests for workflow and entity payloads, export projection tests, and seeded scenario round-trips that prove fixtures still hydrate into the same runtime projections. +- The main app and runtime seams already have meaningful coverage, so this is not a blind refactor. +- The weakest coverage relative to this refactor is fixture-generation and manifest or seed narrowness; characterization there should be the first commit. +- If the refactor introduces any new shared primitive seam, tests should prove representative payloads still parse through that seam rather than asserting implementation details. + +## Findings from ln-review of slice 14a (2026-04-12) + +These findings extend the problem statement above. Slice 14a introduced a new mode/cwd seam that exhibits the same duplication pattern the refactor targets. + +1. **`'greenfield' | 'brownfield'` literal union declared in 3 places** — The mode enum lives in `schema.ts` (Drizzle), `api-types.ts` (`projectModeSchema`/`ProjectMode`), and is restated as inline literals in `db.ts` (`CreateProjectOptions`) and `interview.ts` (`InterviewerModeOptions`). The shared `ProjectMode` type should be canonical. Fits commit 2 (extract/stabilize shared primitives) or commit 3 (collapse server DTOs). + +2. **`CreateProjectInput` in `project-mutations.ts` restates `CreateProjectRequest`** — The client-local type is isomorphic to the shared request type but includes a `cwd` field the client never sends. Should derive from or import `CreateProjectRequest` and drop `cwd`. Fits commit 5 (replace assembled transport aliases). + +3. **`createApp` string-or-object overload** — `dbPathOrOptions?: string | AppOptions` exists for backward compatibility but both real callers now pass objects. Test callers use the string form. Simplify during commit 10 (remove residual duplicates). + +4. **`BaseInterviewerTools & Record>` widens optionality** — The intersection erases the optional `propose_phase_closure` modifier. A mapped type (`{ [K in string]?: Tool }`) would preserve intent. Not strictly a type-duplication issue, but it weakens the contract at the same seam. Fits commit 3 or is a standalone fix. + +## Out of Scope + +- Changing runtime behavior +- Changing database schema or migration shape +- Reworking the knowledge model itself +- Reworking UI architecture or controller or view-model design beyond import and derivation cleanup +- Eliminating legitimate local view models +- Converting persistence-row types into transport types +- Broad naming cleanup unrelated to source-of-truth type duplication +- Brownfield exploration or other feature work +- Any non-type fixture rewrite beyond narrowing fixtures and seeds to canonical contracts From a0a3e3a26329abe5b0550b1ef1531942b9121b00 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 17:33:51 +0200 Subject: [PATCH 04/10] chore: rewrite REFACTOR.md with verified duplication map and parallel streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified all assertions from the original plan against the actual codebase. Restructured 11 sequential commits into prereq → 4 parallel streams → cleanup for concurrent agent execution. Co-Authored-By: Claude Opus 4.6 (1M context) --- memory/REFACTOR.md | 178 ++++++++++++++++++++++++++++++++------------- 1 file changed, 127 insertions(+), 51 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index b3a7d281..3ea8a75e 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -4,79 +4,155 @@ The codebase currently has more than one source of truth for several domain and The problem is not that local types exist. The problem is that some local types restate an importable or derivable contract. Storage-row types and true local view models are acceptable. Duplicated transport unions and DTO shapes are not. -Fixture generation and database seeding are part of this same problem. If the canonical seams change while manifests, scenarios, and seed helpers remain hand-authored and wider than the real contracts, the app becomes correct in production code but loose in its development harness. +## Verified Duplication Map (2026-04-12) -## Solution +| Type | Canonical location | Restated in | Notes | +|------|--------------------|-------------|-------| +| `WorkflowPhaseStatus` | `shared/api-types.ts` (`workflowPhaseStatusSchema`) | `server/db.ts:36` (literal union) | Critical — same union, independent declaration | +| `ReadinessBand` | `shared/api-types.ts` (`readinessBandSchema`) | `server/db.ts:37` (literal union) | Critical — same union, independent declaration | +| `ReviewStatus` | `shared/api-types.ts:100` (`reviewStatusSchema`, **not exported**) | `server/db.ts:529` (literal union, exported) | Shared owns the schema but doesn't export it | +| `Phase` / `PhaseStatus` | `shared/phase-close.ts` (`workflowPhaseSchema`) | `client/components/app-shell.tsx:118-119` (literal unions) | Client view-model restates shared enum | +| `ProjectMode` | `shared/api-types.ts` (`projectModeSchema`) | `server/db.ts:101` (`CreateProjectOptions`), `server/interview.ts:109` (`InterviewerModeOptions`) | Inline `'greenfield' \| 'brownfield'` literals | +| `ManifestTurn.phase` | `shared/phase-close.ts` | `server/fixtures/manifest.ts:32` (inline literal) | Fixture restates | +| `ManifestTurn.impact` | `shared/api-types.ts` (inline in schema, no named export) | `server/fixtures/manifest.ts:36` (inline literal) | No shared named export to import | +| `ManifestKnowledgeItem.kind` | `shared/knowledge.ts` (`KnowledgeKind`) | `server/fixtures/manifest.ts:45` (inline literal) | Shared exists, just not imported | +| `ManifestEdge.relation` | `server/schema.ts:184` (Drizzle enum) | `server/fixtures/manifest.ts:56` (inline literal) | No shared type; schema is only source | +| `InterviewerTools` | — | `server/interview.ts:119` (`& Record>`) | Intersection erases optional modifier | +| `CreateProjectInput` | `shared/api-types.ts` (`createProjectRequestSchema`) | `client/mutations/project-mutations.ts:7` (isomorphic local type with extra `cwd`) | Client type wider than actual contract | -Establish one explicit rule in the codebase: +### Types that are NOT duplicated (verified OK) -- shared transport and domain contracts own API-facing and cross-layer DTO shapes -- storage modules own persistence-row and storage-only input shapes -- UI and controller modules may own true local view models -- fixtures, manifests, scenario builders, and seed helpers must derive from the same canonical shared types rather than restating unions by hand +- `Phase` in `db.ts` — derived from `Turn['phase']` which derives from schema. Acceptable. +- `Impact` in `db.ts` — derived from `NonNullable`. Acceptable. +- `ClosureBasis` in `db.ts` — imports `PhaseClosureBasis` from shared, wraps with `| null`. Acceptable. +- `KnowledgeKind` in `shared/knowledge.ts` — single canonical source, widely imported. -This refactor stops at the type-boundary level. It is not a behavior change. The target state is: +## Solution — Parallel Streams -- server read models project directly into shared transport types -- shared workflow primitives own phase, status, readiness, impact, and review unions -- tests, fixtures, and seeders import or derive those same types -- no duplicated literal unions remain where a canonical type already exists -- no local DTO re-declarations remain where the shared contract is already authoritative +The refactor is structured as one prerequisite commit, then four independent parallel streams, then one cleanup commit. Streams A–D touch non-overlapping file sets and can execute concurrently after the prerequisite lands. -## Commits +``` + ┌─── Stream A: server/db.ts + │ +Prereq (shared/) ───┼─── Stream B: client/app-shell.tsx + stories + │ + ├─── Stream C: server/fixtures/manifest.ts + │ + └─── Stream D: server/interview.ts + client/project-mutations.ts -1. Add characterization coverage for the contract seams most exposed to this refactor. -2. Extract and stabilize the canonical shared primitive types. -3. Collapse duplicated server workflow DTO types onto the shared contracts. -4. Collapse duplicated entity and relationship DTO types onto the shared contracts. -5. Replace server-side assembled transport-shape aliases with shared turn and project transport types. -6. Align client and shared workflow consumers to the canonical primitives. -7. Narrow review-state consumers to the canonical review-status type. -8. Refactor fixture manifests and scenario builders to derive from shared contracts. -9. Refactor seed helpers to consume only the narrowed shared or derived types. -10. Remove residual duplicate type declarations and simplify tests to assert the shared seam directly. -11. Run the full verification gate and do a final drift sweep focused on fixtures and seeds. + │ + ▼ + Cleanup (final sweep) +``` -## Decisions +### Prereq: Stabilize shared exports -- The shared transport and domain layer becomes the authority for cross-layer DTOs. -- The persistence layer continues to own row types and storage-only input types. -- Local controller and view-model types remain allowed when they model a real local abstraction rather than a transport contract. -- The cleanup intentionally distinguishes projection types from persistence types; not every type moves into one module. -- Workflow primitives should be importable from one shared seam rather than recreated across UI, server, tests, and fixtures. -- Review status should become a first-class shared primitive rather than an ad hoc repeated union. -- Fixture manifests and seed helpers are in scope because they are contract producers and can drift if left wider than runtime code. -- The refactor should prefer derivation over re-declaration whenever a type can be indexed from an existing shared type. -- Runtime behavior, schema layout, and endpoint semantics should remain unchanged. +Export the primitives that consumers need but shared doesn't yet expose: -## Testing Decisions +1. Export `reviewStatusSchema` and its inferred type `ReviewStatus` from `shared/api-types.ts` +2. Extract the inline `z.enum(['high', 'medium', 'low'])` in `projectStateTurnSchema` into a named `impactSchema` constant and export its type `Impact` +3. Add and export `edgeRelationSchema` (the relation enum from `schema.ts:184`) in `shared/api-types.ts` or `shared/knowledge.ts` +4. Re-export `WorkflowPhase` from `shared/phase-close.ts` via `shared/api-types.ts` for discoverability (optional — consumers can import from either) + +**Files touched**: `shared/api-types.ts`, possibly `shared/knowledge.ts` +**Verification**: `npm run verify` — existing 274 tests still pass, new exports don't break anything + +### Stream A: Collapse server workflow primitives + +Replace literal unions in `db.ts` with imports from shared: + +1. `WorkflowPhaseStatus` (line 36) → import from `shared/api-types.ts` and re-export +2. `ReadinessBand` (line 37) → import from `shared/api-types.ts` and re-export +3. `ReviewStatus` (line 529) → import from `shared/api-types.ts` and re-export +4. `CreateProjectOptions.mode` → type as `ProjectMode` imported from shared + +Re-export all four so downstream server files that import from `db.ts` don't need to change. + +**Files touched**: `server/db.ts` +**Verification**: `npm run verify` — all 274 tests pass unchanged + +### Stream B: Collapse client primitives + +Replace literal unions in `app-shell.tsx` with imports from shared: + +1. `Phase` (line 118) → import `WorkflowPhase` from `shared/phase-close.ts`, re-export as `Phase` (or rename to match shared lexicon) +2. `PhaseStatus` (line 119) → import `WorkflowPhaseStatus` from `shared/api-types.ts`, re-export as `PhaseStatus` +3. Update `app-shell.stories.tsx` if the type names change (currently imports `Phase` and `PhaseStatus`) + +**Files touched**: `client/components/app-shell.tsx`, `client/components/app-shell.stories.tsx` +**Verification**: `npm run verify` — all tests pass, Ladle stories still build + +### Stream C: Narrow fixture manifests + +Replace inline literal unions in `manifest.ts` with shared types: -- Good tests here prove behavioral compatibility at module boundaries, not the existence of particular type aliases. -- The highest-value tests are shared schema parses for current transport payloads, server projection tests for workflow and entity payloads, export projection tests, and seeded scenario round-trips that prove fixtures still hydrate into the same runtime projections. -- The main app and runtime seams already have meaningful coverage, so this is not a blind refactor. -- The weakest coverage relative to this refactor is fixture-generation and manifest or seed narrowness; characterization there should be the first commit. -- If the refactor introduces any new shared primitive seam, tests should prove representative payloads still parse through that seam rather than asserting implementation details. +1. `ManifestTurn.phase` → use `WorkflowPhase` from shared +2. `ManifestTurn.impact` → use `Impact` from shared (after prereq exports it) +3. `ManifestKnowledgeItem.kind` → use `KnowledgeKind` from `shared/knowledge.ts` +4. `ManifestEdge.relation` → use the edge relation type from shared (after prereq exports it) +5. `ManifestKnowledgeItem.reviewAction` → evaluate whether `'reviewed' | 'rejected'` should derive from shared `ReviewStatus` (it's a different union — probably a legitimate local type) -## Findings from ln-review of slice 14a (2026-04-12) +**Files touched**: `server/fixtures/manifest.ts` +**Verification**: `npm run verify` — manifest.test.ts (3 tests) + all fixture-dependent tests pass + +### Stream D: Collapse 14a interview + mutation types + +Fix the type duplication introduced in slice 14a: + +1. `InterviewerModeOptions.mode` in `interview.ts` → type as `ProjectMode` imported from shared +2. `InterviewerTools` intersection type → use `{ [K in string]?: Tool }` instead of `Record>` to preserve optionality +3. `CreateProjectInput` in `project-mutations.ts` → derive from `Omit` or replace with `CreateProjectRequest` directly (client never sends cwd) +4. Remove the dead `cwd` field from `CreateProjectInput` + +**Files touched**: `server/interview.ts`, `client/mutations/project-mutations.ts` +**Verification**: `npm run verify` — interview.test.ts (14 tests) + ProjectList.test.tsx (4 tests) pass + +### Cleanup: Final sweep + +After all streams merge: + +1. Remove `createApp` string overload if all tests have been updated to pass objects (or leave as-is per YAGNI) +2. Search for any remaining inline literal unions that match a shared type +3. Run `npm run verify` one final time +4. Delete this `REFACTOR.md` file + +**Files touched**: varies (sweep) + +## Decisions + +- The shared transport and domain layer becomes the authority for cross-layer DTO shapes +- `db.ts` re-exports shared types (instead of removing its exports) so downstream server imports don't cascade +- `app-shell.tsx` can alias shared types to its local naming convention if the shared names are awkward for the component context +- Fixture manifests derive from shared types but may keep local interface wrappers for fields that don't correspond to shared contracts (e.g. `capturedAtTurn`, `isProposal`) +- The `createApp` string overload is low-priority — don't force-migrate test callers unless it's trivial +- Edge relation type gets promoted to shared because fixtures and schema both use it + +## Testing Decisions -These findings extend the problem statement above. Slice 14a introduced a new mode/cwd seam that exhibits the same duplication pattern the refactor targets. +- This is a type-only refactor with 274 passing tests as the safety net +- No new characterization tests needed — existing db.test.ts, app.test.ts, interview.test.ts, manifest.test.ts, api-types.test.ts, and ProjectList.test.tsx cover the affected seams +- Each stream runs `npm run verify` independently before merge +- The cleanup commit runs the full suite one final time -1. **`'greenfield' | 'brownfield'` literal union declared in 3 places** — The mode enum lives in `schema.ts` (Drizzle), `api-types.ts` (`projectModeSchema`/`ProjectMode`), and is restated as inline literals in `db.ts` (`CreateProjectOptions`) and `interview.ts` (`InterviewerModeOptions`). The shared `ProjectMode` type should be canonical. Fits commit 2 (extract/stabilize shared primitives) or commit 3 (collapse server DTOs). +## Agent Execution Notes -2. **`CreateProjectInput` in `project-mutations.ts` restates `CreateProjectRequest`** — The client-local type is isomorphic to the shared request type but includes a `cwd` field the client never sends. Should derive from or import `CreateProjectRequest` and drop `cwd`. Fits commit 5 (replace assembled transport aliases). +Each stream should be executed as a separate agent with `isolation: "worktree"`: -3. **`createApp` string-or-object overload** — `dbPathOrOptions?: string | AppOptions` exists for backward compatibility but both real callers now pass objects. Test callers use the string form. Simplify during commit 10 (remove residual duplicates). +- **Prereq** must land on the branch first (sequential) +- **Streams A–D** can run in parallel after prereq (four concurrent agents) +- **Cleanup** runs after all streams merge (sequential) -4. **`BaseInterviewerTools & Record>` widens optionality** — The intersection erases the optional `propose_phase_closure` modifier. A mapped type (`{ [K in string]?: Tool }`) would preserve intent. Not strictly a type-duplication issue, but it weakens the contract at the same seam. Fits commit 3 or is a standalone fix. +Each agent should: +1. Read this file for its stream's scope +2. Make the described changes +3. Run `npm run verify` +4. Commit with message `refactor: [stream description]` ## Out of Scope - Changing runtime behavior - Changing database schema or migration shape - Reworking the knowledge model itself -- Reworking UI architecture or controller or view-model design beyond import and derivation cleanup -- Eliminating legitimate local view models +- Reworking UI architecture beyond import and derivation cleanup - Converting persistence-row types into transport types - Broad naming cleanup unrelated to source-of-truth type duplication -- Brownfield exploration or other feature work -- Any non-type fixture rewrite beyond narrowing fixtures and seeds to canonical contracts From 701cce3dce05aaa0a7da758f1586a68aec0eb584 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 17:35:29 +0200 Subject: [PATCH 05/10] refactor: stabilize shared primitive exports for type deduplication Export reviewStatusSchema, impactSchema, edgeRelationSchema, and re-export WorkflowPhase from shared/api-types.ts. These named exports enable parallel consumer streams to collapse their local literal unions onto shared imports. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/shared/api-types.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/shared/api-types.ts b/src/shared/api-types.ts index daa25b9a..81268798 100644 --- a/src/shared/api-types.ts +++ b/src/shared/api-types.ts @@ -1,9 +1,13 @@ import * as z from 'zod/v4'; -import { phaseClosureBasisSchema, workflowPhaseSchema } from './phase-close.js'; +import { phaseClosureBasisSchema, workflowPhaseSchema, type WorkflowPhase } from './phase-close.js'; + +export type { WorkflowPhase }; export const workflowPhaseStatusSchema = z.enum(['unstarted', 'in_progress', 'closed']); export const readinessBandSchema = z.enum(['low', 'medium', 'high']); +export const impactSchema = z.enum(['high', 'medium', 'low']); +export const edgeRelationSchema = z.enum(['depends_on', 'derived_from', 'constrains', 'verifies', 'refines']); export const projectModeSchema = z.enum(['greenfield', 'brownfield']); @@ -58,7 +62,7 @@ export const projectStateTurnSchema = z.object({ phase: workflowPhaseSchema, question: z.string(), why: z.string().nullable(), - impact: z.enum(['high', 'medium', 'low']).nullable(), + impact: impactSchema.nullable(), answer: z.string().nullable(), is_resolution: z.boolean(), user_parts: z.string().nullable(), @@ -97,7 +101,7 @@ const knowledgeItemKindSchema = z.enum([ 'decision', 'assumption', ]); -const reviewStatusSchema = z.enum(['approved', 'rejected', 'pending']); +export const reviewStatusSchema = z.enum(['approved', 'rejected', 'pending']); export const knowledgeItemSchema = z.object({ id: z.number().int().positive(), @@ -204,6 +208,9 @@ export const submitTurnResponseResponseSchema = z.object({ }); export type ProjectMode = z.infer; +export type Impact = z.infer; +export type ReviewStatus = z.infer; +export type EdgeRelation = z.infer; export type WorkflowPhaseStatus = z.infer; export type ReadinessBand = z.infer; export type Project = z.infer; From 2ad5b7b5ae34d445050d5a6a3c352937d58c56c4 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 17:38:52 +0200 Subject: [PATCH 06/10] refactor: collapse client phase primitives onto shared imports Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/components/app-shell.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/client/components/app-shell.tsx b/src/client/components/app-shell.tsx index 189ae2a7..3588fad8 100644 --- a/src/client/components/app-shell.tsx +++ b/src/client/components/app-shell.tsx @@ -2,6 +2,8 @@ import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; +import type { WorkflowPhase, WorkflowPhaseStatus } from '../../shared/api-types.js'; + // ── Stage sidebar (expanded, 240px) ────────────────────────────────── export interface StageItem { @@ -115,8 +117,8 @@ export function StageSidebar({ // ── Phase sidebar (narrow, 48px) — collapsed view ──────────────────── -export type Phase = 'scope' | 'design' | 'requirements' | 'criteria'; -export type PhaseStatus = 'unstarted' | 'in_progress' | 'closed'; +export type Phase = WorkflowPhase; +export type PhaseStatus = WorkflowPhaseStatus; const phaseOrder: Phase[] = ['scope', 'design', 'requirements', 'criteria']; From 917214edaa22910d65d453086e3a9deb551b1b7a Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 17:39:44 +0200 Subject: [PATCH 07/10] refactor: narrow fixture manifest types to shared contracts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/fixtures/manifest.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/server/fixtures/manifest.ts b/src/server/fixtures/manifest.ts index 69676c61..791ea446 100644 --- a/src/server/fixtures/manifest.ts +++ b/src/server/fixtures/manifest.ts @@ -4,6 +4,9 @@ import { fileURLToPath } from 'node:url'; import { and, eq } from 'drizzle-orm'; +import type { EdgeRelation, Impact } from '../../shared/api-types.js'; +import type { KnowledgeKind } from '../../shared/knowledge.js'; +import type { WorkflowPhase } from '../../shared/phase-close.js'; import { advanceHead, confirmPhaseOutcome, @@ -29,11 +32,11 @@ export interface ManifestOption { } export interface ManifestTurn { - phase: 'scope' | 'design' | 'requirements' | 'criteria'; + phase: WorkflowPhase; question: string; answer: string; why?: string | null; - impact?: 'high' | 'medium' | 'low' | null; + impact?: Impact | null; options?: ManifestOption[]; selectedOptionPositions?: number[]; freeText?: string | null; @@ -42,7 +45,7 @@ export interface ManifestTurn { } export interface ManifestKnowledgeItem { - kind: 'goal' | 'term' | 'context' | 'constraint' | 'decision' | 'assumption' | 'requirement' | 'criterion'; + kind: KnowledgeKind; content: string; rationale?: string | null; capturedAtTurn: number; @@ -53,7 +56,7 @@ export interface ManifestKnowledgeItem { export interface ManifestEdge { fromItemIndex: number; toItemIndex: number; - relation: 'depends_on' | 'derived_from' | 'constrains' | 'verifies' | 'refines'; + relation: EdgeRelation; } export interface ManifestScenario { From 019af8e58ab371bbe4c83cad1713f7e0f2a8c0ee Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 17:41:37 +0200 Subject: [PATCH 08/10] refactor: collapse interview mode and mutation types onto shared contracts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/mutations/project-mutations.ts | 12 ++++-------- src/server/interview.ts | 3 ++- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/client/mutations/project-mutations.ts b/src/client/mutations/project-mutations.ts index a8056246..1e962d5a 100644 --- a/src/client/mutations/project-mutations.ts +++ b/src/client/mutations/project-mutations.ts @@ -1,14 +1,10 @@ import { useNavigate } from '@tanstack/react-router'; import { createProjectResponseSchema } from '../../shared/api-types.js'; -import type { CreateProjectRequest, CreateProjectResponse, ProjectMode } from '../../shared/api-types.js'; +import type { CreateProjectRequest, CreateProjectResponse } from '../../shared/api-types.js'; import { postJsonMutation, useClientMutation } from './client-mutation.js'; -export interface CreateProjectInput { - name: string; - mode?: ProjectMode; - cwd?: string; -} +type CreateProjectInput = Omit; export interface CreateProjectMutationState { readonly createProject: (input: CreateProjectInput) => Promise; @@ -29,8 +25,8 @@ export function useCreateProjectMutation(): CreateProjectMutationState { ); return { - createProject: async ({ name, mode, cwd }: CreateProjectInput) => { - const project = await mutation.run({ name, ...(mode ? { mode } : {}), ...(cwd ? { cwd } : {}) }); + createProject: async ({ name, mode }: CreateProjectInput) => { + const project = await mutation.run({ name, ...(mode ? { mode } : {}) }); void navigate({ to: '/project/$id', params: { id: String(project.id) } }); return project; }, diff --git a/src/server/interview.ts b/src/server/interview.ts index 4cca2f2d..795cdfb5 100644 --- a/src/server/interview.ts +++ b/src/server/interview.ts @@ -2,6 +2,7 @@ import { anthropic } from '@ai-sdk/anthropic'; import type { Tool } from '@ai-sdk/provider-utils'; import { ToolLoopAgent, stepCountIs, tool } from 'ai'; +import type { ProjectMode } from '../shared/api-types.js'; import { askQuestionToolOutputSchema, phaseClosureProposalSchema, @@ -106,7 +107,7 @@ When goals, terms, context, and constraints are sufficiently captured for now, u } export interface InterviewerModeOptions { - mode?: 'greenfield' | 'brownfield'; + mode?: ProjectMode; cwd?: string; } From f9082e1c5b8146052a5684d338ad20807f38d730 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 17:43:40 +0200 Subject: [PATCH 09/10] refactor: collapse server workflow primitives onto shared imports WorkflowPhaseStatus, ReadinessBand, ReviewStatus, and ProjectMode in db.ts now import from shared/api-types.ts and re-export for downstream compatibility. No behavior change. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/db.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/server/db.ts b/src/server/db.ts index a4472f74..b45c8cb9 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -9,6 +9,7 @@ import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; const __dirname = dirname(fileURLToPath(import.meta.url)); const MIGRATIONS_FOLDER = join(__dirname, '..', '..', 'drizzle'); +import type { ProjectMode, ReadinessBand, ReviewStatus, WorkflowPhaseStatus } from '../shared/api-types.js'; import { isAskQuestionUIPart, structuredQuestionSchema, type StructuredQuestion } from '../shared/chat.js'; import { genericKnowledgeKindRegistry, @@ -33,8 +34,7 @@ export type PhaseOutcome = InferSelectModel; export type Phase = Turn['phase']; export type Impact = NonNullable; export type PhaseOutcomeStatus = PhaseOutcome['status']; -export type WorkflowPhaseStatus = 'unstarted' | 'in_progress' | 'closed'; -export type ReadinessBand = 'low' | 'medium' | 'high'; +export type { WorkflowPhaseStatus, ReadinessBand, ReviewStatus }; export type ClosureBasis = PhaseClosureBasis | null; export interface WorkflowPhaseState { @@ -98,7 +98,7 @@ export function listProjects(db: DB): Project[] { } export interface CreateProjectOptions { - mode?: 'greenfield' | 'brownfield'; + mode?: ProjectMode; cwd?: string | null; } @@ -526,8 +526,6 @@ export interface EntityRelationship { target: EntityReference; } -export type ReviewStatus = 'approved' | 'rejected' | 'pending'; - export type RequirementEntity = KnowledgeItem & { reviewStatus?: ReviewStatus; }; From 4aa052c6da8c5b1b7b6cae7ed8d76eb350a3f095 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 17:44:13 +0200 Subject: [PATCH 10/10] =?UTF-8?q?chore:=20delete=20REFACTOR.md=20=E2=80=94?= =?UTF-8?q?=20type=20deduplication=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 4 parallel streams landed. Remaining client-side ReviewStatus literals in knowledge-card.tsx and EntitySidebar.tsx are legitimate local view-model types, not contract duplications. Co-Authored-By: Claude Opus 4.6 (1M context) --- memory/REFACTOR.md | 158 --------------------------------------------- 1 file changed, 158 deletions(-) delete mode 100644 memory/REFACTOR.md diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md deleted file mode 100644 index 3ea8a75e..00000000 --- a/memory/REFACTOR.md +++ /dev/null @@ -1,158 +0,0 @@ -## Problem Statement - -The codebase currently has more than one source of truth for several domain and transport types. Shared contracts already exist, but parallel local declarations still appear in server projection code, workflow primitives, client-facing status unions, and fixture/seeding definitions. This creates drift risk at the boundaries where consistency matters most: producer/consumer seams, seed data, export projections, and tests. - -The problem is not that local types exist. The problem is that some local types restate an importable or derivable contract. Storage-row types and true local view models are acceptable. Duplicated transport unions and DTO shapes are not. - -## Verified Duplication Map (2026-04-12) - -| Type | Canonical location | Restated in | Notes | -|------|--------------------|-------------|-------| -| `WorkflowPhaseStatus` | `shared/api-types.ts` (`workflowPhaseStatusSchema`) | `server/db.ts:36` (literal union) | Critical — same union, independent declaration | -| `ReadinessBand` | `shared/api-types.ts` (`readinessBandSchema`) | `server/db.ts:37` (literal union) | Critical — same union, independent declaration | -| `ReviewStatus` | `shared/api-types.ts:100` (`reviewStatusSchema`, **not exported**) | `server/db.ts:529` (literal union, exported) | Shared owns the schema but doesn't export it | -| `Phase` / `PhaseStatus` | `shared/phase-close.ts` (`workflowPhaseSchema`) | `client/components/app-shell.tsx:118-119` (literal unions) | Client view-model restates shared enum | -| `ProjectMode` | `shared/api-types.ts` (`projectModeSchema`) | `server/db.ts:101` (`CreateProjectOptions`), `server/interview.ts:109` (`InterviewerModeOptions`) | Inline `'greenfield' \| 'brownfield'` literals | -| `ManifestTurn.phase` | `shared/phase-close.ts` | `server/fixtures/manifest.ts:32` (inline literal) | Fixture restates | -| `ManifestTurn.impact` | `shared/api-types.ts` (inline in schema, no named export) | `server/fixtures/manifest.ts:36` (inline literal) | No shared named export to import | -| `ManifestKnowledgeItem.kind` | `shared/knowledge.ts` (`KnowledgeKind`) | `server/fixtures/manifest.ts:45` (inline literal) | Shared exists, just not imported | -| `ManifestEdge.relation` | `server/schema.ts:184` (Drizzle enum) | `server/fixtures/manifest.ts:56` (inline literal) | No shared type; schema is only source | -| `InterviewerTools` | — | `server/interview.ts:119` (`& Record>`) | Intersection erases optional modifier | -| `CreateProjectInput` | `shared/api-types.ts` (`createProjectRequestSchema`) | `client/mutations/project-mutations.ts:7` (isomorphic local type with extra `cwd`) | Client type wider than actual contract | - -### Types that are NOT duplicated (verified OK) - -- `Phase` in `db.ts` — derived from `Turn['phase']` which derives from schema. Acceptable. -- `Impact` in `db.ts` — derived from `NonNullable`. Acceptable. -- `ClosureBasis` in `db.ts` — imports `PhaseClosureBasis` from shared, wraps with `| null`. Acceptable. -- `KnowledgeKind` in `shared/knowledge.ts` — single canonical source, widely imported. - -## Solution — Parallel Streams - -The refactor is structured as one prerequisite commit, then four independent parallel streams, then one cleanup commit. Streams A–D touch non-overlapping file sets and can execute concurrently after the prerequisite lands. - -``` - ┌─── Stream A: server/db.ts - │ -Prereq (shared/) ───┼─── Stream B: client/app-shell.tsx + stories - │ - ├─── Stream C: server/fixtures/manifest.ts - │ - └─── Stream D: server/interview.ts + client/project-mutations.ts - - │ - ▼ - Cleanup (final sweep) -``` - -### Prereq: Stabilize shared exports - -Export the primitives that consumers need but shared doesn't yet expose: - -1. Export `reviewStatusSchema` and its inferred type `ReviewStatus` from `shared/api-types.ts` -2. Extract the inline `z.enum(['high', 'medium', 'low'])` in `projectStateTurnSchema` into a named `impactSchema` constant and export its type `Impact` -3. Add and export `edgeRelationSchema` (the relation enum from `schema.ts:184`) in `shared/api-types.ts` or `shared/knowledge.ts` -4. Re-export `WorkflowPhase` from `shared/phase-close.ts` via `shared/api-types.ts` for discoverability (optional — consumers can import from either) - -**Files touched**: `shared/api-types.ts`, possibly `shared/knowledge.ts` -**Verification**: `npm run verify` — existing 274 tests still pass, new exports don't break anything - -### Stream A: Collapse server workflow primitives - -Replace literal unions in `db.ts` with imports from shared: - -1. `WorkflowPhaseStatus` (line 36) → import from `shared/api-types.ts` and re-export -2. `ReadinessBand` (line 37) → import from `shared/api-types.ts` and re-export -3. `ReviewStatus` (line 529) → import from `shared/api-types.ts` and re-export -4. `CreateProjectOptions.mode` → type as `ProjectMode` imported from shared - -Re-export all four so downstream server files that import from `db.ts` don't need to change. - -**Files touched**: `server/db.ts` -**Verification**: `npm run verify` — all 274 tests pass unchanged - -### Stream B: Collapse client primitives - -Replace literal unions in `app-shell.tsx` with imports from shared: - -1. `Phase` (line 118) → import `WorkflowPhase` from `shared/phase-close.ts`, re-export as `Phase` (or rename to match shared lexicon) -2. `PhaseStatus` (line 119) → import `WorkflowPhaseStatus` from `shared/api-types.ts`, re-export as `PhaseStatus` -3. Update `app-shell.stories.tsx` if the type names change (currently imports `Phase` and `PhaseStatus`) - -**Files touched**: `client/components/app-shell.tsx`, `client/components/app-shell.stories.tsx` -**Verification**: `npm run verify` — all tests pass, Ladle stories still build - -### Stream C: Narrow fixture manifests - -Replace inline literal unions in `manifest.ts` with shared types: - -1. `ManifestTurn.phase` → use `WorkflowPhase` from shared -2. `ManifestTurn.impact` → use `Impact` from shared (after prereq exports it) -3. `ManifestKnowledgeItem.kind` → use `KnowledgeKind` from `shared/knowledge.ts` -4. `ManifestEdge.relation` → use the edge relation type from shared (after prereq exports it) -5. `ManifestKnowledgeItem.reviewAction` → evaluate whether `'reviewed' | 'rejected'` should derive from shared `ReviewStatus` (it's a different union — probably a legitimate local type) - -**Files touched**: `server/fixtures/manifest.ts` -**Verification**: `npm run verify` — manifest.test.ts (3 tests) + all fixture-dependent tests pass - -### Stream D: Collapse 14a interview + mutation types - -Fix the type duplication introduced in slice 14a: - -1. `InterviewerModeOptions.mode` in `interview.ts` → type as `ProjectMode` imported from shared -2. `InterviewerTools` intersection type → use `{ [K in string]?: Tool }` instead of `Record>` to preserve optionality -3. `CreateProjectInput` in `project-mutations.ts` → derive from `Omit` or replace with `CreateProjectRequest` directly (client never sends cwd) -4. Remove the dead `cwd` field from `CreateProjectInput` - -**Files touched**: `server/interview.ts`, `client/mutations/project-mutations.ts` -**Verification**: `npm run verify` — interview.test.ts (14 tests) + ProjectList.test.tsx (4 tests) pass - -### Cleanup: Final sweep - -After all streams merge: - -1. Remove `createApp` string overload if all tests have been updated to pass objects (or leave as-is per YAGNI) -2. Search for any remaining inline literal unions that match a shared type -3. Run `npm run verify` one final time -4. Delete this `REFACTOR.md` file - -**Files touched**: varies (sweep) - -## Decisions - -- The shared transport and domain layer becomes the authority for cross-layer DTO shapes -- `db.ts` re-exports shared types (instead of removing its exports) so downstream server imports don't cascade -- `app-shell.tsx` can alias shared types to its local naming convention if the shared names are awkward for the component context -- Fixture manifests derive from shared types but may keep local interface wrappers for fields that don't correspond to shared contracts (e.g. `capturedAtTurn`, `isProposal`) -- The `createApp` string overload is low-priority — don't force-migrate test callers unless it's trivial -- Edge relation type gets promoted to shared because fixtures and schema both use it - -## Testing Decisions - -- This is a type-only refactor with 274 passing tests as the safety net -- No new characterization tests needed — existing db.test.ts, app.test.ts, interview.test.ts, manifest.test.ts, api-types.test.ts, and ProjectList.test.tsx cover the affected seams -- Each stream runs `npm run verify` independently before merge -- The cleanup commit runs the full suite one final time - -## Agent Execution Notes - -Each stream should be executed as a separate agent with `isolation: "worktree"`: - -- **Prereq** must land on the branch first (sequential) -- **Streams A–D** can run in parallel after prereq (four concurrent agents) -- **Cleanup** runs after all streams merge (sequential) - -Each agent should: -1. Read this file for its stream's scope -2. Make the described changes -3. Run `npm run verify` -4. Commit with message `refactor: [stream description]` - -## Out of Scope - -- Changing runtime behavior -- Changing database schema or migration shape -- Reworking the knowledge model itself -- Reworking UI architecture beyond import and derivation cleanup -- Converting persistence-row types into transport types -- Broad naming cleanup unrelated to source-of-truth type duplication