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',
{