From b4b1a5ca366ee00a3c61dd2142ebfca404292234 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 10 Apr 2026 15:36:45 +0200 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20renumber=20slices=2012=E2=86=9212a?= =?UTF-8?q?,=2013=E2=86=9212b=20under=20FE-574?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amp-Thread-ID: https://ampcode.com/threads/T-019d776b-9a7d-7521-b45c-ee24c4e45fca Co-authored-by: Amp --- memory/CARDS.md | 6 ++--- memory/PLAN.md | 60 ++++++++++++++++++++++++------------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index 751845e4..fa4e0005 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -4,7 +4,7 @@ # Scope Cards -## Card 12: Knowledge workspace review surface + lifecycle API +## Card 12a: Knowledge workspace review surface ### Target Behavior @@ -54,7 +54,7 @@ A dedicated phase-oriented knowledge workspace at `/project/:id/knowledge` lets --- -## Card 13: Spec export from the reviewed knowledge layer +## Card 12b: Spec export from the reviewed knowledge layer ### Target Behavior @@ -109,4 +109,4 @@ The export route at `/project/:id/export` renders a markdown preview of the revi ## Build Order -**12 then 13.** Card 12 establishes the knowledge workspace which card 13's export route can link to ("review your knowledge before exporting"). Card 13's readiness gate and export renderer are independent of 12's UI, but having the knowledge workspace available makes the outer-loop export verification richer. If parallelism is needed, both cards can proceed concurrently since they touch different routes and different server modules. +**12a then 12b.** Both share one branch (FE-574). Card 12a establishes the knowledge workspace which 12b's export route can link to ("review your knowledge before exporting"). Card 12b's readiness gate and export renderer are independent of 12a's UI, but having the knowledge workspace available makes the outer-loop export verification richer. diff --git a/memory/PLAN.md b/memory/PLAN.md index 628792db..1dac0541 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -189,25 +189,25 @@ - Acceptance: the project list shows each project's per-phase status/readiness/closure-basis summary from persisted readiness artifacts plus live workflow projection, distinguishes forced-close or low-readiness debt from ordinary closed state, and updates correctly after refresh/resume - **Verification approach**: inner — workflow-summary projection tests plus project-list route/component tests. Outer — manual multi-project walkthrough covering in-progress, forced-close debt, invalidated, and export-ready states. -12. **Knowledge workspace review surface + lifecycle API** — CRUD/review endpoints for the broader knowledge layer plus a fit-for-purpose workspace for inspecting, editing, and reviewing graph-shaped knowledge. The sidebar may remain a summary/navigation view, but the primary interaction model should no longer assume a narrow tab strip. This slice assumes the redesigned knowledge ontology/graph from 7a + 7b. `not-started` - - Requirements: → SPEC.md §Requirements #6, #11, #12, #13 - - Assumptions: → SPEC.md §Assumptions A14, A40 - - Decisions: → SPEC.md §Decisions D5, D17, D61, D63, D67, D68, D69 - - Candidate invariant goals: review/edit actions are reflected in both knowledge state and readiness state; the knowledge workspace can present graph relationships and review actions without lossy sidebar compression - - Invariants to respect: → SPEC.md §Invariants I23, I24 - - Acceptance: inspect and review/edit canonical knowledge items from a dedicated phase-oriented workspace surface; affected readiness updates visibly and persist across refresh/resume; dependency/provenance context remains legible during those actions - - **Verification approach**: inner — mutation + projection tests. Outer — manual knowledge-workspace review/edit walkthrough. - -13. **Spec export from the reviewed knowledge layer** — Render markdown export from active-path, reviewed knowledge items and explicit phase outcomes, including closure caveats when a mode was closed with low readiness or user-forced basis. Export is enabled only when the new readiness predicate is satisfied. `not-started` - - Requirements: → SPEC.md §Requirements #13 - - Assumptions: — - - Decisions: → SPEC.md §Decisions D5, D17, D26, D65, D66, D70 - - Candidate invariant goals: export reflects active-path reviewed knowledge only; readiness predicate gates export correctly; closure provenance survives into the final artifact when it changes how trustworthy the result is - - Invariants to respect: → SPEC.md §Invariants I18, I21 - - Acceptance: complete all modes, satisfy review completeness, navigate to export, see markdown preview from the reviewed knowledge layer plus relevant phase-outcome caveats, download `.md` file - - **Verification approach**: inner — export projection tests. Outer — manual export after a full walkthrough, after a low-readiness/forced-close path surfaces caveats, and after a readiness-incomplete state blocks export. - -11b. **Fixture scenarios + dev seed CLI** — Extract the programmatic seed helpers from `app.test.ts` into a shared fixture module (`src/server/fixtures/scenarios.ts`) and add a CLI entry point (`src/server/fixtures/seed.ts`) so the dev server can be started at any named project state for outer-loop manual testing. `not-started` +12a. **Knowledge workspace review surface** `FE-574` — Read-only phase-oriented workspace at `/project/:id/knowledge` for inspecting canonical knowledge items grouped by kind, with review-status badges and relationship context. The sidebar remains a compact summary; this is the first dedicated review surface (D63, D69). Assumes the redesigned knowledge ontology/graph from 7a + 7b. `not-started` + - Requirements: → SPEC.md §Requirements #6, #11, #12, #13 + - Assumptions: → SPEC.md §Assumptions A14, A40 + - Decisions: → SPEC.md §Decisions D5, D17, D61, D63, D67, D68, D69 + - Candidate invariant goals: knowledge workspace presents all canonical kinds with review state and relationship context from the existing entities API without lossy sidebar compression + - Invariants to respect: → SPEC.md §Invariants I23, I24 + - Acceptance: navigate to knowledge workspace, see kind-grouped items with review badges and dependency edges, navigate back to interview; no new mutations or edit actions in this slice + - **Verification approach**: inner — route/component tests with mock EntitiesData. Outer — manual walkthrough from seeded project. + +12b. **Spec export from the reviewed knowledge layer** `FE-574` — Render markdown export from active-path reviewed knowledge items and explicit phase outcomes, including closure caveats when a mode was closed with low readiness or user-forced basis. Export is enabled only when all phases are closed. `not-started` + - Requirements: → SPEC.md §Requirements #13 + - Assumptions: — + - Decisions: → SPEC.md §Decisions D5, D17, D26, D65, D66, D70 + - Candidate invariant goals: export reflects active-path reviewed knowledge only; readiness predicate gates export correctly; closure provenance survives into the final artifact when it changes how trustworthy the result is + - Invariants to respect: → SPEC.md §Invariants I18, I21 + - Acceptance: complete all modes, navigate to export, see markdown preview grouped by kind with closure caveats, download `.md` file; export blocked when any phase is not closed + - **Verification approach**: inner — export rendering + API route tests. Outer — manual export from seeded all-phases-closed project. + +11b. **Fixture scenarios + dev seed CLI** `done` — Extract the programmatic seed helpers from `app.test.ts` into a shared fixture module (`src/server/fixtures/scenarios.ts`) and add a CLI entry point (`src/server/fixtures/seed.ts`) so the dev server can be started at any named project state for outer-loop manual testing. - Requirements: → SPEC.md §Requirements #14 (resume), §Verification Design (outer-loop fixture capture) - Assumptions: → SPEC.md §Assumptions A28 - Decisions: — @@ -216,7 +216,7 @@ - Acceptance: `npm run seed ` creates a named-scenario project in a fresh or specified DB; the dev server renders the expected workflow state and turn history from that seeded state; existing tests still pass after the extraction refactor - **Verification approach**: inner — type checking confirms scenario functions share the same DB API contract. Middle — existing test suite passes after extraction (characterization). Outer — manual dev-server walkthrough from each seeded scenario. -13a. **Review lifecycle refinement across requirements + criteria** — Revisit the first-cut review model only after the thin end-to-end path is working, and add the deferred variants that were intentionally excluded from slices 9 and 10 so the app kept moving toward completion. `not-started` +13a. **Review lifecycle refinement across requirements + criteria** — Revisit the first-cut review model only after the thin end-to-end path is working, and add the deferred variants that were intentionally excluded from slices 9 and 10 so the app kept moving toward completion. Depends on 12a + 12b. `not-started` - Requirements: → SPEC.md §Requirements #11, #12, #13 - Assumptions: → SPEC.md §Assumptions A15, A40 - Decisions: → SPEC.md §Decisions D17, D61, D65, D66, D69 @@ -292,20 +292,20 @@ Phase 6: 7 ──┐ 9 ──┤ 10.3 ─┘ 10.3 ──→ 11b (fixture scenarios + dev seed CLI) - 7b ──→ 12 (knowledge workspace review surface + lifecycle API) - 10.3 ──→ 12 - 10.3 ──→ 13 (export) - 12 ──┬──→ 13a (review lifecycle refinement) - 13 ──┘ -Phase 7: 13 ──→ 14 (npx + CLI) + 7b ──→ 12a (knowledge workspace review surface) + 10.3 ──→ 12a + 10.3 ──→ 12b (export) + 12a ──┬──→ 13a (review lifecycle refinement) + 12b ──┘ +Phase 7: 12b ──→ 14 (npx + CLI) Phase 8: 14 ──→ 15 (drizzle-kit audit remediation) ``` ### Parallelism opportunities - 10.1–10.3 are done; the review seam has been unified (refactor landed between 10.3 and 11a). -- 11b (fixture scenarios + dev seed CLI) is unblocked and should land before deep outer-loop testing of 12 or 13. -- 12 (knowledge workspace) and 13 (export) are unblocked and can proceed in parallel. -- 13a (review lifecycle refinement) is explicitly deferred; it should collect rarer review variants after 12 and 13 stabilize rather than fragmenting slices 9 and 10. -- 14 (npx) can start early with a basic launcher, completing after slice 13 when the export predicate stabilizes. +- 11b (fixture scenarios + dev seed CLI) is done; seeded scenarios are available for outer-loop testing. +- 12a (knowledge workspace) and 12b (export) are unblocked and share one branch (FE-574). Build order: 12a → 12b. +- 13a (review lifecycle refinement) is explicitly deferred; it should collect rarer review variants after 12a and 12b stabilize rather than fragmenting slices 9 and 10. +- 14 (npx) can start early with a basic launcher, completing after slice 12b when the export predicate stabilizes. - 15 (drizzle-kit audit remediation) should wait until 14 lands. From 3ad654c5293962611e7a25dacd586c48310df96c Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 10 Apr 2026 15:56:56 +0200 Subject: [PATCH 2/5] feat: knowledge workspace review surface Amp-Thread-ID: https://ampcode.com/threads/T-019d776b-9a7d-7521-b45c-ee24c4e45fca Co-authored-by: Amp --- src/client/router.tsx | 11 +- src/client/routes/InterviewWorkspace.tsx | 7 + src/client/routes/KnowledgeWorkspace.test.tsx | 117 ++++++++++++++++ src/client/routes/KnowledgeWorkspace.tsx | 128 ++++++++++++++++++ 4 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 src/client/routes/KnowledgeWorkspace.test.tsx create mode 100644 src/client/routes/KnowledgeWorkspace.tsx diff --git a/src/client/router.tsx b/src/client/router.tsx index 817fb779..858341d7 100644 --- a/src/client/router.tsx +++ b/src/client/router.tsx @@ -4,6 +4,7 @@ import type { ProjectListItem } from '../shared/api-types.js'; import { DebugSurfaceRouteComponent } from './routes/debug-surface.js'; import { ExportPreview } from './routes/ExportPreview.js'; import { InterviewWorkspace } from './routes/InterviewWorkspace.js'; +import { KnowledgeWorkspace } from './routes/KnowledgeWorkspace.js'; import { ProjectList } from './routes/ProjectList.js'; import { fetchWorkspaceLoaderData } from './workspace/workspace-loader.js'; @@ -36,6 +37,14 @@ const projectRoute = createRoute({ component: InterviewWorkspace, }); +// Knowledge workspace — read-only review surface +const knowledgeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/project/$id/knowledge', + loader: async ({ params }) => fetchWorkspaceLoaderData(params.id), + component: KnowledgeWorkspace, +}); + // Export preview placeholder const exportRoute = createRoute({ getParentRoute: () => rootRoute, @@ -49,7 +58,7 @@ const debugRoute = createRoute({ component: DebugSurfaceRouteComponent, }); -const routeTree = rootRoute.addChildren([indexRoute, projectRoute, exportRoute, debugRoute]); +const routeTree = rootRoute.addChildren([indexRoute, projectRoute, knowledgeRoute, exportRoute, debugRoute]); export const router = createRouter({ routeTree }); diff --git a/src/client/routes/InterviewWorkspace.tsx b/src/client/routes/InterviewWorkspace.tsx index ad54bc93..88b4aa7a 100644 --- a/src/client/routes/InterviewWorkspace.tsx +++ b/src/client/routes/InterviewWorkspace.tsx @@ -298,6 +298,13 @@ export function InterviewWorkspace() { ← Projects

{project.name}

+ + Knowledge +
{(Object.entries(workflow.phases) as Array<[ProjectStateTurn['phase'], WorkflowPhaseState]>).map( ([phase, state]) => ( diff --git a/src/client/routes/KnowledgeWorkspace.test.tsx b/src/client/routes/KnowledgeWorkspace.test.tsx new file mode 100644 index 00000000..28c6a9a1 --- /dev/null +++ b/src/client/routes/KnowledgeWorkspace.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { EntitiesData } from '../../shared/api-types.js'; +import { KnowledgeWorkspaceView } from './KnowledgeWorkspace.js'; + +afterEach(() => { + cleanup(); +}); + +const emptyEntities: EntitiesData = { + goals: [], + terms: [], + contexts: [], + constraints: [], + requirements: [], + criteria: [], + decisions: [], + assumptions: [], + relationships: [], +}; + +describe('KnowledgeWorkspaceView', () => { + it('renders kind-grouped sections in registry order with labels and counts', () => { + const entities: EntitiesData = { + ...emptyEntities, + goals: [{ id: 1, project_id: 1, kind: 'goal', subtype: null, content: 'Ship MVP', rationale: null }], + terms: [ + { id: 2, project_id: 1, kind: 'term', subtype: null, content: 'Brunch', rationale: null }, + { id: 3, project_id: 1, kind: 'term', subtype: null, content: 'Observer', rationale: null }, + ], + }; + + render(); + + expect(screen.getByText('Goals')).toBeTruthy(); + expect(screen.getByText('1')).toBeTruthy(); + expect(screen.getByText('Ship MVP')).toBeTruthy(); + + expect(screen.getByText('Terms')).toBeTruthy(); + expect(screen.getByText('2')).toBeTruthy(); + expect(screen.getByText('Brunch')).toBeTruthy(); + expect(screen.getByText('Observer')).toBeTruthy(); + }); + + it('shows empty-state copy for kinds with no items', () => { + render(); + + expect(screen.getByText("No goals yet. They'll appear as the interview progresses.")).toBeTruthy(); + expect(screen.getByText("No terms yet. They'll appear as the interview progresses.")).toBeTruthy(); + }); + + it('renders review-status badges for requirements and criteria', () => { + const entities: EntitiesData = { + ...emptyEntities, + requirements: [ + { + id: 1, + project_id: 1, + kind: 'requirement', + subtype: null, + content: 'Export spec as markdown', + rationale: null, + reviewStatus: 'approved', + }, + { + id: 2, + project_id: 1, + kind: 'requirement', + subtype: null, + content: 'PDF export', + rationale: null, + reviewStatus: 'rejected', + }, + { + id: 3, + project_id: 1, + kind: 'requirement', + subtype: null, + content: 'Resume from SQLite', + rationale: null, + reviewStatus: 'pending', + }, + ], + }; + + render(); + + expect(screen.getByText('Approved')).toBeTruthy(); + expect(screen.getByText('Rejected')).toBeTruthy(); + expect(screen.getByText('Pending')).toBeTruthy(); + }); + + it('renders relationship context for items with edges', () => { + const entities: EntitiesData = { + ...emptyEntities, + decisions: [{ id: 1, project_id: 1, content: 'Use SQLite', rationale: null }], + assumptions: [{ id: 2, project_id: 1, content: 'Single-user only' }], + relationships: [ + { + type: 'depends_on', + source: { collection: 'decision', kind: 'decision', id: 1 }, + target: { collection: 'assumption', kind: 'assumption', id: 2 }, + }, + ], + }; + + render(); + + expect(screen.getByText('Use SQLite')).toBeTruthy(); + expect(screen.getByText('Depends on')).toBeTruthy(); + // "Single-user only" appears in both the assumptions section and the dependency list + expect(screen.getAllByText('Single-user only').length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/src/client/routes/KnowledgeWorkspace.tsx b/src/client/routes/KnowledgeWorkspace.tsx new file mode 100644 index 00000000..89cdeafa --- /dev/null +++ b/src/client/routes/KnowledgeWorkspace.tsx @@ -0,0 +1,128 @@ +import { Link, useLoaderData, useParams } from '@tanstack/react-router'; + +import { Badge } from '@/components/ui/badge'; + +import type { EntitiesData } from '../../shared/api-types.js'; +import { knowledgeKindRegistry, type KnowledgeCollectionKey } from '../../shared/knowledge.js'; + +function entityKey(collection: string, id: number) { + return `${collection}:${id}`; +} + +function buildContentMap(entities: EntitiesData) { + const map = new Map(); + for (const entry of knowledgeKindRegistry) { + for (const item of entities[entry.collectionKey]) { + map.set(entityKey(entry.entityCollection, item.id), item.content); + } + } + return map; +} + +function getDependencies( + entities: EntitiesData, + contentMap: Map, + sourceCollection: string, + sourceId: number, +) { + return entities.relationships + .filter( + (r) => r.type === 'depends_on' && r.source.collection === sourceCollection && r.source.id === sourceId, + ) + .map((r) => { + const key = entityKey(r.target.collection, r.target.id); + const label = contentMap.get(key); + return label ? { key, label } : null; + }) + .filter((d): d is { key: string; label: string } => d !== null); +} + +function ReviewBadge({ status }: { status: 'approved' | 'rejected' | 'pending' }) { + return ( + + {status === 'approved' ? 'Approved' : status === 'rejected' ? 'Rejected' : 'Pending'} + + ); +} + +export function KnowledgeWorkspaceView({ entities }: { entities: EntitiesData }) { + const contentMap = buildContentMap(entities); + + return ( +
+
+ {knowledgeKindRegistry.map((entry) => { + const items = entities[entry.collectionKey]; + return ( +
+
+

{entry.label}

+ {items.length > 0 && ( + + {items.length} + + )} +
+ + {items.length === 0 ? ( +

{entry.emptyStateCopy}

+ ) : ( +
+ {items.map((item) => { + const reviewStatus = 'reviewStatus' in item ? item.reviewStatus : undefined; + const rationale = 'rationale' in item ? item.rationale : undefined; + const subtype = 'subtype' in item ? item.subtype : undefined; + const deps = getDependencies(entities, contentMap, entry.entityCollection, item.id); + + return ( +
+
+

{item.content}

+ {reviewStatus && } +
+ {subtype &&

{subtype}

} + {rationale &&

{rationale}

} + {deps.length > 0 && ( +
+

Depends on

+
    + {deps.map((d) => ( +
  • {d.label}
  • + ))} +
+
+ )} +
+ ); + })} +
+ )} +
+ ); + })} +
+
+ ); +} + +export function KnowledgeWorkspace() { + const { id } = useParams({ from: '/project/$id/knowledge' }); + const { entitySnapshot } = useLoaderData({ from: '/project/$id/knowledge' }); + + return ( +
+
+ + ← Back to interview + +

Knowledge

+

Review captured knowledge items and relationships.

+
+ +
+ ); +} From ee2e794f8d183fd27486193dd5b42f816d3b68b6 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 10 Apr 2026 15:58:51 +0200 Subject: [PATCH 3/5] feat: spec export from reviewed knowledge layer Amp-Thread-ID: https://ampcode.com/threads/T-019d776b-9a7d-7521-b45c-ee24c4e45fca Co-authored-by: Amp --- src/client/routes/ExportPreview.tsx | 61 ++++++++++++- src/server/app.test.ts | 18 ++++ src/server/app.ts | 23 +++++ src/server/export.test.ts | 127 ++++++++++++++++++++++++++++ src/server/export.ts | 56 ++++++++++++ 5 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 src/server/export.test.ts create mode 100644 src/server/export.ts diff --git a/src/client/routes/ExportPreview.tsx b/src/client/routes/ExportPreview.tsx index 0dee1119..e569e1d7 100644 --- a/src/client/routes/ExportPreview.tsx +++ b/src/client/routes/ExportPreview.tsx @@ -1,15 +1,72 @@ -import { useParams, Link } from '@tanstack/react-router'; +import { useQuery } from '@tanstack/react-query'; +import { Link, useParams } from '@tanstack/react-router'; + +import { Button } from '@/components/ui/button'; export function ExportPreview() { const { id } = useParams({ from: '/project/$id/export' }); + const { data, isLoading } = useQuery({ + queryKey: ['export', id], + queryFn: async () => { + const res = await fetch(`/api/projects/${id}/export`); + if (!res.ok) throw new Error('Failed to load export'); + return res.json() as Promise<{ ready: boolean; markdown?: string }>; + }, + }); + + const handleDownload = () => { + if (!data?.markdown) return; + const blob = new Blob([data.markdown], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'spec.md'; + a.click(); + URL.revokeObjectURL(url); + }; + return (
← Back to project

Export Preview

-

Export coming soon.

+ + {isLoading &&

Loading...

} + + {data && !data.ready && ( +
+

+ Export is not available yet. All workflow phases must be closed before exporting. +

+ + Return to interview → + +
+ )} + + {data?.ready && data.markdown && ( +
+
+ + + Review knowledge → + +
+
+            {data.markdown}
+          
+
+ )}
); } diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 651737a3..13f31a11 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -261,6 +261,24 @@ describe('GET /api/projects', () => { }); }); +describe('GET /api/projects/:id/export', () => { + it('returns not ready when not all phases are closed', async () => { + const projectId = await createTestProject('In Progress'); + seedRequirementsReady(projectId); + const res = await request(app).get(`/api/projects/${projectId}/export`).expect(200); + expect(res.body).toEqual({ ready: false }); + }); + + it('returns ready with markdown when all phases are closed', async () => { + const projectId = await createTestProject('Done'); + seedAllPhasesClosed(projectId); + const res = await request(app).get(`/api/projects/${projectId}/export`).expect(200); + expect(res.body.ready).toBe(true); + expect(res.body.markdown).toContain('# Done'); + expect(res.body.markdown).toContain('Verify SQLite resume'); + }); +}); + describe('POST /api/projects/:id/chat', () => { it('requires typed UI messages', async () => { const projectId = await createTestProject(); diff --git a/src/server/app.ts b/src/server/app.ts index cdd5a18e..5198acf9 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -41,6 +41,7 @@ import { getEntitiesForProject, recordReviewFromTurnResponse, } from './db.js'; +import { isExportReady, renderExportMarkdown } from './export.js'; import { persistFallbackQuestionText, streamInterviewer } from './interview.js'; import { runObserver } from './observer.js'; import { serializeParts } from './parts.js'; @@ -151,6 +152,28 @@ export function createApp(dbPath?: string) { res.json(getEntitiesForProject(db, id) satisfies EntitiesData); }); + // Export spec as markdown + app.get('/api/projects/:id/export', (req: Request, res: Response) => { + const id = Number(req.params.id); + if (Number.isNaN(id)) { + res.status(400).json({ error: 'Invalid project ID' }); + return; + } + const projectState = getProjectState(db, id); + if (!projectState) { + res.status(404).json({ error: 'Project not found' }); + return; + } + const ready = isExportReady(projectState.workflow); + if (!ready) { + res.json({ ready: false }); + return; + } + const entities = getEntitiesForProject(db, id); + const markdown = renderExportMarkdown(projectState.project.name, entities, projectState.workflow); + res.json({ ready: true, markdown }); + }); + // Conduct turn for a specific project app.post('/api/projects/:id/chat', async (req: Request, res: Response) => { const id = Number(req.params.id); diff --git a/src/server/export.test.ts b/src/server/export.test.ts new file mode 100644 index 00000000..0ee20943 --- /dev/null +++ b/src/server/export.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; + +import type { EntitiesData } from '../shared/api-types.js'; +import type { WorkflowState } from './db.js'; +import { renderExportMarkdown } from './export.js'; + +function createClosedPhase(basis: string = 'interviewer_recommended') { + return { + status: 'closed' as const, + closeability: true, + readiness: 'high' as const, + closureBasis: basis, + proposalPending: false, + turnId: 1, + summary: 'Phase completed.', + }; +} + +function createAllClosedWorkflow(overrides?: Partial>): WorkflowState { + return { + phases: { + scope: createClosedPhase(), + design: createClosedPhase(), + requirements: createClosedPhase(), + criteria: createClosedPhase(), + ...overrides, + }, + } as WorkflowState; +} + +const emptyEntities: EntitiesData = { + goals: [], + terms: [], + contexts: [], + constraints: [], + requirements: [], + criteria: [], + decisions: [], + assumptions: [], + relationships: [], +}; + +describe('renderExportMarkdown', () => { + it('renders kind-grouped sections from entities', () => { + const entities: EntitiesData = { + ...emptyEntities, + goals: [{ id: 1, project_id: 1, kind: 'goal', subtype: null, content: 'Ship MVP', rationale: null }], + requirements: [ + { + id: 2, + project_id: 1, + kind: 'requirement', + subtype: null, + content: 'Resume from SQLite', + rationale: null, + reviewStatus: 'approved', + }, + ], + decisions: [{ id: 3, project_id: 1, content: 'Use SQLite', rationale: 'Zero config' }], + }; + + const md = renderExportMarkdown('Test Project', entities, createAllClosedWorkflow()); + + expect(md).toContain('# Test Project'); + expect(md).toContain('## Goals'); + expect(md).toContain('Ship MVP'); + expect(md).toContain('## Requirements'); + expect(md).toContain('Resume from SQLite'); + expect(md).toContain('## Decisions'); + expect(md).toContain('Use SQLite'); + }); + + it('omits empty kind sections', () => { + const entities: EntitiesData = { + ...emptyEntities, + goals: [{ id: 1, project_id: 1, kind: 'goal', subtype: null, content: 'Ship MVP', rationale: null }], + }; + + const md = renderExportMarkdown('Test', entities, createAllClosedWorkflow()); + + expect(md).toContain('## Goals'); + expect(md).not.toContain('## Terms'); + expect(md).not.toContain('## Requirements'); + }); + + it('includes closure caveats for forced-close phases', () => { + const workflow = createAllClosedWorkflow({ + design: createClosedPhase('user_forced'), + }); + + const md = renderExportMarkdown('Test', emptyEntities, workflow); + + expect(md).toContain('design'); + expect(md).toContain('user-forced'); + }); + + it('includes review status for requirements and criteria', () => { + const entities: EntitiesData = { + ...emptyEntities, + requirements: [ + { + id: 1, + project_id: 1, + kind: 'requirement', + subtype: null, + content: 'Export spec', + rationale: null, + reviewStatus: 'approved', + }, + { + id: 2, + project_id: 1, + kind: 'requirement', + subtype: null, + content: 'PDF export', + rationale: null, + reviewStatus: 'rejected', + }, + ], + }; + + const md = renderExportMarkdown('Test', entities, createAllClosedWorkflow()); + + expect(md).toMatch(/Export spec.*approved/i); + expect(md).toMatch(/PDF export.*rejected/i); + }); +}); diff --git a/src/server/export.ts b/src/server/export.ts new file mode 100644 index 00000000..8f0d8ac5 --- /dev/null +++ b/src/server/export.ts @@ -0,0 +1,56 @@ +import { bold, h1, h2, h3, ul } from 'md-pen'; + +import type { EntitiesData } from '../shared/api-types.js'; +import { knowledgeKindRegistry } from '../shared/knowledge.js'; +import type { WorkflowState } from './db.js'; + +function renderItem(item: { content: string; rationale?: string | null; reviewStatus?: string }): string { + const parts = [item.content]; + if (item.reviewStatus) { + parts.push(`(${item.reviewStatus})`); + } + if (item.rationale) { + parts.push(`— ${item.rationale}`); + } + return parts.join(' '); +} + +function renderCaveats(workflow: WorkflowState): string { + const caveats: string[] = []; + for (const [phase, state] of Object.entries(workflow.phases)) { + if (state.closureBasis && state.closureBasis !== 'interviewer_recommended') { + caveats.push(`${bold(phase)} was closed via user-forced closure`); + } + } + if (caveats.length === 0) return ''; + return `${h3('Closure Caveats')}\n\n${ul(caveats)}\n`; +} + +export function renderExportMarkdown( + projectName: string, + entities: EntitiesData, + workflow: WorkflowState, +): string { + const sections: string[] = [h1(projectName), '']; + + const caveatSection = renderCaveats(workflow); + if (caveatSection) { + sections.push(caveatSection); + } + + for (const entry of knowledgeKindRegistry) { + const items = entities[entry.collectionKey]; + if (items.length === 0) continue; + + sections.push(h2(entry.label)); + sections.push(''); + sections.push(ul(items.map(renderItem))); + sections.push(''); + } + + return sections.join('\n'); +} + +export function isExportReady(workflow: WorkflowState): boolean { + return Object.values(workflow.phases).every((phase) => phase.status === 'closed'); +} From b285a36d5bb1388923e1ac1cce7caf982dc725f3 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 10 Apr 2026 15:59:24 +0200 Subject: [PATCH 4/5] chore: mark 12a + 12b done, retire CARDS.md Amp-Thread-ID: https://ampcode.com/threads/T-019d776b-9a7d-7521-b45c-ee24c4e45fca Co-authored-by: Amp --- memory/CARDS.md | 112 ------------------------------------------------ memory/PLAN.md | 4 +- 2 files changed, 2 insertions(+), 114 deletions(-) delete mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index fa4e0005..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,112 +0,0 @@ - - -# Scope Cards - -## Card 12a: Knowledge workspace review surface - -### Target Behavior - -A dedicated phase-oriented knowledge workspace at `/project/:id/knowledge` lets the user inspect, review-state-badge, and browse all canonical knowledge items grouped by kind, with relationship context visible per item. - -### Boundary Crossings - -``` -→ [GET /api/projects/:id/entities] — existing entities API already returns all 8 kind collections + relationships + review status -→ [src/client/router.tsx] — new route /project/$id/knowledge with route loader -→ [src/client/routes/KnowledgeWorkspace] — phase-grouped list/detail view consuming EntitiesData -→ [src/client/routes/InterviewWorkspace] — link/nav to knowledge workspace from sidebar or header -→ [EntitiesData / knowledge registry] — shared types drive kind grouping and labels -``` - -### Risks and Assumptions - -``` -- RISK: the existing entities API returns all items (not active-path-filtered for non-review kinds like goal/term/context/constraint) - → MITIGATION: the API already reads from knowledge_item which stores all captured items; active-path filtering for the review surface can be deferred to 13a since the first workspace is read-only inspection, not edit/invalidation -- RISK: decision/assumption entities use a different shape (Decision/Assumption) from KnowledgeItem - → MITIGATION: the workspace can use the existing registry + EntitiesData shape which already handles this split; normalize display per-kind using the registry -- ASSUMPTION: a read-only inspection workspace with review-status badges is sufficient for the first slice — no inline edit, no review-action mutations from this surface - → VALIDATE: this matches D63 and D69 which say the first workspace is list/detail review, not graph-canvas-first, and 13a owns richer review actions -``` - -### Acceptance Criteria - -``` -✓ knowledge-route-exists — /project/:id/knowledge loads and renders without error -✓ kind-grouped-display — items are grouped by kind in registry order, each group shows label and item count -✓ review-status-badges — requirements and criteria show approved/rejected/pending badges matching the sidebar -✓ relationship-context — at least one relationship type (depends_on) is visible per item that has edges -✓ empty-state — kinds with no items show the registry's emptyStateCopy -✓ navigation — the interview workspace links to the knowledge workspace and vice versa -✓ existing-tests-pass — npm run verify passes; no regression in existing workspace/sidebar/entity tests -``` - -### Verification Approach - -``` -- Inner: route/component tests — knowledge workspace renders kind-grouped items with correct badges from mock EntitiesData -- Inner: type checking — route loader and component props match existing EntitiesData shape -- Inner: lint + fmt + build — standard pipeline -- Outer: manual walkthrough — seed a criteria-ready project, navigate to knowledge workspace, verify kind groups, badges, relationships, empty states, and nav links -``` - ---- - -## Card 12b: Spec export from the reviewed knowledge layer - -### Target Behavior - -The export route at `/project/:id/export` renders a markdown preview of the reviewed knowledge layer from the active path, gates export behind a readiness predicate (all phases closed), and offers a download button for the `.md` file. - -### Boundary Crossings - -``` -→ [GET /api/projects/:id/entities] — existing entities API returns all knowledge + review status -→ [GET /api/projects/:id] — existing project state API returns workflow state with per-phase status/closureBasis -→ [new: GET /api/projects/:id/export] — server-side markdown rendering from entities + workflow + phase outcomes -→ [src/server/export.ts] — pure function: (entities, workflow, project) → markdown string using md-pen -→ [src/client/routes/ExportPreview.tsx] — replace placeholder with markdown preview + download button + readiness gate -→ [src/client/router.tsx] — export route loader fetches export data -``` - -### Risks and Assumptions - -``` -- RISK: the readiness predicate (all phases closed) may be too strict or too loose for the first version - → MITIGATION: start with the simplest rule: all 4 phases must have status === 'closed'; refine in 13a if needed -- RISK: md-pen API surface is unfamiliar — need to verify it supports the rendering primitives we need - → MITIGATION: md-pen is already a dependency; check its exports before building. Fallback: plain string concatenation for the first cut -- ASSUMPTION: export renders from the existing entities API shape without needing a separate export-specific query - → VALIDATE: the entities API already returns kind-grouped collections with review status and relationships; if that's sufficient, no new DB query needed -- ASSUMPTION: closure caveats (forced-close, low-readiness) are visible in the export when closureBasis !== 'interviewer_recommended' - → VALIDATE: workflow state already carries closureBasis per phase; the export renderer can read it -``` - -### Acceptance Criteria - -``` -✓ export-not-ready — when any phase is not closed, the export route shows a "not ready" message with per-phase status -✓ export-renders-markdown — when all phases are closed, the export route renders a markdown preview grouped by knowledge kind -✓ export-includes-caveats — phases closed with basis !== 'interviewer_recommended' show a caveat note in the export -✓ export-download — a download button produces a .md file with the same content as the preview -✓ export-api — GET /api/projects/:id/export returns { ready: boolean, markdown?: string } with the readiness predicate and rendered content -✓ existing-tests-pass — npm run verify passes -``` - -### Verification Approach - -``` -- Inner: export rendering tests — pure function tests: entities + workflow → expected markdown sections, caveat inclusion, empty-kind handling -- Inner: export API route tests — readiness gate returns not-ready when phases aren't all closed; returns markdown when ready -- Inner: type checking + lint + fmt + build -- Outer: manual walkthrough — seed an all-phases-closed project (using npm run seed), navigate to export, verify preview content matches seeded knowledge, download the file, verify it opens correctly -- Outer: manual not-ready walkthrough — seed a criteria-ready project, navigate to export, verify the gate blocks export with clear per-phase status -``` - ---- - -## Build Order - -**12a then 12b.** Both share one branch (FE-574). Card 12a establishes the knowledge workspace which 12b's export route can link to ("review your knowledge before exporting"). Card 12b's readiness gate and export renderer are independent of 12a's UI, but having the knowledge workspace available makes the outer-loop export verification richer. diff --git a/memory/PLAN.md b/memory/PLAN.md index 1dac0541..585c3b7f 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -189,7 +189,7 @@ - Acceptance: the project list shows each project's per-phase status/readiness/closure-basis summary from persisted readiness artifacts plus live workflow projection, distinguishes forced-close or low-readiness debt from ordinary closed state, and updates correctly after refresh/resume - **Verification approach**: inner — workflow-summary projection tests plus project-list route/component tests. Outer — manual multi-project walkthrough covering in-progress, forced-close debt, invalidated, and export-ready states. -12a. **Knowledge workspace review surface** `FE-574` — Read-only phase-oriented workspace at `/project/:id/knowledge` for inspecting canonical knowledge items grouped by kind, with review-status badges and relationship context. The sidebar remains a compact summary; this is the first dedicated review surface (D63, D69). Assumes the redesigned knowledge ontology/graph from 7a + 7b. `not-started` +12a. **Knowledge workspace review surface** `FE-574` `done` — Read-only phase-oriented workspace at `/project/:id/knowledge` for inspecting canonical knowledge items grouped by kind, with review-status badges and relationship context. The sidebar remains a compact summary; this is the first dedicated review surface (D63, D69). Assumes the redesigned knowledge ontology/graph from 7a + 7b. - Requirements: → SPEC.md §Requirements #6, #11, #12, #13 - Assumptions: → SPEC.md §Assumptions A14, A40 - Decisions: → SPEC.md §Decisions D5, D17, D61, D63, D67, D68, D69 @@ -198,7 +198,7 @@ - Acceptance: navigate to knowledge workspace, see kind-grouped items with review badges and dependency edges, navigate back to interview; no new mutations or edit actions in this slice - **Verification approach**: inner — route/component tests with mock EntitiesData. Outer — manual walkthrough from seeded project. -12b. **Spec export from the reviewed knowledge layer** `FE-574` — Render markdown export from active-path reviewed knowledge items and explicit phase outcomes, including closure caveats when a mode was closed with low readiness or user-forced basis. Export is enabled only when all phases are closed. `not-started` +12b. **Spec export from the reviewed knowledge layer** `FE-574` `done` — Render markdown export from active-path reviewed knowledge items and explicit phase outcomes, including closure caveats when a mode was closed with low readiness or user-forced basis. Export is enabled only when all phases are closed. - Requirements: → SPEC.md §Requirements #13 - Assumptions: — - Decisions: → SPEC.md §Decisions D5, D17, D26, D65, D66, D70 From f30c4ff99d8719e823e922d7248b14d2c6bff76c Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 10 Apr 2026 16:03:31 +0200 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20ln-sync=20=E2=80=94=20update=20cov?= =?UTF-8?q?erage=20counts=20for=2012a=20+=2012b?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amp-Thread-ID: https://ampcode.com/threads/T-019d776b-9a7d-7521-b45c-ee24c4e45fca Co-authored-by: Amp --- memory/SPEC.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/memory/SPEC.md b/memory/SPEC.md index d40998f7..d9f342e5 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -542,7 +542,7 @@ This projection difference is a deliberate design choice, not an implementation | ----------------------------- | ----- | ----------------------------------------------------- | | db.test.ts | 43 | I5, I6, I9, I10, I11, I20, I48, I54, I72, I87, I98 | | knowledge.test.ts | 1 | I48 | -| app.test.ts | 39 | I1, I2, I3, I7, I14, I21, I23, I44, I48, I54, I72, I87, I98, I99 | +| app.test.ts | 41 | I1, I2, I3, I7, I14, I21, I23, I44, I48, I54, I72, I87, I98, I99 | | core.test.ts | 10 | I12, I13, I18, I72, I87 | | interview.test.ts | 10 | I16, I72, I87 | | parts.test.ts | 15 | I17, I18, I44, I54, I72 | @@ -562,6 +562,8 @@ This projection difference is a deliberate design choice, not an implementation | message.test.tsx | 2 | I24, I27 | | build-boundary.test.ts | 1 | I24, I28, I30, I32 | | capability-boundaries.test.ts | 2 | I24, I29 | +| KnowledgeWorkspace.test.tsx | 4 | I48 | +| export.test.ts | 4 | D26, D65, D66, D70 | ## Acceptance Criteria (exit conditions)