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..e29631b9 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -81,14 +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. `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. `done` - 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, A35 + - 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, I52, I53 - Acceptance: project state can load and display generic knowledge items and edges from the active path without losing current resume behavior - - **Verification approach**: inner — DB/core tests for generic item persistence and projection. Middle — workspace integration tests for sidebar hydration. + - **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. `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 3c62d571..aeb30f92 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -86,6 +86,8 @@ 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, 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 @@ -127,6 +129,12 @@ 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. + +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 @@ -214,6 +222,12 @@ 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 | +| 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 @@ -406,16 +420,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, 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 | 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, I51, I53 | | ProjectList.test.tsx | 2 | I36 | -| workspace-data.test.ts | 4 | I33 | +| 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 bdc69e92..80ecbf7e 100644 --- a/src/client/components/EntitySidebar.tsx +++ b/src/client/components/EntitySidebar.tsx @@ -4,19 +4,75 @@ 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', '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 { decisions, assumptions, 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), + ]); + + function getDependencyLabels(source: { collection: 'decision' | 'assumption'; id: number }) { + return relationships + .filter( + (relationship) => + relationship.type === 'depends_on' && + relationship.source.collection === source.collection && + relationship.source.id === source.id, + ) + .map((relationship) => + contentByEntity.get(entityKey(relationship.target.collection, relationship.target.id)), + ) + .filter((content): content is string => Boolean(content)); + } return (
{/* Tab bar */}
{tabs.map((tab) => { - const count = tab === 'Decisions' ? decisions.length : assumptions.length; + const count = + tab === 'Framing' + ? framing.length + : tab === 'Constraints' + ? constraints.length + : tab === 'Requirements' + ? requirements.length + : tab === 'Criteria' + ? criteria.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 233c6040..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 = { decisions: [], assumptions: [] } satisfies EntitiesData, + entitySnapshot = { + framing: [], + constraints: [], + requirements: [], + criteria: [], + decisions: [], + assumptions: [], + relationships: [], + } satisfies EntitiesData, }: { projectId?: number; assistantText?: string; @@ -299,6 +307,10 @@ describe('InterviewWorkspace', () => { it('hydrates transcript and sidebar state from the route loader without a post-mount entity fetch', async () => { currentLoaderData = createWorkspaceLoaderData({ entitySnapshot: { + framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [ { id: 7, @@ -308,6 +320,7 @@ describe('InterviewWorkspace', () => { }, ], assumptions: [], + relationships: [], }, }); @@ -322,8 +335,13 @@ describe('InterviewWorkspace', () => { it('refreshes durable loader-owned state for the same project without rewriting the live transcript', async () => { currentLoaderData = createWorkspaceLoaderData({ entitySnapshot: { + framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [], assumptions: [], + relationships: [], }, }); @@ -340,6 +358,10 @@ describe('InterviewWorkspace', () => { assistantText: 'Which platform should we target now?', answer: 'Ship the desktop app', entitySnapshot: { + framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [ { id: 8, @@ -349,6 +371,7 @@ describe('InterviewWorkspace', () => { }, ], assumptions: [], + relationships: [], }, }); rendered.rerender( @@ -381,8 +404,13 @@ describe('InterviewWorkspace', () => { assistantText: 'How should project two start?', answer: 'Begin with the API', entitySnapshot: { + framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [], assumptions: [], + relationships: [], }, }); rendered.rerender( @@ -410,16 +438,109 @@ describe('InterviewWorkspace', () => { expect(screen.getByText('Begin with the API')).toBeTruthy(); }); + it('renders remaining generic knowledge kinds in the sidebar without regressing existing tabs', async () => { + currentLoaderData = createWorkspaceLoaderData({ + entitySnapshot: { + framing: [ + { + id: 9, + project_id: 1, + kind: 'framing', + subtype: null, + content: 'The tool starts from an ambiguous brief', + 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, + project_id: 1, + content: 'Start with the web app', + rationale: 'Fastest launch path', + }, + ], + 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: /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(); + + fireEvent.click(screen.getByRole('button', { name: /decisions/i })); + expect(await screen.findByText('Start with the web app')).toBeTruthy(); + }); + it('refetches sidebar entities when the chat stream emits an observer result', async () => { currentLoaderData = createWorkspaceLoaderData({ entitySnapshot: { + framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [], assumptions: [], + relationships: [], }, }); fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ + framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [ { id: 7, @@ -429,6 +550,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 bb5e247a..30f0e53a 100644 --- a/src/client/workspace/workspace-controller-core.ts +++ b/src/client/workspace/workspace-controller-core.ts @@ -19,8 +19,13 @@ 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']; isLoading: boolean; } @@ -116,8 +121,13 @@ export function createWorkspaceDurableEntityState( isLoading: boolean, ): 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, isLoading, }; } diff --git a/src/client/workspace/workspace-controller.test.tsx b/src/client/workspace/workspace-controller.test.tsx index 737050ac..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 = { decisions: [], assumptions: [] } satisfies EntitiesData, + entitySnapshot = { + framing: [], + constraints: [], + requirements: [], + criteria: [], + decisions: [], + assumptions: [], + relationships: [], + } satisfies EntitiesData, }: { projectId?: number; assistantText?: string; @@ -274,6 +282,10 @@ describe('workspace controller', () => { currentLoaderData = createWorkspaceLoaderData({ options: [{ id: 11, position: 0, content: 'Web', is_recommended: true, is_selected: false }], entitySnapshot: { + framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [ { id: 7, @@ -283,6 +295,7 @@ describe('workspace controller', () => { }, ], assumptions: [], + relationships: [], }, }); @@ -316,6 +329,10 @@ describe('workspace controller', () => { assistantText: 'Which platform should we target now?', answer: 'Ship the desktop app', entitySnapshot: { + framing: [], + constraints: [], + requirements: [], + criteria: [], decisions: [ { id: 8, @@ -325,6 +342,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 2b6d1eb0..400bd35a 100644 --- a/src/client/workspace/workspace-data.test.ts +++ b/src/client/workspace/workspace-data.test.ts @@ -101,23 +101,91 @@ 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: [ + { + type: 'depends_on', + source: { collection: 'decision', kind: 'decision', id: 1 }, + target: { collection: 'assumption', kind: 'assumption', id: 2 }, + }, + ], }; const refreshedEntities: EntitiesData = { + framing: [ + { + id: 6, + project_id: 1, + kind: 'framing', + subtype: null, + content: 'Refetched framing item', + 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: 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, isLoading: true, }); 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, isLoading: false, }); }); diff --git a/src/server/app.test.ts b/src/server/app.test.ts index d7b71c1d..75650e1f 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -193,6 +193,73 @@ describe('POST /api/projects/:id/chat', () => { }); }); +describe('GET /api/projects/:id/entities', () => { + 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); + + const res = await request(app).get(`/api/projects/${projectId}/entities`).expect(200); + + expect(res.body).toMatchObject({ + framing: [ + { + kind: 'framing', + 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: [ + { + type: 'depends_on', + source: { collection: 'decision', kind: 'decision', id: decision.id }, + target: { collection: 'assumption', kind: 'assumption', id: assumption.id }, + }, + ], + }); + }); +}); + describe('GET /api/projects/:id', () => { it('returns structured question state after a tool-driven turn', async () => { const projectId = await createTestProject(); diff --git a/src/server/db.test.ts b/src/server/db.test.ts index 9d26d5e9..c8a18a66 100644 --- a/src/server/db.test.ts +++ b/src/server/db.test.ts @@ -18,8 +18,10 @@ import { getProject, createDecision, createAssumption, + createKnowledgeItem, linkDecisionToTurn, linkAssumptionToTurn, + linkKnowledgeItemToTurn, addDecisionParentDecision, addDecisionParentAssumption, addAssumptionParentAssumption, @@ -38,7 +40,7 @@ afterEach(() => { }); describe('createDb', () => { - it('creates all 13 tables from schema.dbml', () => { + it('creates all 15 tables from schema.dbml', () => { const tables = db.$client .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") .all() as Array<{ name: string }>; @@ -51,8 +53,10 @@ describe('createDb', () => { 'assumption', 'requirement', 'criterion', + 'knowledge_item', 'turn_decision', 'turn_assumption', + 'turn_knowledge_item', 'decision_parent_decision', 'decision_parent_assumption', 'assumption_parent_assumption', @@ -409,7 +413,7 @@ describe('DB lifecycle — turn tree persistence', () => { }); }); -describe('entity persistence — decisions and assumptions', () => { +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'); @@ -446,6 +450,59 @@ describe('entity persistence — decisions and assumptions', () => { expect(entities.assumptions[0].content).toBe('Users have API keys'); }); + 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 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.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 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', () => { const project = createProject(db, 'Test'); const d1 = createDecision(db, project.id, 'Use Express'); @@ -455,14 +512,36 @@ describe('entity persistence — decisions and assumptions', () => { 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 5b782744..033bf45b 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -181,10 +181,35 @@ export function advanceHead(db: DB, projectId: number, turnId: number): void { .run(); } -// --- Entity persistence (decisions, assumptions, dependency edges) --- +// --- Entity persistence (legacy decisions/assumptions + generic knowledge items) --- 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[]; + constraints: KnowledgeItem[]; + requirements: KnowledgeItem[]; + criteria: KnowledgeItem[]; + decisions: Decision[]; + assumptions: Assumption[]; + relationships: EntityRelationship[]; +} export function createDecision( db: DB, @@ -215,6 +240,35 @@ export function linkAssumptionToTurn(db: DB, assumptionId: number, turnId: numbe db.insert(schema.turnAssumption).values({ turn_id: turnId, assumption_id: assumptionId }).run(); } +export function createKnowledgeItem( + db: DB, + projectId: number, + kind: KnowledgeKind, + content: string, + options?: { subtype?: string | null; rationale?: string | null }, +): KnowledgeItem { + return db + .insert(schema.knowledgeItem) + .values({ + project_id: projectId, + kind, + subtype: options?.subtype ?? null, + content, + rationale: options?.rationale ?? null, + }) + .returning() + .get() as KnowledgeItem; +} + +export function linkKnowledgeItemToTurn( + db: DB, + itemId: number, + turnId: number, + relation: InferSelectModel['relation'] = 'captured', +): void { + db.insert(schema.turnKnowledgeItem).values({ turn_id: turnId, item_id: itemId, relation }).run(); +} + export function addDecisionParentDecision(db: DB, decisionId: number, parentDecisionId: number): void { db.insert(schema.decisionParentDecision) .values({ decision_id: decisionId, parent_decision_id: parentDecisionId }) @@ -237,10 +291,23 @@ export function addAssumptionParentAssumption( .run(); } -export function getEntitiesForProject( +function getKnowledgeItemsForProjectByKind( db: DB, projectId: number, -): { decisions: Decision[]; assumptions: Assumption[] } { + kind: Extract, +): KnowledgeItem[] { + return db + .select() + .from(schema.knowledgeItem) + .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) @@ -251,5 +318,90 @@ export function getEntitiesForProject( .from(schema.assumption) .where(eq(schema.assumption.project_id, projectId)) .all() as Assumption[]; - return { 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, + constraints, + requirements, + criteria, + 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, + }, + })), + }; } diff --git a/src/server/schema.ts b/src/server/schema.ts index 1b697e28..c53d4329 100644 --- a/src/server/schema.ts +++ b/src/server/schema.ts @@ -89,6 +89,19 @@ export const criterion = sqliteTable('criterion', { reviewed_at: text(), }); +export const knowledgeItem = sqliteTable('knowledge_item', { + id: integer().primaryKey({ autoIncrement: true }), + project_id: integer() + .notNull() + .references(() => project.id), + kind: text({ + enum: ['framing', 'constraint', 'decision', 'assumption', 'requirement', 'criterion'], + }).notNull(), + subtype: text(), + content: text().notNull(), + rationale: text(), +}); + // --- Join tables (provenance + dependency DAGs) --- export const turnDecision = sqliteTable( @@ -117,6 +130,22 @@ export const turnAssumption = sqliteTable( (table) => [primaryKey({ columns: [table.turn_id, table.assumption_id] })], ); +export const turnKnowledgeItem = sqliteTable( + 'turn_knowledge_item', + { + turn_id: integer() + .notNull() + .references(() => turn.id), + item_id: integer() + .notNull() + .references(() => knowledgeItem.id), + relation: text({ enum: ['captured', 'confirmed', 'edited', 'invalidated', 'reviewed'] }) + .notNull() + .default('captured'), + }, + (table) => [primaryKey({ columns: [table.turn_id, table.item_id, table.relation] })], +); + export const decisionParentDecision = sqliteTable( 'decision_parent_decision', {