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/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 24ddfe58..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,11 +225,8 @@
```
done ─────────────────────────────────────────────────────────────┐
Phase 1–6: all complete │
+ Phase 7: 14 done, 17 done, 17a done, 14a done │
──────────────────────────────────────────────────────────────────┘
- │
-Phase 7: 12b ──→ 14 (local-first storage + npx distribution)
- 14 ──→ 14a (greenfield/brownfield + exploration)
- 17 done
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 +235,7 @@ 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–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 49bd0b41..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
@@ -355,6 +356,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. |
@@ -557,22 +559,22 @@ 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 | 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 +582,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 | — |
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'];
diff --git a/src/client/mutations/project-mutations.ts b/src/client/mutations/project-mutations.ts
index 60538aa0..1e962d5a 100644
--- a/src/client/mutations/project-mutations.ts
+++ b/src/client/mutations/project-mutations.ts
@@ -4,8 +4,10 @@ import { createProjectResponseSchema } from '../../shared/api-types.js';
import type { CreateProjectRequest, CreateProjectResponse } from '../../shared/api-types.js';
import { postJsonMutation, useClientMutation } from './client-mutation.js';
+type CreateProjectInput = Omit;
+
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 +25,8 @@ export function useCreateProjectMutation(): CreateProjectMutationState {
);
return {
- createProject: async (name: string) => {
- const project = await mutation.run({ name });
+ 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/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
-
)}
+
+
);
}
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..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 {
@@ -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?: ProjectMode;
+ 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;
}
@@ -513,8 +526,6 @@ export interface EntityRelationship {
target: EntityReference;
}
-export type ReviewStatus = 'approved' | 'rejected' | 'pending';
-
export type RequirementEntity = KnowledgeItem & {
reviewStatus?: ReviewStatus;
};
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 {
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..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,
@@ -26,6 +27,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 +76,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?: ProjectMode;
+ 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 +190,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 +233,7 @@ export function createInterviewerAgent(
},
},
maxOutputTokens: 16000,
- stopWhen: stepCountIs(4),
+ stopWhen: stepCountIs(isBrownfield ? 12 : 4),
});
}
@@ -189,8 +243,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..81268798 100644
--- a/src/shared/api-types.ts
+++ b/src/shared/api-types.ts
@@ -1,13 +1,21 @@
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']);
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(),
@@ -54,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(),
@@ -65,6 +73,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;
@@ -91,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(),
@@ -197,6 +207,10 @@ export const submitTurnResponseResponseSchema = z.object({
ok: z.literal(true),
});
+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;