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,257 @@
1
+ // _site/_test/hook-trigger-test.js
2
+ //
3
+ // TASK-012: post-commit hook regression test.
4
+ //
5
+ // Verifies:
6
+ // 1. hook-manager.installHook writes a post-commit script under .git/hooks/
7
+ // that is executable and contains the KB marker.
8
+ // 2. installHook refuses to overwrite a non-KB-managed hook without overwrite.
9
+ // 3. uninstallHook refuses to delete a non-KB-managed hook.
10
+ // 4. uninstallHook removes a KB-managed hook.
11
+ // 5. hook-trigger.js dispatches a `safe` job to the KB server, and
12
+ // exits 0 even when the server is unreachable (silent failure).
13
+ // 6. End-to-end: a real `git commit` in a fixture repo fires the hook,
14
+ // which calls hook-trigger.js, which calls /api/jobs/run on a
15
+ // running test server. The job log shows a `safe` run for the
16
+ // fixture's slug.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+ const { spawn, spawnSync } = require('child_process');
22
+ const { installHook, uninstallHook, readHookStatus, HOOK_MARKER } = require('../lib/hook-manager');
23
+
24
+ const ROOT = path.resolve(__dirname, '..', '..');
25
+ const SERVER = path.join(ROOT, '_site', 'server.js');
26
+ const HOOK_TRIGGER = path.join(ROOT, '_site', 'scripts', 'hook-trigger.js');
27
+ const SITE_ROOT = path.join(ROOT, '_site');
28
+ const KB_ROOT = ROOT;
29
+ const PROJECTS_JSON = path.join(ROOT, 'projects.json');
30
+ const JOBS_LOG = path.join(KB_ROOT, '.jobs-log.json');
31
+ const PORT = process.env.KB_HOOK_TEST_PORT || '7802';
32
+ const BASE_URL = `http://127.0.0.1:${PORT}`;
33
+
34
+ const SLUG = 'hook-test-proj';
35
+ const FIXTURE_REPO = path.join(os.tmpdir(), `kb-hook-test-${process.pid}`);
36
+
37
+ function assert(cond, msg) { if (!cond) throw new Error('ASSERT: ' + msg); }
38
+
39
+ function rmrf(p) {
40
+ try { fs.rmSync(p, { recursive: true, force: true }); } catch {}
41
+ }
42
+
43
+ function execGit(cwd, args) {
44
+ return spawnSync('git', args, { cwd, encoding: 'utf-8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
45
+ }
46
+
47
+ function initRepo(p) {
48
+ rmrf(p);
49
+ fs.mkdirSync(p, { recursive: true });
50
+ let r = execGit(p, ['init', '--initial-branch=main']);
51
+ assert(r.status === 0, 'git init failed: ' + r.stderr);
52
+ execGit(p, ['config', 'user.email', 'kb-test@example.com']);
53
+ execGit(p, ['config', 'user.name', 'KB Test']);
54
+ execGit(p, ['config', 'commit.gpgsign', 'false']);
55
+ fs.writeFileSync(path.join(p, 'README.md'), '# hook test\n');
56
+ execGit(p, ['add', 'README.md']);
57
+ r = execGit(p, ['commit', '-m', 'init']);
58
+ assert(r.status === 0, 'initial commit failed: ' + r.stderr);
59
+ }
60
+
61
+ function makeCommit(p, msg) {
62
+ fs.writeFileSync(path.join(p, 'changelog.md'), `+ ${msg}\n`, { flag: 'a' });
63
+ execGit(p, ['add', 'changelog.md']);
64
+ const r = execGit(p, ['commit', '-m', msg]);
65
+ assert(r.status === 0, 'commit failed: ' + r.stderr + ' :: ' + r.stdout);
66
+ }
67
+
68
+ async function waitForServer() {
69
+ const deadline = Date.now() + 15000;
70
+ let last;
71
+ while (Date.now() < deadline) {
72
+ try {
73
+ const r = await fetch(`${BASE_URL}/api/state`);
74
+ if (r.status < 500) return;
75
+ last = new Error('HTTP ' + r.status);
76
+ } catch (e) { last = e; }
77
+ await new Promise(r => setTimeout(r, 250));
78
+ }
79
+ throw last || new Error('server did not start');
80
+ }
81
+
82
+ async function json(method, url, body) {
83
+ const r = await fetch(`${BASE_URL}${url}`, {
84
+ method,
85
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
86
+ body: body ? JSON.stringify(body) : undefined,
87
+ });
88
+ const text = await r.text();
89
+ let data = {};
90
+ if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
91
+ return { res: r, status: r.status, data };
92
+ }
93
+
94
+ async function waitForJob(slug, timeoutMs = 30000) {
95
+ const deadline = Date.now() + timeoutMs;
96
+ while (Date.now() < deadline) {
97
+ const r = await json('GET', '/api/jobs');
98
+ if (r.data && Array.isArray(r.data.history)) {
99
+ const hit = r.data.history.find(j => j.slug === slug && j.mode === 'safe');
100
+ if (hit) return hit;
101
+ }
102
+ await new Promise(r => setTimeout(r, 500));
103
+ }
104
+ return null;
105
+ }
106
+
107
+ (async () => {
108
+ // ---- 1. installHook writes a real post-commit script with the marker ----
109
+ initRepo(FIXTURE_REPO);
110
+ let r = installHook({ repoPath: FIXTURE_REPO, siteRoot: SITE_ROOT, host: '127.0.0.1', port: Number(PORT) });
111
+ assert(r.ok, 'installHook failed: ' + r.error);
112
+ const hookPath = path.join(FIXTURE_REPO, '.git', 'hooks', 'post-commit');
113
+ assert(fs.existsSync(hookPath), 'hook file not created at ' + hookPath);
114
+ const hookText = fs.readFileSync(hookPath, 'utf-8');
115
+ assert(hookText.includes(HOOK_MARKER), 'hook missing KB marker');
116
+ assert(hookText.includes('hook-trigger.js'), 'hook does not call hook-trigger.js');
117
+ assert(hookText.includes('--kb-root'), 'hook missing --kb-root flag');
118
+ assert(hookText.includes(FIXTURE_REPO.replace(/\\/g, '/')), 'hook missing repo path');
119
+
120
+ // On Windows the executable bit is not enforced, but on POSIX it should be set.
121
+ if (process.platform !== 'win32') {
122
+ const st = fs.statSync(hookPath);
123
+ assert((st.mode & 0o111) !== 0, 'hook is not executable on POSIX');
124
+ }
125
+
126
+ // ---- 2. installHook refuses to overwrite a non-KB-managed hook ----
127
+ rmrf(hookPath);
128
+ fs.writeFileSync(hookPath, '#!/bin/sh\necho "user hook, do not touch"\n');
129
+ r = installHook({ repoPath: FIXTURE_REPO, siteRoot: SITE_ROOT, host: '127.0.0.1', port: Number(PORT) });
130
+ assert(!r.ok && r.status === 409, 'installHook should refuse non-KB hook without overwrite');
131
+ assert(fs.readFileSync(hookPath, 'utf-8').includes('do not touch'), 'user hook was overwritten');
132
+ // Now allow overwrite
133
+ r = installHook({ repoPath: FIXTURE_REPO, siteRoot: SITE_ROOT, host: '127.0.0.1', port: Number(PORT), overwrite: true });
134
+ assert(r.ok, 'overwrite installHook failed: ' + r.error);
135
+ assert(fs.readFileSync(hookPath, 'utf-8').includes(HOOK_MARKER), 'overwrite did not write KB hook');
136
+
137
+ // ---- 3. uninstallHook refuses to delete a non-KB-managed hook ----
138
+ rmrf(hookPath);
139
+ fs.writeFileSync(hookPath, '#!/bin/sh\necho "user hook"\n');
140
+ r = uninstallHook({ repoPath: FIXTURE_REPO });
141
+ assert(!r.ok && r.status === 409, 'uninstallHook should refuse non-KB hook');
142
+ assert(fs.existsSync(hookPath), 'non-KB hook was deleted');
143
+
144
+ // ---- 4. uninstallHook removes a KB-managed hook ----
145
+ installHook({ repoPath: FIXTURE_REPO, siteRoot: SITE_ROOT, host: '127.0.0.1', port: Number(PORT), overwrite: true });
146
+ assert(fs.existsSync(hookPath), 'KB hook not present before uninstall');
147
+ const text4 = fs.readFileSync(hookPath, 'utf-8');
148
+ assert(text4.includes(HOOK_MARKER), 'KB hook did not replace the non-KB hook');
149
+ r = uninstallHook({ repoPath: FIXTURE_REPO });
150
+ assert(r.ok && r.removed === true, 'uninstallHook failed: ' + r.error);
151
+ assert(!fs.existsSync(hookPath), 'hook still exists after uninstall');
152
+
153
+ // ---- 5. readHookStatus reports correctly ----
154
+ let s = readHookStatus({ repoPath: FIXTURE_REPO });
155
+ assert(s.installed === false, 'readHookStatus should report not installed');
156
+ installHook({ repoPath: FIXTURE_REPO, siteRoot: SITE_ROOT, host: '127.0.0.1', port: Number(PORT) });
157
+ s = readHookStatus({ repoPath: FIXTURE_REPO });
158
+ assert(s.installed === true && s.kbManaged === true, 'readHookStatus should report KB-managed hook');
159
+
160
+ // ---- 6. hook-trigger.js exits 0 when the server is unreachable ----
161
+ // Point it at a closed port to simulate a downed server.
162
+ const childDown = spawn(process.execPath, [HOOK_TRIGGER, '--kb-root', KB_ROOT, '--repo', FIXTURE_REPO, '--host', '127.0.0.1', '--port', '1'], {
163
+ cwd: ROOT, stdio: 'pipe', windowsHide: true, env: { ...process.env, KB_SITE_PORT: '1' },
164
+ });
165
+ const downExit = await new Promise(res => { childDown.on('exit', res); });
166
+ assert(downExit === 0, `hook-trigger should exit 0 even when server is down, got ${downExit}`);
167
+
168
+ // ---- 7. End-to-end: real commit → hook → server job ----
169
+ // Bring up a test server on PORT.
170
+ const server = spawn(process.execPath, [SERVER], {
171
+ cwd: ROOT,
172
+ env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
173
+ stdio: ['ignore', 'pipe', 'pipe'],
174
+ windowsHide: true,
175
+ });
176
+ let serverOutput = '';
177
+ server.stdout.on('data', d => { serverOutput += d.toString(); });
178
+ server.stderr.on('data', d => { serverOutput += d.toString(); });
179
+
180
+ try {
181
+ await waitForServer();
182
+
183
+ // Register the fixture repo as a project under SLUG, with kbPath pointing
184
+ // at a throwaway dir so the orchestrator can write drafts.
185
+ const projKb = path.join(ROOT, 'projects', SLUG);
186
+ rmrf(projKb);
187
+ fs.mkdirSync(projKb, { recursive: true });
188
+ fs.mkdirSync(path.join(projKb, '_ai', 'drafts'), { recursive: true });
189
+ fs.mkdirSync(path.join(projKb, '_ai', 'runs'), { recursive: true });
190
+ fs.mkdirSync(path.join(projKb, '_ai', 'context-packs'), { recursive: true });
191
+ fs.writeFileSync(path.join(projKb, 'README.md'), '# hook test kb\n');
192
+ fs.writeFileSync(path.join(projKb, 'kb-manifest.json'), JSON.stringify({
193
+ schema: 'kb-manifest/v1', project: SLUG, kbRoot: SLUG,
194
+ goal: { path: 'project-goal.md', status: 'accepted', updatedAt: new Date().toISOString() },
195
+ analysis: { path: null, lastAnalyzedCommit: null, updatedAt: null },
196
+ trustedKnowledge: ['README.md'], draftAreas: ['_ai/drafts/'],
197
+ indexes: {}, aiProfileId: 'mock-agent',
198
+ }, null, 2));
199
+ fs.writeFileSync(path.join(projKb, 'project-goal.md'), '# goal\n\ntest goal\n');
200
+
201
+ // Register projects.json entry
202
+ const projects = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
203
+ projects[SLUG] = {
204
+ displayName: 'Hook Test', localPath: FIXTURE_REPO, gitPath: FIXTURE_REPO,
205
+ isReference: false, primaryLanguage: 'JavaScript', tags: [], docConvention: 'frontmatter-relations',
206
+ kbPath: projKb, enabled: true, repoStatus: 'unknown',
207
+ headCommit: null, lastSeenCommit: null, lastAnalyzedCommit: null,
208
+ aiProfileId: 'mock-agent', kbSchemaVersion: 'v1', goalStatus: 'accepted',
209
+ kbInitialized: true, kbV2Migrated: true,
210
+ };
211
+ fs.writeFileSync(PROJECTS_JSON, JSON.stringify(projects, null, 2) + '\n', 'utf-8');
212
+
213
+ // Reinstall hook (in case installHook needed to update the port)
214
+ uninstallHook({ repoPath: FIXTURE_REPO });
215
+ r = installHook({ repoPath: FIXTURE_REPO, siteRoot: SITE_ROOT, host: '127.0.0.1', port: Number(PORT) });
216
+ assert(r.ok, 'reinstall failed: ' + r.error);
217
+
218
+ // Make a real commit
219
+ makeCommit(FIXTURE_REPO, 'feat: add changelog entry to trigger hook');
220
+
221
+ // Wait for the server to record the safe job
222
+ const job = await waitForJob(SLUG, 30000);
223
+ assert(job, `no safe job for ${SLUG} appeared within 30s — server output: ${serverOutput.slice(-1000)}`);
224
+ assert(job.mode === 'safe', 'wrong mode: ' + job.mode);
225
+ console.log('hook job observed:', job.jobId, job.mode, job.slug, '→', job.status);
226
+
227
+ // ---- 8. The hook-trigger log file is in KB root ----
228
+ const logPath = path.join(KB_ROOT, '.hook-trigger-errors.log');
229
+ // We don't assert it exists — only if there was a failure. But we do
230
+ // assert the file is writable: if it doesn't exist, that's fine.
231
+ if (fs.existsSync(logPath)) {
232
+ const lines = fs.readFileSync(logPath, 'utf-8').split('\n').filter(Boolean);
233
+ console.log('hook error log lines:', lines.length);
234
+ }
235
+
236
+ console.log('TASK-012 hook trigger test passed');
237
+ } catch (e) {
238
+ console.error('TASK-012 hook trigger test failed:', e.message);
239
+ if (serverOutput) console.error('--- server output ---');
240
+ if (serverOutput) console.error(serverOutput.slice(-2000));
241
+ process.exitCode = 1;
242
+ } finally {
243
+ // Cleanup
244
+ try { uninstallHook({ repoPath: FIXTURE_REPO }); } catch {}
245
+ rmrf(FIXTURE_REPO);
246
+ rmrf(path.join(ROOT, 'projects', SLUG));
247
+ try {
248
+ const projects = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
249
+ if (projects[SLUG]) {
250
+ delete projects[SLUG];
251
+ fs.writeFileSync(PROJECTS_JSON, JSON.stringify(projects, null, 2) + '\n', 'utf-8');
252
+ }
253
+ } catch {}
254
+ try { fs.unlinkSync(JOBS_LOG); } catch {}
255
+ server.kill();
256
+ }
257
+ })();
@@ -0,0 +1,228 @@
1
+ // TASK-007: Initial project analysis test
2
+ // Run: node _site/_test/initial-analysis-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
+ runInitialAnalysis, readRun, listRuns, listDrafts,
9
+ } = require('../lib/analysis-orchestrator');
10
+ const { getAdapter } = require('../lib/ai-adapter');
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_INIT_TEST_PORT || '7797';
16
+ const BASE_URL = `http://127.0.0.1:${PORT}`;
17
+ const TEMP_SLUG = 'task-007-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
+ }
56
+
57
+ (async () => {
58
+ assert(fs.existsSync(SERVER), 'server.js missing');
59
+ JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
60
+
61
+ // 1. Unit: runInitialAnalysis on a v2-initialized fixture
62
+ const repo = makeRepo({ kind: 'multi-commit' });
63
+ const kbBase = path.join(ROOT, 'projects', TEMP_SLUG);
64
+ fs.mkdirSync(kbBase, { recursive: true });
65
+ // Pre-existing KB files that must NOT be overwritten by the analysis
66
+ const existingGoal = '# Project Goal — PRESERVE-ME\n\npre-existing human goal text\n';
67
+ fs.writeFileSync(path.join(kbBase, 'project-goal.md'), existingGoal);
68
+
69
+ const project = {
70
+ slug: TEMP_SLUG,
71
+ kbPath: kbBase,
72
+ gitPath: repo.path,
73
+ localPath: repo.path,
74
+ aiProfileId: 'mock-agent',
75
+ };
76
+
77
+ let r = await runInitialAnalysis(project);
78
+ assert(r.ok, `initial analysis should succeed: ${r.error || ''}`);
79
+ assert(r.runRecord.status === 'succeeded', `run status should be succeeded, got ${r.runRecord.status}`);
80
+ assert(r.runRecord.aiProfileId === 'mock-agent', 'run should record aiProfileId');
81
+ assert(r.runRecord.drafts.length >= 2, 'run should have at least 2 drafts (goal + analysis)');
82
+
83
+ // 2. Drafts written to disk
84
+ const drafts = listDrafts(kbBase, r.runId);
85
+ assert(drafts.length >= 2, `drafts should be on disk, got ${drafts.length}`);
86
+ const goalDraft = drafts.find(d => d.path === 'project-goal.md');
87
+ assert(goalDraft, 'goal draft should exist');
88
+ const analysisDraft = drafts.find(d => d.path === 'project-analysis.md');
89
+ assert(analysisDraft, 'analysis draft should exist');
90
+
91
+ // 3. Draft contents
92
+ const goalText = fs.readFileSync(path.join(kbBase, '_ai', 'drafts', r.runId, 'project-goal.md'), 'utf-8');
93
+ assert(goalText.includes('One-Sentence Goal') || goalText.includes('oneSentenceGoal') || goalText.includes('AI Proposal'),
94
+ 'goal draft should reference the AI proposal section');
95
+ const analysisText = fs.readFileSync(path.join(kbBase, '_ai', 'drafts', r.runId, 'project-analysis.md'), 'utf-8');
96
+ assert(analysisText.includes('AI Proposal') || analysisText.includes('AI-Inferred'), 'analysis draft should include AI proposal');
97
+
98
+ // 4. Pre-existing project-goal.md is preserved
99
+ const stillThere = fs.readFileSync(path.join(kbBase, 'project-goal.md'), 'utf-8');
100
+ assert(stillThere === existingGoal, 'pre-existing project-goal.md MUST NOT be overwritten by analysis');
101
+
102
+ // 5. Run metadata on disk
103
+ const run = readRun(kbBase, r.runId);
104
+ assert(run, 'run record should be readable');
105
+ assert(run.aiProfileId === 'mock-agent', 'run should record aiProfileId');
106
+ assert(run.finishedAt, 'run should have finishedAt');
107
+ assert(run.startedAt, 'run should have startedAt');
108
+ assert(Array.isArray(run.drafts), 'run should list drafts');
109
+ assert(Array.isArray(run.outputPaths) && run.outputPaths.length > 0, 'run should list outputPaths');
110
+ assert(run.contextPackPath, 'run should record contextPackPath');
111
+
112
+ // 6. runRecord.goalAccepted is false
113
+ assert(r.runRecord.goalAccepted === false, 'goalAccepted must default to false');
114
+
115
+ // 7. lastAnalyzedCommit was NOT advanced (project state must be untouched)
116
+ // Re-read projects.json to confirm the slug is still in place with no lastAnalyzedCommit
117
+ const cur = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
118
+ // The orchestrator does not touch projects.json, so the temp slug may or may not be there yet.
119
+ // We focus on the run record.
120
+ assert(run.goalAccepted === false, 'run.goalAccepted should be false');
121
+
122
+ // 8. Language setting controls generated draft language markers.
123
+ const repoLang = makeRepo({ kind: 'one-commit' });
124
+ const kbZh = path.join(ROOT, 'projects', TEMP_SLUG + '-zh');
125
+ const kbEn = path.join(ROOT, 'projects', TEMP_SLUG + '-en');
126
+ fs.mkdirSync(kbZh, { recursive: true });
127
+ fs.mkdirSync(kbEn, { recursive: true });
128
+ let zhRun = await runInitialAnalysis({
129
+ slug: TEMP_SLUG + '-zh',
130
+ kbPath: kbZh,
131
+ gitPath: repoLang.path,
132
+ localPath: repoLang.path,
133
+ aiProfileId: 'mock-agent',
134
+ knowledgeLanguage: 'zh-CN',
135
+ });
136
+ assert(zhRun.ok, 'zh initial analysis should succeed');
137
+ let zhGoal = fs.readFileSync(path.join(kbZh, '_ai', 'drafts', zhRun.runId, 'project-goal.md'), 'utf-8');
138
+ assert(zhGoal.includes('AI 建议') && zhGoal.includes('一句话目标'), 'zh draft should use Chinese labels');
139
+ assert(zhRun.runRecord.knowledgeLanguage === 'zh-CN', 'zh run should record knowledgeLanguage');
140
+
141
+ let enRun = await runInitialAnalysis({
142
+ slug: TEMP_SLUG + '-en',
143
+ kbPath: kbEn,
144
+ gitPath: repoLang.path,
145
+ localPath: repoLang.path,
146
+ aiProfileId: 'mock-agent',
147
+ knowledgeLanguage: 'en-US',
148
+ });
149
+ assert(enRun.ok, 'en initial analysis should succeed');
150
+ let enGoal = fs.readFileSync(path.join(kbEn, '_ai', 'drafts', enRun.runId, 'project-goal.md'), 'utf-8');
151
+ assert(enGoal.includes('AI Proposal') && enGoal.includes('One-Sentence Goal'), 'en draft should use English labels');
152
+ assert(enRun.runRecord.knowledgeLanguage === 'en-US', 'en run should record knowledgeLanguage');
153
+ repoLang.cleanup();
154
+ fs.rmSync(kbZh, { recursive: true, force: true });
155
+ fs.rmSync(kbEn, { recursive: true, force: true });
156
+
157
+ repo.cleanup();
158
+ fs.rmSync(kbBase, { recursive: true, force: true });
159
+
160
+ // 9. Server tests
161
+ const child = spawn(process.execPath, [SERVER], {
162
+ cwd: ROOT,
163
+ env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
164
+ stdio: ['ignore', 'pipe', 'pipe'],
165
+ windowsHide: true,
166
+ });
167
+ let serverOutput = '';
168
+ child.stdout.on('data', d => { serverOutput += d.toString(); });
169
+ child.stderr.on('data', d => { serverOutput += d.toString(); });
170
+
171
+ try {
172
+ await cleanup();
173
+ await waitForServer();
174
+
175
+ // 10. End-to-end: upsert, init, analyze
176
+ const repo2 = makeRepo({ kind: 'one-commit' });
177
+ const slug = TEMP_SLUG;
178
+ const kbPath = path.join(ROOT, 'projects', slug);
179
+ fs.mkdirSync(kbPath, { recursive: true });
180
+ fs.writeFileSync(path.join(kbPath, 'project-goal.md'), '# Goal — server\n');
181
+
182
+ r = await json('PUT', '/api/projects', {
183
+ slug,
184
+ config: { displayName: 'TASK-007', localPath: repo2.path, gitPath: repo2.path, kbPath },
185
+ });
186
+ assert(r.res.ok, 'upsert should succeed');
187
+
188
+ r = await json('POST', `/api/projects/${slug}/analyze/initial`, {});
189
+ assert(r.res.ok, `analyze initial should succeed: ${JSON.stringify(r.data)}`);
190
+ assert(r.data.runId, `should return runId: ${JSON.stringify(r.data)}`);
191
+ assert(r.data.run && r.data.run.status === 'succeeded', `run should be succeeded: ${JSON.stringify(r.data)}`);
192
+ const newRunId = r.data.runId;
193
+
194
+ // 11. List runs
195
+ r = await json('GET', `/api/projects/${slug}/runs`);
196
+ assert(r.res.ok, `list runs should succeed: ${JSON.stringify(r.data)}`);
197
+ assert(r.data.runs.length >= 1, `should have at least one run, got ${r.data.runs.length}`);
198
+ const listedIds = r.data.runs.map(x => x.runId);
199
+ assert(listedIds.includes(newRunId), `list should include the new run; newRunId=${newRunId} listed=${listedIds.join(',')}`);
200
+
201
+ // 12. Read run + drafts
202
+ r = await json('GET', `/api/projects/${slug}/runs/${newRunId}`);
203
+ assert(r.res.ok, `get run should succeed: ${JSON.stringify(r.data)}`);
204
+ assert(r.data.drafts.length >= 2, 'should have drafts');
205
+
206
+ // 13. List drafts separately
207
+ r = await json('GET', `/api/projects/${slug}/drafts/${newRunId}`);
208
+ assert(r.res.ok, `list drafts should succeed: ${JSON.stringify(r.data)}`);
209
+ assert(r.data.drafts.length >= 2, 'drafts should be listed');
210
+
211
+ // 14. Unknown adapter should 400
212
+ const cur2 = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
213
+ cur2[slug].aiProfileId = 'nonexistent';
214
+ fs.writeFileSync(PROJECTS_JSON, JSON.stringify(cur2, null, 2) + '\n', 'utf-8');
215
+ r = await json('POST', `/api/projects/${slug}/analyze/initial`, {});
216
+ assert(!r.res.ok && r.res.status === 400, 'unknown adapter should 400');
217
+
218
+ repo2.cleanup();
219
+ console.log('TASK-007 initial analysis test passed');
220
+ } catch (e) {
221
+ console.error('TASK-007 initial analysis test failed:', e.message);
222
+ if (serverOutput) console.error(serverOutput);
223
+ process.exitCode = 1;
224
+ } finally {
225
+ await cleanup().catch(() => {});
226
+ child.kill();
227
+ }
228
+ })();