project-knowledge 0.1.0 → 1.0.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 (34) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +184 -58
  3. package/_site/_test/ai-profile-test.js +59 -1
  4. package/_site/_test/baseline-schema-test.js +4 -3
  5. package/_site/_test/claude-workbench-test.js +72 -0
  6. package/_site/_test/draft-apply-test.js +12 -6
  7. package/_site/_test/kb-v2-templates-test.js +31 -43
  8. package/_site/_test/knowledge-store-logs-supervision-test.js +143 -0
  9. package/_site/_test/project-control-panel-task14-test.js +151 -0
  10. package/_site/_test/task15-20-integration-test.js +194 -0
  11. package/_site/_test/task15-20-ui-flow-test.js +144 -0
  12. package/_site/_test/ui-smoke-test.js +2 -2
  13. package/_site/index.html +1640 -90
  14. package/_site/lib/ai-adapter.js +3 -3
  15. package/_site/lib/ai-workspace.js +120 -0
  16. package/_site/lib/analysis-orchestrator.js +117 -32
  17. package/_site/lib/claude-cli-runner.js +862 -0
  18. package/_site/lib/context-pack-builder.js +19 -11
  19. package/_site/lib/draft-apply.js +80 -31
  20. package/_site/lib/index-builder.js +100 -0
  21. package/_site/lib/job-orchestrator.js +12 -9
  22. package/_site/lib/kb-v3.js +188 -0
  23. package/_site/lib/kb-validator.js +84 -0
  24. package/_site/lib/knowledge-store.js +141 -0
  25. package/_site/lib/llm-client.js +103 -56
  26. package/_site/lib/prompt-registry.js +102 -0
  27. package/_site/lib/structured-logger.js +120 -0
  28. package/_site/lib/supervision.js +103 -0
  29. package/_site/server.js +835 -19
  30. package/_site/vendor/tailwind-browser.js +947 -0
  31. package/_site/vendor/vue.global.prod.js +9 -0
  32. package/ai-profiles.json +12 -10
  33. package/docs/development-progress.md +141 -0
  34. package/package.json +7 -2
package/_site/server.js CHANGED
@@ -15,6 +15,14 @@ const { applyDrafts, rejectDrafts, readDraftContent } = require('./lib/draft-app
15
15
  const { runJob, makeJob, readJobLog, KNOWN_MODES } = require('./lib/job-orchestrator');
16
16
  const { validateKb, buildPrContextPack } = require('./lib/kb-validator');
17
17
  const { installHook, uninstallHook, readHookStatus } = require('./lib/hook-manager');
18
+ const { completeText, readConfig: readLlmConfig } = require('./lib/llm-client');
19
+ const claudeCliRunner = require('./lib/claude-cli-runner');
20
+ const promptRegistry = require('./lib/prompt-registry');
21
+ const knowledgeStore = require('./lib/knowledge-store');
22
+ const structuredLogger = require('./lib/structured-logger');
23
+ const supervision = require('./lib/supervision');
24
+ const aiWorkspace = require('./lib/ai-workspace');
25
+ const kbV3 = require('./lib/kb-v3');
18
26
 
19
27
  const KB_ROOT = path.resolve(__dirname, '..');
20
28
  const SITE_ROOT = __dirname;
@@ -23,11 +31,13 @@ const HOST = process.env.KB_SITE_HOST || '127.0.0.1';
23
31
  const TASK_NAME = 'KB-GitCommits-Daily';
24
32
  const SCRIPT = 'D:\\SanQian.Xu\\project-knowledge-base\\scripts\\gen-commit-doc.ps1';
25
33
  const SAFE_RUNNER = 'D:\\SanQian.Xu\\project-knowledge-base\\_site\\scripts\\safe-runner.js';
26
- const PROJECT_SCHEMA_VERSION = 'v1';
34
+ const PROJECT_SCHEMA_VERSION = 'v3';
27
35
  const DEFAULT_AI_PROFILE_ID = 'mock-agent';
28
36
  const DEFAULT_KNOWLEDGE_LANGUAGE = 'zh-CN';
29
37
  const AI_PROFILES_PATH = path.join(KB_ROOT, 'ai-profiles.json');
30
38
  const JOBS_LOG_PATH = path.join(KB_ROOT, '.jobs-log.json');
39
+ const KNOWLEDGE_STORE_PATH = path.join(KB_ROOT, 'knowledge-store.json');
40
+ const LOGGING_CONFIG_PATH = path.join(KB_ROOT, 'logging.json');
31
41
 
32
42
  // ---- state ----
33
43
  let lastRun = { time: null, status: null, slug: null, output: '' };
@@ -77,7 +87,30 @@ function normalizeKnowledgeLanguage(value) {
77
87
  }
78
88
 
79
89
  function defaultProjectKbPath(slug) {
80
- return path.join(KB_ROOT, 'projects', slug);
90
+ return knowledgeStore.defaultProjectKbPath(slug, KNOWLEDGE_STORE_PATH, KB_ROOT);
91
+ }
92
+
93
+ function readKnowledgeStore() {
94
+ return knowledgeStore.readConfig(KNOWLEDGE_STORE_PATH, KB_ROOT);
95
+ }
96
+
97
+ function readLoggingConfig() {
98
+ return structuredLogger.readConfig(LOGGING_CONFIG_PATH, KB_ROOT);
99
+ }
100
+
101
+ function logEvent(level, event, message, meta = {}) {
102
+ try {
103
+ structuredLogger.appendLog(LOGGING_CONFIG_PATH, KB_ROOT, {
104
+ level,
105
+ event,
106
+ message,
107
+ projectSlug: meta.projectSlug || meta.slug || '',
108
+ source: meta.source || 'server',
109
+ jobId: meta.jobId || '',
110
+ runId: meta.runId || '',
111
+ meta,
112
+ });
113
+ } catch {}
81
114
  }
82
115
 
83
116
  function isLegacyKbPath(value) {
@@ -88,7 +121,20 @@ function isLegacyKbPath(value) {
88
121
  function readAiProfiles() {
89
122
  if (!fs.existsSync(AI_PROFILES_PATH)) {
90
123
  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,
124
+ id: a.id,
125
+ name: a.name,
126
+ description: a.description,
127
+ enabled: a.id !== 'claude-code-agent',
128
+ implementation: a.id,
129
+ ...(a.id === 'claude-code-agent' ? {
130
+ baseUrl: 'https://api.anthropic.com',
131
+ apiKey: '',
132
+ model: 'claude-haiku-4-5',
133
+ version: '2023-06-01',
134
+ temperature: 0.2,
135
+ maxTokens: 4096,
136
+ timeoutMs: 300000,
137
+ } : {}),
92
138
  })) };
93
139
  }
94
140
  return JSON.parse(fs.readFileSync(AI_PROFILES_PATH, 'utf-8'));
@@ -98,6 +144,28 @@ function writeAiProfiles(cfg) {
98
144
  writeJson(AI_PROFILES_PATH, cfg);
99
145
  }
100
146
 
147
+ function findAiProfile(profileId, cfg = readAiProfiles()) {
148
+ return (cfg.profiles || []).find(item => item && item.id === profileId) || null;
149
+ }
150
+
151
+ function profileImplementation(profile) {
152
+ return profile && (profile.implementation || profile.id);
153
+ }
154
+
155
+ function validateUsableAiProfile(profileId) {
156
+ if (!profileId) return { ok: false, status: 400, error: 'aiProfileId required' };
157
+ const profile = findAiProfile(profileId);
158
+ if (!profile && profileId === DEFAULT_AI_PROFILE_ID && getAdapter(DEFAULT_AI_PROFILE_ID)) {
159
+ return { ok: true, profile: { id: DEFAULT_AI_PROFILE_ID, enabled: true, implementation: DEFAULT_AI_PROFILE_ID }, implementation: DEFAULT_AI_PROFILE_ID, adapter: getAdapter(DEFAULT_AI_PROFILE_ID) };
160
+ }
161
+ if (!profile) return { ok: false, status: 400, error: `AI profile not configured: ${profileId}` };
162
+ const implementation = profileImplementation(profile);
163
+ const adapter = getAdapter(implementation);
164
+ if (!adapter) return { ok: false, status: 400, error: `unknown adapter: ${implementation}` };
165
+ if (profile.enabled === false) return { ok: false, status: 400, error: `AI profile disabled: ${profileId}` };
166
+ return { ok: true, profile, implementation, adapter };
167
+ }
168
+
101
169
  function validateProfileConfig(cfg) {
102
170
  const errors = [];
103
171
  if (!cfg || typeof cfg !== 'object') return ['not an object'];
@@ -109,9 +177,21 @@ function validateProfileConfig(cfg) {
109
177
  if (!p.id) { errors.push(`profiles[${i}] missing id`); continue; }
110
178
  if (ids.has(p.id)) errors.push(`duplicate id: ${p.id}`);
111
179
  ids.add(p.id);
112
- if (!getAdapter(p.id)) errors.push(`unknown adapter id: ${p.id}`);
180
+ const implementation = profileImplementation(p);
181
+ if (!implementation || typeof implementation !== 'string') errors.push(`profiles[${i}].implementation must be a string`);
182
+ else if (!getAdapter(implementation)) errors.push(`unknown adapter implementation: ${implementation}`);
183
+ if (p.baseUrl && typeof p.baseUrl !== 'string') errors.push(`profiles[${i}].baseUrl must be a string`);
184
+ if (p.apiKey && typeof p.apiKey !== 'string') errors.push(`profiles[${i}].apiKey must be a string`);
185
+ if (p.apiKeyEnv && typeof p.apiKeyEnv !== 'string') errors.push(`profiles[${i}].apiKeyEnv must be a string`);
186
+ if (p.model && typeof p.model !== 'string') errors.push(`profiles[${i}].model must be a string`);
187
+ if (p.version && typeof p.version !== 'string') errors.push(`profiles[${i}].version must be a string`);
188
+ if (p.temperature != null && (typeof p.temperature !== 'number' || p.temperature < 0 || p.temperature > 2)) errors.push(`profiles[${i}].temperature must be a number between 0 and 2`);
189
+ if (p.maxTokens != null && (!Number.isInteger(p.maxTokens) || p.maxTokens < 1)) errors.push(`profiles[${i}].maxTokens must be a positive integer`);
190
+ if (p.timeoutMs != null && (!Number.isInteger(p.timeoutMs) || p.timeoutMs < 1000)) errors.push(`profiles[${i}].timeoutMs must be an integer >= 1000`);
113
191
  }
114
192
  if (cfg.defaultProfileId && !ids.has(cfg.defaultProfileId)) errors.push(`defaultProfileId not in profiles: ${cfg.defaultProfileId}`);
193
+ const defaultProfile = cfg.defaultProfileId ? (cfg.profiles || []).find(p => p && p.id === cfg.defaultProfileId) : null;
194
+ if (defaultProfile && defaultProfile.enabled === false) errors.push(`defaultProfileId is disabled: ${cfg.defaultProfileId}`);
115
195
  return errors;
116
196
  }
117
197
 
@@ -192,6 +272,82 @@ function listSubTree(root, prefix, depth, max) {
192
272
  return out;
193
273
  }
194
274
 
275
+ function dirStats(root) {
276
+ const result = { fileCount: 0, kbSizeBytes: 0 };
277
+ if (!root || !fs.existsSync(root)) return result;
278
+ const walk = (dir) => {
279
+ let entries = [];
280
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
281
+ for (const entry of entries) {
282
+ const full = path.join(dir, entry.name);
283
+ if (entry.isDirectory()) walk(full);
284
+ else if (entry.isFile()) {
285
+ result.fileCount += 1;
286
+ try { result.kbSizeBytes += fs.statSync(full).size; } catch {}
287
+ }
288
+ }
289
+ };
290
+ walk(root);
291
+ return result;
292
+ }
293
+
294
+ function hasRunningProjectJob(slug) {
295
+ for (const job of runningJobs.values()) {
296
+ if (!job || job.status !== 'running') continue;
297
+ if (job.slug === slug || job.slug === 'ALL') return true;
298
+ }
299
+ return false;
300
+ }
301
+
302
+ function allowedKbDeletionRootPaths() {
303
+ const cfg = readKnowledgeStore();
304
+ return [
305
+ path.join(KB_ROOT, 'projects'),
306
+ cfg.rootPath,
307
+ ].filter(Boolean).map(p => path.resolve(p));
308
+ }
309
+
310
+ function validateKbDeletionPath(kbPath) {
311
+ if (!kbPath || typeof kbPath !== 'string') return { ok: false, error: 'kbPath is required' };
312
+ if (!path.isAbsolute(kbPath)) return { ok: false, error: 'kbPath must be absolute' };
313
+ const resolved = path.resolve(kbPath);
314
+ const roots = allowedKbDeletionRootPaths();
315
+ for (const root of roots) {
316
+ if (resolved !== root && knowledgeStore.isInside(resolved, root)) {
317
+ return { ok: true, path: resolved, root };
318
+ }
319
+ }
320
+ return { ok: false, path: resolved, error: 'kbPath is outside configured knowledge roots' };
321
+ }
322
+
323
+ function removeKnowledgeStoreProjectOverride(slug) {
324
+ if (!fs.existsSync(KNOWLEDGE_STORE_PATH)) return;
325
+ let cfg;
326
+ try { cfg = JSON.parse(fs.readFileSync(KNOWLEDGE_STORE_PATH, 'utf-8')); } catch { return; }
327
+ if (cfg && cfg.projectOverrides && Object.prototype.hasOwnProperty.call(cfg.projectOverrides, slug)) {
328
+ delete cfg.projectOverrides[slug];
329
+ writeJson(KNOWLEDGE_STORE_PATH, cfg);
330
+ }
331
+ }
332
+
333
+ function projectRemovePreview(slug, cfg) {
334
+ const kbPath = path.resolve(cfg.kbPath || defaultProjectKbPath(slug));
335
+ const stats = dirStats(kbPath);
336
+ const hook = readHookStatus({ repoPath: cfg.gitPath || cfg.localPath || '' });
337
+ return {
338
+ slug,
339
+ displayName: cfg.displayName || slug,
340
+ kbPath,
341
+ kbExists: fs.existsSync(kbPath),
342
+ kbSizeBytes: stats.kbSizeBytes,
343
+ fileCount: stats.fileCount,
344
+ hasRunningJobs: hasRunningProjectJob(slug),
345
+ hookInstalled: hook.installed === true,
346
+ kbManagedHook: hook.kbManaged === true,
347
+ isReference: cfg.isReference === true,
348
+ };
349
+ }
350
+
195
351
  function shellEscape(s) {
196
352
  return '"' + String(s).replace(/"/g, '""').replace(/`/g, '``') + '"';
197
353
  }
@@ -308,6 +464,7 @@ function startJob({ mode, slug }) {
308
464
  if (!KNOWN_MODES.has(mode)) return { ok: false, status: 400, error: `unknown mode: ${mode}` };
309
465
  const job = makeJob({ mode, slug: slug || 'ALL' });
310
466
  runningJobs.set(job.jobId, job);
467
+ logEvent('info', 'job_started', `${job.mode} job started`, { source: 'job-orchestrator', jobId: job.jobId, projectSlug: job.slug, mode: job.mode });
311
468
  // Run the job in the background so the HTTP request returns immediately.
312
469
  // The job orchestrator updates `job` in place; the route handler returns
313
470
  // just the jobId so the UI can poll.
@@ -320,14 +477,18 @@ function startJob({ mode, slug }) {
320
477
  projectsPath: path.join(KB_ROOT, 'projects.json'),
321
478
  jobsLogPath: JOBS_LOG_PATH,
322
479
  writeProjects: () => writeJson(path.join(KB_ROOT, 'projects.json'), projects),
480
+ defaultProjectKbPath,
481
+ log: (level, event, message, meta = {}) => logEvent(level, event, message, { ...meta, source: meta.source || 'job-orchestrator', jobId: job.jobId, projectSlug: meta.projectSlug || job.slug }),
323
482
  });
324
483
  } catch (e) {
325
484
  job.status = 'failed';
326
485
  job.endTime = new Date().toISOString();
327
486
  job.exitCode = 1;
328
487
  job.output += `\n[dispatch error] ${e.message}\n${e.stack || ''}`;
488
+ logEvent('error', 'job_dispatch_failed', e.message, { source: 'job-orchestrator', jobId: job.jobId, projectSlug: job.slug });
329
489
  } finally {
330
490
  lastRun = { time: job.endTime, status: job.status, slug: job.slug, mode: job.mode, output: (job.output || '').slice(-6000) };
491
+ logEvent(job.status === 'success' ? 'info' : 'warn', 'job_finished', `${job.mode} job ${job.status}`, { source: 'job-orchestrator', jobId: job.jobId, projectSlug: job.slug, mode: job.mode, status: job.status });
331
492
  // Keep the live record for 10 minutes so the UI can poll completion.
332
493
  setTimeout(() => runningJobs.delete(job.jobId), 10 * 60 * 1000);
333
494
  }
@@ -431,6 +592,203 @@ async function validateAndPersistProject(slug, projects) {
431
592
  return { ok: true, slug, inspection };
432
593
  }
433
594
 
595
+ async function projectImportPreflight({ localPath, gitPath }) {
596
+ const targetPath = gitPath || localPath;
597
+ const result = {
598
+ ok: false,
599
+ localPath: localPath || '',
600
+ gitPath: targetPath || '',
601
+ exists: false,
602
+ canImport: false,
603
+ needsGitInit: false,
604
+ canInitGit: false,
605
+ inspection: null,
606
+ error: null,
607
+ };
608
+ if (!targetPath) {
609
+ result.error = 'localPath or gitPath is required';
610
+ return result;
611
+ }
612
+ result.exists = fs.existsSync(targetPath);
613
+ if (!result.exists) {
614
+ result.error = `path not found: ${targetPath}`;
615
+ return result;
616
+ }
617
+ const stat = fs.statSync(targetPath);
618
+ if (!stat.isDirectory()) {
619
+ result.error = `path is not a directory: ${targetPath}`;
620
+ return result;
621
+ }
622
+ const inspection = await inspectGit(targetPath);
623
+ result.inspection = inspection;
624
+ result.canImport = ['ok', 'empty'].includes(inspection.repoStatus);
625
+ result.needsGitInit = inspection.repoStatus === 'not-git';
626
+ result.canInitGit = result.needsGitInit;
627
+ result.ok = result.canImport || result.canInitGit;
628
+ if (!result.ok) result.error = inspection.error || 'project cannot be imported';
629
+ return result;
630
+ }
631
+
632
+ async function initializeLocalGit({ repoPath, createInitialCommit = false, remoteUrl = '' }) {
633
+ const result = {
634
+ ok: false,
635
+ repoPath: repoPath || '',
636
+ initialized: false,
637
+ initialCommit: false,
638
+ remoteConfigured: false,
639
+ inspection: null,
640
+ steps: [],
641
+ error: null,
642
+ };
643
+ if (!repoPath || !fs.existsSync(repoPath) || !fs.statSync(repoPath).isDirectory()) {
644
+ result.error = 'repoPath must be an existing directory';
645
+ return result;
646
+ }
647
+ let inspection = await inspectGit(repoPath);
648
+ if (inspection.repoStatus === 'not-git') {
649
+ const init = await execGit(repoPath, ['init']);
650
+ result.steps.push({ step: 'git init', ok: init.ok, stderr: init.stderr });
651
+ if (!init.ok) {
652
+ result.error = init.stderr || init.error || 'git init failed';
653
+ return result;
654
+ }
655
+ result.initialized = true;
656
+ }
657
+ await execGit(repoPath, ['config', 'user.name', 'Project Knowledge Base']);
658
+ await execGit(repoPath, ['config', 'user.email', 'project-knowledge-base@example.local']);
659
+ await execGit(repoPath, ['config', 'commit.gpgsign', 'false']);
660
+
661
+ inspection = await inspectGit(repoPath);
662
+ if (createInitialCommit && inspection.repoStatus === 'empty') {
663
+ const add = await execGit(repoPath, ['add', '-A'], 30000);
664
+ result.steps.push({ step: 'git add -A', ok: add.ok, stderr: add.stderr });
665
+ if (!add.ok) {
666
+ result.error = add.stderr || add.error || 'git add failed';
667
+ return result;
668
+ }
669
+ const commit = await execGit(repoPath, ['commit', '-m', 'chore: initial import'], 30000);
670
+ result.steps.push({ step: 'git commit', ok: commit.ok, stderr: commit.stderr });
671
+ if (!commit.ok) {
672
+ result.error = commit.stderr || commit.error || 'git commit failed';
673
+ return result;
674
+ }
675
+ result.initialCommit = true;
676
+ }
677
+
678
+ if (remoteUrl && String(remoteUrl).trim()) {
679
+ const remote = await execGit(repoPath, ['remote', 'get-url', 'origin']);
680
+ const args = remote.ok ? ['remote', 'set-url', 'origin', remoteUrl.trim()] : ['remote', 'add', 'origin', remoteUrl.trim()];
681
+ const setRemote = await execGit(repoPath, args);
682
+ result.steps.push({ step: args.join(' '), ok: setRemote.ok, stderr: setRemote.stderr });
683
+ if (!setRemote.ok) {
684
+ result.error = setRemote.stderr || setRemote.error || 'git remote setup failed';
685
+ return result;
686
+ }
687
+ result.remoteConfigured = true;
688
+ }
689
+
690
+ result.inspection = await inspectGit(repoPath);
691
+ result.ok = ['ok', 'empty'].includes(result.inspection.repoStatus);
692
+ if (!result.ok) result.error = result.inspection.error || 'git initialization did not produce a usable repository';
693
+ return result;
694
+ }
695
+
696
+ async function runKnowledgeUpdate(slug) {
697
+ const projects = readProjects({ persistMigrations: true });
698
+ if (!projects[slug]) return { ok: false, status: 404, error: 'Slug not in projects.json' };
699
+ const project = projects[slug];
700
+ const kbPath = project.kbPath || defaultProjectKbPath(slug);
701
+ if (!fs.existsSync(kbPath) || !fs.existsSync(path.join(kbPath, 'README.md'))) {
702
+ const init = initProjectDirs(slug, kbPath);
703
+ project.kbSchemaVersion = init.kbSchemaVersion || 'v3';
704
+ logEvent('info', 'kb_initialized', 'Knowledge base initialized before update.', { source: 'knowledge-update', projectSlug: slug, kbPath });
705
+ init.created = init.created || [];
706
+ }
707
+
708
+ const scanResult = await scanAndPersistProject(slug, projects, { maxCommits: 200 });
709
+ if (!scanResult.ok) return scanResult;
710
+ const scan = scanResult.scan;
711
+ if (scan.repoStatus !== 'ok') {
712
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
713
+ logEvent('error', 'knowledge_update_blocked', `Knowledge update blocked: git status ${scan.repoStatus}`, { source: 'knowledge-update', projectSlug: slug, repoStatus: scan.repoStatus, error: scan.error });
714
+ return { ok: false, status: 400, slug, stage: 'scan', scan, error: scan.error || `git status ${scan.repoStatus}` };
715
+ }
716
+
717
+ const isV3 = project.kbSchemaVersion === 'v3' || fs.existsSync(path.join(kbPath, 'GOAL.md'));
718
+ const goalPath = path.join(kbPath, isV3 ? 'GOAL.md' : 'project-goal.md');
719
+ const needsInitial = project.goalStatus !== 'accepted' || !fs.existsSync(goalPath) || !project.lastAnalyzedCommit;
720
+ let analysis = null;
721
+ let applyResult = null;
722
+ let validation = null;
723
+ let reviewRequired = false;
724
+ let reviewReason = '';
725
+
726
+ if (needsInitial) {
727
+ analysis = await runInitialAnalysis({ slug, ...project, kbPath });
728
+ reviewRequired = true;
729
+ reviewReason = 'initial analysis may create or update project-goal.md';
730
+ logEvent(analysis.ok ? 'warn' : 'error', analysis.ok ? 'initial_analysis_review_required' : 'initial_analysis_failed', analysis.ok ? 'Initial analysis produced drafts that require review.' : analysis.error, { source: 'knowledge-update', projectSlug: slug, runId: analysis.runId || '' });
731
+ } else if ((scan.pendingCount || 0) > 0) {
732
+ analysis = await runCommitAnalysis({ slug, ...project, kbPath });
733
+ if (analysis.ok && !analysis.noop && analysis.runId) {
734
+ const drafts = listDrafts(kbPath, analysis.runId);
735
+ const safeDrafts = [];
736
+ const blocked = [];
737
+ for (const draft of drafts) {
738
+ if (draft.path === 'project-goal.md' || draft.path === 'GOAL.md' || draft.path === 'ARCHITECTURE.md') {
739
+ blocked.push(draft.path);
740
+ continue;
741
+ }
742
+ const content = readDraftContent(kbPath, analysis.runId, draft.path);
743
+ if (content != null) safeDrafts.push({ path: draft.path, content });
744
+ }
745
+ if (safeDrafts.length) {
746
+ applyResult = applyDrafts({
747
+ kbPath,
748
+ slug,
749
+ runId: analysis.runId,
750
+ drafts: safeDrafts,
751
+ allowGoalEdit: false,
752
+ headCommitAtRun: analysis.runRecord && analysis.runRecord.headCommitAtRun,
753
+ });
754
+ if (applyResult.ok && analysis.runRecord && analysis.runRecord.headCommitAtRun) {
755
+ projects[slug].lastAnalyzedCommit = analysis.runRecord.headCommitAtRun;
756
+ } else if (!applyResult.ok) {
757
+ reviewRequired = true;
758
+ reviewReason = applyResult.error || 'auto apply failed';
759
+ }
760
+ }
761
+ if (blocked.length) {
762
+ reviewRequired = true;
763
+ reviewReason = `blocked goal-related drafts: ${blocked.join(', ')}`;
764
+ }
765
+ logEvent(applyResult && applyResult.ok ? 'info' : 'warn', 'commit_analysis_auto_apply', applyResult && applyResult.ok ? 'Commit analysis drafts auto-applied.' : 'Commit analysis requires review.', { source: 'knowledge-update', projectSlug: slug, runId: analysis.runId, applied: applyResult && applyResult.applied || [], reviewRequired });
766
+ } else if (analysis.ok && analysis.noop) {
767
+ logEvent('info', 'knowledge_update_noop', 'No pending commits to analyze.', { source: 'knowledge-update', projectSlug: slug });
768
+ } else if (!analysis.ok) {
769
+ logEvent('error', 'commit_analysis_failed', analysis.error, { source: 'knowledge-update', projectSlug: slug, runId: analysis.runId || '' });
770
+ }
771
+ } else {
772
+ analysis = { ok: true, noop: true, message: 'no pending commits' };
773
+ logEvent('info', 'knowledge_update_noop', 'No knowledge update needed.', { source: 'knowledge-update', projectSlug: slug });
774
+ }
775
+
776
+ validation = validateKb(kbPath);
777
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
778
+ return {
779
+ ok: !!(analysis && analysis.ok) && validation.ok !== false,
780
+ status: validation.ok === false ? validation.status || 422 : 200,
781
+ slug,
782
+ kbPath,
783
+ scan,
784
+ analysis,
785
+ applyResult,
786
+ validation,
787
+ reviewRequired,
788
+ reviewReason,
789
+ };
790
+ }
791
+
434
792
  // ---- project init (create dir structure) ----
435
793
  const V2_DIRS = [
436
794
  'architecture', 'modules', 'commits', 'operations', 'references',
@@ -452,8 +810,12 @@ function todayIso() {
452
810
  return new Date().toISOString().slice(0, 10);
453
811
  }
454
812
 
455
- function initProjectDirs(slug) {
456
- const base = path.join(KB_ROOT, 'projects', slug);
813
+ function initProjectDirs(slug, kbPath = null) {
814
+ return kbV3.initV3ProjectDirs(slug, kbPath || defaultProjectKbPath(slug));
815
+ }
816
+
817
+ function initProjectDirsV2(slug, kbPath = null) {
818
+ const base = kbPath || defaultProjectKbPath(slug);
457
819
  const created = [];
458
820
  for (const sub of V2_DIRS) {
459
821
  const p = path.join(base, sub);
@@ -509,8 +871,8 @@ function initProjectDirs(slug) {
509
871
  }
510
872
 
511
873
  // Idempotently add v2 layout to an existing project. Used by migration endpoint.
512
- function ensureV2Layout(slug) {
513
- return initProjectDirs(slug);
874
+ function ensureV2Layout(slug, kbPath = null) {
875
+ return initProjectDirsV2(slug, kbPath);
514
876
  }
515
877
 
516
878
  // ---- routing ----
@@ -559,7 +921,133 @@ const server = http.createServer(async (req, res) => {
559
921
  }
560
922
  }
561
923
  const schedule = await getScheduleInfo();
562
- return send(res, 200, { projects, schedule, lastRun, kbRoot: KB_ROOT, projectSchemaVersion: PROJECT_SCHEMA_VERSION });
924
+ return send(res, 200, {
925
+ projects,
926
+ schedule,
927
+ lastRun,
928
+ kbRoot: KB_ROOT,
929
+ knowledgeStore: readKnowledgeStore(),
930
+ logging: readLoggingConfig(),
931
+ projectSchemaVersion: PROJECT_SCHEMA_VERSION,
932
+ });
933
+ }
934
+
935
+ // GET /api/knowledge-store/config
936
+ if (m === 'GET' && p === '/api/knowledge-store/config') {
937
+ const cfg = readKnowledgeStore();
938
+ const validation = knowledgeStore.validateRoot(cfg.rootPath);
939
+ return send(res, 200, { ok: validation.ok, config: cfg, validation });
940
+ }
941
+
942
+ // PUT /api/knowledge-store/config
943
+ if (m === 'PUT' && p === '/api/knowledge-store/config') {
944
+ const body = JSON.parse(await readBody(req));
945
+ const validation = knowledgeStore.validateRoot(body.rootPath);
946
+ if (!validation.ok) return send(res, 400, { ok: false, error: validation.error, validation });
947
+ const cfg = knowledgeStore.writeConfig(KNOWLEDGE_STORE_PATH, KB_ROOT, body);
948
+ logEvent('info', 'knowledge_store_config_updated', 'Knowledge store configuration updated.', { source: 'knowledge-store', rootPath: cfg.rootPath });
949
+ return send(res, 200, { ok: true, config: cfg, validation });
950
+ }
951
+
952
+ // POST /api/knowledge-store/migrate { execute?, overwrite?, move? }
953
+ if (m === 'POST' && p === '/api/knowledge-store/migrate') {
954
+ const body = await readBody(req).catch(() => '{}');
955
+ const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
956
+ const projects = readProjects({ persistMigrations: true });
957
+ const storeConfig = readKnowledgeStore();
958
+ if (!parsed.execute) {
959
+ const plan = knowledgeStore.buildMigrationPlan({ projects, appRoot: KB_ROOT, storeConfig });
960
+ return send(res, 200, { ok: true, execute: false, plan });
961
+ }
962
+ const result = knowledgeStore.migrateProjects({
963
+ projects,
964
+ appRoot: KB_ROOT,
965
+ storeConfig,
966
+ overwrite: parsed.overwrite === true,
967
+ move: parsed.move === true,
968
+ });
969
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
970
+ logEvent('info', 'knowledge_store_migrated', 'Knowledge store migration executed.', { source: 'knowledge-store', count: result.migrated.length });
971
+ return send(res, 200, { ok: true, execute: true, ...result });
972
+ }
973
+
974
+ // GET /api/logging/config
975
+ if (m === 'GET' && p === '/api/logging/config') {
976
+ const cfg = readLoggingConfig();
977
+ return send(res, 200, { ok: true, config: cfg });
978
+ }
979
+
980
+ // PUT /api/logging/config
981
+ if (m === 'PUT' && p === '/api/logging/config') {
982
+ const body = JSON.parse(await readBody(req));
983
+ const cfg = structuredLogger.writeConfig(LOGGING_CONFIG_PATH, KB_ROOT, body);
984
+ logEvent('info', 'logging_config_updated', 'Logging configuration updated.', { source: 'logging', rootPath: cfg.rootPath });
985
+ return send(res, 200, { ok: true, config: cfg });
986
+ }
987
+
988
+ // GET /api/logs
989
+ if (m === 'GET' && p === '/api/logs') {
990
+ const logs = structuredLogger.readLogs(LOGGING_CONFIG_PATH, KB_ROOT, {
991
+ dateFrom: url.searchParams.get('dateFrom') || '',
992
+ dateTo: url.searchParams.get('dateTo') || '',
993
+ level: url.searchParams.get('level') || '',
994
+ projectSlug: url.searchParams.get('projectSlug') || '',
995
+ source: url.searchParams.get('source') || '',
996
+ q: url.searchParams.get('q') || '',
997
+ limit: Number(url.searchParams.get('limit') || 500),
998
+ });
999
+ return send(res, 200, { ok: true, logs, config: readLoggingConfig() });
1000
+ }
1001
+
1002
+ // GET /api/supervision/issues
1003
+ if (m === 'GET' && p === '/api/supervision/issues') {
1004
+ const projects = readProjects({ persistMigrations: false });
1005
+ const history = readJobLog(JOBS_LOG_PATH);
1006
+ const issues = [];
1007
+ for (const [slug, cfg] of Object.entries(projects)) {
1008
+ issues.push(...supervision.projectIssues(slug, cfg, cfg.kbPath || defaultProjectKbPath(slug)));
1009
+ }
1010
+ issues.push(...supervision.jobIssues(history));
1011
+ return send(res, 200, { ok: true, issues });
1012
+ }
1013
+
1014
+ // GET /api/supervision/pending-commits
1015
+ if (m === 'GET' && p === '/api/supervision/pending-commits') {
1016
+ const projects = readProjects({ persistMigrations: false });
1017
+ const items = await supervision.pendingCommits(projects, (slug, cfg) => cfg.kbPath || defaultProjectKbPath(slug));
1018
+ return send(res, 200, { ok: true, items });
1019
+ }
1020
+
1021
+ // GET /api/supervision/summary
1022
+ if (m === 'GET' && p === '/api/supervision/summary') {
1023
+ const projects = readProjects({ persistMigrations: false });
1024
+ const history = readJobLog(JOBS_LOG_PATH);
1025
+ const issues = [
1026
+ ...Object.entries(projects).flatMap(([slug, cfg]) => supervision.projectIssues(slug, cfg, cfg.kbPath || defaultProjectKbPath(slug))),
1027
+ ...supervision.jobIssues(history),
1028
+ ];
1029
+ return send(res, 200, { ok: true, summary: supervision.summary(projects, [...runningJobs.values()], issues), issues });
1030
+ }
1031
+
1032
+ // POST /api/projects/import-preflight { localPath, gitPath? }
1033
+ if (m === 'POST' && p === '/api/projects/import-preflight') {
1034
+ const body = await readBody(req).catch(() => '{}');
1035
+ const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
1036
+ const result = await projectImportPreflight({ localPath: parsed.localPath, gitPath: parsed.gitPath });
1037
+ return send(res, result.ok ? 200 : 400, result);
1038
+ }
1039
+
1040
+ // POST /api/git/init { path, createInitialCommit?, remoteUrl? }
1041
+ if (m === 'POST' && p === '/api/git/init') {
1042
+ const body = await readBody(req).catch(() => '{}');
1043
+ const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
1044
+ const result = await initializeLocalGit({
1045
+ repoPath: parsed.path || parsed.repoPath,
1046
+ createInitialCommit: parsed.createInitialCommit === true,
1047
+ remoteUrl: parsed.remoteUrl || '',
1048
+ });
1049
+ logEvent(result.ok ? 'info' : 'error', result.ok ? 'git_initialized' : 'git_init_failed', result.ok ? 'Local Git repository is ready.' : result.error, { source: 'git-import', repoPath: result.repoPath });
1050
+ return send(res, result.ok ? 200 : 400, result);
563
1051
  }
564
1052
 
565
1053
  // GET /api/projects
@@ -576,6 +1064,16 @@ const server = http.createServer(async (req, res) => {
576
1064
  if (typeof body.config !== 'object' || body.config === null) {
577
1065
  return send(res, 400, { error: 'Invalid config' });
578
1066
  }
1067
+ const importOptions = body.importOptions && typeof body.importOptions === 'object' ? body.importOptions : {};
1068
+ const targetPathBeforeImport = body.config.gitPath || body.config.localPath;
1069
+ if (importOptions.initGit === true) {
1070
+ const gitInit = await initializeLocalGit({
1071
+ repoPath: targetPathBeforeImport,
1072
+ createInitialCommit: importOptions.createInitialCommit === true,
1073
+ remoteUrl: importOptions.remoteUrl || '',
1074
+ });
1075
+ if (!gitInit.ok) return send(res, 400, { ok: false, error: gitInit.error, gitInit });
1076
+ }
579
1077
  projects[body.slug] = normalizeProjectConfig(body.slug, body.config).config;
580
1078
  // Auto-validate git on add/update — populates repoStatus, headCommit, etc.
581
1079
  const targetPath = projects[body.slug].gitPath || projects[body.slug].localPath;
@@ -592,17 +1090,81 @@ const server = http.createServer(async (req, res) => {
592
1090
  return send(res, 400, { error: 'Need { slug, config } or { projects }' });
593
1091
  }
594
1092
 
1093
+ // GET /api/projects/:slug/remove-preview
1094
+ if (m === 'GET' && p.startsWith('/api/projects/') && p.endsWith('/remove-preview')) {
1095
+ const slug = p.split('/')[3];
1096
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
1097
+ const projects = readProjects({ persistMigrations: false });
1098
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
1099
+ return send(res, 200, { ok: true, preview: projectRemovePreview(slug, projects[slug]) });
1100
+ }
1101
+
1102
+ // POST /api/projects/:slug/remove { deleteKb?, reason? }
1103
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/remove')) {
1104
+ const slug = p.split('/')[3];
1105
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
1106
+ const body = await readBody(req).catch(() => '{}');
1107
+ const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
1108
+ const projects = readProjects({ persistMigrations: false });
1109
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
1110
+ if (hasRunningProjectJob(slug)) return send(res, 409, { ok: false, error: 'project has a running job' });
1111
+ const cfg = projects[slug];
1112
+ const kbPath = path.resolve(cfg.kbPath || defaultProjectKbPath(slug));
1113
+ const deleteKb = parsed.deleteKb === true;
1114
+ let removedKb = false;
1115
+ let hookResult = null;
1116
+ if (deleteKb) {
1117
+ const safe = validateKbDeletionPath(kbPath);
1118
+ if (!safe.ok) return send(res, 400, { ok: false, error: safe.error, kbPath: safe.path || kbPath });
1119
+ }
1120
+ try {
1121
+ hookResult = uninstallHook({ repoPath: cfg.gitPath || cfg.localPath || '' });
1122
+ } catch (e) {
1123
+ hookResult = { ok: false, warning: e.message };
1124
+ }
1125
+ delete projects[slug];
1126
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
1127
+ removeKnowledgeStoreProjectOverride(slug);
1128
+ if (deleteKb && fs.existsSync(kbPath)) {
1129
+ fs.rmSync(kbPath, { recursive: true, force: true });
1130
+ removedKb = true;
1131
+ }
1132
+ logEvent('info', 'project_removed', `Project removed: ${slug}`, {
1133
+ source: 'project-remove',
1134
+ projectSlug: slug,
1135
+ kbPath,
1136
+ deleteKb,
1137
+ removedKb,
1138
+ reason: parsed.reason || '',
1139
+ hookResult,
1140
+ });
1141
+ return send(res, 200, { ok: true, slug, kbPath, removedKb, hookResult });
1142
+ }
1143
+
595
1144
  // POST /api/projects/:slug/init
596
1145
  if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/init')) {
597
1146
  const slug = p.split('/')[3];
598
1147
  if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
599
1148
  const projects = readProjects({ persistMigrations: true });
600
1149
  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';
1150
+ const result = initProjectDirs(slug, projects[slug].kbPath || defaultProjectKbPath(slug));
1151
+ // Mark project as v3 after init
1152
+ projects[slug].kbSchemaVersion = 'v3';
604
1153
  writeJson(path.join(KB_ROOT, 'projects.json'), projects);
605
- return send(res, 200, { ok: true, ...result, kbSchemaVersion: 'v2' });
1154
+ return send(res, 200, { ok: true, ...result, kbSchemaVersion: 'v3' });
1155
+ }
1156
+
1157
+ // POST /api/projects/:slug/migrate-v3
1158
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/migrate-v3')) {
1159
+ const slug = p.split('/')[3];
1160
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
1161
+ const projects = readProjects({ persistMigrations: true });
1162
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
1163
+ const result = kbV3.migrateToV3({ slug, kbPath: projects[slug].kbPath || defaultProjectKbPath(slug), preserveLegacyAI: false });
1164
+ projects[slug].kbSchemaVersion = 'v3';
1165
+ writeJson(path.join(KB_ROOT, 'projects.json'), projects);
1166
+ logEvent('info', 'kb_v3_migrated', 'Knowledge base migrated to v3.', { source: 'kb-v3', projectSlug: slug, kbPath: result.kbPath });
1167
+ return send(res, 200, { ok: true, ...result });
606
1168
  }
607
1169
 
608
1170
  // POST /api/projects/:slug/migrate-v2 — add v2 layout to an existing project
@@ -611,7 +1173,7 @@ const server = http.createServer(async (req, res) => {
611
1173
  if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
612
1174
  const projects = readProjects({ persistMigrations: true });
613
1175
  if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
614
- const result = ensureV2Layout(slug);
1176
+ const result = ensureV2Layout(slug, projects[slug].kbPath || defaultProjectKbPath(slug));
615
1177
  projects[slug].kbSchemaVersion = 'v2';
616
1178
  writeJson(path.join(KB_ROOT, 'projects.json'), projects);
617
1179
  return send(res, 200, { ok: true, ...result, kbSchemaVersion: 'v2' });
@@ -700,6 +1262,13 @@ const server = http.createServer(async (req, res) => {
700
1262
  }
701
1263
 
702
1264
  // POST /api/scan-all — scan every enabled project
1265
+ if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/knowledge-update')) {
1266
+ const slug = p.split('/')[3];
1267
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
1268
+ const result = await runKnowledgeUpdate(slug);
1269
+ return send(res, result.status || (result.ok ? 200 : 500), result);
1270
+ }
1271
+
703
1272
  if (m === 'POST' && p === '/api/scan-all') {
704
1273
  const projects = readProjects({ persistMigrations: false });
705
1274
  const results = [];
@@ -726,13 +1295,56 @@ const server = http.createServer(async (req, res) => {
726
1295
  return send(res, 200, { ok: true });
727
1296
  }
728
1297
 
1298
+ // POST /api/ai-profiles/test { profileId, prompt? }
1299
+ if (m === 'POST' && p === '/api/ai-profiles/test') {
1300
+ const body = await readBody(req).catch(() => '{}');
1301
+ const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
1302
+ const cfg = readAiProfiles();
1303
+ const profileId = parsed.profileId || cfg.defaultProfileId || DEFAULT_AI_PROFILE_ID;
1304
+ const profile = (cfg.profiles || []).find(item => item && item.id === profileId);
1305
+ if (!profile) return send(res, 404, { ok: false, error: `profile not found: ${profileId}` });
1306
+ const implementation = profileImplementation(profile);
1307
+ if (!getAdapter(implementation)) return send(res, 400, { ok: false, error: `unknown adapter: ${implementation}` });
1308
+
1309
+ const prompt = parsed.prompt || 'what model are you?';
1310
+ if (implementation === 'mock-agent') {
1311
+ return send(res, 200, {
1312
+ ok: true,
1313
+ profileId,
1314
+ model: 'mock-agent',
1315
+ text: `[mock-agent] received: ${prompt}`,
1316
+ usage: null,
1317
+ });
1318
+ }
1319
+
1320
+ try {
1321
+ const llmCfg = readLlmConfig({ profileId });
1322
+ const result = await completeText({
1323
+ profileId,
1324
+ user: prompt,
1325
+ maxTokens: Math.min(Number(profile.testMaxTokens || 128), 512),
1326
+ temperature: profile.testTemperature != null ? Number(profile.testTemperature) : 0,
1327
+ });
1328
+ return send(res, 200, {
1329
+ ok: true,
1330
+ profileId,
1331
+ model: result.model || llmCfg.model,
1332
+ baseUrl: llmCfg.baseUrl,
1333
+ text: result.text,
1334
+ usage: result.usage,
1335
+ });
1336
+ } catch (e) {
1337
+ return send(res, 400, { ok: false, profileId, error: e.message });
1338
+ }
1339
+ }
1340
+
729
1341
  // PUT /api/projects/:slug/ai-profile — set a project's AI profile
730
1342
  if (m === 'PUT' && p.startsWith('/api/projects/') && p.endsWith('/ai-profile')) {
731
1343
  const slug = p.split('/')[3];
732
1344
  if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
733
1345
  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}` });
1346
+ const profileCheck = validateUsableAiProfile(body.aiProfileId);
1347
+ if (!profileCheck.ok) return send(res, profileCheck.status, { error: profileCheck.error });
736
1348
  const projects = readProjects({ persistMigrations: true });
737
1349
  if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
738
1350
  projects[slug].aiProfileId = body.aiProfileId;
@@ -749,8 +1361,8 @@ const server = http.createServer(async (req, res) => {
749
1361
  if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
750
1362
 
751
1363
  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}` });
1364
+ const profileCheck = validateUsableAiProfile(body.aiProfileId);
1365
+ if (!profileCheck.ok) return send(res, profileCheck.status, { error: profileCheck.error });
754
1366
  projects[slug].aiProfileId = body.aiProfileId;
755
1367
  }
756
1368
 
@@ -795,12 +1407,47 @@ const server = http.createServer(async (req, res) => {
795
1407
  }
796
1408
 
797
1409
  // POST /api/projects/:slug/analyze/initial — run initial analysis (TASK-007)
1410
+ // ?mode=cli → spawn Claude Code subprocess and return sessionId for SSE streaming
798
1411
  if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/analyze/initial')) {
799
1412
  const slug = p.split('/')[3];
800
1413
  if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
801
1414
  const projects = readProjects({ persistMigrations: false });
802
1415
  if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
803
1416
  const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
1417
+
1418
+ // CLI mode: kick off Claude Code subprocess, return sessionId immediately
1419
+ if (url.searchParams.get('mode') === 'cli') {
1420
+ const projectPath = projects[slug].gitPath || projects[slug].localPath;
1421
+ if (!projectPath) return send(res, 400, { error: 'Project has no gitPath/localPath' });
1422
+ const aiProfileId = projects[slug].aiProfileId || readAiProfiles().defaultProfileId || DEFAULT_AI_PROFILE_ID;
1423
+ const profileCheck = validateUsableAiProfile(aiProfileId);
1424
+ if (!profileCheck.ok) return send(res, profileCheck.status, { ok: false, error: profileCheck.error });
1425
+ if (profileCheck.implementation !== 'claude-code-agent') {
1426
+ return send(res, 400, { ok: false, error: `Claude terminal requires claude-code-agent implementation, got ${profileCheck.implementation}` });
1427
+ }
1428
+ const runner = url.searchParams.get('runner') === 'sdk' || profileCheck.profile.runner === 'sdk' ? 'sdk' : 'cli';
1429
+ try {
1430
+ const { sessionId } = claudeCliRunner.startSession({
1431
+ slug,
1432
+ projectPath,
1433
+ kbPath,
1434
+ promptKey: 'initial-analysis',
1435
+ aiProfile: profileCheck.profile,
1436
+ runner,
1437
+ vars: {
1438
+ SLUG: slug,
1439
+ PROJECT_PATH: projectPath,
1440
+ KB_PATH: kbPath,
1441
+ PRIMARY_LANGUAGE: projects[slug].primaryLanguage || '',
1442
+ KNOWLEDGE_LANGUAGE: projects[slug].knowledgeLanguage || 'zh-CN',
1443
+ },
1444
+ });
1445
+ return send(res, 200, { ok: true, slug, sessionId, mode: 'cli', runner, aiProfileId, pendingPermission: claudeCliRunner.getState(sessionId)?.pendingPermission || null });
1446
+ } catch (e) {
1447
+ return send(res, 500, { ok: false, error: e.message });
1448
+ }
1449
+ }
1450
+
804
1451
  const result = await runInitialAnalysis({ slug, ...projects[slug], kbPath });
805
1452
  if (!result.ok) {
806
1453
  return send(res, result.status, { ok: false, error: result.error, runId: result.runId, run: result.runRecord });
@@ -808,6 +1455,137 @@ const server = http.createServer(async (req, res) => {
808
1455
  return send(res, 200, { ok: true, slug, runId: result.runId, run: result.runRecord });
809
1456
  }
810
1457
 
1458
+ // GET /api/claude/sessions — list active sessions
1459
+ if (m === 'GET' && p === '/api/claude/sessions') {
1460
+ const slug = url.searchParams.get('slug');
1461
+ if (slug && !isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
1462
+ return send(res, 200, { sessions: claudeCliRunner.listSessions({ projectSlug: slug || null }) });
1463
+ }
1464
+
1465
+ // GET /api/claude/sessions/:id — session state
1466
+ if (m === 'GET' && p.startsWith('/api/claude/sessions/') && !p.includes('/')) {
1467
+ // skipped — path has slashes; handled by next branch
1468
+ }
1469
+ if (m === 'GET' && p.startsWith('/api/claude/sessions/') && p.split('/').length === 5) {
1470
+ // /api/claude/sessions/:id → parts = ['', 'api', 'claude', 'sessions', '<id>']
1471
+ const sessionId = p.split('/')[4];
1472
+ const st = claudeCliRunner.getState(sessionId);
1473
+ if (!st) return send(res, 404, { error: 'session not found' });
1474
+ return send(res, 200, st);
1475
+ }
1476
+
1477
+ // GET /api/claude/sessions/:id/stream — SSE stream of events
1478
+ if (m === 'GET' && p.startsWith('/api/claude/sessions/') && p.endsWith('/stream')) {
1479
+ const sessionId = p.split('/')[4];
1480
+ if (!claudeCliRunner.getSession(sessionId)) return send(res, 404, { error: 'session not found' });
1481
+ res.writeHead(200, {
1482
+ 'Content-Type': 'text/event-stream',
1483
+ 'Cache-Control': 'no-store, no-transform',
1484
+ 'Connection': 'keep-alive',
1485
+ 'Access-Control-Allow-Origin': '*',
1486
+ 'X-Accel-Buffering': 'no',
1487
+ });
1488
+ res.write(`event: claude/hello\ndata: ${JSON.stringify({ sessionId, time: new Date().toISOString() })}\n\n`);
1489
+ const unsubscribe = claudeCliRunner.subscribe(sessionId, (event) => {
1490
+ try {
1491
+ res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
1492
+ } catch (e) {
1493
+ // client gone
1494
+ }
1495
+ });
1496
+ const heartbeat = setInterval(() => {
1497
+ try { res.write(`: keepalive ${Date.now()}\n\n`); } catch {}
1498
+ }, 15000);
1499
+ const cleanup = () => {
1500
+ clearInterval(heartbeat);
1501
+ try { unsubscribe(); } catch {}
1502
+ };
1503
+ req.on('close', cleanup);
1504
+ req.on('error', cleanup);
1505
+ return; // do NOT call send() — connection stays open
1506
+ }
1507
+
1508
+ // POST /api/claude/sessions/:id/input — send follow-up prompt (uses --resume)
1509
+ if (m === 'POST' && p.startsWith('/api/claude/sessions/') && p.endsWith('/input')) {
1510
+ const sessionId = p.split('/')[4];
1511
+ if (!claudeCliRunner.getSession(sessionId)) return send(res, 404, { error: 'session not found' });
1512
+ let body;
1513
+ try { body = JSON.parse(await readBody(req)); } catch { return send(res, 400, { error: 'invalid JSON body' }); }
1514
+ if (!body || typeof body.text !== 'string' || !body.text.trim()) {
1515
+ return send(res, 400, { error: 'body.text (non-empty string) required' });
1516
+ }
1517
+ try {
1518
+ const st = claudeCliRunner.getState(sessionId);
1519
+ const projects = readProjects({ persistMigrations: false });
1520
+ const project = st && st.projectSlug && projects[st.projectSlug] ? projects[st.projectSlug] : null;
1521
+ let profile = null;
1522
+ let runner = st && st.runner === 'sdk' ? 'sdk' : null;
1523
+ if (project) {
1524
+ const aiProfileId = project.aiProfileId || readAiProfiles().defaultProfileId || DEFAULT_AI_PROFILE_ID;
1525
+ const profileCheck = validateUsableAiProfile(aiProfileId);
1526
+ if (!profileCheck.ok) return send(res, profileCheck.status, { ok: false, error: profileCheck.error });
1527
+ if (profileCheck.implementation !== 'claude-code-agent') {
1528
+ return send(res, 400, { ok: false, error: `Claude workbench requires claude-code-agent implementation, got ${profileCheck.implementation}` });
1529
+ }
1530
+ profile = profileCheck.profile;
1531
+ runner = body.runner === 'sdk' || profile.runner === 'sdk' || runner === 'sdk' ? 'sdk' : 'cli';
1532
+ }
1533
+ const result = await claudeCliRunner.sendInput(sessionId, body.text, profile, runner);
1534
+ return send(res, 200, { ok: true, sessionId, pendingPermission: result && result.pendingPermission || null });
1535
+ } catch (e) {
1536
+ return send(res, 400, { ok: false, error: e.message });
1537
+ }
1538
+ }
1539
+
1540
+ // POST /api/claude/sessions/:id/permission
1541
+ if (m === 'POST' && p.startsWith('/api/claude/sessions/') && p.endsWith('/permission')) {
1542
+ const sessionId = p.split('/')[4];
1543
+ if (!claudeCliRunner.getSession(sessionId)) return send(res, 404, { error: 'session not found' });
1544
+ let body;
1545
+ try { body = JSON.parse(await readBody(req)); } catch { return send(res, 400, { error: 'invalid JSON body' }); }
1546
+ if (!body || typeof body.requestId !== 'string') {
1547
+ return send(res, 400, { error: 'body.requestId required' });
1548
+ }
1549
+ try {
1550
+ const result = claudeCliRunner.resolvePermission(sessionId, body.requestId, {
1551
+ allow: body.allow === true,
1552
+ message: typeof body.message === 'string' ? body.message : '',
1553
+ });
1554
+ return send(res, 200, { ok: true, sessionId, ...result });
1555
+ } catch (e) {
1556
+ return send(res, 400, { ok: false, error: e.message });
1557
+ }
1558
+ }
1559
+
1560
+ // POST /api/claude/sessions/:id/abort — terminate current subprocess
1561
+ if (m === 'POST' && p.startsWith('/api/claude/sessions/') && p.endsWith('/abort')) {
1562
+ const sessionId = p.split('/')[4];
1563
+ if (!claudeCliRunner.getSession(sessionId)) return send(res, 404, { error: 'session not found' });
1564
+ try {
1565
+ claudeCliRunner.abort(sessionId);
1566
+ return send(res, 200, { ok: true, sessionId });
1567
+ } catch (e) {
1568
+ return send(res, 400, { ok: false, error: e.message });
1569
+ }
1570
+ }
1571
+
1572
+ // GET /api/prompts — read prompt registry
1573
+ if (m === 'GET' && p === '/api/prompts') {
1574
+ return send(res, 200, promptRegistry.readPrompts());
1575
+ }
1576
+
1577
+ // PUT /api/prompts — write prompt registry
1578
+ if (m === 'PUT' && p === '/api/prompts') {
1579
+ let body;
1580
+ try { body = JSON.parse(await readBody(req)); } catch { return send(res, 400, { error: 'invalid JSON body' }); }
1581
+ try {
1582
+ const written = promptRegistry.writePrompts(body);
1583
+ return send(res, 200, { ok: true, prompts: written });
1584
+ } catch (e) {
1585
+ return send(res, 400, { ok: false, error: e.message });
1586
+ }
1587
+ }
1588
+
811
1589
  // POST /api/projects/:slug/analyze/commits — run incremental commit analysis (TASK-008)
812
1590
  if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/analyze/commits')) {
813
1591
  const slug = p.split('/')[3];
@@ -859,6 +1637,27 @@ const server = http.createServer(async (req, res) => {
859
1637
  }
860
1638
 
861
1639
  // GET /api/projects/:slug/drafts/:runId/raw?path=... — read a single draft's text
1640
+ if (m === 'GET' && p.match(/^\/api\/projects\/[a-z0-9-]+\/drafts-by-branch$/)) {
1641
+ const slug = p.split('/')[3];
1642
+ if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
1643
+ const branch = url.searchParams.get('branch') || '';
1644
+ const status = url.searchParams.get('status') || 'pending';
1645
+ const projects = readProjects({ persistMigrations: false });
1646
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
1647
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
1648
+ const runs = listRuns(kbPath);
1649
+ const drafts = [];
1650
+ for (const run of runs) {
1651
+ if (status === 'pending' && (run.applyStatus === 'applied' || run.applyStatus === 'rejected')) continue;
1652
+ const sourceBranch = run.sourceBranch ?? 'unknown (pre-TASK-016)';
1653
+ if (branch && sourceBranch !== branch) continue;
1654
+ for (const draft of listDrafts(kbPath, run.runId)) {
1655
+ drafts.push({ ...draft, runId: run.runId, runStatus: run.status, applyStatus: run.applyStatus || 'pending', sourceBranch });
1656
+ }
1657
+ }
1658
+ return send(res, 200, { ok: true, slug, branch: branch || null, status, drafts });
1659
+ }
1660
+
862
1661
  if (m === 'GET' && p.match(/^\/api\/projects\/[a-z0-9-]+\/drafts\/[A-Za-z0-9_-]+\/raw$/)) {
863
1662
  const parts = p.split('/');
864
1663
  const slug = parts[3];
@@ -1023,7 +1822,9 @@ const server = http.createServer(async (req, res) => {
1023
1822
  if (m === 'GET' && p.startsWith('/api/dirs/')) {
1024
1823
  const slug = p.split('/')[3];
1025
1824
  if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
1026
- const kbPath = path.join(KB_ROOT, 'projects', slug);
1825
+ const projects = readProjects({ persistMigrations: false });
1826
+ if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
1827
+ const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
1027
1828
  if (!fs.existsSync(kbPath)) return send(res, 404, { error: 'Project KB not initialized' });
1028
1829
  return send(res, 200, listSubTree(kbPath, '', 0, 3));
1029
1830
  }
@@ -1044,6 +1845,21 @@ const server = http.createServer(async (req, res) => {
1044
1845
  return send(res, 200, { type: 'file', content: buf.toString('utf-8'), size: stat.size }, 'text/plain');
1045
1846
  }
1046
1847
 
1848
+ // Static vendor assets (Vue / Tailwind browser bundles)
1849
+ if (m === 'GET' && p.startsWith('/vendor/')) {
1850
+ const rel = decodeURIComponent(p.slice('/vendor/'.length));
1851
+ if (rel.includes('..') || path.isAbsolute(rel)) return send(res, 400, { error: 'bad path' });
1852
+ const vendorRoot = path.join(SITE_ROOT, 'vendor');
1853
+ const abs = path.resolve(vendorRoot, rel);
1854
+ if (!abs.startsWith(vendorRoot + path.sep)) return send(res, 403, { error: 'outside vendor' });
1855
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return send(res, 404, { error: 'not found' });
1856
+ const text = fs.readFileSync(abs, 'utf-8');
1857
+ const ct = abs.endsWith('.js') ? 'application/javascript'
1858
+ : abs.endsWith('.css') ? 'text/css'
1859
+ : 'application/octet-stream';
1860
+ return send(res, 200, text, ct);
1861
+ }
1862
+
1047
1863
  send(res, 404, { error: 'Not found', path: p });
1048
1864
  } catch (e) {
1049
1865
  console.error('[server error]', e);