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,1058 @@
1
+ // KB management site server — zero npm deps (Node built-ins only).
2
+ // Run: node "D:\SanQian.Xu\project-knowledge-base\_site\server.js"
3
+ // Listens on http://localhost:7777 (override with KB_SITE_PORT env)
4
+
5
+ const http = require('http');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { spawn, exec } = require('child_process');
9
+ const { getAdapter, listAdapters, validateInitialOutput, validateCommitBatchOutput } = require('./lib/ai-adapter');
10
+ const { execGit } = require('./lib/git-runner');
11
+ const { buildContextPack } = require('./lib/context-pack-builder');
12
+ const { scanProject, applyScanResult } = require('./lib/scanner');
13
+ const { runInitialAnalysis, runCommitAnalysis, readRun, listRuns, listDrafts } = require('./lib/analysis-orchestrator');
14
+ const { applyDrafts, rejectDrafts, readDraftContent } = require('./lib/draft-apply');
15
+ const { runJob, makeJob, readJobLog, KNOWN_MODES } = require('./lib/job-orchestrator');
16
+ const { validateKb, buildPrContextPack } = require('./lib/kb-validator');
17
+ const { installHook, uninstallHook, readHookStatus } = require('./lib/hook-manager');
18
+
19
+ const KB_ROOT = path.resolve(__dirname, '..');
20
+ const SITE_ROOT = __dirname;
21
+ const PORT = parseInt(process.env.KB_SITE_PORT || '7777', 10);
22
+ const HOST = process.env.KB_SITE_HOST || '127.0.0.1';
23
+ const TASK_NAME = 'KB-GitCommits-Daily';
24
+ const SCRIPT = 'D:\\SanQian.Xu\\project-knowledge-base\\scripts\\gen-commit-doc.ps1';
25
+ const SAFE_RUNNER = 'D:\\SanQian.Xu\\project-knowledge-base\\_site\\scripts\\safe-runner.js';
26
+ const PROJECT_SCHEMA_VERSION = 'v1';
27
+ const DEFAULT_AI_PROFILE_ID = 'mock-agent';
28
+ const DEFAULT_KNOWLEDGE_LANGUAGE = 'zh-CN';
29
+ const AI_PROFILES_PATH = path.join(KB_ROOT, 'ai-profiles.json');
30
+ const JOBS_LOG_PATH = path.join(KB_ROOT, '.jobs-log.json');
31
+
32
+ // ---- state ----
33
+ let lastRun = { time: null, status: null, slug: null, output: '' };
34
+ const runningJobs = new Map();
35
+
36
+ // Read a fresh copy of projects.json (re-loaded on every dispatch so that
37
+ // background jobs see the latest registry state).
38
+ function readProjectsForJob() {
39
+ return JSON.parse(fs.readFileSync(path.join(KB_ROOT, 'projects.json'), 'utf-8'));
40
+ }
41
+
42
+ // ---- helpers ----
43
+ function send(res, status, body, type) {
44
+ type = type || 'application/json';
45
+ const text = typeof body === 'string' ? body : JSON.stringify(body, null, 2);
46
+ res.writeHead(status, {
47
+ 'Content-Type': type + '; charset=utf-8',
48
+ 'Access-Control-Allow-Origin': '*',
49
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
50
+ 'Access-Control-Allow-Headers': 'Content-Type',
51
+ 'Cache-Control': 'no-store',
52
+ });
53
+ res.end(text);
54
+ }
55
+
56
+ function readBody(req) {
57
+ return new Promise((resolve, reject) => {
58
+ const chunks = [];
59
+ req.on('data', c => chunks.push(c));
60
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
61
+ req.on('error', reject);
62
+ });
63
+ }
64
+
65
+ function readJson(p) {
66
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
67
+ }
68
+
69
+ function writeJson(p, obj) {
70
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
71
+ }
72
+
73
+ function isSafeSlug(s) { return typeof s === 'string' && /^[a-z0-9][a-z0-9-]{0,40}$/.test(s); }
74
+
75
+ function normalizeKnowledgeLanguage(value) {
76
+ return value === 'en-US' ? 'en-US' : DEFAULT_KNOWLEDGE_LANGUAGE;
77
+ }
78
+
79
+ function defaultProjectKbPath(slug) {
80
+ return path.join(KB_ROOT, 'projects', slug);
81
+ }
82
+
83
+ function isLegacyKbPath(value) {
84
+ return typeof value === 'string' && /[\\/]SanQian\.Xu[\\/]kb[\\/]projects[\\/]/i.test(value);
85
+ }
86
+
87
+ // ---- AI profiles (TASK-005) ----
88
+ function readAiProfiles() {
89
+ if (!fs.existsSync(AI_PROFILES_PATH)) {
90
+ return { schema: 'ai-profiles/v1', defaultProfileId: DEFAULT_AI_PROFILE_ID, profiles: listAdapters().map(a => ({
91
+ id: a.id, name: a.name, description: a.description, enabled: a.id !== 'claude-code-agent', implementation: a.id,
92
+ })) };
93
+ }
94
+ return JSON.parse(fs.readFileSync(AI_PROFILES_PATH, 'utf-8'));
95
+ }
96
+
97
+ function writeAiProfiles(cfg) {
98
+ writeJson(AI_PROFILES_PATH, cfg);
99
+ }
100
+
101
+ function validateProfileConfig(cfg) {
102
+ const errors = [];
103
+ if (!cfg || typeof cfg !== 'object') return ['not an object'];
104
+ if (cfg.schema !== 'ai-profiles/v1') errors.push('schema must be ai-profiles/v1');
105
+ if (!Array.isArray(cfg.profiles)) { errors.push('profiles must be an array'); return errors; }
106
+ const ids = new Set();
107
+ for (let i = 0; i < cfg.profiles.length; i++) {
108
+ const p = cfg.profiles[i];
109
+ if (!p.id) { errors.push(`profiles[${i}] missing id`); continue; }
110
+ if (ids.has(p.id)) errors.push(`duplicate id: ${p.id}`);
111
+ ids.add(p.id);
112
+ if (!getAdapter(p.id)) errors.push(`unknown adapter id: ${p.id}`);
113
+ }
114
+ if (cfg.defaultProfileId && !ids.has(cfg.defaultProfileId)) errors.push(`defaultProfileId not in profiles: ${cfg.defaultProfileId}`);
115
+ return errors;
116
+ }
117
+
118
+ function normalizeProjectConfig(slug, input) {
119
+ const source = input && typeof input === 'object' ? input : {};
120
+ const before = JSON.stringify(source);
121
+ const cfg = { ...source };
122
+
123
+ cfg.displayName = cfg.displayName || slug;
124
+ cfg.localPath = cfg.localPath || '';
125
+ cfg.gitPath = cfg.gitPath || cfg.localPath;
126
+ cfg.isReference = !!cfg.isReference;
127
+ cfg.primaryLanguage = cfg.primaryLanguage || '';
128
+ cfg.docConvention = cfg.docConvention || 'frontmatter-relations';
129
+
130
+ if (!Array.isArray(cfg.tags)) {
131
+ cfg.tags = typeof cfg.tags === 'string'
132
+ ? cfg.tags.split(',').map(s => s.trim()).filter(Boolean)
133
+ : [];
134
+ }
135
+
136
+ if (!cfg.kbPath || isLegacyKbPath(cfg.kbPath)) {
137
+ cfg.kbPath = defaultProjectKbPath(slug);
138
+ }
139
+
140
+ if (cfg.enabled == null) cfg.enabled = true;
141
+ if (!cfg.repoStatus) cfg.repoStatus = 'unknown';
142
+ if (!Object.prototype.hasOwnProperty.call(cfg, 'headCommit')) cfg.headCommit = null;
143
+ if (!Object.prototype.hasOwnProperty.call(cfg, 'lastSeenCommit')) cfg.lastSeenCommit = null;
144
+ if (!Object.prototype.hasOwnProperty.call(cfg, 'lastAnalyzedCommit')) cfg.lastAnalyzedCommit = null;
145
+ if (!cfg.aiProfileId) cfg.aiProfileId = DEFAULT_AI_PROFILE_ID;
146
+ cfg.knowledgeLanguage = normalizeKnowledgeLanguage(cfg.knowledgeLanguage);
147
+ if (!cfg.kbSchemaVersion) cfg.kbSchemaVersion = PROJECT_SCHEMA_VERSION;
148
+ if (!cfg.goalStatus) cfg.goalStatus = 'not-created';
149
+
150
+ return { config: cfg, changed: JSON.stringify(cfg) !== before };
151
+ }
152
+
153
+ function normalizeProjects(rawProjects) {
154
+ const out = {};
155
+ let changed = false;
156
+ for (const slug of Object.keys(rawProjects || {})) {
157
+ const result = normalizeProjectConfig(slug, rawProjects[slug]);
158
+ out[slug] = result.config;
159
+ changed = changed || result.changed;
160
+ }
161
+ return { projects: out, changed };
162
+ }
163
+
164
+ function readProjects(options = {}) {
165
+ const projectsPath = path.join(KB_ROOT, 'projects.json');
166
+ const rawProjects = readJson(projectsPath);
167
+ const result = normalizeProjects(rawProjects);
168
+ if (options.persistMigrations && result.changed) {
169
+ writeJson(projectsPath, result.projects);
170
+ }
171
+ return result.projects;
172
+ }
173
+
174
+ function listSubTree(root, prefix, depth, max) {
175
+ if (depth > max) return [];
176
+ const out = [];
177
+ let entries;
178
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return out; }
179
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
180
+ if (entry.name.startsWith('.')) continue;
181
+ if (entry.name === 'node_modules') continue;
182
+ const rel = prefix + entry.name;
183
+ if (entry.isDirectory()) {
184
+ const children = listSubTree(path.join(root, entry.name), rel + '/', depth + 1, max);
185
+ out.push({ type: 'dir', name: entry.name, path: rel, children });
186
+ } else {
187
+ let size = 0;
188
+ try { size = fs.statSync(path.join(root, entry.name)).size; } catch {}
189
+ out.push({ type: 'file', name: entry.name, path: rel, size });
190
+ }
191
+ }
192
+ return out;
193
+ }
194
+
195
+ function shellEscape(s) {
196
+ return '"' + String(s).replace(/"/g, '""').replace(/`/g, '``') + '"';
197
+ }
198
+
199
+ // ---- scanner (TASK-004): read-only commit range + state without invoking AI ----
200
+ // (scanProject and applyScanResult now live in ./lib/scanner.js so the analysis
201
+ // orchestrator can share the same code path.)
202
+
203
+ async function scanAndPersistProject(slug, projects, options = {}) {
204
+ if (!projects[slug]) return { ok: false, status: 404, error: 'Slug not in projects.json' };
205
+ const scan = await scanProject({ slug, ...projects[slug] }, options);
206
+ applyScanResult(projects[slug], scan);
207
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
208
+ return { ok: true, slug, scan };
209
+ }
210
+
211
+ // ---- schedule (uses schtasks.exe directly to avoid PowerShell module load issues) ----
212
+ function schtasksQuery() {
213
+ return new Promise((resolve) => {
214
+ // Force UTF-8 console output by setting code page 65001 first.
215
+ // The chcp message goes to stderr; the >nul redirects chcp's "Active code page" stdout message.
216
+ const cmd = `chcp 65001 >nul 2>&1 & schtasks /query /tn "${TASK_NAME}" /v /fo list`;
217
+ exec(cmd, { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
218
+ if (err) {
219
+ const msg = (stderr || '').toString().trim();
220
+ if (/the system cannot find/i.test(msg) || /cannot find the specified file/i.test(msg) || /指定的文件|找不到/i.test(msg)) {
221
+ return resolve({ registered: false, error: 'task not registered' });
222
+ }
223
+ return resolve({ registered: false, error: msg || err.message });
224
+ }
225
+ // Strip leading chcp echo line if present
226
+ let text = stdout;
227
+ const lines = text.split(/\r?\n/);
228
+ if (lines.length && !/^\s*\S+:.+/.test(lines[0])) {
229
+ lines.shift();
230
+ text = lines.join('\n');
231
+ }
232
+ // Parse "Key: Value" lines
233
+ const map = {};
234
+ text.split(/\r?\n/).forEach(line => {
235
+ const m = line.match(/^([^:]+):\s*(.*)$/);
236
+ if (m) {
237
+ const k = m[1].trim();
238
+ const v = m[2].trim();
239
+ if (k && v) map[k] = v;
240
+ }
241
+ });
242
+ if (Object.keys(map).length === 0) return resolve({ registered: false, error: 'empty output', raw: stdout.slice(0, 500) });
243
+ resolve({ registered: true, raw: map });
244
+ });
245
+ });
246
+ }
247
+
248
+ function getScheduleInfo() {
249
+ return schtasksQuery().then(r => {
250
+ if (!r.registered) return r;
251
+ const m = r.raw;
252
+ return {
253
+ registered: true,
254
+ hostName: m['HostName'] || m['主机名'] || '',
255
+ taskName: m['TaskName'] || m['任务名称'] || TASK_NAME,
256
+ nextRun: m['Next Run Time'] || m['下次运行时间'] || '',
257
+ lastRun: m['Last Run Time'] || m['上次运行时间'] || '',
258
+ lastResult: m['Last Result'] || m['上次结果'] || '',
259
+ status: m['Status'] || m['状态'] || '',
260
+ scheduleType: m['Schedule Type'] || m['计划类型'] || '',
261
+ startTime: m['Start Time'] || m['开始时间'] || '',
262
+ runAsUser: m['Run As User'] || m['以用户身份运行'] || '',
263
+ raw: m,
264
+ };
265
+ });
266
+ }
267
+
268
+ function buildScheduleArgs(frequency, time, options = {}) {
269
+ // Default to the safe-runner (scan + analyze-commits; never apply).
270
+ // Callers can opt into the legacy PowerShell script for backward compatibility.
271
+ const useLegacy = options && options.runner === 'legacy';
272
+ const tr = useLegacy
273
+ ? `powershell -ExecutionPolicy Bypass -File "${SCRIPT}" -ProjectSlug ALL`
274
+ : `node "${SAFE_RUNNER}" --slug ALL`;
275
+ switch (frequency) {
276
+ case 'off': return null;
277
+ case 'hourly': return ['/create', '/tn', TASK_NAME, '/tr', tr, '/sc', 'hourly', '/f'];
278
+ case 'every6h': return ['/create', '/tn', TASK_NAME, '/tr', tr, '/sc', 'hourly', '/mo', '6', '/f'];
279
+ case 'every12h': return ['/create', '/tn', TASK_NAME, '/tr', tr, '/sc', 'hourly', '/mo', '12', '/f'];
280
+ case 'daily': return ['/create', '/tn', TASK_NAME, '/tr', tr, '/sc', 'daily', '/st', time || '08:00', '/f'];
281
+ case 'weekly': return ['/create', '/tn', TASK_NAME, '/tr', tr, '/sc', 'weekly', '/d', 'MON', '/st', time || '08:00', '/f'];
282
+ default: throw new Error('Unknown frequency: ' + frequency);
283
+ }
284
+ }
285
+
286
+ function updateSchedule(frequency, time, options = {}) {
287
+ return new Promise((resolve) => {
288
+ // Always delete first
289
+ const del = spawn('schtasks', ['/delete', '/tn', TASK_NAME, '/f'], { windowsHide: true });
290
+ del.on('close', () => {
291
+ if (frequency === 'off') return resolve({ ok: true, mode: 'off' });
292
+ const args = buildScheduleArgs(frequency, time, options);
293
+ const p = spawn('schtasks', args, { windowsHide: true });
294
+ let out = '', err = '';
295
+ p.stdout.on('data', d => out += d);
296
+ p.stderr.on('data', d => err += d);
297
+ p.on('close', code => {
298
+ if (code !== 0) return resolve({ ok: false, error: (err || out).trim(), code });
299
+ resolve({ ok: true, mode: frequency, time: time || '08:00', runner: options.runner || 'safe' });
300
+ });
301
+ p.on('error', e => resolve({ ok: false, error: e.message }));
302
+ });
303
+ del.on('error', e => resolve({ ok: false, error: e.message }));
304
+ });
305
+ }
306
+
307
+ function startJob({ mode, slug }) {
308
+ if (!KNOWN_MODES.has(mode)) return { ok: false, status: 400, error: `unknown mode: ${mode}` };
309
+ const job = makeJob({ mode, slug: slug || 'ALL' });
310
+ runningJobs.set(job.jobId, job);
311
+ // Run the job in the background so the HTTP request returns immediately.
312
+ // The job orchestrator updates `job` in place; the route handler returns
313
+ // just the jobId so the UI can poll.
314
+ (async () => {
315
+ try {
316
+ const projects = readProjectsForJob();
317
+ await runJob({
318
+ job,
319
+ projects,
320
+ projectsPath: path.join(KB_ROOT, 'projects.json'),
321
+ jobsLogPath: JOBS_LOG_PATH,
322
+ writeProjects: () => writeJson(path.join(KB_ROOT, 'projects.json'), projects),
323
+ });
324
+ } catch (e) {
325
+ job.status = 'failed';
326
+ job.endTime = new Date().toISOString();
327
+ job.exitCode = 1;
328
+ job.output += `\n[dispatch error] ${e.message}\n${e.stack || ''}`;
329
+ } finally {
330
+ lastRun = { time: job.endTime, status: job.status, slug: job.slug, mode: job.mode, output: (job.output || '').slice(-6000) };
331
+ // Keep the live record for 10 minutes so the UI can poll completion.
332
+ setTimeout(() => runningJobs.delete(job.jobId), 10 * 60 * 1000);
333
+ }
334
+ })();
335
+ return { ok: true, jobId: job.jobId, mode: job.mode, slug: job.slug };
336
+ }
337
+
338
+ // ---- git inspector (TASK-002) ----
339
+ // (execGit is now imported from ./lib/git-runner so the context-pack-builder can share it.)
340
+
341
+ async function inspectGit(gitPath) {
342
+ const result = {
343
+ gitPath: gitPath || '',
344
+ repoStatus: 'unknown',
345
+ defaultBranch: null,
346
+ currentBranch: null,
347
+ headCommit: null,
348
+ remoteUrl: null,
349
+ error: null,
350
+ };
351
+ if (!gitPath) {
352
+ result.repoStatus = 'missing-path';
353
+ result.error = 'no git path configured';
354
+ return result;
355
+ }
356
+ if (!fs.existsSync(gitPath)) {
357
+ result.repoStatus = 'missing-path';
358
+ result.error = `path not found: ${gitPath}`;
359
+ return result;
360
+ }
361
+
362
+ const inside = await execGit(gitPath, ['rev-parse', '--is-inside-work-tree']);
363
+ if (!inside.ok || inside.stdout.trim() !== 'true') {
364
+ result.repoStatus = 'not-git';
365
+ result.error = 'not inside a git work tree';
366
+ return result;
367
+ }
368
+
369
+ const toplevel = await execGit(gitPath, ['rev-parse', '--show-toplevel']);
370
+ if (toplevel.ok) result.gitPath = toplevel.stdout.trim();
371
+
372
+ const head = await execGit(gitPath, ['rev-parse', 'HEAD']);
373
+ if (!head.ok) {
374
+ // Empty repo: rev-parse HEAD fails with "unknown revision"
375
+ const empty = await execGit(gitPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
376
+ if (empty.ok) {
377
+ result.currentBranch = (empty.stdout || '').trim() || null;
378
+ }
379
+ result.repoStatus = 'empty';
380
+ result.error = 'repository has no commits';
381
+ return result;
382
+ }
383
+ result.headCommit = head.stdout.trim() || null;
384
+
385
+ const branch = await execGit(gitPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
386
+ if (branch.ok) result.currentBranch = (branch.stdout || '').trim() || null;
387
+
388
+ const defaultRef = await execGit(gitPath, ['symbolic-ref', 'refs/remotes/origin/HEAD']);
389
+ if (defaultRef.ok) {
390
+ const ref = (defaultRef.stdout || '').trim();
391
+ const m = ref.match(/refs\/remotes\/origin\/(.+)$/);
392
+ if (m) result.defaultBranch = m[1];
393
+ }
394
+ if (!result.defaultBranch) {
395
+ // Fallback: detect by checking local branches that match origin/<name>
396
+ const branches = await execGit(gitPath, ['branch', '--list']);
397
+ if (branches.ok) {
398
+ const lines = (branches.stdout || '').split(/\r?\n/);
399
+ const mainLine = lines.find(l => /\bmain\b/.test(l)) || lines.find(l => /\bmaster\b/.test(l));
400
+ if (mainLine) {
401
+ result.defaultBranch = mainLine.replace(/^\*\s*/, '').trim();
402
+ }
403
+ }
404
+ }
405
+
406
+ const remote = await execGit(gitPath, ['remote', 'get-url', 'origin']);
407
+ if (remote.ok) result.remoteUrl = (remote.stdout || '').trim() || null;
408
+ else result.remoteUrl = null;
409
+
410
+ result.repoStatus = 'ok';
411
+ return result;
412
+ }
413
+
414
+ function applyGitInspection(project, inspection) {
415
+ project.repoStatus = inspection.repoStatus;
416
+ project.headCommit = inspection.headCommit;
417
+ project.currentBranch = inspection.currentBranch;
418
+ project.defaultBranch = inspection.defaultBranch;
419
+ project.remoteUrl = inspection.remoteUrl;
420
+ project.gitPath = inspection.gitPath || project.gitPath;
421
+ return project;
422
+ }
423
+
424
+ async function validateAndPersistProject(slug, projects) {
425
+ if (!projects[slug]) return { ok: false, status: 404, error: 'Slug not in projects.json' };
426
+ const cfg = projects[slug];
427
+ const targetPath = cfg.gitPath || cfg.localPath;
428
+ const inspection = await inspectGit(targetPath);
429
+ applyGitInspection(cfg, inspection);
430
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
431
+ return { ok: true, slug, inspection };
432
+ }
433
+
434
+ // ---- project init (create dir structure) ----
435
+ const V2_DIRS = [
436
+ 'architecture', 'modules', 'commits', 'operations', 'references',
437
+ 'requirements', 'features', 'changes', 'quality',
438
+ '_ai/drafts', '_ai/runs', '_ai/context-packs',
439
+ ];
440
+
441
+ function readTemplate(name) {
442
+ const p = path.join(KB_ROOT, 'templates', name);
443
+ if (!fs.existsSync(p)) return null;
444
+ return fs.readFileSync(p, 'utf-8');
445
+ }
446
+
447
+ function renderTemplate(content, vars) {
448
+ return content.replace(/__([A-Z_][A-Z0-9_]*)__/g, (_, key) => (key in vars ? String(vars[key]) : `__${key}__`));
449
+ }
450
+
451
+ function todayIso() {
452
+ return new Date().toISOString().slice(0, 10);
453
+ }
454
+
455
+ function initProjectDirs(slug) {
456
+ const base = path.join(KB_ROOT, 'projects', slug);
457
+ const created = [];
458
+ for (const sub of V2_DIRS) {
459
+ const p = path.join(base, sub);
460
+ if (!fs.existsSync(p)) { fs.mkdirSync(p, { recursive: true }); created.push(sub); }
461
+ }
462
+ const vars = {
463
+ PROJECT: slug,
464
+ SLUG: slug,
465
+ DATE: todayIso(),
466
+ AUTHOR: process.env.USERNAME || process.env.USER || 'unknown',
467
+ COMMIT: '0000000000000000000000000000000000000000',
468
+ SHORTCOMMIT: '0000000',
469
+ SUBJECT: 'initial',
470
+ TYPE: 'chore',
471
+ CLASSIFICATION: 'infrastructure',
472
+ };
473
+ const tplDir = path.join(KB_ROOT, 'templates');
474
+ const seeds = [
475
+ { rel: 'README.md', tpl: 'project-readme.md' },
476
+ { rel: 'framework.md', tpl: 'framework.md' },
477
+ { rel: 'project-goal.md', tpl: 'project-goal.md' },
478
+ { rel: 'project-analysis.md', tpl: 'project-analysis.md' },
479
+ { rel: 'kb-manifest.json', tpl: 'kb-manifest.json' },
480
+ { rel: 'architecture/overview.md', tpl: null, fallback: `# Architecture — ${slug}\n` },
481
+ { rel: 'modules/00-index.md', tpl: null, fallback: `# Modules — ${slug}\n\n(No modules yet)\n` },
482
+ { rel: 'commits/00-index.md', tpl: 'index-header.md' },
483
+ { rel: 'features/00-index.md', tpl: null, fallback: `# Features — ${slug}\n\n(No features yet)\n` },
484
+ { rel: 'changes/00-index.md', tpl: null, fallback: `# Changes — ${slug}\n\n(No analyzed changes yet)\n` },
485
+ { rel: 'quality/quality-review-rules.md', tpl: 'quality-review-rules.md' },
486
+ { rel: 'requirements/roadmap.md', tpl: null, fallback: `# Roadmap — ${slug}\n` },
487
+ { rel: 'requirements/constraints.md', tpl: null, fallback: `# Constraints — ${slug}\n` },
488
+ { rel: 'requirements/success-criteria.md', tpl: null, fallback: `# Success Criteria — ${slug}\n` },
489
+ { rel: 'references/source-map.md', tpl: null, fallback: `# Source Map — ${slug}\n` },
490
+ ];
491
+ for (const { rel, tpl, fallback } of seeds) {
492
+ const target = path.join(base, rel);
493
+ if (fs.existsSync(target)) continue;
494
+ let content = fallback;
495
+ if (tpl) {
496
+ const tplPath = path.join(tplDir, tpl);
497
+ if (fs.existsSync(tplPath)) {
498
+ content = renderTemplate(fs.readFileSync(tplPath, 'utf-8'), vars);
499
+ } else if (fallback) {
500
+ content = fallback;
501
+ }
502
+ }
503
+ if (content != null) {
504
+ fs.writeFileSync(target, content, 'utf-8');
505
+ created.push(rel);
506
+ }
507
+ }
508
+ return { created, basePath: base };
509
+ }
510
+
511
+ // Idempotently add v2 layout to an existing project. Used by migration endpoint.
512
+ function ensureV2Layout(slug) {
513
+ return initProjectDirs(slug);
514
+ }
515
+
516
+ // ---- routing ----
517
+ const server = http.createServer(async (req, res) => {
518
+ try {
519
+ const url = new URL(req.url, 'http://x');
520
+ const p = url.pathname;
521
+ const m = req.method;
522
+
523
+ if (m === 'OPTIONS') {
524
+ res.writeHead(204, {
525
+ 'Access-Control-Allow-Origin': '*',
526
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
527
+ 'Access-Control-Allow-Headers': 'Content-Type',
528
+ });
529
+ return res.end();
530
+ }
531
+
532
+ // Static
533
+ if (m === 'GET' && (p === '/' || p === '/index.html')) {
534
+ const html = fs.readFileSync(path.join(SITE_ROOT, 'index.html'), 'utf-8');
535
+ return send(res, 200, html, 'text/html');
536
+ }
537
+ if (m === 'GET' && p === '/favicon.ico') return send(res, 204, '');
538
+
539
+ // GET /api/state — aggregate (enriches projects with kbInitialized flag)
540
+ if (m === 'GET' && p === '/api/state') {
541
+ const projects = readProjects({ persistMigrations: true });
542
+ for (const slug of Object.keys(projects)) {
543
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
544
+ // Project is "KB-initialized" if the dir exists AND README.md is present
545
+ try {
546
+ const stat = fs.statSync(kbPath);
547
+ if (stat.isDirectory() && fs.existsSync(path.join(kbPath, 'README.md'))) {
548
+ projects[slug].kbInitialized = true;
549
+ // Count commits and modules
550
+ const commitsDir = path.join(kbPath, 'commits');
551
+ const modulesDir = path.join(kbPath, 'modules');
552
+ try { projects[slug].commitCount = fs.readdirSync(commitsDir).filter(f => f.endsWith('.md') && f !== '00-index.md').length; } catch {}
553
+ try { projects[slug].moduleCount = fs.readdirSync(modulesDir).filter(f => f.endsWith('.md') && f !== '00-index.md').length; } catch {}
554
+ } else {
555
+ projects[slug].kbInitialized = false;
556
+ }
557
+ } catch {
558
+ projects[slug].kbInitialized = false;
559
+ }
560
+ }
561
+ const schedule = await getScheduleInfo();
562
+ return send(res, 200, { projects, schedule, lastRun, kbRoot: KB_ROOT, projectSchemaVersion: PROJECT_SCHEMA_VERSION });
563
+ }
564
+
565
+ // GET /api/projects
566
+ if (m === 'GET' && p === '/api/projects') {
567
+ return send(res, 200, readProjects({ persistMigrations: true }));
568
+ }
569
+
570
+ // PUT /api/projects — replace or upsert one
571
+ if (m === 'PUT' && p === '/api/projects') {
572
+ const body = JSON.parse(await readBody(req));
573
+ const projects = readProjects({ persistMigrations: true });
574
+ if (body.slug && body.config) {
575
+ if (!isSafeSlug(body.slug)) return send(res, 400, { error: 'Invalid slug' });
576
+ if (typeof body.config !== 'object' || body.config === null) {
577
+ return send(res, 400, { error: 'Invalid config' });
578
+ }
579
+ projects[body.slug] = normalizeProjectConfig(body.slug, body.config).config;
580
+ // Auto-validate git on add/update — populates repoStatus, headCommit, etc.
581
+ const targetPath = projects[body.slug].gitPath || projects[body.slug].localPath;
582
+ const inspection = await inspectGit(targetPath);
583
+ applyGitInspection(projects[body.slug], inspection);
584
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
585
+ return send(res, 200, { ok: true, slug: body.slug, repoStatus: inspection.repoStatus });
586
+ }
587
+ if (body.projects && typeof body.projects === 'object') {
588
+ const normalized = normalizeProjects(body.projects).projects;
589
+ writeJson(path.join(KB_ROOT, 'projects.json'), normalized);
590
+ return send(res, 200, { ok: true });
591
+ }
592
+ return send(res, 400, { error: 'Need { slug, config } or { projects }' });
593
+ }
594
+
595
+ // POST /api/projects/:slug/init
596
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/init')) {
597
+ const slug = p.split('/')[3];
598
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
599
+ const projects = readProjects({ persistMigrations: true });
600
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
601
+ const result = initProjectDirs(slug);
602
+ // Mark project as v2 after init
603
+ projects[slug].kbSchemaVersion = 'v2';
604
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
605
+ return send(res, 200, { ok: true, ...result, kbSchemaVersion: 'v2' });
606
+ }
607
+
608
+ // POST /api/projects/:slug/migrate-v2 — add v2 layout to an existing project
609
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/migrate-v2')) {
610
+ const slug = p.split('/')[3];
611
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
612
+ const projects = readProjects({ persistMigrations: true });
613
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
614
+ const result = ensureV2Layout(slug);
615
+ projects[slug].kbSchemaVersion = 'v2';
616
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
617
+ return send(res, 200, { ok: true, ...result, kbSchemaVersion: 'v2' });
618
+ }
619
+
620
+ // POST /api/projects/:slug/validate-git
621
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/validate-git')) {
622
+ const slug = p.split('/')[3];
623
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
624
+ const projects = readProjects({ persistMigrations: false });
625
+ const result = await validateAndPersistProject(slug, projects);
626
+ if (!result.ok) return send(res, result.status, { error: result.error });
627
+ return send(res, 200, { ok: true, slug, ...result.inspection });
628
+ }
629
+
630
+ // GET /api/projects/:slug/git-status (read-only inspection without writing)
631
+ if (m === 'GET' && p.startsWith('/api/projects/') && p.endsWith('/git-status')) {
632
+ const slug = p.split('/')[3];
633
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
634
+ const projects = readProjects({ persistMigrations: false });
635
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
636
+ const cfg = projects[slug];
637
+ const targetPath = cfg.gitPath || cfg.localPath;
638
+ const inspection = await inspectGit(targetPath);
639
+ return send(res, 200, { ok: true, slug, ...inspection });
640
+ }
641
+
642
+ // GET /api/projects/:slug/hook-status — read whether the post-commit hook is installed
643
+ if (m === 'GET' && p.startsWith('/api/projects/') && p.endsWith('/hook-status')) {
644
+ const slug = p.split('/')[3];
645
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
646
+ const projects = readProjects({ persistMigrations: false });
647
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
648
+ const cfg = projects[slug];
649
+ const repoPath = cfg.gitPath || cfg.localPath;
650
+ const status = readHookStatus({ repoPath });
651
+ return send(res, status.ok ? 200 : status.status || 500, { ok: true, slug, ...status });
652
+ }
653
+
654
+ // POST /api/projects/:slug/hook-install { overwrite?: boolean }
655
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/hook-install')) {
656
+ const slug = p.split('/')[3];
657
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
658
+ const projects = readProjects({ persistMigrations: false });
659
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
660
+ const cfg = projects[slug];
661
+ const repoPath = cfg.gitPath || cfg.localPath;
662
+ const body = JSON.parse(await readBody(req).catch(() => '{}'));
663
+ const overwrite = !!(body && body.overwrite);
664
+ const result = installHook({ repoPath, siteRoot: SITE_ROOT, host: HOST, port: PORT, overwrite });
665
+ return send(res, result.ok ? 200 : result.status || 500, { ok: !!result.ok, slug, ...result });
666
+ }
667
+
668
+ // POST /api/projects/:slug/hook-uninstall
669
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/hook-uninstall')) {
670
+ const slug = p.split('/')[3];
671
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
672
+ const projects = readProjects({ persistMigrations: false });
673
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
674
+ const cfg = projects[slug];
675
+ const repoPath = cfg.gitPath || cfg.localPath;
676
+ const result = uninstallHook({ repoPath });
677
+ return send(res, result.ok ? 200 : result.status || 500, { ok: !!result.ok, slug, ...result });
678
+ }
679
+
680
+ // POST /api/projects/:slug/scan — read-only scanner; updates headCommit + lastSeenCommit only
681
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/scan')) {
682
+ const slug = p.split('/')[3];
683
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
684
+ const body = await readBody(req).catch(() => '{}');
685
+ const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
686
+ const projects = readProjects({ persistMigrations: false });
687
+ const result = await scanAndPersistProject(slug, projects, { maxCommits: parsed.maxCommits || 200 });
688
+ if (!result.ok) return send(res, result.status, { error: result.error });
689
+ return send(res, 200, { ok: true, slug, ...result.scan });
690
+ }
691
+
692
+ // GET /api/projects/:slug/scan — read-only scan preview
693
+ if (m === 'GET' && p.startsWith('/api/projects/') && p.endsWith('/scan')) {
694
+ const slug = p.split('/')[3];
695
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
696
+ const projects = readProjects({ persistMigrations: false });
697
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
698
+ const scan = await scanProject({ slug, ...projects[slug] });
699
+ return send(res, 200, { ok: true, slug, ...scan });
700
+ }
701
+
702
+ // POST /api/scan-all — scan every enabled project
703
+ if (m === 'POST' && p === '/api/scan-all') {
704
+ const projects = readProjects({ persistMigrations: false });
705
+ const results = [];
706
+ for (const slug of Object.keys(projects)) {
707
+ if (projects[slug].enabled === false) continue;
708
+ const r = await scanAndPersistProject(slug, projects);
709
+ results.push({ slug, ok: r.ok, ...(r.ok ? r.scan : { error: r.error }) });
710
+ }
711
+ return send(res, 200, { ok: true, results });
712
+ }
713
+
714
+ // GET /api/ai-profiles — list configured profiles and available adapters
715
+ if (m === 'GET' && p === '/api/ai-profiles') {
716
+ const cfg = readAiProfiles();
717
+ return send(res, 200, { ok: true, config: cfg, adapters: listAdapters() });
718
+ }
719
+
720
+ // PUT /api/ai-profiles — replace the whole ai-profiles.json
721
+ if (m === 'PUT' && p === '/api/ai-profiles') {
722
+ const body = JSON.parse(await readBody(req));
723
+ const errors = validateProfileConfig(body);
724
+ if (errors.length) return send(res, 400, { ok: false, errors });
725
+ writeAiProfiles(body);
726
+ return send(res, 200, { ok: true });
727
+ }
728
+
729
+ // PUT /api/projects/:slug/ai-profile — set a project's AI profile
730
+ if (m === 'PUT' && p.startsWith('/api/projects/') && p.endsWith('/ai-profile')) {
731
+ const slug = p.split('/')[3];
732
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
733
+ const body = JSON.parse(await readBody(req));
734
+ if (!body.aiProfileId) return send(res, 400, { error: 'aiProfileId required' });
735
+ if (!getAdapter(body.aiProfileId)) return send(res, 400, { error: `unknown adapter: ${body.aiProfileId}` });
736
+ const projects = readProjects({ persistMigrations: true });
737
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
738
+ projects[slug].aiProfileId = body.aiProfileId;
739
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
740
+ return send(res, 200, { ok: true, slug, aiProfileId: body.aiProfileId });
741
+ }
742
+
743
+ // PUT /api/projects/:slug/settings - set project-level AI and KB generation settings
744
+ if (m === 'PUT' && p.startsWith('/api/projects/') && p.endsWith('/settings')) {
745
+ const slug = p.split('/')[3];
746
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
747
+ const body = JSON.parse(await readBody(req));
748
+ const projects = readProjects({ persistMigrations: true });
749
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
750
+
751
+ if (body.aiProfileId != null) {
752
+ if (!body.aiProfileId) return send(res, 400, { error: 'aiProfileId required' });
753
+ if (!getAdapter(body.aiProfileId)) return send(res, 400, { error: `unknown adapter: ${body.aiProfileId}` });
754
+ projects[slug].aiProfileId = body.aiProfileId;
755
+ }
756
+
757
+ if (body.knowledgeLanguage != null) {
758
+ if (!['zh-CN', 'en-US'].includes(body.knowledgeLanguage)) {
759
+ return send(res, 400, { error: 'knowledgeLanguage must be zh-CN or en-US' });
760
+ }
761
+ projects[slug].knowledgeLanguage = body.knowledgeLanguage;
762
+ }
763
+
764
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
765
+ return send(res, 200, {
766
+ ok: true,
767
+ slug,
768
+ aiProfileId: projects[slug].aiProfileId,
769
+ knowledgeLanguage: projects[slug].knowledgeLanguage,
770
+ });
771
+ }
772
+
773
+ // POST /api/projects/:slug/context-pack — build a context pack (TASK-006)
774
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/context-pack')) {
775
+ const slug = p.split('/')[3];
776
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
777
+ const body = await readBody(req).catch(() => '{}');
778
+ const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
779
+ const projects = readProjects({ persistMigrations: false });
780
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
781
+ const trigger = parsed.trigger === 'commits' ? 'commits' : 'initial';
782
+ let commits = [];
783
+ if (trigger === 'commits') {
784
+ const scan = await scanProject({ slug, ...projects[slug] }, { maxCommits: parsed.maxCommits || 200 });
785
+ commits = scan.commits;
786
+ }
787
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
788
+ const pack = await buildContextPack({
789
+ project: { slug, ...projects[slug], kbPath },
790
+ runId: parsed.runId,
791
+ trigger,
792
+ commits,
793
+ });
794
+ return send(res, 200, { ok: true, slug, runId: pack.runId, entryCount: pack.entries.length, contextPack: pack });
795
+ }
796
+
797
+ // POST /api/projects/:slug/analyze/initial — run initial analysis (TASK-007)
798
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/analyze/initial')) {
799
+ const slug = p.split('/')[3];
800
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
801
+ const projects = readProjects({ persistMigrations: false });
802
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
803
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
804
+ const result = await runInitialAnalysis({ slug, ...projects[slug], kbPath });
805
+ if (!result.ok) {
806
+ return send(res, result.status, { ok: false, error: result.error, runId: result.runId, run: result.runRecord });
807
+ }
808
+ return send(res, 200, { ok: true, slug, runId: result.runId, run: result.runRecord });
809
+ }
810
+
811
+ // POST /api/projects/:slug/analyze/commits — run incremental commit analysis (TASK-008)
812
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/analyze/commits')) {
813
+ const slug = p.split('/')[3];
814
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
815
+ const projects = readProjects({ persistMigrations: false });
816
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
817
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
818
+ const result = await runCommitAnalysis({ slug, ...projects[slug], kbPath });
819
+ if (!result.ok) {
820
+ return send(res, result.status, { ok: false, error: result.error, runId: result.runId, run: result.runRecord });
821
+ }
822
+ return send(res, 200, { ok: true, slug, runId: result.runId, noop: !!result.noop, run: result.runRecord });
823
+ }
824
+
825
+ // GET /api/projects/:slug/runs — list run records
826
+ if (m === 'GET' && p.startsWith('/api/projects/') && p.endsWith('/runs')) {
827
+ const slug = p.split('/')[3];
828
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
829
+ const projects = readProjects({ persistMigrations: false });
830
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
831
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
832
+ return send(res, 200, { ok: true, slug, runs: listRuns(kbPath) });
833
+ }
834
+
835
+ // GET /api/projects/:slug/runs/:runId — read a single run
836
+ if (m === 'GET' && p.match(/^\/api\/projects\/[a-z0-9-]+\/runs\/[A-Za-z0-9_-]+$/)) {
837
+ const parts = p.split('/');
838
+ const slug = parts[3];
839
+ const runId = parts[5];
840
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
841
+ const projects = readProjects({ persistMigrations: false });
842
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
843
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
844
+ const run = readRun(kbPath, runId);
845
+ if (!run) return send(res, 404, { error: 'run not found' });
846
+ return send(res, 200, { ok: true, slug, run, drafts: listDrafts(kbPath, runId) });
847
+ }
848
+
849
+ // GET /api/projects/:slug/drafts/:runId — list drafts in a run
850
+ if (m === 'GET' && p.match(/^\/api\/projects\/[a-z0-9-]+\/drafts\/[A-Za-z0-9_-]+$/)) {
851
+ const parts = p.split('/');
852
+ const slug = parts[3];
853
+ const runId = parts[5];
854
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
855
+ const projects = readProjects({ persistMigrations: false });
856
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
857
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
858
+ return send(res, 200, { ok: true, slug, runId, drafts: listDrafts(kbPath, runId) });
859
+ }
860
+
861
+ // GET /api/projects/:slug/drafts/:runId/raw?path=... — read a single draft's text
862
+ if (m === 'GET' && p.match(/^\/api\/projects\/[a-z0-9-]+\/drafts\/[A-Za-z0-9_-]+\/raw$/)) {
863
+ const parts = p.split('/');
864
+ const slug = parts[3];
865
+ const runId = parts[5];
866
+ const rel = url.searchParams.get('path') || '';
867
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
868
+ const projects = readProjects({ persistMigrations: false });
869
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
870
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
871
+ const text = readDraftContent(kbPath, runId, rel);
872
+ if (text == null) return send(res, 404, { error: 'draft not found' });
873
+ return send(res, 200, { ok: true, slug, runId, path: rel, content: text }, 'text/plain');
874
+ }
875
+
876
+ // POST /api/projects/:slug/drafts/:runId/apply — apply selected drafts to the KB (TASK-009)
877
+ if (m === 'POST' && p.match(/^\/api\/projects\/[a-z0-9-]+\/drafts\/[A-Za-z0-9_-]+\/apply$/)) {
878
+ const parts = p.split('/');
879
+ const slug = parts[3];
880
+ const runId = parts[5];
881
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
882
+ const projects = readProjects({ persistMigrations: false });
883
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
884
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
885
+ const body = await readBody(req).catch(() => '{}');
886
+ const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
887
+ if (!Array.isArray(parsed.drafts)) return send(res, 400, { error: 'drafts array required' });
888
+ // Default headCommitAtRun to the value recorded in the run record so callers
889
+ // can just POST {drafts, allowGoalEdit} and have lastAnalyzedCommit advance correctly.
890
+ const runRecord = readRun(kbPath, runId);
891
+ const headCommitAtRun = parsed.headCommitAtRun || (runRecord && runRecord.headCommitAtRun) || null;
892
+ const result = applyDrafts({
893
+ kbPath,
894
+ slug,
895
+ runId,
896
+ drafts: parsed.drafts,
897
+ allowGoalEdit: !!parsed.allowGoalEdit,
898
+ headCommitAtRun,
899
+ });
900
+ if (!result.ok) return send(res, result.status, { ok: false, error: result.error, ...result });
901
+ // On successful apply, advance the project's lastAnalyzedCommit to the run's head commit
902
+ if (headCommitAtRun) {
903
+ projects[slug].lastAnalyzedCommit = headCommitAtRun;
904
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
905
+ }
906
+ return send(res, 200, { ok: true, ...result });
907
+ }
908
+
909
+ // POST /api/projects/:slug/validate-kb — validate KB contract (TASK-011)
910
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/validate-kb')) {
911
+ const slug = p.split('/')[3];
912
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
913
+ const projects = readProjects({ persistMigrations: false });
914
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
915
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
916
+ const result = validateKb(kbPath);
917
+ return send(res, result.status, { ok: result.ok, ...result });
918
+ }
919
+
920
+ // GET /api/projects/:slug/pr-context — build a PR consumer context pack (TASK-011)
921
+ if (m === 'GET' && p.startsWith('/api/projects/') && p.endsWith('/pr-context')) {
922
+ const slug = p.split('/')[3];
923
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
924
+ const projects = readProjects({ persistMigrations: false });
925
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
926
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
927
+ const result = buildPrContextPack(kbPath);
928
+ return send(res, result.status || (result.ok ? 200 : 422), { ok: result.ok, ...result });
929
+ }
930
+
931
+ // POST /api/projects/:slug/drafts/:runId/reject — reject all drafts in a run (TASK-009)
932
+ if (m === 'POST' && p.match(/^\/api\/projects\/[a-z0-9-]+\/drafts\/[A-Za-z0-9_-]+\/reject$/)) {
933
+ const parts = p.split('/');
934
+ const slug = parts[3];
935
+ const runId = parts[5];
936
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
937
+ const projects = readProjects({ persistMigrations: false });
938
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
939
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
940
+ const body = await readBody(req).catch(() => '{}');
941
+ const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
942
+ const result = rejectDrafts({ kbPath, runId, reason: parsed.reason });
943
+ if (!result.ok) return send(res, result.status, result);
944
+ return send(res, 200, { ok: true, ...result });
945
+ }
946
+
947
+ // GET /api/schedule
948
+ if (m === 'GET' && p === '/api/schedule') {
949
+ return send(res, 200, await getScheduleInfo());
950
+ }
951
+
952
+ // PUT /api/schedule { frequency, time?, runner? }
953
+ if (m === 'PUT' && p === '/api/schedule') {
954
+ const body = JSON.parse(await readBody(req));
955
+ if (!body.frequency) return send(res, 400, { error: 'frequency required' });
956
+ const result = await updateSchedule(body.frequency, body.time, { runner: body.runner });
957
+ return send(res, 200, result);
958
+ }
959
+
960
+ // POST /api/jobs/run — run a job in one of the supported modes (TASK-010)
961
+ if (m === 'POST' && p === '/api/jobs/run') {
962
+ const body = await readBody(req).catch(() => '{}');
963
+ const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
964
+ const mode = parsed.mode || 'legacy';
965
+ const slug = parsed.slug || 'ALL';
966
+ const result = startJob({ mode, slug });
967
+ if (!result.ok) return send(res, result.status, result);
968
+ return send(res, 200, result);
969
+ }
970
+
971
+ // GET /api/jobs — recent job history (persisted to .jobs-log.json)
972
+ if (m === 'GET' && p === '/api/jobs') {
973
+ const history = readJobLog(JOBS_LOG_PATH);
974
+ return send(res, 200, {
975
+ ok: true,
976
+ history,
977
+ running: [...runningJobs.values()].map(j => ({
978
+ jobId: j.jobId, mode: j.mode, slug: j.slug, status: j.status,
979
+ startTime: j.startTime, endTime: j.endTime,
980
+ })),
981
+ knownModes: [...KNOWN_MODES],
982
+ lastRun,
983
+ });
984
+ }
985
+
986
+ // GET /api/jobs/:jobId — read a single job (live or persisted)
987
+ if (m === 'GET' && p.match(/^\/api\/jobs\/job-[0-9]+-[0-9]+$/)) {
988
+ const jobId = p.split('/')[3];
989
+ const live = runningJobs.get(jobId);
990
+ if (live) {
991
+ return send(res, 200, {
992
+ ok: true, job: {
993
+ jobId: live.jobId, mode: live.mode, slug: live.slug,
994
+ status: live.status, startTime: live.startTime, endTime: live.endTime,
995
+ exitCode: live.exitCode, summary: live.summary,
996
+ output: (live.output || '').slice(-6000),
997
+ }
998
+ });
999
+ }
1000
+ const history = readJobLog(JOBS_LOG_PATH);
1001
+ const persisted = history.find(j => j.jobId === jobId);
1002
+ if (!persisted) return send(res, 404, { error: 'job not found' });
1003
+ return send(res, 200, { ok: true, job: persisted });
1004
+ }
1005
+
1006
+ // POST /api/script/run — backward-compat alias for /api/jobs/run with mode=legacy
1007
+ if (m === 'POST' && p === '/api/script/run') {
1008
+ const body = await readBody(req).catch(() => '{}');
1009
+ const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
1010
+ const mode = parsed.mode || 'legacy';
1011
+ const slug = parsed.slug || 'ALL';
1012
+ const result = startJob({ mode, slug });
1013
+ if (!result.ok) return send(res, result.status, result);
1014
+ return send(res, 200, result);
1015
+ }
1016
+
1017
+ // GET /api/script/status — backward-compat alias
1018
+ if (m === 'GET' && p === '/api/script/status') {
1019
+ return send(res, 200, { lastRun, running: [...runningJobs.values()] });
1020
+ }
1021
+
1022
+ // GET /api/dirs/:slug
1023
+ if (m === 'GET' && p.startsWith('/api/dirs/')) {
1024
+ const slug = p.split('/')[3];
1025
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
1026
+ const kbPath = path.join(KB_ROOT, 'projects', slug);
1027
+ if (!fs.existsSync(kbPath)) return send(res, 404, { error: 'Project KB not initialized' });
1028
+ return send(res, 200, listSubTree(kbPath, '', 0, 3));
1029
+ }
1030
+
1031
+ // GET /api/raw?path=relative (read a markdown file under projects/)
1032
+ if (m === 'GET' && p === '/api/raw') {
1033
+ const rel = url.searchParams.get('path');
1034
+ if (!rel) return send(res, 400, { error: 'path required' });
1035
+ const abs = path.resolve(KB_ROOT, rel);
1036
+ if (!abs.startsWith(KB_ROOT)) return send(res, 403, { error: 'outside kb root' });
1037
+ if (!fs.existsSync(abs)) return send(res, 404, { error: 'not found' });
1038
+ const stat = fs.statSync(abs);
1039
+ if (stat.isDirectory()) {
1040
+ const tree = listSubTree(abs, rel.replace(/\\/g, '/') + '/', 0, 4);
1041
+ return send(res, 200, { type: 'dir', tree });
1042
+ }
1043
+ const buf = fs.readFileSync(abs);
1044
+ return send(res, 200, { type: 'file', content: buf.toString('utf-8'), size: stat.size }, 'text/plain');
1045
+ }
1046
+
1047
+ send(res, 404, { error: 'Not found', path: p });
1048
+ } catch (e) {
1049
+ console.error('[server error]', e);
1050
+ send(res, 500, { error: e.message, stack: e.stack });
1051
+ }
1052
+ });
1053
+
1054
+ server.listen(PORT, HOST, () => {
1055
+ console.log(`[kb-site] listening on http://${HOST}:${PORT}`);
1056
+ console.log(`[kb-site] KB root: ${KB_ROOT}`);
1057
+ console.log(`[kb-site] task: ${TASK_NAME}`);
1058
+ });