From 2a6ca3ec486eceb4e04f360fc648751d85fd7817 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 7 Apr 2026 18:02:11 +0200 Subject: [PATCH 1/3] feat: surface framing items through the generic knowledge seam --- drizzle/0003_tidy_knowledge_layer.sql | 18 ++++++++ drizzle/meta/_journal.json | 7 +++ memory/PLAN.md | 11 +++-- memory/SPEC.md | 13 ++++-- src/client/components/EntitySidebar.tsx | 24 +++++++++-- src/client/routes/InterviewWorkspace.test.tsx | 43 ++++++++++++++++++- .../workspace/workspace-controller-core.ts | 2 + .../workspace/workspace-controller.test.tsx | 4 +- src/client/workspace/workspace-data.test.ts | 13 ++++++ src/server/app.test.ts | 24 +++++++++++ src/server/db.test.ts | 28 +++++++++++- src/server/db.ts | 42 ++++++++++++++++-- src/server/schema.ts | 29 +++++++++++++ 13 files changed, 241 insertions(+), 17 deletions(-) create mode 100644 drizzle/0003_tidy_knowledge_layer.sql diff --git a/drizzle/0003_tidy_knowledge_layer.sql b/drizzle/0003_tidy_knowledge_layer.sql new file mode 100644 index 00000000..647df58d --- /dev/null +++ b/drizzle/0003_tidy_knowledge_layer.sql @@ -0,0 +1,18 @@ +CREATE TABLE `knowledge_item` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `project_id` integer NOT NULL, + `kind` text NOT NULL, + `subtype` text, + `content` text NOT NULL, + `rationale` text, + FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `turn_knowledge_item` ( + `turn_id` integer NOT NULL, + `item_id` integer NOT NULL, + `relation` text DEFAULT 'captured' NOT NULL, + PRIMARY KEY(`turn_id`, `item_id`, `relation`), + FOREIGN KEY (`turn_id`) REFERENCES `turn`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`item_id`) REFERENCES `knowledge_item`(`id`) ON UPDATE no action ON DELETE no action +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6c32a70c..1eed3cde 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1775127507844, "tag": "0002_bright_master_mold", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1775580000000, + "tag": "0003_tidy_knowledge_layer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/memory/PLAN.md b/memory/PLAN.md index 4b961a83..0622d4ea 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -81,14 +81,19 @@ - `6d.2` Zero selections + required free-text-only response. `done` - `6d.3` True many-selection UX + persistence/hydration. `done` -6e. **Generic knowledge layer schema + sidebar projection** — Introduce the broader semantic layer (`framing`, `constraint`, `decision`, `assumption`, `requirement`, `criterion`) with generic provenance and graph edges, then project it cleanly into the sidebar without regressing existing reads. `not-started` +6e. **Generic knowledge layer schema + sidebar projection** — Introduce the broader semantic layer (`framing`, `constraint`, `decision`, `assumption`, `requirement`, `criterion`) with generic provenance and graph edges, then project it cleanly into the sidebar without regressing existing reads. `in-progress` - Requirements: → SPEC.md §Requirements #5, #6, #14 - - Assumptions: → SPEC.md §Assumptions A14 - - Decisions: → SPEC.md §Decisions D5, D13, D25 + - Assumptions: → SPEC.md §Assumptions A14, A34 + - Decisions: → SPEC.md §Decisions D5, D13, D25, D49 - Candidate invariant goals: generic knowledge-item persistence with turn linkage; graph-edge fidelity across item kinds - Invariants to respect: → SPEC.md §Invariants I20, I21, I23, I34 + - Invariants established: → SPEC.md §Invariants I48, I49 - Acceptance: project state can load and display generic knowledge items and edges from the active path without losing current resume behavior + - **Observed current state (2026-04-07, tracer bullet 1):** generic `knowledge_item` + `turn_knowledge_item` persistence now carries `framing` items with turn provenance, `/api/projects/:id/entities` returns `framing` alongside legacy decisions/assumptions, and the workspace sidebar renders a Framing tab from loader/query snapshots without regressing the existing decision tab. Remaining work is widening beyond `framing` and adding generic edge projection. - **Verification approach**: inner — DB/core tests for generic item persistence and projection. Middle — workspace integration tests for sidebar hydration. + - Tracer bullets: + - `6e.1` Framing items through the generic knowledge seam. `done` + - `6e.2` Remaining kind widening + generic edge projection. `not-started` 6f. **Phase-aware observer extraction** — Teach the observer to bias extraction by mode: scope prefers framing/constraints, design prefers decisions/assumptions, later modes can surface requirements/criteria and revisions. Keep the observer as a single structured extraction pass, but give it richer context and a broader ontology. `not-started` - Requirements: → SPEC.md §Requirements #5, #6, #11, #12 diff --git a/memory/SPEC.md b/memory/SPEC.md index 3c62d571..5ce55de4 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -86,6 +86,7 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. | A28 | AI SDK `ToolLoopAgent` with `stopWhen: stepCountIs(N)` is sufficient for brunch's multi-step interviewing, review, and phase-transition needs — no custom agent loop required | high | D31 | Agent loop, Phase transitions | Validate with mode-transition and review slices: agent must ask, synthesize, and propose closure without a handwritten loop. | | A29 | Models can reliably compose generic filesystem tools (read, write, edit, bash, grep, find, ls) to explore and characterize an existing project | **validated** | D32 | Characterization kickoff | Validated (spike): `ToolLoopAgent` with 7 core tools explored brunch in 22 tool calls across 23 steps. See `spike/filesystem-tools.ts`. | | A33 | Structured turn responses can replace today's single-select flow while keeping persisted response parts, transcript hydration, and interviewer-context projection aligned for the first thin slice | **validated** | D23, D24, D25, D45, D46, D47, D48 | 6d flexible turn-response model | Validated: `parts.test.ts`, `app.test.ts`, `context.test.ts`, and `InterviewWorkspace.test.tsx` now prove zero/one/many selected options plus optional free-text persist, rehydrate, and reach interviewer context coherently through the shared turn-response seam. | +| A34 | A generic knowledge-item read model can coexist with today's decision/assumption-specific writes long enough to prove sidebar projection before observer widening | **validated** | D5, D13, D22, D49 | 6e generic knowledge layer, 6f observer widening | Validated: `db.test.ts`, `app.test.ts`, `workspace-data.test.ts`, and `InterviewWorkspace.test.tsx` now prove a generic `framing` item can persist with turn provenance, project through `/api/projects/:id/entities`, and render in the workspace sidebar without regressing legacy decision/assumption views. | ## Decisions @@ -127,6 +128,8 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. 48. **Choice-turn cards stage many selections locally and submit one array-shaped response seam** — Turn-card interaction now toggles zero/one/many selected options locally, then submits a single turn response carrying `positions[]` plus optional free-text through the same mutation/server boundary used by the other response paths. This keeps transcript hydration, persistence, and interviewer-context projection aligned without preserving the old immediate single-click selection behavior. Depends on: D45, D47. Supersedes: immediate single-option submit UI. +49. **Generic knowledge reads widen the entity seam before observer writes migrate** — `knowledge_item` plus `turn_knowledge_item` form the first generic knowledge persistence seam, but current observer writes for decisions and assumptions remain on their legacy tables. The shared `/api/projects/:id/entities` projection and workspace sidebar now read `framing` from the generic seam alongside legacy `decision`/`assumption` collections so slice 6e can prove the migration path incrementally before widening observer output. Depends on: D5, D13, D22. Supersedes: decision/assumption-only entity reads in the sidebar. + 26. **`md-pen` for programmatic markdown rendering** — Structured data (entity tables, dependency graphs, checklists) rendered to markdown via `md-pen` rather than hand-rolled string concatenation. Pure string-return functions (`table()`, `taskList()`, `mermaid()`, `heading()`, `alert()`, `details()`) compose by nesting — no AST, no intermediate representation. Escaping is context-aware per function (table cells, URLs, code fences), eliminating a class of bugs when rendering user-supplied text from interviews. Primary use cases: (1) observer context builders presenting growing entity graphs to agents (`table()` for decisions/assumptions with metadata, `taskList()` for reviewed/unreviewed items), (2) spec export rendering active-path entities into downloadable markdown (slice 13), (3) any future agent-facing or user-facing projection of structured data. Zero dependencies, ESM-only, TypeScript-first. Depends on: —. Supersedes: hand-rolled string assembly in context builders. ### Domain model @@ -214,6 +217,8 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. | I45 | Interviewer history projects chosen options and/or free-text from structured turn responses when available | Slice 6d.1 / 6d.2 (single-option + free-text; free-text-only) | context.test.ts | D25, D46 | | I46 | Free-text-only choice-turn replies require non-empty text and submit through the same turn-response seam as option picks | Slice 6d.2 (zero-selection + required free-text) | parts.test.ts, app.test.ts, InterviewWorkspace.test.tsx | D24, D47 | | I47 | Choice-turn replies can stage and persist many selected options as one structured turn response without collapsing back to scalar selection semantics | Slice 6d.3 (many-selection UX + persistence/hydration) | app.test.ts, parts.test.ts, context.test.ts, InterviewWorkspace.test.tsx | D24, D45, D46, D48 | +| I48 | Generic `framing` knowledge items persist with project linkage and turn provenance without disturbing legacy decision/assumption storage | Slice 6e.1 (generic framing seam + sidebar projection) | db.test.ts | D49 | +| I49 | Workspace entity projections can surface `framing` items alongside legacy decision/assumption collections through the shared entity seam | Slice 6e.1 (generic framing seam + sidebar projection) | app.test.ts, workspace-data.test.ts, InterviewWorkspace.test.tsx | D22, D49 | ## Lexicon @@ -406,16 +411,16 @@ This projection difference is a deliberate design choice, not an implementation | File | Tests | Protects | | ----------------------------- | ----- | ----------------------------------------------------- | -| db.test.ts | 32 | I5, I6, I9, I10, I11, I20 | -| app.test.ts | 9 | I1, I2, I3, I7, I14, I21, I23, I44, I46, I47 | +| db.test.ts | 33 | I5, I6, I9, I10, I11, I20, I48 | +| app.test.ts | 10 | I1, I2, I3, I7, I14, I21, I23, I44, I46, I47, I49 | | core.test.ts | 6 | I12, I13, I18 | | interview.test.ts | 6 | I16 | | parts.test.ts | 10 | I17, I18, I44, I46, I47 | | context.test.ts | 11 | I19, I45, I47 | | observer.test.ts | 2 | I20, I21 | -| InterviewWorkspace.test.tsx | 9 | I24, I25, I23, I33, I34, I35, I36, I43, I44, I46, I47 | +| InterviewWorkspace.test.tsx | 10 | I24, I25, I23, I33, I34, I35, I36, I43, I44, I46, I47, I49 | | ProjectList.test.tsx | 2 | I36 | -| workspace-data.test.ts | 4 | I33 | +| workspace-data.test.ts | 4 | I33, I49 | | chat-hydration.test.ts | 3 | I35 | | workspace-controller.test.tsx | 3 | I41, I43 | | client-mutation.test.ts | 3 | I42 | diff --git a/src/client/components/EntitySidebar.tsx b/src/client/components/EntitySidebar.tsx index bdc69e92..ce8a99b1 100644 --- a/src/client/components/EntitySidebar.tsx +++ b/src/client/components/EntitySidebar.tsx @@ -4,19 +4,20 @@ import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import type { WorkspaceDurableEntityState } from '@/workspace/workspace-controller-core'; -const tabs = ['Decisions', 'Assumptions'] as const; +const tabs = ['Framing', 'Decisions', 'Assumptions'] as const; type Tab = (typeof tabs)[number]; export function EntitySidebar({ entityState }: { entityState: WorkspaceDurableEntityState }) { const [activeTab, setActiveTab] = useState('Decisions'); - const { decisions, assumptions, isLoading } = entityState; + const { framing, decisions, assumptions, isLoading } = entityState; return (
{/* Tab bar */}
{tabs.map((tab) => { - const count = tab === 'Decisions' ? decisions.length : assumptions.length; + const count = + tab === 'Framing' ? framing.length : tab === 'Decisions' ? decisions.length : assumptions.length; return (
diff --git a/src/client/routes/InterviewWorkspace.test.tsx b/src/client/routes/InterviewWorkspace.test.tsx index b4bab883..d890cbfb 100644 --- a/src/client/routes/InterviewWorkspace.test.tsx +++ b/src/client/routes/InterviewWorkspace.test.tsx @@ -166,7 +166,7 @@ function createWorkspaceLoaderData({ assistantText = 'What should we build first?', answer = 'Build the web app', options = [], - entitySnapshot = { framing: [], decisions: [], assumptions: [] } satisfies EntitiesData, + entitySnapshot = { framing: [], decisions: [], assumptions: [], relationships: [] } satisfies EntitiesData, }: { projectId?: number; assistantText?: string; @@ -309,6 +309,7 @@ describe('InterviewWorkspace', () => { }, ], assumptions: [], + relationships: [], }, }); @@ -326,6 +327,7 @@ describe('InterviewWorkspace', () => { framing: [], decisions: [], assumptions: [], + relationships: [], }, }); @@ -352,6 +354,7 @@ describe('InterviewWorkspace', () => { }, ], assumptions: [], + relationships: [], }, }); rendered.rerender( @@ -387,6 +390,7 @@ describe('InterviewWorkspace', () => { framing: [], decisions: [], assumptions: [], + relationships: [], }, }); rendered.rerender( @@ -414,7 +418,7 @@ describe('InterviewWorkspace', () => { expect(screen.getByText('Begin with the API')).toBeTruthy(); }); - it('renders framing items in the sidebar without regressing the decisions tab', async () => { + it('renders persisted dependency relationships in the sidebar without regressing existing tabs', async () => { currentLoaderData = createWorkspaceLoaderData({ entitySnapshot: { framing: [ @@ -435,13 +439,23 @@ describe('InterviewWorkspace', () => { rationale: 'Fastest launch path', }, ], - assumptions: [], + assumptions: [{ id: 5, project_id: 1, content: 'Users arrive with a concrete goal' }], + relationships: [ + { + type: 'depends_on', + source: { collection: 'decision', kind: 'decision', id: 7 }, + target: { collection: 'assumption', kind: 'assumption', id: 5 }, + }, + ], } as EntitiesData, }); renderWorkspace(); expect(await screen.findByText('Start with the web app')).toBeTruthy(); + expect(screen.getByText(/depends on/i)).toBeTruthy(); + expect(screen.getByText('Users arrive with a concrete goal')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: /framing/i })); expect(await screen.findByText('The tool starts from an ambiguous brief')).toBeTruthy(); @@ -455,6 +469,7 @@ describe('InterviewWorkspace', () => { framing: [], decisions: [], assumptions: [], + relationships: [], }, }); fetchMock.mockResolvedValueOnce( @@ -470,6 +485,7 @@ describe('InterviewWorkspace', () => { }, ], assumptions: [], + relationships: [], } satisfies EntitiesData), { status: 200, diff --git a/src/client/workspace/workspace-controller-core.ts b/src/client/workspace/workspace-controller-core.ts index f1e5b0c8..de806856 100644 --- a/src/client/workspace/workspace-controller-core.ts +++ b/src/client/workspace/workspace-controller-core.ts @@ -22,6 +22,7 @@ export interface WorkspaceDurableEntityState { framing: EntitiesData['framing']; decisions: EntitiesData['decisions']; assumptions: EntitiesData['assumptions']; + relationships: EntitiesData['relationships']; isLoading: boolean; } @@ -120,6 +121,7 @@ export function createWorkspaceDurableEntityState( framing: queryData?.framing ?? entitySnapshot.framing, decisions: queryData?.decisions ?? entitySnapshot.decisions, assumptions: queryData?.assumptions ?? entitySnapshot.assumptions, + relationships: queryData?.relationships ?? entitySnapshot.relationships, isLoading, }; } diff --git a/src/client/workspace/workspace-controller.test.tsx b/src/client/workspace/workspace-controller.test.tsx index b3b31ee2..cda01d5a 100644 --- a/src/client/workspace/workspace-controller.test.tsx +++ b/src/client/workspace/workspace-controller.test.tsx @@ -129,7 +129,7 @@ function createWorkspaceLoaderData({ assistantText = 'What should we build first?', answer = 'Build the web app', options = [], - entitySnapshot = { framing: [], decisions: [], assumptions: [] } satisfies EntitiesData, + entitySnapshot = { framing: [], decisions: [], assumptions: [], relationships: [] } satisfies EntitiesData, }: { projectId?: number; assistantText?: string; @@ -284,6 +284,7 @@ describe('workspace controller', () => { }, ], assumptions: [], + relationships: [], }, }); @@ -327,6 +328,7 @@ describe('workspace controller', () => { }, ], assumptions: [], + relationships: [], }, }); diff --git a/src/client/workspace/workspace-data.test.ts b/src/client/workspace/workspace-data.test.ts index 6c235a17..47e549b9 100644 --- a/src/client/workspace/workspace-data.test.ts +++ b/src/client/workspace/workspace-data.test.ts @@ -104,6 +104,13 @@ describe('workspace controller core', () => { framing: [], decisions: [{ id: 1, project_id: 1, content: 'Loader decision', rationale: null }], assumptions: [{ id: 2, project_id: 1, content: 'Loader assumption' }], + relationships: [ + { + type: 'depends_on', + source: { collection: 'decision', kind: 'decision', id: 1 }, + target: { collection: 'assumption', kind: 'assumption', id: 2 }, + }, + ], }; const refreshedEntities: EntitiesData = { framing: [ @@ -118,12 +125,20 @@ describe('workspace controller core', () => { ], decisions: [{ id: 3, project_id: 1, content: 'Refetched decision', rationale: 'Newer' }], assumptions: [], + relationships: [ + { + type: 'depends_on', + source: { collection: 'decision', kind: 'decision', id: 3 }, + target: { collection: 'knowledge_item', kind: 'framing', id: 4 }, + }, + ], }; expect(createWorkspaceDurableEntityState(entitySnapshot, undefined, true)).toEqual({ framing: entitySnapshot.framing, decisions: entitySnapshot.decisions, assumptions: entitySnapshot.assumptions, + relationships: entitySnapshot.relationships, isLoading: true, }); @@ -131,6 +146,7 @@ describe('workspace controller core', () => { framing: refreshedEntities.framing, decisions: refreshedEntities.decisions, assumptions: refreshedEntities.assumptions, + relationships: refreshedEntities.relationships, isLoading: false, }); }); diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 2608fd41..a0ca3b07 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -194,13 +194,15 @@ describe('POST /api/projects/:id/chat', () => { }); describe('GET /api/projects/:id/entities', () => { - it('returns a framing collection alongside legacy decisions and assumptions', async () => { + it('returns relationship data alongside framing, decisions, and assumptions', async () => { const projectId = await createTestProject(); - const { createDecision, createAssumption, createKnowledgeItem } = await import('./db.js'); + const { createDecision, createAssumption, createKnowledgeItem, addDecisionParentAssumption } = + await import('./db.js'); createKnowledgeItem(db, projectId, 'framing', 'The project starts from an ambiguous brief'); - createDecision(db, projectId, 'Start with the web app'); - createAssumption(db, projectId, 'Users arrive with a concrete goal'); + const decision = createDecision(db, projectId, 'Start with the web app'); + const assumption = createAssumption(db, projectId, 'Users arrive with a concrete goal'); + addDecisionParentAssumption(db, decision.id, assumption.id); const res = await request(app).get(`/api/projects/${projectId}/entities`).expect(200); @@ -213,6 +215,13 @@ describe('GET /api/projects/:id/entities', () => { ], decisions: [{ content: 'Start with the web app' }], assumptions: [{ content: 'Users arrive with a concrete goal' }], + relationships: [ + { + type: 'depends_on', + source: { collection: 'decision', kind: 'decision', id: decision.id }, + target: { collection: 'assumption', kind: 'assumption', id: assumption.id }, + }, + ], }); }); }); diff --git a/src/server/db.test.ts b/src/server/db.test.ts index a13a7184..93b0d710 100644 --- a/src/server/db.test.ts +++ b/src/server/db.test.ts @@ -479,14 +479,36 @@ describe('entity persistence — decisions, assumptions, and framing items', () expect(entities.decisions).toHaveLength(2); }); - it('creates dependency edges between decisions and assumptions', () => { + it('projects legacy parent links through one typed relationship read model', () => { const project = createProject(db, 'Test'); - const a = createAssumption(db, project.id, 'SDK supports streaming'); - const d = createDecision(db, project.id, 'Use SDK streaming'); - addDecisionParentAssumption(db, d.id, a.id); + const parentDecision = createDecision(db, project.id, 'Use Express'); + const dependentDecision = createDecision(db, project.id, 'Use SSE for streaming'); + const parentAssumption = createAssumption(db, project.id, 'SDK supports streaming'); + const dependentAssumption = createAssumption(db, project.id, 'Single-user tool'); + + addDecisionParentDecision(db, dependentDecision.id, parentDecision.id); + addDecisionParentAssumption(db, dependentDecision.id, parentAssumption.id); + addAssumptionParentAssumption(db, dependentAssumption.id, parentAssumption.id); + const entities = getEntitiesForProject(db, project.id); - expect(entities.decisions).toHaveLength(1); - expect(entities.assumptions).toHaveLength(1); + + expect(entities.relationships).toEqual([ + { + type: 'depends_on', + source: { collection: 'decision', kind: 'decision', id: dependentDecision.id }, + target: { collection: 'decision', kind: 'decision', id: parentDecision.id }, + }, + { + type: 'depends_on', + source: { collection: 'decision', kind: 'decision', id: dependentDecision.id }, + target: { collection: 'assumption', kind: 'assumption', id: parentAssumption.id }, + }, + { + type: 'depends_on', + source: { collection: 'assumption', kind: 'assumption', id: dependentAssumption.id }, + target: { collection: 'assumption', kind: 'assumption', id: parentAssumption.id }, + }, + ]); }); it('creates dependency edges between assumptions', () => { diff --git a/src/server/db.ts b/src/server/db.ts index f9fae2a0..b88ece53 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -187,6 +187,26 @@ export type Decision = InferSelectModel; export type Assumption = InferSelectModel; export type KnowledgeItem = InferSelectModel; export type KnowledgeKind = KnowledgeItem['kind']; +export type EntityCollection = 'knowledge_item' | 'decision' | 'assumption'; + +export interface EntityReference { + collection: EntityCollection; + kind: KnowledgeKind; + id: number; +} + +export interface EntityRelationship { + type: 'depends_on'; + source: EntityReference; + target: EntityReference; +} + +export interface EntitiesForProject { + framing: KnowledgeItem[]; + decisions: Decision[]; + assumptions: Assumption[]; + relationships: EntityRelationship[]; +} export function createDecision( db: DB, @@ -268,10 +288,7 @@ export function addAssumptionParentAssumption( .run(); } -export function getEntitiesForProject( - db: DB, - projectId: number, -): { framing: KnowledgeItem[]; decisions: Decision[]; assumptions: Assumption[] } { +export function getEntitiesForProject(db: DB, projectId: number): EntitiesForProject { const framing = db .select() .from(schema.knowledgeItem) @@ -287,5 +304,87 @@ export function getEntitiesForProject( .from(schema.assumption) .where(eq(schema.assumption.project_id, projectId)) .all() as Assumption[]; - return { framing, decisions, assumptions }; + const relationships = db.all(sql` + SELECT + 'depends_on' AS type, + source_collection, + source_kind, + source_id, + target_collection, + target_kind, + target_id + FROM ( + SELECT + 'decision' AS source_collection, + 'decision' AS source_kind, + edge.decision_id AS source_id, + 'decision' AS target_collection, + 'decision' AS target_kind, + edge.parent_decision_id AS target_id + FROM decision_parent_decision edge + JOIN decision source ON source.id = edge.decision_id + JOIN decision target ON target.id = edge.parent_decision_id + WHERE source.project_id = ${projectId} AND target.project_id = ${projectId} + + UNION ALL + + SELECT + 'decision' AS source_collection, + 'decision' AS source_kind, + edge.decision_id AS source_id, + 'assumption' AS target_collection, + 'assumption' AS target_kind, + edge.parent_assumption_id AS target_id + FROM decision_parent_assumption edge + JOIN decision source ON source.id = edge.decision_id + JOIN assumption target ON target.id = edge.parent_assumption_id + WHERE source.project_id = ${projectId} AND target.project_id = ${projectId} + + UNION ALL + + SELECT + 'assumption' AS source_collection, + 'assumption' AS source_kind, + edge.assumption_id AS source_id, + 'assumption' AS target_collection, + 'assumption' AS target_kind, + edge.parent_assumption_id AS target_id + FROM assumption_parent_assumption edge + JOIN assumption source ON source.id = edge.assumption_id + JOIN assumption target ON target.id = edge.parent_assumption_id + WHERE source.project_id = ${projectId} AND target.project_id = ${projectId} + ) relationships + ORDER BY + CASE source_collection WHEN 'decision' THEN 0 WHEN 'assumption' THEN 1 ELSE 2 END, + source_id, + CASE target_collection WHEN 'decision' THEN 0 WHEN 'assumption' THEN 1 ELSE 2 END, + target_id + `) as Array<{ + type: EntityRelationship['type']; + source_collection: EntityReference['collection']; + source_kind: EntityReference['kind']; + source_id: number; + target_collection: EntityReference['collection']; + target_kind: EntityReference['kind']; + target_id: number; + }>; + + return { + framing, + decisions, + assumptions, + relationships: relationships.map((relationship) => ({ + type: relationship.type, + source: { + collection: relationship.source_collection, + kind: relationship.source_kind, + id: relationship.source_id, + }, + target: { + collection: relationship.target_collection, + kind: relationship.target_kind, + id: relationship.target_id, + }, + })), + }; } From 5a204a6995224c9f75e0558b423690d2565900c7 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 7 Apr 2026 21:55:37 +0200 Subject: [PATCH 3/3] feat: project remaining generic knowledge kinds through the sidebar seam --- memory/PLAN.md | 10 +-- memory/SPEC.md | 14 ++-- src/client/components/EntitySidebar.tsx | 83 +++++++++++++++---- src/client/routes/InterviewWorkspace.test.tsx | 69 ++++++++++++++- .../workspace/workspace-controller-core.ts | 6 ++ .../workspace/workspace-controller.test.tsx | 16 +++- src/client/workspace/workspace-data.test.ts | 43 +++++++++- src/server/app.test.ts | 36 +++++++- src/server/db.test.ts | 61 ++++++++++---- src/server/db.ts | 23 ++++- 10 files changed, 314 insertions(+), 47 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index 1d54a101..e29631b9 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -81,20 +81,20 @@ - `6d.2` Zero selections + required free-text-only response. `done` - `6d.3` True many-selection UX + persistence/hydration. `done` -6e. **Generic knowledge layer schema + sidebar projection** — Introduce the broader semantic layer (`framing`, `constraint`, `decision`, `assumption`, `requirement`, `criterion`) with generic provenance and graph edges, then project it cleanly into the sidebar without regressing existing reads. `in-progress` +6e. **Generic knowledge layer schema + sidebar projection** — Introduce the broader semantic layer (`framing`, `constraint`, `decision`, `assumption`, `requirement`, `criterion`) with generic provenance and graph edges, then project it cleanly into the sidebar without regressing existing reads. `done` - Requirements: → SPEC.md §Requirements #5, #6, #14 - Assumptions: → SPEC.md §Assumptions A14, A34, A35 - - Decisions: → SPEC.md §Decisions D5, D13, D25, D49, D50 + - Decisions: → SPEC.md §Decisions D5, D13, D25, D49, D50, D51 - Candidate invariant goals: generic knowledge-item persistence with turn linkage; graph-edge fidelity across item kinds - Invariants to respect: → SPEC.md §Invariants I20, I21, I23, I34 - - Invariants established: → SPEC.md §Invariants I48, I49, I50, I51 + - Invariants established: → SPEC.md §Invariants I48, I49, I50, I51, I52, I53 - Acceptance: project state can load and display generic knowledge items and edges from the active path without losing current resume behavior - - **Observed current state (2026-04-07, tracer bullets 1–2a):** generic `knowledge_item` + `turn_knowledge_item` persistence now carries `framing` items with turn provenance, `/api/projects/:id/entities` returns `framing` plus a typed `relationships[]` projection alongside legacy decisions/assumptions, and the workspace sidebar renders both the Framing tab and dependency affordances without regressing existing decision/assumption views. Remaining work is widening beyond `framing` and legacy dependency reads into the broader generic knowledge kinds/edges. + - **Observed current state (2026-04-07, tracer bullets 1–2b):** generic `knowledge_item` + `turn_knowledge_item` persistence now carries `framing`, `constraint`, `requirement`, and `criterion` items with subtype/rationale metadata, `/api/projects/:id/entities` returns those kind-specific collections plus a typed `relationships[]` projection alongside legacy decisions/assumptions, and the workspace sidebar renders Framing, Constraints, Requirements, Criteria, Decisions, and Assumptions tabs without regressing existing dependency affordances. - **Verification approach**: inner — DB/core tests for generic item persistence and relationship projection. Middle — workspace integration tests for sidebar hydration and dependency rendering. - Tracer bullets: - `6e.1` Framing items through the generic knowledge seam. `done` - `6e.2a` Legacy dependency edges through the generic entity seam. `done` - - `6e.2b` Remaining kind widening through the sidebar seam. `not-started` + - `6e.2b` Remaining kind widening through the sidebar seam. `done` 6f. **Phase-aware observer extraction** — Teach the observer to bias extraction by mode: scope prefers framing/constraints, design prefers decisions/assumptions, later modes can surface requirements/criteria and revisions. Keep the observer as a single structured extraction pass, but give it richer context and a broader ontology. `not-started` - Requirements: → SPEC.md §Requirements #5, #6, #11, #12 diff --git a/memory/SPEC.md b/memory/SPEC.md index 121515f2..aeb30f92 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -86,7 +86,7 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. | A28 | AI SDK `ToolLoopAgent` with `stopWhen: stepCountIs(N)` is sufficient for brunch's multi-step interviewing, review, and phase-transition needs — no custom agent loop required | high | D31 | Agent loop, Phase transitions | Validate with mode-transition and review slices: agent must ask, synthesize, and propose closure without a handwritten loop. | | A29 | Models can reliably compose generic filesystem tools (read, write, edit, bash, grep, find, ls) to explore and characterize an existing project | **validated** | D32 | Characterization kickoff | Validated (spike): `ToolLoopAgent` with 7 core tools explored brunch in 22 tool calls across 23 steps. See `spike/filesystem-tools.ts`. | | A33 | Structured turn responses can replace today's single-select flow while keeping persisted response parts, transcript hydration, and interviewer-context projection aligned for the first thin slice | **validated** | D23, D24, D25, D45, D46, D47, D48 | 6d flexible turn-response model | Validated: `parts.test.ts`, `app.test.ts`, `context.test.ts`, and `InterviewWorkspace.test.tsx` now prove zero/one/many selected options plus optional free-text persist, rehydrate, and reach interviewer context coherently through the shared turn-response seam. | -| A34 | A generic knowledge-item read model can coexist with today's decision/assumption-specific writes long enough to prove sidebar projection before observer widening | **validated** | D5, D13, D22, D49 | 6e generic knowledge layer, 6f observer widening | Validated: `db.test.ts`, `app.test.ts`, `workspace-data.test.ts`, and `InterviewWorkspace.test.tsx` now prove a generic `framing` item can persist with turn provenance, project through `/api/projects/:id/entities`, and render in the workspace sidebar without regressing legacy decision/assumption views. | +| A34 | A generic knowledge-item read model can coexist with today's decision/assumption-specific writes long enough to prove sidebar projection before observer widening | **validated** | D5, D13, D22, D49, D51 | 6e generic knowledge layer, 6f observer widening | Validated: `db.test.ts`, `app.test.ts`, `workspace-data.test.ts`, and `InterviewWorkspace.test.tsx` now prove generic `framing`, `constraint`, `requirement`, and `criterion` items can persist with metadata, project through `/api/projects/:id/entities`, and render in the workspace sidebar without regressing legacy decision/assumption views. | | A35 | A mixed-source relationship read model can project today's legacy dependency tables and new generic knowledge items through one sidebar graph seam before observer writes migrate | **validated** | D5, D13, D22, D49, D50 | 6e generic knowledge layer, 6f observer widening | Validated: `db.test.ts`, `app.test.ts`, `workspace-data.test.ts`, and `InterviewWorkspace.test.tsx` now prove legacy parent/dependency links project through the shared entities API, hydrate through the workspace loader/query seam, and render as visible sidebar dependency affordances without regressing current framing/decision/assumption tabs. | ## Decisions @@ -133,6 +133,8 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. 50. **Mixed-source dependency edges read through one typed entity-graph seam** — Legacy dependency tables still own today’s persisted edges, but `/api/projects/:id/entities` now projects them as one typed `relationships[]` payload with explicit source/target identity (`collection`, `kind`, `id`) so the workspace sidebar can render dependency affordances without waiting for observer writes to migrate to generic graph tables. Depends on: D5, D13, D22, D49. Supersedes: flat entity reads with no graph relationship projection. +51. **Generic knowledge reads stay kind-specific at the sidebar seam** — While generic knowledge items share one persistence table, the shared entities API and workspace sidebar should project `framing`, `constraint`, `requirement`, and `criterion` as distinct collections rather than a flat mixed list. This keeps tab-level affordances simple and preserves room for mixed legacy/generic writes during the migration. Depends on: D22, D49, D50. Supersedes: framing-only generic projection in the sidebar. + 26. **`md-pen` for programmatic markdown rendering** — Structured data (entity tables, dependency graphs, checklists) rendered to markdown via `md-pen` rather than hand-rolled string concatenation. Pure string-return functions (`table()`, `taskList()`, `mermaid()`, `heading()`, `alert()`, `details()`) compose by nesting — no AST, no intermediate representation. Escaping is context-aware per function (table cells, URLs, code fences), eliminating a class of bugs when rendering user-supplied text from interviews. Primary use cases: (1) observer context builders presenting growing entity graphs to agents (`table()` for decisions/assumptions with metadata, `taskList()` for reviewed/unreviewed items), (2) spec export rendering active-path entities into downloadable markdown (slice 13), (3) any future agent-facing or user-facing projection of structured data. Zero dependencies, ESM-only, TypeScript-first. Depends on: —. Supersedes: hand-rolled string assembly in context builders. ### Domain model @@ -224,6 +226,8 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. | I49 | Workspace entity projections can surface `framing` items alongside legacy decision/assumption collections through the shared entity seam | Slice 6e.1 (generic framing seam + sidebar projection) | app.test.ts, workspace-data.test.ts, InterviewWorkspace.test.tsx | D22, D49 | | I50 | Legacy decision/assumption parent links project into one typed relationship read model with stable source/target identity | Slice 6e.2a (legacy dependency edges through the entity seam) | db.test.ts | D50 | | I51 | Workspace entity projections hydrate and render dependency relationships through the shared entity seam without regressing existing tabs | Slice 6e.2a (legacy dependency edges through the entity seam) | app.test.ts, workspace-data.test.ts, InterviewWorkspace.test.tsx | D22, D50 | +| I52 | Generic `constraint`, `requirement`, and `criterion` items project through kind-specific sidebar collections with preserved subtype/rationale metadata | Slice 6e.2b (remaining generic kinds through the sidebar seam) | db.test.ts | D49, D51 | +| I53 | Workspace entity projections hydrate and render remaining generic knowledge kinds through the shared entity seam without regressing existing tabs | Slice 6e.2b (remaining generic kinds through the sidebar seam) | app.test.ts, workspace-data.test.ts, InterviewWorkspace.test.tsx | D22, D51 | ## Lexicon @@ -416,16 +420,16 @@ This projection difference is a deliberate design choice, not an implementation | File | Tests | Protects | | ----------------------------- | ----- | ----------------------------------------------------- | -| db.test.ts | 33 | I5, I6, I9, I10, I11, I20, I48, I50 | -| app.test.ts | 10 | I1, I2, I3, I7, I14, I21, I23, I44, I46, I47, I49, I51 | +| db.test.ts | 33 | I5, I6, I9, I10, I11, I20, I48, I50, I52 | +| app.test.ts | 10 | I1, I2, I3, I7, I14, I21, I23, I44, I46, I47, I49, I51, I53 | | core.test.ts | 6 | I12, I13, I18 | | interview.test.ts | 6 | I16 | | parts.test.ts | 10 | I17, I18, I44, I46, I47 | | context.test.ts | 11 | I19, I45, I47 | | observer.test.ts | 2 | I20, I21 | -| InterviewWorkspace.test.tsx | 10 | I24, I25, I23, I33, I34, I35, I36, I43, I44, I46, I47, I49, I51 | +| InterviewWorkspace.test.tsx | 10 | I24, I25, I23, I33, I34, I35, I36, I43, I44, I46, I47, I49, I51, I53 | | ProjectList.test.tsx | 2 | I36 | -| workspace-data.test.ts | 4 | I33, I49, I51 | +| workspace-data.test.ts | 4 | I33, I49, I51, I53 | | chat-hydration.test.ts | 3 | I35 | | workspace-controller.test.tsx | 3 | I41, I43 | | client-mutation.test.ts | 3 | I42 | diff --git a/src/client/components/EntitySidebar.tsx b/src/client/components/EntitySidebar.tsx index 4962e830..80ecbf7e 100644 --- a/src/client/components/EntitySidebar.tsx +++ b/src/client/components/EntitySidebar.tsx @@ -4,18 +4,40 @@ import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import type { WorkspaceDurableEntityState } from '@/workspace/workspace-controller-core'; -const tabs = ['Framing', 'Decisions', 'Assumptions'] as const; +const tabs = ['Framing', 'Constraints', 'Requirements', 'Criteria', 'Decisions', 'Assumptions'] as const; type Tab = (typeof tabs)[number]; function entityKey(collection: 'knowledge_item' | 'decision' | 'assumption', id: number) { return `${collection}:${id}`; } +function renderKnowledgeItems( + items: Array<{ id: number; content: string; subtype: string | null; rationale: string | null }>, + emptyMessage: string, + isLoading: boolean, +) { + if (items.length === 0 && !isLoading) { + return

{emptyMessage}

; + } + + return items.map((item) => ( +
+

{item.content}

+ {item.subtype &&

{item.subtype}

} + {item.rationale &&

{item.rationale}

} +
+ )); +} + export function EntitySidebar({ entityState }: { entityState: WorkspaceDurableEntityState }) { const [activeTab, setActiveTab] = useState('Decisions'); - const { framing, decisions, assumptions, relationships, isLoading } = entityState; + const { framing, constraints, requirements, criteria, decisions, assumptions, relationships, isLoading } = + entityState; const contentByEntity = new Map([ ...framing.map((item) => [entityKey('knowledge_item', item.id), item.content] as const), + ...constraints.map((item) => [entityKey('knowledge_item', item.id), item.content] as const), + ...requirements.map((item) => [entityKey('knowledge_item', item.id), item.content] as const), + ...criteria.map((item) => [entityKey('knowledge_item', item.id), item.content] as const), ...decisions.map((decision) => [entityKey('decision', decision.id), decision.content] as const), ...assumptions.map((assumption) => [entityKey('assumption', assumption.id), assumption.content] as const), ]); @@ -40,7 +62,17 @@ export function EntitySidebar({ entityState }: { entityState: WorkspaceDurableEn
{tabs.map((tab) => { const count = - tab === 'Framing' ? framing.length : tab === 'Decisions' ? decisions.length : assumptions.length; + tab === 'Framing' + ? framing.length + : tab === 'Constraints' + ? constraints.length + : tab === 'Requirements' + ? requirements.length + : tab === 'Criteria' + ? criteria.length + : tab === 'Decisions' + ? decisions.length + : assumptions.length; return (
+ )} + + {activeTab === 'Constraints' && ( +
+ {renderKnowledgeItems( + constraints, + "No constraints yet. They'll appear as the interview progresses.", + isLoading, + )} +
+ )} + + {activeTab === 'Requirements' && ( +
+ {renderKnowledgeItems( + requirements, + "No requirements yet. They'll appear as the interview progresses.", + isLoading, + )} +
+ )} + + {activeTab === 'Criteria' && ( +
+ {renderKnowledgeItems( + criteria, + "No criteria yet. They'll appear as the interview progresses.", + isLoading, )} - {framing.map((item) => ( -
-

{item.content}

- {item.subtype &&

{item.subtype}

} - {item.rationale &&

{item.rationale}

} -
- ))}
)} diff --git a/src/client/routes/InterviewWorkspace.test.tsx b/src/client/routes/InterviewWorkspace.test.tsx index d890cbfb..c5972f00 100644 --- a/src/client/routes/InterviewWorkspace.test.tsx +++ b/src/client/routes/InterviewWorkspace.test.tsx @@ -166,7 +166,15 @@ function createWorkspaceLoaderData({ assistantText = 'What should we build first?', answer = 'Build the web app', options = [], - entitySnapshot = { framing: [], decisions: [], assumptions: [], relationships: [] } satisfies EntitiesData, + entitySnapshot = { + framing: [], + constraints: [], + requirements: [], + criteria: [], + decisions: [], + assumptions: [], + relationships: [], + } satisfies EntitiesData, }: { projectId?: number; assistantText?: string; @@ -300,6 +308,9 @@ describe('InterviewWorkspace', () => { currentLoaderData = createWorkspaceLoaderData({ entitySnapshot: { framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [ { id: 7, @@ -325,6 +336,9 @@ describe('InterviewWorkspace', () => { currentLoaderData = createWorkspaceLoaderData({ entitySnapshot: { framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [], assumptions: [], relationships: [], @@ -345,6 +359,9 @@ describe('InterviewWorkspace', () => { answer: 'Ship the desktop app', entitySnapshot: { framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [ { id: 8, @@ -388,6 +405,9 @@ describe('InterviewWorkspace', () => { answer: 'Begin with the API', entitySnapshot: { framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [], assumptions: [], relationships: [], @@ -418,7 +438,7 @@ describe('InterviewWorkspace', () => { expect(screen.getByText('Begin with the API')).toBeTruthy(); }); - it('renders persisted dependency relationships in the sidebar without regressing existing tabs', async () => { + it('renders remaining generic knowledge kinds in the sidebar without regressing existing tabs', async () => { currentLoaderData = createWorkspaceLoaderData({ entitySnapshot: { framing: [ @@ -431,6 +451,36 @@ describe('InterviewWorkspace', () => { rationale: null, }, ], + constraints: [ + { + id: 10, + project_id: 1, + kind: 'constraint', + subtype: 'non-goal', + content: 'Keep setup instant', + rationale: 'Avoid a heavyweight launcher', + }, + ], + requirements: [ + { + id: 11, + project_id: 1, + kind: 'requirement', + subtype: null, + content: 'Resume interviews after browser restart', + rationale: 'People leave mid-session', + }, + ], + criteria: [ + { + id: 12, + project_id: 1, + kind: 'criterion', + subtype: 'acceptance', + content: 'Restoring the project shows the active path', + rationale: 'Protects the persistence seam', + }, + ], decisions: [ { id: 7, @@ -456,6 +506,15 @@ describe('InterviewWorkspace', () => { expect(screen.getByText(/depends on/i)).toBeTruthy(); expect(screen.getByText('Users arrive with a concrete goal')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: /constraints/i })); + expect(await screen.findByText('Keep setup instant')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: /requirements/i })); + expect(await screen.findByText('Resume interviews after browser restart')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: /criteria/i })); + expect(await screen.findByText('Restoring the project shows the active path')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: /framing/i })); expect(await screen.findByText('The tool starts from an ambiguous brief')).toBeTruthy(); @@ -467,6 +526,9 @@ describe('InterviewWorkspace', () => { currentLoaderData = createWorkspaceLoaderData({ entitySnapshot: { framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [], assumptions: [], relationships: [], @@ -476,6 +538,9 @@ describe('InterviewWorkspace', () => { new Response( JSON.stringify({ framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [ { id: 7, diff --git a/src/client/workspace/workspace-controller-core.ts b/src/client/workspace/workspace-controller-core.ts index de806856..30f0e53a 100644 --- a/src/client/workspace/workspace-controller-core.ts +++ b/src/client/workspace/workspace-controller-core.ts @@ -20,6 +20,9 @@ export interface WorkspaceDurableProjectState { export interface WorkspaceDurableEntityState { framing: EntitiesData['framing']; + constraints: EntitiesData['constraints']; + requirements: EntitiesData['requirements']; + criteria: EntitiesData['criteria']; decisions: EntitiesData['decisions']; assumptions: EntitiesData['assumptions']; relationships: EntitiesData['relationships']; @@ -119,6 +122,9 @@ export function createWorkspaceDurableEntityState( ): WorkspaceDurableEntityState { return { framing: queryData?.framing ?? entitySnapshot.framing, + constraints: queryData?.constraints ?? entitySnapshot.constraints, + requirements: queryData?.requirements ?? entitySnapshot.requirements, + criteria: queryData?.criteria ?? entitySnapshot.criteria, decisions: queryData?.decisions ?? entitySnapshot.decisions, assumptions: queryData?.assumptions ?? entitySnapshot.assumptions, relationships: queryData?.relationships ?? entitySnapshot.relationships, diff --git a/src/client/workspace/workspace-controller.test.tsx b/src/client/workspace/workspace-controller.test.tsx index cda01d5a..a0690427 100644 --- a/src/client/workspace/workspace-controller.test.tsx +++ b/src/client/workspace/workspace-controller.test.tsx @@ -129,7 +129,15 @@ function createWorkspaceLoaderData({ assistantText = 'What should we build first?', answer = 'Build the web app', options = [], - entitySnapshot = { framing: [], decisions: [], assumptions: [], relationships: [] } satisfies EntitiesData, + entitySnapshot = { + framing: [], + constraints: [], + requirements: [], + criteria: [], + decisions: [], + assumptions: [], + relationships: [], + } satisfies EntitiesData, }: { projectId?: number; assistantText?: string; @@ -275,6 +283,9 @@ describe('workspace controller', () => { options: [{ id: 11, position: 0, content: 'Web', is_recommended: true, is_selected: false }], entitySnapshot: { framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [ { id: 7, @@ -319,6 +330,9 @@ describe('workspace controller', () => { answer: 'Ship the desktop app', entitySnapshot: { framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [ { id: 8, diff --git a/src/client/workspace/workspace-data.test.ts b/src/client/workspace/workspace-data.test.ts index 47e549b9..400bd35a 100644 --- a/src/client/workspace/workspace-data.test.ts +++ b/src/client/workspace/workspace-data.test.ts @@ -102,6 +102,27 @@ describe('workspace controller core', () => { it('prefers refreshed entity query data while preserving loader snapshot fallback', () => { const entitySnapshot: EntitiesData = { framing: [], + constraints: [ + { + id: 4, + project_id: 1, + kind: 'constraint', + subtype: 'non-goal', + content: 'Keep setup instant', + rationale: 'Avoid a heavy launcher', + }, + ], + requirements: [ + { + id: 5, + project_id: 1, + kind: 'requirement', + subtype: null, + content: 'Support resume', + rationale: 'Users leave mid-flow', + }, + ], + criteria: [], decisions: [{ id: 1, project_id: 1, content: 'Loader decision', rationale: null }], assumptions: [{ id: 2, project_id: 1, content: 'Loader assumption' }], relationships: [ @@ -115,7 +136,7 @@ describe('workspace controller core', () => { const refreshedEntities: EntitiesData = { framing: [ { - id: 4, + id: 6, project_id: 1, kind: 'framing', subtype: null, @@ -123,19 +144,34 @@ describe('workspace controller core', () => { rationale: null, }, ], + constraints: [], + requirements: [], + criteria: [ + { + id: 7, + project_id: 1, + kind: 'criterion', + subtype: 'acceptance', + content: 'Refetched criterion', + rationale: 'Protects refresh behavior', + }, + ], decisions: [{ id: 3, project_id: 1, content: 'Refetched decision', rationale: 'Newer' }], assumptions: [], relationships: [ { type: 'depends_on', source: { collection: 'decision', kind: 'decision', id: 3 }, - target: { collection: 'knowledge_item', kind: 'framing', id: 4 }, + target: { collection: 'knowledge_item', kind: 'framing', id: 6 }, }, ], }; expect(createWorkspaceDurableEntityState(entitySnapshot, undefined, true)).toEqual({ framing: entitySnapshot.framing, + constraints: entitySnapshot.constraints, + requirements: entitySnapshot.requirements, + criteria: entitySnapshot.criteria, decisions: entitySnapshot.decisions, assumptions: entitySnapshot.assumptions, relationships: entitySnapshot.relationships, @@ -144,6 +180,9 @@ describe('workspace controller core', () => { expect(createWorkspaceDurableEntityState(entitySnapshot, refreshedEntities, false)).toEqual({ framing: refreshedEntities.framing, + constraints: refreshedEntities.constraints, + requirements: refreshedEntities.requirements, + criteria: refreshedEntities.criteria, decisions: refreshedEntities.decisions, assumptions: refreshedEntities.assumptions, relationships: refreshedEntities.relationships, diff --git a/src/server/app.test.ts b/src/server/app.test.ts index a0ca3b07..75650e1f 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -194,12 +194,23 @@ describe('POST /api/projects/:id/chat', () => { }); describe('GET /api/projects/:id/entities', () => { - it('returns relationship data alongside framing, decisions, and assumptions', async () => { + it('returns remaining generic knowledge kinds alongside framing, decisions, assumptions, and relationships', async () => { const projectId = await createTestProject(); const { createDecision, createAssumption, createKnowledgeItem, addDecisionParentAssumption } = await import('./db.js'); createKnowledgeItem(db, projectId, 'framing', 'The project starts from an ambiguous brief'); + createKnowledgeItem(db, projectId, 'constraint', 'Keep setup instant', { + subtype: 'non-goal', + rationale: 'The launcher should stay simple', + }); + createKnowledgeItem(db, projectId, 'requirement', 'Resume interviews from SQLite', { + rationale: 'Users will close the browser mid-session', + }); + createKnowledgeItem(db, projectId, 'criterion', 'Resuming restores the active path', { + subtype: 'acceptance', + rationale: 'Protects the persistence seam', + }); const decision = createDecision(db, projectId, 'Start with the web app'); const assumption = createAssumption(db, projectId, 'Users arrive with a concrete goal'); addDecisionParentAssumption(db, decision.id, assumption.id); @@ -213,6 +224,29 @@ describe('GET /api/projects/:id/entities', () => { content: 'The project starts from an ambiguous brief', }, ], + constraints: [ + { + kind: 'constraint', + subtype: 'non-goal', + content: 'Keep setup instant', + rationale: 'The launcher should stay simple', + }, + ], + requirements: [ + { + kind: 'requirement', + content: 'Resume interviews from SQLite', + rationale: 'Users will close the browser mid-session', + }, + ], + criteria: [ + { + kind: 'criterion', + subtype: 'acceptance', + content: 'Resuming restores the active path', + rationale: 'Protects the persistence seam', + }, + ], decisions: [{ content: 'Start with the web app' }], assumptions: [{ content: 'Users arrive with a concrete goal' }], relationships: [ diff --git a/src/server/db.test.ts b/src/server/db.test.ts index 93b0d710..c8a18a66 100644 --- a/src/server/db.test.ts +++ b/src/server/db.test.ts @@ -413,7 +413,7 @@ describe('DB lifecycle — turn tree persistence', () => { }); }); -describe('entity persistence — decisions, assumptions, and framing items', () => { +describe('entity persistence — decisions, assumptions, and generic knowledge items', () => { it('creates a decision with project linkage', () => { const project = createProject(db, 'Test'); const d = createDecision(db, project.id, 'Use SQLite for persistence'); @@ -450,24 +450,57 @@ describe('entity persistence — decisions, assumptions, and framing items', () expect(entities.assumptions[0].content).toBe('Users have API keys'); }); - it('persists a framing item with project linkage and turn provenance', () => { + it('persists remaining generic knowledge kinds with project linkage, metadata, and turn provenance', () => { const project = createProject(db, 'Test'); const turn = createTurn(db, project.id, { phase: 'scope', question: 'Q', answer: 'A' }); - const item = createKnowledgeItem(db, project.id, 'framing', 'The brief starts ambiguous'); - linkKnowledgeItemToTurn(db, item.id, turn.id); + const constraint = createKnowledgeItem(db, project.id, 'constraint', 'Must run locally', { + subtype: 'non-goal', + rationale: 'Keep setup instant', + }); + const requirement = createKnowledgeItem(db, project.id, 'requirement', 'Support resumable interviews', { + rationale: 'Users will leave and come back', + }); + const criterion = createKnowledgeItem(db, project.id, 'criterion', 'Resume works after browser restart', { + subtype: 'acceptance', + rationale: 'Protects the persistence seam', + }); + linkKnowledgeItemToTurn(db, constraint.id, turn.id); + linkKnowledgeItemToTurn(db, requirement.id, turn.id); + linkKnowledgeItemToTurn(db, criterion.id, turn.id); const entities = getEntitiesForProject(db, project.id); - expect(entities.framing).toHaveLength(1); - expect(entities.framing[0]).toMatchObject({ - project_id: project.id, - kind: 'framing', - content: 'The brief starts ambiguous', - }); + expect(entities.constraints).toEqual([ + expect.objectContaining({ + project_id: project.id, + kind: 'constraint', + subtype: 'non-goal', + content: 'Must run locally', + rationale: 'Keep setup instant', + }), + ]); + expect(entities.requirements).toEqual([ + expect.objectContaining({ + project_id: project.id, + kind: 'requirement', + subtype: null, + content: 'Support resumable interviews', + rationale: 'Users will leave and come back', + }), + ]); + expect(entities.criteria).toEqual([ + expect.objectContaining({ + project_id: project.id, + kind: 'criterion', + subtype: 'acceptance', + content: 'Resume works after browser restart', + rationale: 'Protects the persistence seam', + }), + ]); - const provenance = db.$client - .prepare('SELECT * FROM turn_knowledge_item WHERE turn_id = ? AND item_id = ?') - .get(turn.id, item.id) as { relation: string } | undefined; - expect(provenance?.relation).toBe('captured'); + const provenanceRows = db.$client + .prepare('SELECT relation FROM turn_knowledge_item WHERE turn_id = ? ORDER BY item_id') + .all(turn.id) as Array<{ relation: string }>; + expect(provenanceRows.map((row) => row.relation)).toEqual(['captured', 'captured', 'captured']); }); it('creates dependency edges between decisions', () => { diff --git a/src/server/db.ts b/src/server/db.ts index b88ece53..033bf45b 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -203,6 +203,9 @@ export interface EntityRelationship { export interface EntitiesForProject { framing: KnowledgeItem[]; + constraints: KnowledgeItem[]; + requirements: KnowledgeItem[]; + criteria: KnowledgeItem[]; decisions: Decision[]; assumptions: Assumption[]; relationships: EntityRelationship[]; @@ -288,12 +291,23 @@ export function addAssumptionParentAssumption( .run(); } -export function getEntitiesForProject(db: DB, projectId: number): EntitiesForProject { - const framing = db +function getKnowledgeItemsForProjectByKind( + db: DB, + projectId: number, + kind: Extract, +): KnowledgeItem[] { + return db .select() .from(schema.knowledgeItem) - .where(and(eq(schema.knowledgeItem.project_id, projectId), eq(schema.knowledgeItem.kind, 'framing'))) + .where(and(eq(schema.knowledgeItem.project_id, projectId), eq(schema.knowledgeItem.kind, kind))) .all() as KnowledgeItem[]; +} + +export function getEntitiesForProject(db: DB, projectId: number): EntitiesForProject { + const framing = getKnowledgeItemsForProjectByKind(db, projectId, 'framing'); + const constraints = getKnowledgeItemsForProjectByKind(db, projectId, 'constraint'); + const requirements = getKnowledgeItemsForProjectByKind(db, projectId, 'requirement'); + const criteria = getKnowledgeItemsForProjectByKind(db, projectId, 'criterion'); const decisions = db .select() .from(schema.decision) @@ -371,6 +385,9 @@ export function getEntitiesForProject(db: DB, projectId: number): EntitiesForPro return { framing, + constraints, + requirements, + criteria, decisions, assumptions, relationships: relationships.map((relationship) => ({