From 0952f016d807166e78203264f240b7f8d9bd6304 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:10:29 +0200 Subject: [PATCH 01/17] refactor: collapse remaining shared transport and review-status types --- src/client/components/EntitySidebar.tsx | 3 +- src/client/components/knowledge-card.tsx | 7 +- src/server/app.test.ts | 4 +- src/server/core.ts | 10 ++- src/server/db.ts | 82 +++++++----------------- src/server/export.test.ts | 5 +- src/server/export.ts | 5 +- src/shared/phase-close.ts | 4 +- 8 files changed, 43 insertions(+), 77 deletions(-) diff --git a/src/client/components/EntitySidebar.tsx b/src/client/components/EntitySidebar.tsx index f9b599d1..0bc84c44 100644 --- a/src/client/components/EntitySidebar.tsx +++ b/src/client/components/EntitySidebar.tsx @@ -4,6 +4,7 @@ import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import type { WorkspaceDurableEntityState } from '@/workspace/workspace-controller-core'; +import type { ReviewStatus } from '../../shared/api-types.js'; import { knowledgeKindRegistry, knowledgeKindRegistryByCollectionKey, @@ -20,7 +21,7 @@ function renderKnowledgeItems( content: string; subtype: string | null; rationale: string | null; - reviewStatus?: 'approved' | 'rejected' | 'pending'; + reviewStatus?: ReviewStatus; }>, emptyMessage: string, isLoading: boolean, diff --git a/src/client/components/knowledge-card.tsx b/src/client/components/knowledge-card.tsx index 9d06b822..abe74c57 100644 --- a/src/client/components/knowledge-card.tsx +++ b/src/client/components/knowledge-card.tsx @@ -3,6 +3,7 @@ import { ChevronDown, Link as LinkIcon } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { cn } from '@/lib/utils'; +import type { EdgeRelation, ReviewStatus } from '../../shared/api-types.js'; import type { KnowledgeKind } from '../../shared/knowledge.js'; import { knowledgeKindRegistry } from '../../shared/knowledge.js'; @@ -33,7 +34,7 @@ export function KindBadge({ kind }: { kind: KnowledgeKind }) { ); } -export function ReviewBadge({ state }: { state: 'approved' | 'rejected' | 'pending' }) { +export function ReviewBadge({ state }: { state: ReviewStatus }) { return ( >; -} +export type TurnWithOptions = ProjectStateTurn; export function loadActivePathWithOptions(db: DB, projectId: number): TurnWithOptions[] { const rawActivePath = getActivePath(db, projectId); @@ -63,7 +61,7 @@ export function finalizeTurn(db: DB, projectId: number, turnId: number): void { } /** Get project state: project + active path turns enriched with options. */ -export function getProjectState(db: DB, projectId: number) { +export function getProjectState(db: DB, projectId: number): ProjectState | null { const project = getProject(db, projectId); if (!project) return null; const turns = loadActivePathWithOptions(db, projectId); @@ -72,7 +70,7 @@ export function getProjectState(db: DB, projectId: number) { } /** List all projects with compact workflow summary. */ -export function listProjectStates(db: DB) { +export function listProjectStates(db: DB): ProjectListItem[] { return listProjects(db).map((project) => { const workflow = getCurrentWorkflowState(db, project.id); return { diff --git a/src/server/db.ts b/src/server/db.ts index b45c8cb9..06f5ed65 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -9,7 +9,21 @@ 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 type { + AssumptionEntity as SharedAssumption, + CriterionEntity as SharedCriterionEntity, + DecisionEntity as SharedDecision, + EntitiesData, + EntityReference as SharedEntityReference, + EntityRelationship as SharedEntityRelationship, + ProjectMode, + ReadinessBand, + RequirementEntity as SharedRequirementEntity, + ReviewStatus, + WorkflowPhaseState as SharedWorkflowPhaseState, + WorkflowPhaseStatus, + WorkflowState as SharedWorkflowState, +} from '../shared/api-types.js'; import { isAskQuestionUIPart, structuredQuestionSchema, type StructuredQuestion } from '../shared/chat.js'; import { genericKnowledgeKindRegistry, @@ -37,19 +51,8 @@ export type PhaseOutcomeStatus = PhaseOutcome['status']; export type { WorkflowPhaseStatus, ReadinessBand, ReviewStatus }; export type ClosureBasis = PhaseClosureBasis | null; -export interface WorkflowPhaseState { - status: WorkflowPhaseStatus; - closeability: boolean; - readiness: ReadinessBand; - closureBasis: ClosureBasis; - proposalPending: boolean; - turnId: number | null; - summary: string | null; -} - -export interface WorkflowState { - phases: Record; -} +export type WorkflowPhaseState = SharedWorkflowPhaseState; +export type WorkflowState = SharedWorkflowState; export interface CreatePhaseOutcomeInput { projectId: number; @@ -501,50 +504,13 @@ export type KnowledgeItem = InferSelectModel; export type KnowledgeKind = Extract; export type EntityCollection = KnowledgeEntityCollection; -export interface Decision { - id: number; - project_id: number; - content: string; - rationale: string | null; -} - -export interface Assumption { - id: number; - project_id: number; - content: string; -} - -export interface EntityReference { - collection: EntityCollection; - kind: KnowledgeKind; - id: number; -} - -export interface EntityRelationship { - type: 'depends_on'; - source: EntityReference; - target: EntityReference; -} - -export type RequirementEntity = KnowledgeItem & { - reviewStatus?: ReviewStatus; -}; - -export type CriterionEntity = KnowledgeItem & { - reviewStatus?: ReviewStatus; -}; - -export interface EntitiesForProject { - goals: KnowledgeItem[]; - terms: KnowledgeItem[]; - contexts: KnowledgeItem[]; - constraints: KnowledgeItem[]; - requirements: RequirementEntity[]; - criteria: CriterionEntity[]; - decisions: Decision[]; - assumptions: Assumption[]; - relationships: EntityRelationship[]; -} +export type Decision = SharedDecision; +export type Assumption = SharedAssumption; +export type EntityReference = SharedEntityReference; +export type EntityRelationship = SharedEntityRelationship; +export type RequirementEntity = SharedRequirementEntity; +export type CriterionEntity = SharedCriterionEntity; +export type EntitiesForProject = EntitiesData; function toDecision(item: KnowledgeItem): Decision { return { diff --git a/src/server/export.test.ts b/src/server/export.test.ts index 93c4763a..aed889ed 100644 --- a/src/server/export.test.ts +++ b/src/server/export.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest'; -import type { EntitiesData } from '../shared/api-types.js'; +import type { EntitiesData, ReadinessBand, WorkflowState } from '../shared/api-types.js'; import { getProjectState } from './core.js'; import { advanceHead, @@ -11,7 +11,6 @@ import { getEntitiesForProject, getEntitiesForProjectOnActivePath, linkKnowledgeItemToTurn, - type WorkflowState, } from './db.js'; import { buildReviewedExportProjection, renderExportMarkdown } from './export.js'; import { @@ -24,7 +23,7 @@ function createClosedPhase({ readiness = 'high', }: { basis?: string; - readiness?: 'low' | 'medium' | 'high'; + readiness?: ReadinessBand; } = {}) { return { status: 'closed' as const, diff --git a/src/server/export.ts b/src/server/export.ts index 43c5aba2..c6ee125b 100644 --- a/src/server/export.ts +++ b/src/server/export.ts @@ -1,8 +1,7 @@ import { bold, h1, h2, h3, ul } from 'md-pen'; -import type { EntitiesData } from '../shared/api-types.js'; +import type { EntitiesData, ReviewStatus, WorkflowState } from '../shared/api-types.js'; import { knowledgeKindRegistry } from '../shared/knowledge.js'; -import type { WorkflowState } from './db.js'; export interface ReviewedExportItem { content: string; @@ -28,7 +27,7 @@ function renderItem(item: ReviewedExportItem): string { } function getReviewedExportItems( - items: Array<{ content: string; rationale?: string | null; reviewStatus?: string }>, + items: Array<{ content: string; rationale?: string | null; reviewStatus?: ReviewStatus }>, ) { return items.filter((item) => !('reviewStatus' in item) || item.reviewStatus === 'approved'); } diff --git a/src/shared/phase-close.ts b/src/shared/phase-close.ts index cea68ec6..bdfa06e8 100644 --- a/src/shared/phase-close.ts +++ b/src/shared/phase-close.ts @@ -1,5 +1,7 @@ import * as z from 'zod/v4'; +import type { WorkflowPhaseStatus } from './api-types.js'; + export const workflowPhaseOrder = ['scope', 'design', 'requirements', 'criteria'] as const; export const workflowPhaseSchema = z.enum(workflowPhaseOrder); export const phaseClosureBasisSchema = z.enum(['interviewer_recommended', 'user_forced']); @@ -39,7 +41,7 @@ export type PhaseClosureCommand = export type DataConfirmation = z.infer; export type WorkflowPhaseActionState = { - status: 'unstarted' | 'in_progress' | 'closed'; + status: WorkflowPhaseStatus; closeability: boolean; proposalPending: boolean; }; From 580aec0bae333ec36da7744c339f62a63856fe46 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:15:29 +0200 Subject: [PATCH 02/17] chore: remove unused packages and add provider utils --- package-lock.json | 279 ++---------------- package.json | 6 +- .../capabilities/markdown-rendering.test.tsx | 2 - .../components/ai-elements/message.test.tsx | 2 - 4 files changed, 21 insertions(+), 268 deletions(-) diff --git a/package-lock.json b/package-lock.json index 78785513..f8a6ca04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,13 @@ "license": "(MIT OR Apache-2.0)", "dependencies": { "@ai-sdk/anthropic": "^3.0.66", + "@ai-sdk/provider-utils": "^4.0.21", "@ai-sdk/react": "^3.0.145", - "@anthropic-ai/sdk": "^0.82.0", "@fontsource-variable/inter": "^5.2.8", "@ladle/react": "^5.1.1", - "@modelcontextprotocol/sdk": "^1.27.1", "@radix-ui/react-use-controllable-state": "^1.2.2", "@streamdown/cjk": "^1.0.3", - "@streamdown/code": "^1.1.1", "@streamdown/math": "^1.0.2", - "@streamdown/mermaid": "^1.0.2", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.96.1", "@tanstack/react-router": "^1.168.10", @@ -57,7 +54,6 @@ "@types/react-dom": "^19.2.3", "@types/supertest": "^7.2.0", "agent-tail": "^0.4.0", - "concurrently": "^9.2.1", "drizzle-kit": "^0.31.10", "happy-dom": "^20.8.9", "oxfmt": "^0.43.0", @@ -181,26 +177,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.82.0.tgz", - "integrity": "sha512-xdHTjL1GlUlDugHq/I47qdOKp/ROPvuHl7ROJCgUQigbvPu7asf9KcAcU1EqdrP2LuVhEKaTs7Z+ShpZDRzHdQ==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1814,6 +1790,7 @@ "version": "1.19.11", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.14.1" @@ -3313,6 +3290,7 @@ "version": "1.27.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "dev": true, "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -3353,6 +3331,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3369,6 +3348,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, "license": "MIT" }, "node_modules/@mswjs/interceptors": { @@ -6417,95 +6397,6 @@ "react": "^18.0.0 || ^19.0.0" } }, - "node_modules/@streamdown/code": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@streamdown/code/-/code-1.1.1.tgz", - "integrity": "sha512-i7HTNuDgZWb+VdrNVOam9gQhIc5MSSDXKWXgbUrn/4vSRaSMM+Rtl10MQj4wLWPNpF7p80waJsAqFP8HZfb0Jg==", - "license": "Apache-2.0", - "dependencies": { - "shiki": "^3.19.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@streamdown/code/node_modules/@shikijs/core": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", - "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.5" - } - }, - "node_modules/@streamdown/code/node_modules/@shikijs/engine-javascript": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", - "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" - } - }, - "node_modules/@streamdown/code/node_modules/@shikijs/engine-oniguruma": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", - "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@streamdown/code/node_modules/@shikijs/langs": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", - "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" - } - }, - "node_modules/@streamdown/code/node_modules/@shikijs/themes": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", - "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" - } - }, - "node_modules/@streamdown/code/node_modules/@shikijs/types": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", - "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@streamdown/code/node_modules/shiki": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", - "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "3.23.0", - "@shikijs/engine-javascript": "3.23.0", - "@shikijs/engine-oniguruma": "3.23.0", - "@shikijs/langs": "3.23.0", - "@shikijs/themes": "3.23.0", - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, "node_modules/@streamdown/math": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@streamdown/math/-/math-1.0.2.tgz", @@ -6520,18 +6411,6 @@ "react": "^18.0.0 || ^19.0.0" } }, - "node_modules/@streamdown/mermaid": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@streamdown/mermaid/-/mermaid-1.0.2.tgz", - "integrity": "sha512-Fr/4sBWnAeSnxM3PcrV/+DiZe5oPMq9gOkUIAH7ZauJeuwrZ/DVzD4g0zlav6AH0axh2m/sOfrfLtY5aLT7niw==", - "license": "Apache-2.0", - "dependencies": { - "mermaid": "^11.12.2" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0" - } - }, "node_modules/@swc/core-darwin-arm64": { "version": "1.15.24", "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz", @@ -8134,6 +8013,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -8151,6 +8031,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -8167,6 +8048,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, "license": "MIT" }, "node_modules/ansi-align": { @@ -8776,23 +8658,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -9089,47 +8954,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -9212,6 +9036,7 @@ "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, "license": "MIT", "dependencies": { "object-assign": "^4", @@ -11143,6 +10968,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -11253,6 +11079,7 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "dev": true, "license": "MIT", "dependencies": { "ip-address": "10.1.0" @@ -11277,6 +11104,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -11306,6 +11134,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, "funding": [ { "type": "github", @@ -11871,16 +11700,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -12284,6 +12103,7 @@ "version": "4.12.9", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=16.9.0" @@ -12494,6 +12314,7 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -12812,6 +12633,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -12861,23 +12683,11 @@ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)" }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/json-schema-typed": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/json5": { @@ -15799,6 +15609,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=16.20.0" @@ -16743,6 +16554,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16920,16 +16732,6 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -17199,19 +17001,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/shiki": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", @@ -17695,19 +17484,6 @@ "node": ">=14.18.0" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/swr": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", @@ -17912,16 +17688,6 @@ "node": ">=16" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -17942,12 +17708,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, "node_modules/ts-dedent": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", @@ -18968,6 +18728,7 @@ "version": "3.25.1", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "dev": true, "license": "ISC", "peerDependencies": { "zod": "^3.25 || ^4" diff --git a/package.json b/package.json index 640ae5d2..d6d2ca83 100644 --- a/package.json +++ b/package.json @@ -34,16 +34,13 @@ }, "dependencies": { "@ai-sdk/anthropic": "^3.0.66", + "@ai-sdk/provider-utils": "^4.0.21", "@ai-sdk/react": "^3.0.145", - "@anthropic-ai/sdk": "^0.82.0", "@fontsource-variable/inter": "^5.2.8", "@ladle/react": "^5.1.1", - "@modelcontextprotocol/sdk": "^1.27.1", "@radix-ui/react-use-controllable-state": "^1.2.2", "@streamdown/cjk": "^1.0.3", - "@streamdown/code": "^1.1.1", "@streamdown/math": "^1.0.2", - "@streamdown/mermaid": "^1.0.2", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.96.1", "@tanstack/react-router": "^1.168.10", @@ -80,7 +77,6 @@ "@types/react-dom": "^19.2.3", "@types/supertest": "^7.2.0", "agent-tail": "^0.4.0", - "concurrently": "^9.2.1", "drizzle-kit": "^0.31.10", "happy-dom": "^20.8.9", "oxfmt": "^0.43.0", diff --git a/src/client/capabilities/markdown-rendering.test.tsx b/src/client/capabilities/markdown-rendering.test.tsx index a8aff17e..e34142c8 100644 --- a/src/client/capabilities/markdown-rendering.test.tsx +++ b/src/client/capabilities/markdown-rendering.test.tsx @@ -4,9 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; vi.mock('@streamdown/cjk', () => ({ cjk: {} })); -vi.mock('@streamdown/code', () => ({ code: {} })); vi.mock('@streamdown/math', () => ({ math: {} })); -vi.mock('@streamdown/mermaid', () => ({ mermaid: {} })); vi.mock('streamdown', () => ({ Streamdown: ({ children, ...props }: React.HTMLAttributes) => (
{children}
diff --git a/src/client/components/ai-elements/message.test.tsx b/src/client/components/ai-elements/message.test.tsx index bfed8e12..315a6476 100644 --- a/src/client/components/ai-elements/message.test.tsx +++ b/src/client/components/ai-elements/message.test.tsx @@ -4,9 +4,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; vi.mock('@streamdown/cjk', () => ({ cjk: {} })); -vi.mock('@streamdown/code', () => ({ code: {} })); vi.mock('@streamdown/math', () => ({ math: {} })); -vi.mock('@streamdown/mermaid', () => ({ mermaid: {} })); vi.mock('streamdown', () => ({ Streamdown: ({ children }: { children: React.ReactNode }) =>
{children}
, })); From 692a705c7742c87663bdb7e2deadfcfa253c5ff9 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:19:40 +0200 Subject: [PATCH 03/17] chore: clear verification warnings --- src/server/app.test.ts | 32 ++++++++++++++++---------------- src/server/launcher.test.ts | 2 +- src/server/project.test.ts | 2 +- vite.config.ts | 3 +++ 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/server/app.test.ts b/src/server/app.test.ts index a3c99b13..a27a3a31 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -234,7 +234,7 @@ describe('GET /api/projects', () => { it('returns workflow summary reflecting closed scope and in-progress design', async () => { const projectId = await createTestProject('Active project'); - await seedActiveDesign(projectId); + seedActiveDesign(projectId); const res = await request(app).get('/api/projects').expect(200); expect(res.body[0]).toMatchObject({ workflowSummary: { @@ -248,7 +248,7 @@ describe('GET /api/projects', () => { it('returns workflow summary with all phases closed for a completed project', async () => { const projectId = await createTestProject('Done project'); - await seedAllPhasesClosed(projectId); + seedAllPhasesClosed(projectId); const res = await request(app).get('/api/projects').expect(200); expect(res.body[0]).toMatchObject({ workflowSummary: { @@ -1246,7 +1246,7 @@ describe('phase outcomes + scope closure', () => { it('persists a missing requirement through the requirements-review response loop and keeps requirements not yet closeable', async () => { const projectId = await createTestProject(); - const seededRequirements = await seedRequirementsReady(projectId); + const seededRequirements = seedRequirementsReady(projectId); const { advanceHead, createKnowledgeItem, createOption, createTurn } = await import('./db.js'); createKnowledgeItem(db, projectId, 'requirement', 'Resume the interview from SQLite after restart'); @@ -1363,7 +1363,7 @@ describe('phase outcomes + scope closure', () => { it('emits a requirements phase-summary proposal once every requirement is explicitly reviewed', async () => { const projectId = await createTestProject(); - const seededRequirements = await seedRequirementsReady(projectId); + const seededRequirements = seedRequirementsReady(projectId); const { advanceHead, createKnowledgeItem, createTurn, linkKnowledgeItemToTurn } = await import('./db.js'); const approvedRequirement = createKnowledgeItem(db, projectId, 'requirement', 'Export the reviewed spec'); @@ -1437,7 +1437,7 @@ describe('phase outcomes + scope closure', () => { it('confirms a proposed requirements phase outcome, closes requirements, and uses criteria on the next turn', async () => { const projectId = await createTestProject(); - const seededRequirements = await seedRequirementsReady(projectId); + const seededRequirements = seedRequirementsReady(projectId); const { advanceHead, createKnowledgeItem, createPhaseOutcome, createTurn, linkKnowledgeItemToTurn } = await import('./db.js'); @@ -1562,7 +1562,7 @@ describe('phase outcomes + scope closure', () => { it('grounds the first criteria turn in approved requirements and round-trips a criterion through observer persistence', async () => { const projectId = await createTestProject(); - await seedCriteriaReady(projectId); + seedCriteriaReady(projectId); mockStreamInterviewer.mockImplementation(async () => makeTextInterviewer('What would prove the resume flow is complete?'), @@ -1635,7 +1635,7 @@ describe('phase outcomes + scope closure', () => { it('emits a criteria phase-summary proposal once every criterion is explicitly reviewed', async () => { const projectId = await createTestProject(); - const seededCriteria = await seedCriteriaReady(projectId); + const seededCriteria = seedCriteriaReady(projectId); const { advanceHead, createKnowledgeItem, createTurn, linkKnowledgeItemToTurn } = await import('./db.js'); const approvedCriterion = createKnowledgeItem( @@ -1708,7 +1708,7 @@ describe('phase outcomes + scope closure', () => { it('confirms a proposed criteria outcome, closes criteria, and projects all workflow phases as closed', async () => { const projectId = await createTestProject(); - const seededCriteria = await seedCriteriaReady(projectId); + const seededCriteria = seedCriteriaReady(projectId); const { advanceHead, createKnowledgeItem, createPhaseOutcome, createTurn, linkKnowledgeItemToTurn } = await import('./db.js'); @@ -1804,7 +1804,7 @@ describe('phase outcomes + scope closure', () => { it('projects no stale active interviewer phase after criteria closure confirmation', async () => { const projectId = await createTestProject(); - const seededCriteria = await seedCriteriaReady(projectId); + const seededCriteria = seedCriteriaReady(projectId); const { advanceHead, createKnowledgeItem, createPhaseOutcome, createTurn, linkKnowledgeItemToTurn } = await import('./db.js'); @@ -2029,7 +2029,7 @@ describe('phase outcomes + scope closure', () => { { name: 'inactive phases', seed: async (projectId: number) => { - await seedRequirementsReady(projectId); + seedRequirementsReady(projectId); }, phase: 'design', expectedError: 'Only the active phase can be force-closed', @@ -2037,7 +2037,7 @@ describe('phase outcomes + scope closure', () => { { name: 'design that is not closeable yet', seed: async (projectId: number) => { - await seedClosedScope(projectId); + seedClosedScope(projectId); }, phase: 'design', expectedError: 'Phase is not closeable yet', @@ -2046,7 +2046,7 @@ describe('phase outcomes + scope closure', () => { name: 'design with a pending proposal', seed: async (projectId: number) => { const { createPhaseOutcome } = await import('./db.js'); - const { designTurn } = await seedActiveDesign(projectId); + const { designTurn } = seedActiveDesign(projectId); createPhaseOutcome(db, { projectId, phase: 'design', @@ -2241,7 +2241,7 @@ describe('POST /api/projects/:id/turns/:turnId/response', () => { it('persists explicit approved review state for a targeted requirement through the response seam', async () => { const projectId = await createTestProject(); - const seededRequirements = await seedRequirementsReady(projectId); + const seededRequirements = seedRequirementsReady(projectId); const { advanceHead, createKnowledgeItem, createOption, createTurn, updateTurn } = await import('./db.js'); @@ -2336,7 +2336,7 @@ describe('POST /api/projects/:id/turns/:turnId/response', () => { it('persists explicit rejected review state for a targeted requirement through the response seam', async () => { const projectId = await createTestProject(); - const seededRequirements = await seedRequirementsReady(projectId); + const seededRequirements = seedRequirementsReady(projectId); const { advanceHead, createKnowledgeItem, createOption, createTurn, updateTurn } = await import('./db.js'); @@ -2436,7 +2436,7 @@ describe('POST /api/projects/:id/turns/:turnId/response', () => { it('persists explicit approved review state for a targeted criterion through the response seam', async () => { const projectId = await createTestProject(); - const seededCriteria = await seedCriteriaReady(projectId); + const seededCriteria = seedCriteriaReady(projectId); const { advanceHead, createKnowledgeItem, createOption, createTurn, updateTurn } = await import('./db.js'); @@ -2536,7 +2536,7 @@ describe('POST /api/projects/:id/turns/:turnId/response', () => { it('persists explicit rejected review state for a targeted criterion through the response seam', async () => { const projectId = await createTestProject(); - const seededCriteria = await seedCriteriaReady(projectId); + const seededCriteria = seedCriteriaReady(projectId); const { advanceHead, createKnowledgeItem, createOption, createTurn, updateTurn } = await import('./db.js'); diff --git a/src/server/launcher.test.ts b/src/server/launcher.test.ts index 89528919..ba5b0edd 100644 --- a/src/server/launcher.test.ts +++ b/src/server/launcher.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; diff --git a/src/server/project.test.ts b/src/server/project.test.ts index 50a188e1..4e37bf78 100644 --- a/src/server/project.test.ts +++ b/src/server/project.test.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; -import { homedir, tmpdir } from 'node:os'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; diff --git a/vite.config.ts b/vite.config.ts index 5218fd3e..fdac63d1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,6 +20,9 @@ export default defineConfig({ '/api': 'http://localhost:3000', }, }, + build: { + chunkSizeWarningLimit: 800, + }, test: { include: ['src/**/*.test.{js,ts,jsx,tsx}'], }, From 8ec0d60a7a95c8ea87393b7cc5720c46726b612f Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:32:48 +0200 Subject: [PATCH 04/17] Add characterization coverage for refactor seams --- src/client/routes/ProjectList.test.tsx | 4 ++-- src/server/interview.test.ts | 14 +++++++++++++ src/shared/api-types.test.ts | 27 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/client/routes/ProjectList.test.tsx b/src/client/routes/ProjectList.test.tsx index 6c8171ef..02c6bc29 100644 --- a/src/client/routes/ProjectList.test.tsx +++ b/src/client/routes/ProjectList.test.tsx @@ -186,8 +186,8 @@ describe('ProjectList', () => { 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'); + expect(body).toEqual({ name: 'New project', mode: 'brownfield' }); + expect(body.cwd).toBeUndefined(); }); }); diff --git a/src/server/interview.test.ts b/src/server/interview.test.ts index 477d7c24..503b8a2e 100644 --- a/src/server/interview.test.ts +++ b/src/server/interview.test.ts @@ -164,6 +164,20 @@ describe('brownfield interviewer configuration', () => { expect(toolNames).toContain('ask_question'); }); + it('currently exposes repo-mutating tools in brownfield mode before tool-surface tightening', () => { + 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('write_file'); + expect(toolNames).toContain('edit_file'); + expect(toolNames).toContain('bash'); + }); + it('excludes core tools when mode is greenfield', () => { const project = createProject(db, 'GF'); const turn = createTurn(db, project.id, { phase: 'scope', question: '', answer: '' }); diff --git a/src/shared/api-types.test.ts b/src/shared/api-types.test.ts index 37d4fa97..1b8e5131 100644 --- a/src/shared/api-types.test.ts +++ b/src/shared/api-types.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest'; import { + criterionEntitySchema, entitiesDataSchema, exportLoaderDataSchema, mutationErrorResponseSchema, projectListItemSchema, projectStateSchema, + requirementEntitySchema, submitTurnResponseRequestSchema, submitTurnResponseResponseSchema, } from './api-types.js'; @@ -200,12 +202,37 @@ describe('api transport contracts', () => { ready: true, markdown: '# Reviewed Spec', }); + expect(exportLoaderDataSchema.parse({ ready: true })).toEqual({ ready: true }); expect(mutationErrorResponseSchema.parse({ error: 'Failed to save response' })).toEqual({ error: 'Failed to save response', }); expect(submitTurnResponseResponseSchema.parse({ ok: true })).toEqual({ ok: true }); }); + it('currently accepts mismatched requirement and criterion kinds before seam hardening', () => { + expect( + requirementEntitySchema.parse({ + id: 2, + project_id: 1, + kind: 'goal', + subtype: null, + content: 'This should not be a requirement', + rationale: null, + }), + ).toMatchObject({ kind: 'goal' }); + + expect( + criterionEntitySchema.parse({ + id: 3, + project_id: 1, + kind: 'constraint', + subtype: null, + content: 'This should not be a criterion', + rationale: null, + }), + ).toMatchObject({ kind: 'constraint' }); + }); + it('models turn responses through explicit request modes', () => { expect( submitTurnResponseRequestSchema.parse({ From a17bcf03a62f9b89b842b47fa878efc109427674 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:34:03 +0200 Subject: [PATCH 05/17] Parse refreshed entities through the shared contract --- src/client/routes/InterviewWorkspace.test.tsx | 75 +++++++++++++++++++ src/client/workspace/workspace-data.ts | 4 +- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/client/routes/InterviewWorkspace.test.tsx b/src/client/routes/InterviewWorkspace.test.tsx index 4eb37691..90bb55c0 100644 --- a/src/client/routes/InterviewWorkspace.test.tsx +++ b/src/client/routes/InterviewWorkspace.test.tsx @@ -669,6 +669,81 @@ describe('InterviewWorkspace', () => { expect(await screen.findByText('The project starts from a fuzzy brief')).toBeTruthy(); }); + it('ignores invalid entity refresh payloads and keeps the loader snapshot visible', async () => { + currentLoaderData = createWorkspaceLoaderData({ + entitySnapshot: { + goals: [], + terms: [], + contexts: [], + constraints: [], + requirements: [], + criteria: [], + decisions: [ + { + id: 9, + project_id: 1, + content: 'Loader decision', + rationale: 'Still authoritative when refresh parsing fails', + }, + ], + assumptions: [], + relationships: [], + }, + }); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + goals: [], + terms: [], + contexts: [], + constraints: [], + requirements: [], + criteria: [], + decisions: [ + { + content: 'Broken decision', + rationale: null, + }, + ], + assumptions: [], + relationships: [], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); + + renderWorkspace(); + expect(await screen.findByText('Loader decision')).toBeTruthy(); + + await act(async () => { + useChatHarness.onData?.({ + type: 'data-observer-result', + data: { + entityIds: { + goals: [], + terms: [], + contexts: [], + constraints: [], + requirements: [], + criteria: [], + decisions: [99], + assumptions: [], + }, + }, + }); + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + expect(screen.getByText('Loader decision')).toBeTruthy(); + expect(screen.queryByText('Broken decision')).toBeNull(); + }); + it('refetches sidebar entities when the chat stream emits mixed observer-created design entities', async () => { currentLoaderData = createWorkspaceLoaderData({ entitySnapshot: { diff --git a/src/client/workspace/workspace-data.ts b/src/client/workspace/workspace-data.ts index 435ffb70..1ef866bf 100644 --- a/src/client/workspace/workspace-data.ts +++ b/src/client/workspace/workspace-data.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; -import type { EntitiesData } from '../../shared/api-types.js'; +import { entitiesDataSchema, type EntitiesData } from '../../shared/api-types.js'; import { createWorkspaceDurableEntityState, createWorkspaceDurableProjectState, @@ -32,7 +32,7 @@ async function fetchWorkspaceEntities(projectId: number): Promise throw new Error('Failed to fetch entities'); } - return response.json(); + return entitiesDataSchema.parse(await response.json()); } function getActiveWorkspaceEntityRefreshState( From 2876d8b86b10080f70d73e66480ae9703d3ac2eb Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:34:52 +0200 Subject: [PATCH 06/17] Tighten requirement and criterion entity contracts --- src/server/db.ts | 2 ++ src/shared/api-types.test.ts | 10 +++++----- src/shared/api-types.ts | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/server/db.ts b/src/server/db.ts index 06f5ed65..17d8195d 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -697,6 +697,7 @@ function getRequirementEntitiesForProject(db: DB, projectId: number): Requiremen const reviewStatuses = getReviewStatusesOnActivePath(db, projectId, 'requirement'); return getKnowledgeItemsForProjectByKind(db, projectId, 'requirement').map((item) => ({ ...item, + kind: 'requirement', reviewStatus: reviewStatuses.get(item.id) ?? 'pending', })); } @@ -705,6 +706,7 @@ function getCriterionEntitiesForProject(db: DB, projectId: number): CriterionEnt const reviewStatuses = getReviewStatusesOnActivePath(db, projectId, 'criterion'); return getKnowledgeItemsForProjectByKind(db, projectId, 'criterion').map((item) => ({ ...item, + kind: 'criterion', reviewStatus: reviewStatuses.get(item.id) ?? 'pending', })); } diff --git a/src/shared/api-types.test.ts b/src/shared/api-types.test.ts index 1b8e5131..3e052c4c 100644 --- a/src/shared/api-types.test.ts +++ b/src/shared/api-types.test.ts @@ -209,8 +209,8 @@ describe('api transport contracts', () => { expect(submitTurnResponseResponseSchema.parse({ ok: true })).toEqual({ ok: true }); }); - it('currently accepts mismatched requirement and criterion kinds before seam hardening', () => { - expect( + it('rejects mismatched requirement and criterion kinds', () => { + expect(() => requirementEntitySchema.parse({ id: 2, project_id: 1, @@ -219,9 +219,9 @@ describe('api transport contracts', () => { content: 'This should not be a requirement', rationale: null, }), - ).toMatchObject({ kind: 'goal' }); + ).toThrow(); - expect( + expect(() => criterionEntitySchema.parse({ id: 3, project_id: 1, @@ -230,7 +230,7 @@ describe('api transport contracts', () => { content: 'This should not be a criterion', rationale: null, }), - ).toMatchObject({ kind: 'constraint' }); + ).toThrow(); }); it('models turn responses through explicit request modes', () => { diff --git a/src/shared/api-types.ts b/src/shared/api-types.ts index 81268798..0a5d759d 100644 --- a/src/shared/api-types.ts +++ b/src/shared/api-types.ts @@ -115,7 +115,7 @@ export const knowledgeItemSchema = z.object({ export const requirementEntitySchema = z.object({ id: z.number().int().positive(), project_id: z.number().int().positive(), - kind: knowledgeItemKindSchema, + kind: z.literal('requirement'), subtype: z.string().nullable(), content: z.string(), rationale: z.string().nullable(), @@ -125,7 +125,7 @@ export const requirementEntitySchema = z.object({ export const criterionEntitySchema = z.object({ id: z.number().int().positive(), project_id: z.number().int().positive(), - kind: knowledgeItemKindSchema, + kind: z.literal('criterion'), subtype: z.string().nullable(), content: z.string(), rationale: z.string().nullable(), From f75d178e2a6b739b48d88aac590d4a0aea39c8a1 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:35:40 +0200 Subject: [PATCH 07/17] Make export readiness a discriminated contract --- src/client/routes/ExportPreview.tsx | 2 +- src/client/routes/export-loader.test.ts | 12 ++++++++++++ src/shared/api-types.test.ts | 2 +- src/shared/api-types.ts | 13 +++++++++---- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/client/routes/ExportPreview.tsx b/src/client/routes/ExportPreview.tsx index c45acac9..5af61923 100644 --- a/src/client/routes/ExportPreview.tsx +++ b/src/client/routes/ExportPreview.tsx @@ -7,7 +7,7 @@ export function ExportPreview() { const data = useLoaderData({ from: '/project/$id/export' }); const handleDownload = () => { - if (!data?.markdown) return; + if (!data?.ready) return; const blob = new Blob([data.markdown], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); diff --git a/src/client/routes/export-loader.test.ts b/src/client/routes/export-loader.test.ts index 1f38405b..03439bdb 100644 --- a/src/client/routes/export-loader.test.ts +++ b/src/client/routes/export-loader.test.ts @@ -54,4 +54,16 @@ describe('export route loader', () => { await expect(fetchExportPreviewLoaderData(7)).rejects.toThrow(); expect(fetchMock).toHaveBeenCalledWith('/api/projects/7/export'); }); + + it('rejects when a ready export payload omits markdown', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ ready: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + await expect(fetchExportPreviewLoaderData(7)).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledWith('/api/projects/7/export'); + }); }); diff --git a/src/shared/api-types.test.ts b/src/shared/api-types.test.ts index 3e052c4c..248d48c9 100644 --- a/src/shared/api-types.test.ts +++ b/src/shared/api-types.test.ts @@ -202,7 +202,7 @@ describe('api transport contracts', () => { ready: true, markdown: '# Reviewed Spec', }); - expect(exportLoaderDataSchema.parse({ ready: true })).toEqual({ ready: true }); + expect(() => exportLoaderDataSchema.parse({ ready: true })).toThrow(); expect(mutationErrorResponseSchema.parse({ error: 'Failed to save response' })).toEqual({ error: 'Failed to save response', }); diff --git a/src/shared/api-types.ts b/src/shared/api-types.ts index 0a5d759d..d92c42f7 100644 --- a/src/shared/api-types.ts +++ b/src/shared/api-types.ts @@ -178,10 +178,15 @@ export const entitiesDataSchema = z.object({ relationships: z.array(entityRelationshipSchema), }); -export const exportLoaderDataSchema = z.object({ - ready: z.boolean(), - markdown: z.string().optional(), -}); +export const exportLoaderDataSchema = z.discriminatedUnion('ready', [ + z.object({ + ready: z.literal(false), + }), + z.object({ + ready: z.literal(true), + markdown: z.string(), + }), +]); export const mutationErrorResponseSchema = z.object({ error: z.string().optional(), From 674c5421f2697122eb80f66b44bf96f8f7f90bb4 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:36:20 +0200 Subject: [PATCH 08/17] Use stable semantic keys for knowledge relationships --- src/client/components/knowledge-card.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/client/components/knowledge-card.tsx b/src/client/components/knowledge-card.tsx index abe74c57..0f138e28 100644 --- a/src/client/components/knowledge-card.tsx +++ b/src/client/components/knowledge-card.tsx @@ -77,6 +77,10 @@ export interface KnowledgeEdgeData { targetLabel?: string; } +function getKnowledgeEdgeKey(edge: KnowledgeEdgeData): string { + return [edge.type, edge.sourceCollection, edge.sourceId, edge.targetCollection, edge.targetId].join(':'); +} + export function KnowledgeRow({ item, indent = false, @@ -175,12 +179,15 @@ export function KnowledgeGroupCard({
- {edges.map((edge, i) => { + {edges.map((edge) => { const edgeLabel = edge.type.replace(/_/g, ' '); const displayLabel = edgeLabel.charAt(0).toUpperCase() + edgeLabel.slice(1); const targetText = edge.targetLabel ?? `item #${edge.targetId}`; return ( -
+
{displayLabel} {targetText}
@@ -242,12 +249,15 @@ export function KnowledgeDetailCard({ {edges && edges.length > 0 && (

Connections

- {edges.map((edge, i) => { + {edges.map((edge) => { const edgeLabel = edge.type.replace(/_/g, ' '); const displayLabel = edgeLabel.charAt(0).toUpperCase() + edgeLabel.slice(1); const targetText = edge.targetLabel ?? `item #${edge.targetId}`; return ( -
+
{displayLabel} {targetText}
From 70eb34715c074e61469dafb56b67235a4ef5c533 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:37:24 +0200 Subject: [PATCH 09/17] Remove client cwd from create-project transport --- src/client/mutations/project-mutations.ts | 2 +- src/server/app.test.ts | 7 +++++++ src/server/app.ts | 12 +++++++----- src/shared/api-types.test.ts | 11 +++++++++++ src/shared/api-types.ts | 11 ++++++----- 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/client/mutations/project-mutations.ts b/src/client/mutations/project-mutations.ts index 1e962d5a..091556e9 100644 --- a/src/client/mutations/project-mutations.ts +++ b/src/client/mutations/project-mutations.ts @@ -4,7 +4,7 @@ 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; +type CreateProjectInput = CreateProjectRequest; export interface CreateProjectMutationState { readonly createProject: (input: CreateProjectInput) => Promise; diff --git a/src/server/app.test.ts b/src/server/app.test.ts index a27a3a31..119db18c 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -297,6 +297,13 @@ describe('POST /api/projects', () => { expect(res.body.cwd).toBe(process.cwd()); }); + it('rejects client-supplied cwd data on project creation', async () => { + await request(app) + .post('/api/projects') + .send({ name: 'Brownfield', mode: 'brownfield', cwd: '/tmp/repo' }) + .expect(400); + }); + it('persists mode in project state', async () => { const createRes = await request(app) .post('/api/projects') diff --git a/src/server/app.ts b/src/server/app.ts index 481ade39..854e6eb1 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -2,7 +2,7 @@ import { createUIMessageStream, pipeUIMessageStreamToResponse, validateUIMessage import express from 'express'; import type { Express, Request, Response } from 'express'; -import { submitTurnResponseRequestSchema } from '../shared/api-types.js'; +import { createProjectRequestSchema, submitTurnResponseRequestSchema } from '../shared/api-types.js'; import type { EntitiesData, ExportLoaderData, @@ -78,12 +78,14 @@ export function createApp(dbPathOrOptions?: string | AppOptions): AppServices { // Create a new project app.post('/api/projects', (req: Request, res: Response) => { - const name = typeof req.body.name === 'string' ? req.body.name.trim() : ''; - if (!name) { - res.status(400).json({ error: 'name is required' } satisfies MutationErrorResponse); + const parsedRequest = createProjectRequestSchema.safeParse(req.body); + if (!parsedRequest.success) { + res.status(400).json({ error: 'Invalid project payload' } satisfies MutationErrorResponse); return; } - const mode = req.body.mode === 'brownfield' ? ('brownfield' as const) : undefined; + + const { name } = parsedRequest.data; + const mode = parsedRequest.data.mode === 'brownfield' ? ('brownfield' as const) : undefined; const cwd = mode === 'brownfield' ? projectCwd : undefined; const project = createNewProject(db, name, { mode, cwd }); res.status(201).json(project); diff --git a/src/shared/api-types.test.ts b/src/shared/api-types.test.ts index 248d48c9..f5b309ac 100644 --- a/src/shared/api-types.test.ts +++ b/src/shared/api-types.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + createProjectRequestSchema, criterionEntitySchema, entitiesDataSchema, exportLoaderDataSchema, @@ -233,6 +234,16 @@ describe('api transport contracts', () => { ).toThrow(); }); + it('keeps create-project transport limited to client-authorable fields', () => { + expect(createProjectRequestSchema.parse({ name: 'Brunch', mode: 'brownfield' })).toEqual({ + name: 'Brunch', + mode: 'brownfield', + }); + expect(() => + createProjectRequestSchema.parse({ name: 'Brunch', mode: 'brownfield', cwd: '/tmp/repo' }), + ).toThrow(); + }); + it('models turn responses through explicit request modes', () => { expect( submitTurnResponseRequestSchema.parse({ diff --git a/src/shared/api-types.ts b/src/shared/api-types.ts index d92c42f7..5c0a4605 100644 --- a/src/shared/api-types.ts +++ b/src/shared/api-types.ts @@ -71,11 +71,12 @@ export const projectStateTurnSchema = z.object({ options: z.array(turnOptionSchema).optional(), }); -export const createProjectRequestSchema = z.object({ - name: z.string().trim().min(1), - mode: projectModeSchema.optional(), - cwd: z.string().optional(), -}); +export const createProjectRequestSchema = z + .object({ + name: z.string().trim().min(1), + mode: projectModeSchema.optional(), + }) + .strict(); export const createProjectResponseSchema = projectSchema; From 8f38e61a642d525873f9d04210f49f65769c00e5 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:37:59 +0200 Subject: [PATCH 10/17] Limit brownfield exploration prompts to scope --- src/server/interview.test.ts | 13 +++++++++++++ src/server/interview.ts | 19 ++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/server/interview.test.ts b/src/server/interview.test.ts index 503b8a2e..fb161cb9 100644 --- a/src/server/interview.test.ts +++ b/src/server/interview.test.ts @@ -5,6 +5,7 @@ import { createDb, createProject, createTurn, getOptionsForTurn, getTurn, type D import { canProposePhaseClosure, getBrownfieldScopePrompt, + getInterviewerInstructions, getInterviewerTools, getSystemPrompt, persistFallbackQuestionText, @@ -195,6 +196,18 @@ describe('brownfield interviewer configuration', () => { expect(brownfieldPrompt).toContain('explore'); expect(brownfieldPrompt).toContain('/tmp/repo'); }); + + it('limits brownfield exploration instructions to the scope phase', () => { + expect(getInterviewerInstructions('scope', { mode: 'brownfield', cwd: '/tmp/repo' })).toContain( + 'explore', + ); + expect(getInterviewerInstructions('design', { mode: 'brownfield', cwd: '/tmp/repo' })).toBe( + getSystemPrompt('design'), + ); + expect(getInterviewerInstructions('requirements', { mode: 'brownfield', cwd: '/tmp/repo' })).toBe( + getSystemPrompt('requirements'), + ); + }); }); describe('persistFallbackQuestionText', () => { diff --git a/src/server/interview.ts b/src/server/interview.ts index 795cdfb5..d3e44abc 100644 --- a/src/server/interview.ts +++ b/src/server/interview.ts @@ -111,6 +111,19 @@ export interface InterviewerModeOptions { cwd?: string; } +function isBrownfieldScopeExploration( + phase: Phase, + options?: InterviewerModeOptions, +): options is InterviewerModeOptions & { mode: 'brownfield'; cwd: string } { + return phase === 'scope' && options?.mode === 'brownfield' && Boolean(options.cwd); +} + +export function getInterviewerInstructions(phase: Phase, options?: InterviewerModeOptions): string { + return isBrownfieldScopeExploration(phase, options) + ? getBrownfieldScopePrompt(options.cwd) + : getSystemPrompt(phase); +} + export type AskQuestionTool = Tool; export type ProposePhaseClosureTool = Tool; export type BaseInterviewerTools = { @@ -216,8 +229,8 @@ export function createInterviewerAgent( options?: InterviewerModeOptions, ): InterviewerAgent { const tools = getInterviewerTools(db, turnId, phase, projectId, options); - const isBrownfield = options?.mode === 'brownfield' && options.cwd; - const instructions = isBrownfield ? getBrownfieldScopePrompt(options.cwd!) : getSystemPrompt(phase); + const usesBrownfieldScopeExploration = isBrownfieldScopeExploration(phase, options); + const instructions = getInterviewerInstructions(phase, options); return new ToolLoopAgent({ model: anthropic(process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514'), @@ -233,7 +246,7 @@ export function createInterviewerAgent( }, }, maxOutputTokens: 16000, - stopWhen: stepCountIs(isBrownfield ? 12 : 4), + stopWhen: stepCountIs(usesBrownfieldScopeExploration ? 12 : 4), }); } From dbb236d07d5b6c7106dbb647adc53aedb53a67d2 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:38:55 +0200 Subject: [PATCH 11/17] Split brownfield exploration tools from core tools --- src/server/interview.test.ts | 26 +++++++++++++++++++++----- src/server/interview.ts | 4 ++-- src/server/tools/index.ts | 10 ++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/server/interview.test.ts b/src/server/interview.test.ts index fb161cb9..ff477c13 100644 --- a/src/server/interview.test.ts +++ b/src/server/interview.test.ts @@ -150,7 +150,7 @@ describe('createProposePhaseClosureTool', () => { }); describe('brownfield interviewer configuration', () => { - it('includes core tools when mode is brownfield', () => { + it('adds read-only exploration tools during brownfield scope', () => { 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, { @@ -165,7 +165,7 @@ describe('brownfield interviewer configuration', () => { expect(toolNames).toContain('ask_question'); }); - it('currently exposes repo-mutating tools in brownfield mode before tool-surface tightening', () => { + it('keeps brownfield exploration tools read-only', () => { 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, { @@ -174,9 +174,25 @@ describe('brownfield interviewer configuration', () => { }); const toolNames = Object.keys(tools); - expect(toolNames).toContain('write_file'); - expect(toolNames).toContain('edit_file'); - expect(toolNames).toContain('bash'); + expect(toolNames).not.toContain('write_file'); + expect(toolNames).not.toContain('edit_file'); + expect(toolNames).not.toContain('bash'); + }); + + it('removes brownfield exploration tools after scope', () => { + const project = createProject(db, 'BF', { mode: 'brownfield', cwd: '/tmp/repo' }); + const turn = createTurn(db, project.id, { phase: 'design', question: '', answer: '' }); + const tools = getInterviewerTools(db, turn.id, 'design', project.id, { + mode: 'brownfield', + cwd: '/tmp/repo', + }); + const toolNames = Object.keys(tools); + + expect(toolNames).not.toContain('read_file'); + expect(toolNames).not.toContain('grep'); + expect(toolNames).not.toContain('find_files'); + expect(toolNames).not.toContain('list_directory'); + expect(toolNames).toContain('ask_question'); }); it('excludes core tools when mode is greenfield', () => { diff --git a/src/server/interview.ts b/src/server/interview.ts index d3e44abc..0220bd2f 100644 --- a/src/server/interview.ts +++ b/src/server/interview.ts @@ -27,7 +27,7 @@ import { type Impact, type Phase, } from './db.js'; -import { createCoreTools } from './tools/index.js'; +import { createExplorationTools } from './tools/index.js'; const SYSTEM_PROMPTS: Record = { scope: `You are a spec elicitation interviewer conducting the SCOPE phase. @@ -217,7 +217,7 @@ export function getInterviewerTools( ...(canProposePhaseClosure(phase, closeability) ? { propose_phase_closure: createProposePhaseClosureTool(db, turnId, phase, projectId) } : {}), - ...(options?.mode === 'brownfield' && options.cwd ? createCoreTools(options.cwd) : {}), + ...(isBrownfieldScopeExploration(phase, options) ? createExplorationTools(options.cwd) : {}), }; } diff --git a/src/server/tools/index.ts b/src/server/tools/index.ts index 4e27e971..fd4a721d 100644 --- a/src/server/tools/index.ts +++ b/src/server/tools/index.ts @@ -26,3 +26,13 @@ export function createCoreTools(cwd: string) { list_directory: createLsTool(cwd), }; } + +/** Create the read-only exploration tools available during brownfield scope discovery. */ +export function createExplorationTools(cwd: string) { + return { + read_file: createReadTool(cwd), + grep: createGrepTool(cwd), + find_files: createFindTool(cwd), + list_directory: createLsTool(cwd), + }; +} From 6ea7023254a15b889a65be026a278c0ffb0b9fbb Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:41:59 +0200 Subject: [PATCH 12/17] Publish a real JavaScript CLI entrypoint --- bin/brunch.js | 25 ++++++++++++++++++++++++ package-lock.json | 7 ++----- package.json | 4 ++-- src/server/cli.test.ts | 43 ++++++++++++++++++++++++++++++++++++++++++ src/server/cli.ts | 9 +++++++++ src/server/launcher.ts | 4 ++++ 6 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 bin/brunch.js create mode 100644 src/server/cli.test.ts diff --git a/bin/brunch.js b/bin/brunch.js new file mode 100644 index 00000000..65618f65 --- /dev/null +++ b/bin/brunch.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const cliEntrypoint = join(packageRoot, 'src', 'server', 'cli.ts'); +const require = createRequire(import.meta.url); +const tsxEntrypoint = require.resolve('tsx'); + +const child = spawn(process.execPath, ['--import', tsxEntrypoint, cliEntrypoint, ...process.argv.slice(2)], { + stdio: 'inherit', + env: process.env, +}); + +child.on('close', (code) => { + process.exit(code ?? 1); +}); + +child.on('error', (error) => { + console.error('Failed to start brunch:', error); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index f8a6ca04..0cd3053d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,12 +39,13 @@ "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", + "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", "use-stick-to-bottom": "^1.1.3", "zod": "^4.3.6" }, "bin": { - "brunch": "src/server/cli.ts" + "brunch": "bin/brunch.js" }, "devDependencies": { "@testing-library/react": "^16.3.2", @@ -61,7 +62,6 @@ "oxlint-tsgolint": "^0.19.0", "shadcn": "^4.1.2", "supertest": "^7.2.2", - "tsx": "^4.21.0", "typescript": "^5.9.3", "vite": "^7.0.4", "vitest": "^4.1.0" @@ -11562,7 +11562,6 @@ "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", - "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -16574,7 +16573,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -17782,7 +17780,6 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", diff --git a/package.json b/package.json index d6d2ca83..9074aa26 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "license": "(MIT OR Apache-2.0)", "type": "module", "bin": { - "brunch": "./src/server/cli.ts" + "brunch": "./bin/brunch.js" }, "scripts": { "build": "vite build", @@ -65,6 +65,7 @@ "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", + "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", "use-stick-to-bottom": "^1.1.3", "zod": "^4.3.6" @@ -84,7 +85,6 @@ "oxlint-tsgolint": "^0.19.0", "shadcn": "^4.1.2", "supertest": "^7.2.2", - "tsx": "^4.21.0", "typescript": "^5.9.3", "vite": "^7.0.4", "vitest": "^4.1.0" diff --git a/src/server/cli.test.ts b/src/server/cli.test.ts new file mode 100644 index 00000000..c5262da2 --- /dev/null +++ b/src/server/cli.test.ts @@ -0,0 +1,43 @@ +import { spawn } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const binEntrypoint = join(packageRoot, 'bin', 'brunch.js'); + +function runCli(args: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [binEntrypoint, ...args], { + cwd: packageRoot, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.once('error', reject); + child.once('close', (code) => { + resolve({ code, stdout, stderr }); + }); + }); +} + +describe('published CLI entrypoint', () => { + it('executes through the package bin wrapper', async () => { + const result = await runCli(['--help']); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('Usage: brunch'); + expect(result.stdout).toContain('Launch the Brunch web UI in the current project directory.'); + }); +}); diff --git a/src/server/cli.ts b/src/server/cli.ts index e9dd5b37..72ec90bc 100644 --- a/src/server/cli.ts +++ b/src/server/cli.ts @@ -2,6 +2,15 @@ import { launch } from './launcher.js'; +const args = new Set(process.argv.slice(2)); + +if (args.has('--help') || args.has('-h') || args.has('help')) { + console.log('Usage: brunch'); + console.log(''); + console.log('Launch the Brunch web UI in the current project directory.'); + process.exit(0); +} + launch(process.cwd()).catch((error) => { console.error('Failed to start brunch:', error); process.exit(1); diff --git a/src/server/launcher.ts b/src/server/launcher.ts index b175c6df..19a1b8e4 100644 --- a/src/server/launcher.ts +++ b/src/server/launcher.ts @@ -35,6 +35,10 @@ export async function launch(cwd: string): Promise { }); }); + if (process.env.BRUNCH_NO_OPEN === '1') { + return; + } + // Open browser try { const { default: open } = await import('open'); From 9c8eb2bc9d323fe4d14a673449c48e5877cf1cc9 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:42:38 +0200 Subject: [PATCH 13/17] Fall back safely on empty database env vars --- src/server/index.ts | 6 ++-- src/server/runtime-config.test.ts | 48 +++++++++++++++++++++++++++++++ src/server/runtime-config.ts | 6 ++++ 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 src/server/runtime-config.test.ts create mode 100644 src/server/runtime-config.ts diff --git a/src/server/index.ts b/src/server/index.ts index 4371c219..2f3736df 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,12 +1,12 @@ import { createApp } from './app.js'; -import { resolveBrunchProject } from './project.js'; +import { resolveConfiguredDbPath } from './runtime-config.js'; 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 +// In dev mode, use BRUNCH_DB env var if set to a non-empty value, otherwise resolve .brunch/ project const projectCwd = process.cwd(); -const dbPath = DB_PATH ?? resolveBrunchProject(projectCwd).dbPath; +const dbPath = resolveConfiguredDbPath(DB_PATH, projectCwd); const { app } = createApp({ dbPath, projectCwd }); diff --git a/src/server/runtime-config.test.ts b/src/server/runtime-config.test.ts new file mode 100644 index 00000000..8c210f90 --- /dev/null +++ b/src/server/runtime-config.test.ts @@ -0,0 +1,48 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveConfiguredDbPath } from './runtime-config.js'; + +describe('runtime config', () => { + const tempDirs: string[] = []; + + const makeTempDir = () => { + const dir = mkdtempSync(join(tmpdir(), 'brunch-runtime-config-')); + tempDirs.push(dir); + return dir; + }; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('falls back to the local .brunch project when BRUNCH_DB is empty', () => { + const cwd = makeTempDir(); + + const dbPath = resolveConfiguredDbPath('', cwd); + + expect(dbPath).toBe(join(cwd, '.brunch', 'brunch.db')); + expect(existsSync(join(cwd, '.brunch'))).toBe(true); + }); + + it('falls back to the local .brunch project when BRUNCH_DB is whitespace', () => { + const cwd = makeTempDir(); + + const dbPath = resolveConfiguredDbPath(' ', cwd); + + expect(dbPath).toBe(join(cwd, '.brunch', 'brunch.db')); + expect(existsSync(join(cwd, '.brunch'))).toBe(true); + }); + + it('keeps an explicit BRUNCH_DB path when one is provided', () => { + const cwd = makeTempDir(); + + expect(resolveConfiguredDbPath('/tmp/custom.db', cwd)).toBe('/tmp/custom.db'); + expect(existsSync(join(cwd, '.brunch'))).toBe(false); + }); +}); diff --git a/src/server/runtime-config.ts b/src/server/runtime-config.ts new file mode 100644 index 00000000..7d15ac09 --- /dev/null +++ b/src/server/runtime-config.ts @@ -0,0 +1,6 @@ +import { resolveBrunchProject } from './project.js'; + +export function resolveConfiguredDbPath(configuredPath: string | undefined, cwd: string): string { + const normalizedPath = configuredPath?.trim(); + return normalizedPath ? normalizedPath : resolveBrunchProject(cwd).dbPath; +} From 747683bcb56db1b5ad108e65fc1616f503f0deb1 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:43:31 +0200 Subject: [PATCH 14/17] Preserve API 404s when static assets are mounted --- src/server/launcher.test.ts | 21 +++++++++++++-------- src/server/launcher.ts | 27 ++++++++++++++++++--------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/server/launcher.test.ts b/src/server/launcher.test.ts index ba5b0edd..0894763c 100644 --- a/src/server/launcher.test.ts +++ b/src/server/launcher.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -6,6 +6,7 @@ import request from 'supertest'; import { afterEach, describe, expect, it } from 'vitest'; import { createApp } from './app.js'; +import { mountStaticClient } from './launcher.js'; import { resolveBrunchProject } from './project.js'; describe('launcher integration', () => { @@ -32,17 +33,21 @@ describe('launcher integration', () => { expect(Array.isArray(res.body)).toBe(true); }); - it('serves static files when dist/ exists alongside the API', async () => { + it('serves static files while preserving API 404s', async () => { const cwd = makeTempDir(); + const distDir = join(makeTempDir(), 'dist'); + mkdirSync(distDir, { recursive: true }); + writeFileSync(join(distDir, 'index.html'), 'Brunch'); + const project = resolveBrunchProject(cwd); const { app } = createApp(project.dbPath); + mountStaticClient(app, distDir); - // createLauncher mounts express.static(distDir) as a fallback - // For this test, we verify the API works and that the launcher - // function can mount static files. Full static serving is tested - // via the launcher function. - const res = await request(app).get('/api/projects').expect(200); - expect(res.body).toBeDefined(); + await request(app) + .get('/project/123') + .expect(200) + .expect(/Brunch/); + await request(app).get('/api/missing').expect(404); }); it('resolves drizzle migrations when cwd differs from package root', () => { diff --git a/src/server/launcher.ts b/src/server/launcher.ts index 19a1b8e4..e48596ca 100644 --- a/src/server/launcher.ts +++ b/src/server/launcher.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import express from 'express'; +import express, { type Express } from 'express'; import { createApp } from './app.js'; import { resolveBrunchProject } from './project.js'; @@ -10,6 +10,22 @@ import { resolveBrunchProject } from './project.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DIST_DIR = join(__dirname, '..', '..', 'dist'); +export function mountStaticClient(app: Express, distDir: string = DIST_DIR): void { + if (!existsSync(distDir)) { + return; + } + + app.use(express.static(distDir)); + app.use((req, res, next) => { + if (req.path.startsWith('/api/')) { + next(); + return; + } + + res.sendFile(join(distDir, 'index.html')); + }); +} + export async function launch(cwd: string): Promise { const project = resolveBrunchProject(cwd); console.log(`.brunch/ directory: ${project.root}`); @@ -17,14 +33,7 @@ export async function launch(cwd: string): Promise { const { app } = createApp({ dbPath: project.dbPath, projectCwd: cwd }); // Serve built Vite assets as static files (production mode) - if (existsSync(DIST_DIR)) { - app.use(express.static(DIST_DIR)); - - // SPA fallback: serve index.html for all non-API routes - app.get('*', (_req, res) => { - res.sendFile(join(DIST_DIR, 'index.html')); - }); - } + mountStaticClient(app); const port = Number(process.env.PORT) || 3000; From f8aea7a51fac549d756d480ac7070f4e5cd8d1bd Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:44:08 +0200 Subject: [PATCH 15/17] Reject invalid .brunch path shapes during discovery --- src/server/project.test.ts | 18 +++++++++++++++++- src/server/project.ts | 20 ++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/server/project.test.ts b/src/server/project.test.ts index 4e37bf78..4b196010 100644 --- a/src/server/project.test.ts +++ b/src/server/project.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -86,6 +86,13 @@ describe('project resolution', () => { expect(() => initBrunchProject(cwd)).toThrow(); }); + it('rejects invalid .brunch path shapes during initialization', () => { + const cwd = makeTempDir(); + writeFileSync(join(cwd, '.brunch'), 'not a directory'); + + expect(() => initBrunchProject(cwd)).toThrow('exists but is not a directory'); + }); + // ── resolveBrunchProject ──────────────────────────────────────── it('creates .brunch/ when none found', () => { @@ -110,4 +117,13 @@ describe('project resolution', () => { // Should not create a second .brunch/ in the child expect(existsSync(join(child, '.brunch'))).toBe(false); }); + + it('rejects invalid .brunch path shapes during walk-up discovery', () => { + const root = makeTempDir(); + writeFileSync(join(root, '.brunch'), 'not a directory'); + const child = join(root, 'src'); + mkdirSync(child); + + expect(() => resolveBrunchProject(child)).toThrow('exists but is not a directory'); + }); }); diff --git a/src/server/project.ts b/src/server/project.ts index d4ef9f04..3127909a 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join, parse } from 'node:path'; @@ -26,12 +26,24 @@ function isStopDirectory(dir: string): boolean { return dir === root || dir === home; } +function hasValidBrunchDirectory(candidate: string): boolean { + if (!existsSync(candidate)) { + return false; + } + + if (!statSync(candidate).isDirectory()) { + throw new Error(`${candidate} exists but is not a directory`); + } + + return true; +} + export function findBrunchProject(startDir: string): BrunchProject | null { let current = startDir; for (let i = 0; i <= MAX_WALK_UP; i++) { const candidate = join(current, BRUNCH_DIR); - if (existsSync(candidate)) { + if (hasValidBrunchDirectory(candidate)) { return toBrunchProject(candidate, current); } @@ -52,6 +64,10 @@ export function findBrunchProject(startDir: string): BrunchProject | null { export function initBrunchProject(cwd: string): BrunchProject { const brunchDir = join(cwd, BRUNCH_DIR); if (existsSync(brunchDir)) { + if (!statSync(brunchDir).isDirectory()) { + throw new Error(`${brunchDir} exists but is not a directory`); + } + throw new Error(`.brunch/ already exists in ${cwd}`); } mkdirSync(brunchDir, { recursive: true }); From 5f8c659d9d138306726fa6c62143871f559287a5 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 18:45:15 +0200 Subject: [PATCH 16/17] Strengthen launcher seam coverage --- src/server/launcher.test.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/server/launcher.test.ts b/src/server/launcher.test.ts index 0894763c..ba29d894 100644 --- a/src/server/launcher.test.ts +++ b/src/server/launcher.test.ts @@ -11,6 +11,7 @@ import { resolveBrunchProject } from './project.js'; describe('launcher integration', () => { const tempDirs: string[] = []; + const originalCwd = process.cwd(); const makeTempDir = () => { const dir = mkdtempSync(join(tmpdir(), 'brunch-launcher-')); @@ -19,6 +20,7 @@ describe('launcher integration', () => { }; afterEach(() => { + process.chdir(originalCwd); for (const dir of tempDirs.splice(0)) { rmSync(dir, { force: true, recursive: true }); } @@ -33,16 +35,21 @@ describe('launcher integration', () => { expect(Array.isArray(res.body)).toBe(true); }); - it('serves static files while preserving API 404s', async () => { + it('serves static assets and SPA fallback while preserving API 404s', async () => { const cwd = makeTempDir(); const distDir = join(makeTempDir(), 'dist'); mkdirSync(distDir, { recursive: true }); writeFileSync(join(distDir, 'index.html'), 'Brunch'); + writeFileSync(join(distDir, 'app.js'), 'console.log("brunch")'); const project = resolveBrunchProject(cwd); const { app } = createApp(project.dbPath); mountStaticClient(app, distDir); + await request(app) + .get('/app.js') + .expect(200) + .expect(/console\.log\("brunch"\)/); await request(app) .get('/project/123') .expect(200) @@ -50,12 +57,14 @@ describe('launcher integration', () => { await request(app).get('/api/missing').expect(404); }); - it('resolves drizzle migrations when cwd differs from package root', () => { - // The key risk: migrations path is relative to import.meta.url, not cwd - const cwd = makeTempDir(); - const project = resolveBrunchProject(cwd); + it('resolves drizzle migrations when cwd differs from the package root', async () => { + const projectCwd = makeTempDir(); + const unrelatedCwd = makeTempDir(); + const project = resolveBrunchProject(projectCwd); + + process.chdir(unrelatedCwd); - // This would throw if migrations can't be found - expect(() => createApp(project.dbPath)).not.toThrow(); + const { app } = createApp(project.dbPath); + await request(app).get('/api/projects').expect(200); }); }); From b200022d673a08c39c86e86e9dbd8d25c73559d4 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Sun, 12 Apr 2026 19:00:32 +0200 Subject: [PATCH 17/17] Sync phase 7 docs with shipped seams --- README.md | 8 ++++---- docs/design/BROWNFIELD_EXPLORATION.md | 21 ++++++++++---------- memory/PLAN.md | 19 +++++++++--------- memory/SPEC.md | 28 ++++++++++++++------------- 4 files changed, 39 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index a52c1288..27df814f 100644 --- a/README.md +++ b/README.md @@ -129,20 +129,20 @@ src/ ### Key patterns - **Turn tree**: Conversations are branching trees, not flat logs. Each turn points to a parent. `active_turn_id` is HEAD. Active path resolved via recursive CTE. -- **Two-agent pattern**: Interviewer asks structured questions. Observer extracts decisions/assumptions after each turn. Different models, different prompts, independent testability. +- **Two-agent pattern**: Interviewer asks structured questions. Observer extracts typed knowledge items after each turn. Different models, different prompts, independent testability. - **Typed message contract**: `BrunchUIMessage` (AI SDK `UIMessage` with custom generics) spans server validation, persistence, streaming, and client hydration. - **Parts-based persistence**: `assistant_parts` and `user_parts` JSON columns store the full UI state per turn. Scalar fields (`question`, `why`, `impact`) retained for queryability. - **Zod everywhere**: Tool input/output schemas, data part schemas, parts deserialization, API payload validation. ## Current state -**Working**: Full four-phase interview (scope → design → requirements → criteria), phase-aware observer extraction across all 8 canonical knowledge kinds, explicit phase outcomes with closure provenance, requirements and criteria review with approve/reject state, knowledge workspace, markdown export, project dashboard with workflow state, fixture scenarios with rich seeded content. +**Working**: Full four-phase interview (scope → design → requirements → criteria), phase-aware observer extraction across all 8 canonical knowledge kinds, explicit phase outcomes with closure provenance, requirements and criteria review with approve/reject state, knowledge workspace, markdown export, project dashboard with workflow state, fixture scenarios with rich seeded content, local-first `.brunch/` storage, and greenfield/brownfield project kickoff with scope-grounded brownfield exploration. -**Not yet built**: Review lifecycle refinement (13a), npx distribution (14). See `memory/PLAN.md` for the full roadmap. +**Not yet built**: Knowledge-graph revisit / edit-mode cascade flow (phase 8 stretch). See `memory/PLAN.md` for the full roadmap. ## Tests -223 tests across 24 test files covering DB operations, app routes, core logic, interview flow, observer extraction, parts serialization, context builders, workspace hydration/controller/data, client components, phase-close logic, and build boundaries. Provider calls are mocked for CI; prompt quality depends on manual evaluation. +288 tests across 33 test files covering DB operations, app routes, launcher/distribution seams, core logic, interview flow, observer extraction, parts serialization, context builders, workspace hydration/controller/data, client components, phase-close logic, and build boundaries. Provider calls are mocked for CI; prompt quality depends on manual evaluation. ```bash npm test diff --git a/docs/design/BROWNFIELD_EXPLORATION.md b/docs/design/BROWNFIELD_EXPLORATION.md index c18707ad..4c46870c 100644 --- a/docs/design/BROWNFIELD_EXPLORATION.md +++ b/docs/design/BROWNFIELD_EXPLORATION.md @@ -1,14 +1,14 @@ # Brownfield Exploration Design > Design exploration from 2026-04-12. Referenced by SPEC.md D82, D83. -> Status: **approved direction** — First-turn exploration via prompt + core tools. +> Status: **implemented** — Scope-only exploration via prompt + read-only exploration tools. ## Shape No new module boundary. Brownfield exploration is a prompt/context/tool-configuration concern: -1. **Tool set:** In brownfield mode, the interviewer agent receives core tools (read, grep, find, ls, bash) alongside interview tools (ask_question, propose_phase_closure). -2. **System prompt:** A brownfield variant of the scope system prompt instructs the agent to explore the codebase before asking its first scope question. +1. **Tool set:** During brownfield scope only, the interviewer agent receives a read-only exploration subset (`read`, `grep`, `find`, `ls`) alongside interview tools (`ask_question`, `propose_phase_closure`). +2. **System prompt:** A brownfield variant of the scope system prompt instructs the agent to explore the codebase before asking its first scope question. Later phases keep their normal phase prompts. 3. **Context builder:** `buildInterviewerContext()` receives the project's `cwd` and `mode` (greenfield/brownfield) to construct the appropriate first-turn prompt. ## Interviewer configuration change @@ -18,11 +18,11 @@ No new module boundary. Brownfield exploration is a prompt/context/tool-configur const tools = { ask_question, ...(closeable ? { propose_phase_closure } : {}), - ...(mode === 'brownfield' ? createCoreTools(projectCwd) : {}), + ...(phase === 'scope' && mode === 'brownfield' ? createExplorationTools(projectCwd) : {}), } -const instructions = mode === 'brownfield' - ? getBrownfieldSystemPrompt(phase) +const instructions = phase === 'scope' && mode === 'brownfield' + ? getBrownfieldScopePrompt(projectCwd) : getSystemPrompt(phase) ``` @@ -68,8 +68,9 @@ The brownfield system prompt should: - Set a budget: "Spend no more than 5-8 tool calls on exploration before synthesizing" - Transition: "Once you have a working understanding, summarize what you found and begin scope questions grounded in that context" -## Open questions +## Current implementation notes -- Should brownfield mode persist core tool access throughout the entire interview, or only on the first turn? (Probably throughout — the user might say "look at the auth module" during design phase.) -- Should the exploration summary be stored as a special data part for context rebuilding? (Probably not for V1 — it's just part of the first turn's assistant_parts.) -- Does the `ToolLoopAgent` `stopWhen: stepCountIs(4)` need to increase for brownfield first turns? (Likely yes — exploration needs more steps.) +- Brownfield exploration is deliberately **scope-only**; later phases keep their normal prompts and tool surface. +- The exploration tool surface is deliberately **read-only** — no `write`, `edit`, or `bash` during brownfield discovery. +- The exploration summary is not stored as a special data part; it remains part of the first assistant turn. +- Brownfield scope raises the `ToolLoopAgent` step budget from 4 to 12 to allow exploration before the first structured question. diff --git a/memory/PLAN.md b/memory/PLAN.md index b54b16ac..c5c730c4 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -139,7 +139,7 @@ - Acceptance: deferred review refinements such as edit/add/merge/stale semantics across requirements and criteria can land behind one cross-cutting slice without regressing completion, export, or workflow-state coherence - **Verification approach**: inner — mutation/read-model/invalidation tests per refinement added. Outer — manual cross-phase review lifecycle walkthrough after the dedicated knowledge workspace exists. -## Phase 7: Distribution + Brownfield + UI Alignment +## Phase 7: Distribution + Brownfield + UI Alignment `done`