project-knowledge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/INDEX.md +53 -0
  3. package/README.md +79 -0
  4. package/_site/README.md +63 -0
  5. package/_site/_test/ai-profile-test.js +199 -0
  6. package/_site/_test/baseline-schema-test.js +132 -0
  7. package/_site/_test/commit-analysis-test.js +184 -0
  8. package/_site/_test/context-pack-test.js +199 -0
  9. package/_site/_test/draft-apply-test.js +363 -0
  10. package/_site/_test/git-validation-test.js +171 -0
  11. package/_site/_test/hook-trigger-test.js +257 -0
  12. package/_site/_test/initial-analysis-test.js +228 -0
  13. package/_site/_test/job-orchestrator-test.js +297 -0
  14. package/_site/_test/kb-v2-templates-test.js +189 -0
  15. package/_site/_test/pr-consumer-contract-test.js +236 -0
  16. package/_site/_test/run-all-tests.js +135 -0
  17. package/_site/_test/scanner-test.js +206 -0
  18. package/_site/_test/ui-smoke-test.js +237 -0
  19. package/_site/_test/ui-test.js +237 -0
  20. package/_site/index.html +1166 -0
  21. package/_site/lib/ai-adapter.js +287 -0
  22. package/_site/lib/analysis-orchestrator.js +433 -0
  23. package/_site/lib/context-pack-builder.js +290 -0
  24. package/_site/lib/draft-apply.js +219 -0
  25. package/_site/lib/git-runner.js +26 -0
  26. package/_site/lib/hook-manager.js +148 -0
  27. package/_site/lib/job-orchestrator.js +231 -0
  28. package/_site/lib/kb-validator.js +224 -0
  29. package/_site/lib/llm-client.js +126 -0
  30. package/_site/lib/scanner.js +94 -0
  31. package/_site/scripts/hook-trigger.js +133 -0
  32. package/_site/scripts/safe-runner.js +151 -0
  33. package/_site/server.js +1058 -0
  34. package/_site/start.bat +26 -0
  35. package/_site/stop.bat +11 -0
  36. package/ai-profiles.json +18 -0
  37. package/docs/ai-knowledge-base-system-design.md +395 -0
  38. package/docs/pr-consumer-contract.md +198 -0
  39. package/docs/project-goal.md +72 -0
  40. package/docs/project-registry-schema.md +46 -0
  41. package/docs/testing-strategy.md +169 -0
  42. package/iterations.json +23 -0
  43. package/package.json +47 -0
  44. package/scripts/gen-commit-doc.ps1 +178 -0
  45. package/scripts/gen-commit-doc.sh +197 -0
  46. package/scripts/list-features.ps1 +41 -0
  47. package/scripts/register-scheduled-task.bat +5 -0
  48. package/templates/change.md +59 -0
  49. package/templates/commit-feature.md +56 -0
  50. package/templates/feature.md +44 -0
  51. package/templates/framework.md +80 -0
  52. package/templates/index-header.md +3 -0
  53. package/templates/kb-manifest.json +38 -0
  54. package/templates/module.md +58 -0
  55. package/templates/project-analysis.md +48 -0
  56. package/templates/project-goal.md +55 -0
  57. package/templates/project-readme.md +60 -0
  58. package/templates/quality-review-rules.md +37 -0
  59. package/templates/update-entry.md +7 -0
@@ -0,0 +1,297 @@
1
+ // TASK-010: Job orchestrator test
2
+ // Run: node _site/_test/job-orchestrator-test.js
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawn } = require('child_process');
6
+ const { makeRepo } = require('./fixtures/make-git-repos');
7
+ const {
8
+ runJob, makeJob, readJobLog, appendJobLog, KNOWN_MODES,
9
+ } = require('../lib/job-orchestrator');
10
+ const { runInitialAnalysis, runCommitAnalysis, readRun, listDrafts } = require('../lib/analysis-orchestrator');
11
+
12
+ const ROOT = path.resolve(__dirname, '..', '..');
13
+ const SERVER = path.join(ROOT, '_site', 'server.js');
14
+ const PROJECTS_JSON = path.join(ROOT, 'projects.json');
15
+ const PORT = process.env.KB_JOB_TEST_PORT || '7800';
16
+ const BASE_URL = `http://127.0.0.1:${PORT}`;
17
+ const TEMP_SLUG = 'task-010-temp';
18
+
19
+ function assert(cond, msg) { if (!cond) throw new Error(msg); }
20
+
21
+ async function waitForServer() {
22
+ const deadline = Date.now() + 15000;
23
+ let lastError;
24
+ while (Date.now() < deadline) {
25
+ try {
26
+ const res = await fetch(`${BASE_URL}/api/state`);
27
+ if (res.ok) return;
28
+ lastError = new Error(`HTTP ${res.status}`);
29
+ } catch (e) { lastError = e; }
30
+ await new Promise(r => setTimeout(r, 250));
31
+ }
32
+ throw lastError || new Error('server did not start');
33
+ }
34
+
35
+ async function json(method, url, body) {
36
+ const res = await fetch(`${BASE_URL}${url}`, {
37
+ method,
38
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
39
+ body: body ? JSON.stringify(body) : undefined,
40
+ });
41
+ const text = await res.text();
42
+ let data = {};
43
+ if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
44
+ return { res, data };
45
+ }
46
+
47
+ async function cleanup() {
48
+ const base = path.join(ROOT, 'projects', TEMP_SLUG);
49
+ fs.rmSync(base, { recursive: true, force: true });
50
+ const cur = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
51
+ if (cur[TEMP_SLUG]) {
52
+ delete cur[TEMP_SLUG];
53
+ fs.writeFileSync(PROJECTS_JSON, JSON.stringify(cur, null, 2) + '\n', 'utf-8');
54
+ }
55
+ const log = path.join(ROOT, '.jobs-log.json');
56
+ if (fs.existsSync(log)) fs.unlinkSync(log);
57
+ }
58
+
59
+ function makeProjectsDir(slug) {
60
+ const base = path.join(ROOT, 'projects', slug);
61
+ fs.rmSync(base, { recursive: true, force: true });
62
+ fs.mkdirSync(base, { recursive: true });
63
+ fs.writeFileSync(path.join(base, 'project-goal.md'), '# Goal\n');
64
+ fs.mkdirSync(path.join(base, 'features'), { recursive: true });
65
+ fs.mkdirSync(path.join(base, 'changes'), { recursive: true });
66
+ return base;
67
+ }
68
+
69
+ (async () => {
70
+ assert(fs.existsSync(SERVER), 'server.js missing');
71
+
72
+ // 1. KNOWN_MODES lists the expected set
73
+ for (const m of ['legacy', 'scan', 'analyze-initial', 'analyze-commits', 'safe']) {
74
+ assert(KNOWN_MODES.has(m), `KNOWN_MODES should include ${m}`);
75
+ }
76
+
77
+ // 2. makeJob returns a job with the expected shape
78
+ const j = makeJob({ mode: 'scan', slug: 'ALL' });
79
+ assert(j.jobId && j.jobId.startsWith('job-'), 'jobId should start with job-');
80
+ assert(j.mode === 'scan' && j.slug === 'ALL', 'mode and slug set');
81
+ assert(j.status === 'running', 'initial status is running');
82
+ assert(typeof j.startTime === 'string', 'startTime is an ISO string');
83
+
84
+ // 3. appendJobLog / readJobLog round-trip
85
+ const log = path.join(ROOT, '.jobs-log-test.json');
86
+ if (fs.existsSync(log)) fs.unlinkSync(log);
87
+ appendJobLog(log, { jobId: 'job-a', status: 'success' });
88
+ appendJobLog(log, { jobId: 'job-b', status: 'failed' });
89
+ const readBack = readJobLog(log);
90
+ assert(readBack.length === 2, `expected 2 entries, got ${readBack.length}`);
91
+ assert(readBack[0].jobId === 'job-a' && readBack[1].jobId === 'job-b', 'entries in order');
92
+ fs.unlinkSync(log);
93
+
94
+ // 4. runJob with unknown mode returns failure but still records
95
+ const badJob = makeJob({ mode: 'no-such-mode', slug: 'ALL' });
96
+ const badResult = await runJob({
97
+ job: badJob,
98
+ projects: {},
99
+ projectsPath: PROJECTS_JSON,
100
+ jobsLogPath: log,
101
+ });
102
+ assert(!badResult.summary || badResult.summary.error, 'unknown mode should set summary.error');
103
+ assert(badResult.status === 'failed', 'unknown mode should fail');
104
+ fs.unlinkSync(log);
105
+
106
+ // ----- Integration: scan mode -----
107
+ const repo = makeRepo({ kind: 'multi-commit' });
108
+ const kbBase = makeProjectsDir(TEMP_SLUG);
109
+ const project = {
110
+ slug: TEMP_SLUG,
111
+ kbPath: kbBase,
112
+ gitPath: repo.path,
113
+ localPath: repo.path,
114
+ aiProfileId: 'mock-agent',
115
+ enabled: true,
116
+ headCommit: null,
117
+ lastSeenCommit: null,
118
+ lastAnalyzedCommit: null,
119
+ };
120
+ const projectsMap = { [TEMP_SLUG]: project };
121
+
122
+ const scanJob = makeJob({ mode: 'scan', slug: TEMP_SLUG });
123
+ const scanResult = await runJob({
124
+ job: scanJob,
125
+ projects: projectsMap,
126
+ projectsPath: PROJECTS_JSON,
127
+ jobsLogPath: log,
128
+ });
129
+ assert(scanResult.status === 'success', `scan should succeed, got ${scanResult.status}`);
130
+ assert(scanResult.summary && scanResult.summary.scanned === 1, `expected 1 scanned, got ${JSON.stringify(scanResult.summary)}`);
131
+ assert(projectsMap[TEMP_SLUG].headCommit, 'scan should populate headCommit');
132
+ assert(projectsMap[TEMP_SLUG].lastSeenCommit, 'scan should populate lastSeenCommit');
133
+ assert(projectsMap[TEMP_SLUG].lastScanPendingCount === 3, `expected 3 pending, got ${projectsMap[TEMP_SLUG].lastScanPendingCount}`);
134
+
135
+ // 5. safe mode runs scan + analyze-commits, and crucially does NOT apply drafts
136
+ // Reset the project so analyze-commits sees 3 pending
137
+ projectsMap[TEMP_SLUG].lastAnalyzedCommit = null;
138
+ const safeJob = makeJob({ mode: 'safe', slug: TEMP_SLUG });
139
+ const safeResult = await runJob({
140
+ job: safeJob,
141
+ projects: projectsMap,
142
+ projectsPath: PROJECTS_JSON,
143
+ jobsLogPath: log,
144
+ });
145
+ assert(safeResult.status === 'success', `safe should succeed, got ${safeResult.status}: ${safeResult.summary ? JSON.stringify(safeResult.summary) : ''}`);
146
+ assert(safeResult.summary && safeResult.summary.scan && safeResult.summary.commits, 'safe should include both scan and commits summaries');
147
+ assert(safeResult.summary.applied === 0, `safe must apply 0 drafts, got ${safeResult.summary.applied}`);
148
+ // Drafts were created under _ai/drafts/ but NOT applied to the formal KB
149
+ // Verify there is at least one draft on disk
150
+ const runs = fs.readdirSync(path.join(kbBase, '_ai', 'runs'));
151
+ const appliedRuns = runs.filter(r => {
152
+ try { return JSON.parse(fs.readFileSync(path.join(kbBase, '_ai', 'runs', r), 'utf-8')).applyStatus === 'applied'; }
153
+ catch { return false; }
154
+ });
155
+ assert(appliedRuns.length === 0, `safe mode must NOT apply drafts; found ${appliedRuns.length} applied run(s)`);
156
+ // Verify the formal KB does NOT have any features/<slug>.md or changes/<short>.md (except those from the init template)
157
+ const featuresDir = path.join(kbBase, 'features');
158
+ const changesDir = path.join(kbBase, 'changes');
159
+ const realFeatureFiles = fs.existsSync(featuresDir)
160
+ ? fs.readdirSync(featuresDir).filter(f => f !== '00-index.md')
161
+ : [];
162
+ const realChangeFiles = fs.existsSync(changesDir)
163
+ ? fs.readdirSync(changesDir).filter(f => f !== '00-index.md')
164
+ : [];
165
+ assert(realFeatureFiles.length === 0, `safe mode must not write features files; found: ${realFeatureFiles.join(',')}`);
166
+ assert(realChangeFiles.length === 0, `safe mode must not write changes files; found: ${realChangeFiles.join(',')}`);
167
+
168
+ // 6. analyze-commits mode runs analysis and creates drafts
169
+ projectsMap[TEMP_SLUG].lastAnalyzedCommit = null;
170
+ const acJob = makeJob({ mode: 'analyze-commits', slug: TEMP_SLUG });
171
+ const acResult = await runJob({
172
+ job: acJob,
173
+ projects: projectsMap,
174
+ projectsPath: PROJECTS_JSON,
175
+ jobsLogPath: log,
176
+ });
177
+ assert(acResult.status === 'success', `analyze-commits should succeed, got ${acResult.status}`);
178
+ assert(acResult.summary && acResult.summary.ran === 1, `expected 1 ran, got ${JSON.stringify(acResult.summary)}`);
179
+
180
+ // 7. analyze-initial mode skips a project that already has a goal
181
+ const aiJob = makeJob({ mode: 'analyze-initial', slug: TEMP_SLUG });
182
+ const aiResult = await runJob({
183
+ job: aiJob,
184
+ projects: projectsMap,
185
+ projectsPath: PROJECTS_JSON,
186
+ jobsLogPath: log,
187
+ });
188
+ assert(aiResult.summary && aiResult.summary.skipped === 1, `analyze-initial should skip project with existing goal; got ${JSON.stringify(aiResult.summary)}`);
189
+
190
+ // 8. analyze-initial on a project with no goal creates an initial run
191
+ const noGoalSlug = TEMP_SLUG + '-no-goal';
192
+ const noGoalBase = makeProjectsDir(noGoalSlug);
193
+ fs.unlinkSync(path.join(noGoalBase, 'project-goal.md'));
194
+ const noGoalRepo = makeRepo({ kind: 'one-commit' });
195
+ projectsMap[noGoalSlug] = {
196
+ slug: noGoalSlug,
197
+ kbPath: noGoalBase,
198
+ gitPath: noGoalRepo.path,
199
+ localPath: noGoalRepo.path,
200
+ aiProfileId: 'mock-agent',
201
+ enabled: true,
202
+ };
203
+ const aiJob2 = makeJob({ mode: 'analyze-initial', slug: noGoalSlug });
204
+ const aiResult2 = await runJob({
205
+ job: aiJob2,
206
+ projects: projectsMap,
207
+ projectsPath: PROJECTS_JSON,
208
+ jobsLogPath: log,
209
+ });
210
+ assert(aiResult2.summary && aiResult2.summary.ran === 1, `analyze-initial should run on a no-goal project; got ${JSON.stringify(aiResult2.summary)}`);
211
+ // Note: the orchestrator does not write project-goal.md; it writes a draft only.
212
+ assert(!fs.existsSync(path.join(noGoalBase, 'project-goal.md')), 'analyze-initial must NOT create project-goal.md directly (it creates a draft)');
213
+
214
+ // 9. scan with slug=ALL iterates enabled projects
215
+ projectsMap[TEMP_SLUG].headCommit = null;
216
+ projectsMap[TEMP_SLUG].lastSeenCommit = null;
217
+ const scanAllJob = makeJob({ mode: 'scan', slug: 'ALL' });
218
+ const scanAllResult = await runJob({
219
+ job: scanAllJob,
220
+ projects: projectsMap,
221
+ projectsPath: PROJECTS_JSON,
222
+ jobsLogPath: log,
223
+ });
224
+ assert(scanAllResult.summary.scanned >= 1, 'scan ALL should hit at least the test project');
225
+
226
+ // 10. unknown slug returns a clear error
227
+ const badSlugJob = makeJob({ mode: 'scan', slug: 'no-such-slug' });
228
+ const badSlugResult = await runJob({
229
+ job: badSlugJob,
230
+ projects: projectsMap,
231
+ projectsPath: PROJECTS_JSON,
232
+ jobsLogPath: log,
233
+ });
234
+ assert(badSlugResult.status === 'failed', 'scan on unknown slug should fail');
235
+
236
+ // 11. Job log persisted
237
+ const persistedLog = readJobLog(log);
238
+ assert(persistedLog.length >= 5, `expected ≥5 persisted jobs, got ${persistedLog.length}`);
239
+
240
+ repo.cleanup();
241
+ noGoalRepo.cleanup();
242
+ fs.rmSync(kbBase, { recursive: true, force: true });
243
+ fs.rmSync(noGoalBase, { recursive: true, force: true });
244
+ fs.unlinkSync(log);
245
+
246
+ // ----- Server tests -----
247
+ const child = spawn(process.execPath, [SERVER], {
248
+ cwd: ROOT,
249
+ env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
250
+ stdio: ['ignore', 'pipe', 'pipe'],
251
+ windowsHide: true,
252
+ });
253
+ let serverOutput = '';
254
+ child.stdout.on('data', d => { serverOutput += d.toString(); });
255
+ child.stderr.on('data', d => { serverOutput += d.toString(); });
256
+
257
+ try {
258
+ await cleanup();
259
+ await waitForServer();
260
+
261
+ // 12. /api/jobs/run dispatches a job and returns jobId
262
+ r = await json('POST', '/api/jobs/run', { mode: 'scan', slug: 'ALL' });
263
+ assert(r.res.ok, `jobs/run should succeed: ${JSON.stringify(r.data)}`);
264
+ assert(r.data.jobId && r.data.jobId.startsWith('job-'), 'should return jobId');
265
+ assert(r.data.mode === 'scan' && r.data.slug === 'ALL', 'should echo mode and slug');
266
+ const dispatchedJobId = r.data.jobId;
267
+
268
+ // 13. /api/jobs returns history
269
+ r = await json('GET', '/api/jobs');
270
+ assert(r.res.ok, 'GET /api/jobs should succeed');
271
+ assert(Array.isArray(r.data.history), 'history should be an array');
272
+ assert(Array.isArray(r.data.knownModes), 'knownModes should be an array');
273
+ assert(r.data.knownModes.includes('safe'), 'knownModes should include safe');
274
+
275
+ // 14. /api/jobs/:jobId returns the job (live or persisted)
276
+ r = await json('GET', `/api/jobs/${dispatchedJobId}`);
277
+ assert(r.res.ok, `GET job should succeed: ${JSON.stringify(r.data)}`);
278
+ assert(r.data.job.jobId === dispatchedJobId, 'should return the right job');
279
+
280
+ // 15. unknown mode returns 400
281
+ r = await json('POST', '/api/jobs/run', { mode: 'no-such-mode' });
282
+ assert(!r.res.ok && r.res.status === 400, 'unknown mode should 400');
283
+
284
+ // 16. unknown jobId returns 404
285
+ r = await json('GET', '/api/jobs/job-999999-9999');
286
+ assert(!r.res.ok && r.res.status === 404, 'unknown job should 404');
287
+
288
+ console.log('TASK-010 job orchestrator test passed');
289
+ } catch (e) {
290
+ console.error('TASK-010 job orchestrator test failed:', e.message);
291
+ if (serverOutput) console.error(serverOutput);
292
+ process.exitCode = 1;
293
+ } finally {
294
+ await cleanup().catch(() => {});
295
+ child.kill();
296
+ }
297
+ })();
@@ -0,0 +1,189 @@
1
+ // TASK-003: KB v2 templates test
2
+ // Run: node _site/_test/kb-v2-templates-test.js
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawn } = require('child_process');
6
+
7
+ const ROOT = path.resolve(__dirname, '..', '..');
8
+ const SERVER = path.join(ROOT, '_site', 'server.js');
9
+ const PROJECTS_JSON = path.join(ROOT, 'projects.json');
10
+ const PORT = process.env.KB_V2_TEST_PORT || '7793';
11
+ const BASE_URL = `http://127.0.0.1:${PORT}`;
12
+ const TEMP_SLUG = 'task-003-temp';
13
+
14
+ const REQUIRED_V2_DIRS = [
15
+ 'architecture', 'modules', 'commits', 'operations', 'references',
16
+ 'requirements', 'features', 'changes', 'quality',
17
+ '_ai/drafts', '_ai/runs', '_ai/context-packs',
18
+ ];
19
+
20
+ const REQUIRED_V2_FILES = [
21
+ 'README.md',
22
+ 'framework.md',
23
+ 'project-goal.md',
24
+ 'project-analysis.md',
25
+ 'kb-manifest.json',
26
+ 'architecture/overview.md',
27
+ 'modules/00-index.md',
28
+ 'commits/00-index.md',
29
+ 'features/00-index.md',
30
+ 'changes/00-index.md',
31
+ 'quality/quality-review-rules.md',
32
+ 'requirements/roadmap.md',
33
+ 'requirements/constraints.md',
34
+ 'requirements/success-criteria.md',
35
+ 'references/source-map.md',
36
+ ];
37
+
38
+ function assert(cond, msg) { if (!cond) throw new Error(msg); }
39
+
40
+ async function waitForServer() {
41
+ const deadline = Date.now() + 15000;
42
+ let lastError;
43
+ while (Date.now() < deadline) {
44
+ try {
45
+ const res = await fetch(`${BASE_URL}/api/state`);
46
+ if (res.ok) return;
47
+ lastError = new Error(`HTTP ${res.status}`);
48
+ } catch (e) { lastError = e; }
49
+ await new Promise(r => setTimeout(r, 250));
50
+ }
51
+ throw lastError || new Error('server did not start');
52
+ }
53
+
54
+ async function json(method, url, body) {
55
+ const res = await fetch(`${BASE_URL}${url}`, {
56
+ method,
57
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
58
+ body: body ? JSON.stringify(body) : undefined,
59
+ });
60
+ const text = await res.text();
61
+ let data = {};
62
+ if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
63
+ return { res, data };
64
+ }
65
+
66
+ async function cleanupTemp() {
67
+ const base = path.join(ROOT, 'projects', TEMP_SLUG);
68
+ fs.rmSync(base, { recursive: true, force: true });
69
+ const cur = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
70
+ if (cur[TEMP_SLUG]) {
71
+ delete cur[TEMP_SLUG];
72
+ fs.writeFileSync(PROJECTS_JSON, JSON.stringify(cur, null, 2) + '\n', 'utf-8');
73
+ }
74
+ }
75
+
76
+ (async () => {
77
+ assert(fs.existsSync(SERVER), 'server.js missing');
78
+ JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
79
+
80
+ const child = spawn(process.execPath, [SERVER], {
81
+ cwd: ROOT,
82
+ env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
83
+ stdio: ['ignore', 'pipe', 'pipe'],
84
+ windowsHide: true,
85
+ });
86
+ let serverOutput = '';
87
+ child.stdout.on('data', d => { serverOutput += d.toString(); });
88
+ child.stderr.on('data', d => { serverOutput += d.toString(); });
89
+
90
+ try {
91
+ await cleanupTemp();
92
+ await waitForServer();
93
+
94
+ // 1. Upsert project + init
95
+ let r = await json('PUT', '/api/projects', {
96
+ slug: TEMP_SLUG,
97
+ config: {
98
+ displayName: 'TASK-003 Temp',
99
+ localPath: 'D:\\__no_such__',
100
+ gitPath: 'D:\\__no_such__',
101
+ },
102
+ });
103
+ assert(r.res.ok, 'upsert should succeed');
104
+
105
+ r = await json('POST', `/api/projects/${TEMP_SLUG}/init`);
106
+ assert(r.res.ok, 'init should succeed');
107
+ assert(r.data.kbSchemaVersion === 'v2', `init should set schema v2, got ${r.data.kbSchemaVersion}`);
108
+
109
+ const base = path.join(ROOT, 'projects', TEMP_SLUG);
110
+ for (const sub of REQUIRED_V2_DIRS) {
111
+ const p = path.join(base, sub);
112
+ assert(fs.existsSync(p) && fs.statSync(p).isDirectory(), `missing dir: ${sub}`);
113
+ }
114
+ for (const rel of REQUIRED_V2_FILES) {
115
+ const p = path.join(base, rel);
116
+ assert(fs.existsSync(p), `missing file: ${rel}`);
117
+ }
118
+
119
+ // 2. Manifest parses and points to existing files
120
+ const manifestPath = path.join(base, 'kb-manifest.json');
121
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
122
+ assert(manifest.schema === 'kb-manifest/v1', 'manifest schema should be v1');
123
+ assert(manifest.project === TEMP_SLUG, 'manifest project should match');
124
+ assert(manifest.goal.path === 'project-goal.md', 'manifest should point at project-goal.md');
125
+ assert(Array.isArray(manifest.trustedKnowledge) && manifest.trustedKnowledge.length > 0, 'trustedKnowledge should be non-empty');
126
+ for (const tpath of [manifest.goal.path, manifest.analysis.path, ...manifest.indexes ? Object.values(manifest.indexes) : []]) {
127
+ const full = path.join(base, tpath);
128
+ assert(fs.existsSync(full), `manifest should not point to missing file: ${tpath}`);
129
+ }
130
+ for (const trusted of manifest.trustedKnowledge) {
131
+ const full = path.join(base, trusted);
132
+ assert(fs.existsSync(full), `trusted entry should exist: ${trusted}`);
133
+ }
134
+
135
+ // 3. Templates rendered with project name
136
+ const goal = fs.readFileSync(path.join(base, 'project-goal.md'), 'utf-8');
137
+ assert(goal.includes(TEMP_SLUG), 'project-goal should contain project name');
138
+ assert(goal.includes('TODO'), 'project-goal should still have TODO placeholders');
139
+ const analysis = fs.readFileSync(path.join(base, 'project-analysis.md'), 'utf-8');
140
+ assert(analysis.includes(TEMP_SLUG), 'project-analysis should contain project name');
141
+ const feature = fs.readFileSync(path.join(base, 'features/00-index.md'), 'utf-8');
142
+ assert(feature.includes('Features'), 'features index should be created');
143
+
144
+ // 4. Idempotent re-init
145
+ const before = fs.readFileSync(manifestPath, 'utf-8');
146
+ r = await json('POST', `/api/projects/${TEMP_SLUG}/init`);
147
+ assert(r.res.ok, 're-init should succeed');
148
+ const after = fs.readFileSync(manifestPath, 'utf-8');
149
+ assert(before === after, 're-init should not rewrite manifest');
150
+
151
+ // 5. Legacy commits/ is preserved when migrating an existing v1 project
152
+ const legacySlug = 'task-003-legacy';
153
+ const legacyBase = path.join(ROOT, 'projects', legacySlug);
154
+ fs.mkdirSync(path.join(legacyBase, 'commits'), { recursive: true });
155
+ const legacyCommitFile = path.join(legacyBase, 'commits', '2026-01-01_aaaaaaa_test.md');
156
+ fs.writeFileSync(legacyCommitFile, 'legacy commit file — should not be deleted\n');
157
+ fs.writeFileSync(path.join(legacyBase, 'README.md'), 'legacy readme\n');
158
+ fs.mkdirSync(path.join(legacyBase, 'modules'), { recursive: true });
159
+ fs.writeFileSync(path.join(legacyBase, 'modules', 'foo.md'), 'legacy module\n');
160
+
161
+ r = await json('PUT', '/api/projects', { slug: legacySlug, config: { displayName: 'legacy' } });
162
+ assert(r.res.ok, 'legacy upsert should succeed');
163
+ r = await json('POST', `/api/projects/${legacySlug}/migrate-v2`);
164
+ assert(r.res.ok, 'migrate-v2 should succeed');
165
+ assert(fs.existsSync(legacyCommitFile), 'migrate-v2 must preserve legacy commits/ files');
166
+ assert(fs.existsSync(path.join(legacyBase, 'kb-manifest.json')), 'migrate-v2 should create manifest');
167
+ assert(fs.existsSync(path.join(legacyBase, 'project-goal.md')), 'migrate-v2 should create project-goal.md');
168
+
169
+ // 6. Bad slug still 400
170
+ r = await json('POST', '/api/projects/INVALID../migrate-v2');
171
+ assert(!r.res.ok && r.res.status === 400, 'migrate-v2 bad slug should 400');
172
+
173
+ console.log('TASK-003 KB v2 templates test passed');
174
+ } catch (e) {
175
+ console.error('TASK-003 KB v2 templates test failed:', e.message);
176
+ if (serverOutput) console.error(serverOutput);
177
+ process.exitCode = 1;
178
+ } finally {
179
+ await cleanupTemp().catch(() => {});
180
+ const legacyBase = path.join(ROOT, 'projects', 'task-003-legacy');
181
+ fs.rmSync(legacyBase, { recursive: true, force: true });
182
+ const cur = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
183
+ if (cur['task-003-legacy']) {
184
+ delete cur['task-003-legacy'];
185
+ fs.writeFileSync(PROJECTS_JSON, JSON.stringify(cur, null, 2) + '\n', 'utf-8');
186
+ }
187
+ child.kill();
188
+ }
189
+ })();