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,433 @@
1
+ // Analysis Orchestrator (TASK-007 + TASK-008)
2
+ // Coordinates: build context pack → run selected AI adapter → validate output
3
+ // → write drafts and run metadata.
4
+ //
5
+ // Hard rules:
6
+ // * `project-goal.md` is never overwritten by an analysis pass.
7
+ // Drafts always land under `_ai/drafts/<run-id>/`.
8
+ // * `lastAnalyzedCommit` is NEVER updated by analysis. Only the apply step (TASK-009)
9
+ // advances that pointer.
10
+ // * Failed analysis produces a `failed` run record and does NOT write any drafts.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { getAdapter } = require('./ai-adapter');
15
+ const { buildContextPack } = require('./context-pack-builder');
16
+ const { execGit } = require('./git-runner');
17
+ const { scanProject } = require('./scanner');
18
+
19
+ function readTemplate(name) {
20
+ const p = path.join(__dirname, '..', '..', 'templates', name);
21
+ if (!fs.existsSync(p)) return null;
22
+ return fs.readFileSync(p, 'utf-8');
23
+ }
24
+
25
+ function renderTemplate(content, vars) {
26
+ return content.replace(/__([A-Z_][A-Z0-9_]*)__/g, (_, key) => (key in vars ? String(vars[key]) : `__${key}__`));
27
+ }
28
+
29
+ function todayIso() {
30
+ return new Date().toISOString().slice(0, 10);
31
+ }
32
+
33
+ function shortRunId(input) {
34
+ return input.toString().slice(0, 12).replace(/[^a-zA-Z0-9-]/g, '-');
35
+ }
36
+
37
+ function knowledgeLanguage(project) {
38
+ return project && project.knowledgeLanguage === 'en-US' ? 'en-US' : 'zh-CN';
39
+ }
40
+
41
+ function labels(project) {
42
+ if (knowledgeLanguage(project) === 'en-US') {
43
+ return {
44
+ aiProposal: 'AI Proposal (pending acceptance)',
45
+ oneSentenceGoal: 'One-Sentence Goal',
46
+ targetUsers: 'Target Users',
47
+ coreScenarios: 'Core Scenarios',
48
+ successCriteria: 'Success Criteria',
49
+ nonGoals: 'Non-Goals',
50
+ priorityPrinciples: 'Priority Principles',
51
+ prReviewPrinciples: 'PR Review Principles',
52
+ aiPurpose: 'AI-Inferred Purpose',
53
+ capabilities: 'Capabilities',
54
+ architecture: 'Architecture',
55
+ modules: 'Modules',
56
+ dataFlow: 'Data Flow',
57
+ goalSupport: 'Goal Support',
58
+ gaps: 'Gaps',
59
+ evidence: 'Evidence',
60
+ featureDraft: 'AI-generated feature draft (pending acceptance).',
61
+ summaryProposed: 'Summary (proposed)',
62
+ goalImpact: 'Goal Impact',
63
+ proposedOperations: 'Proposed Operations',
64
+ noEvidence: '(none reported)',
65
+ };
66
+ }
67
+ return {
68
+ aiProposal: 'AI 建议(待确认)',
69
+ oneSentenceGoal: '一句话目标',
70
+ targetUsers: '目标用户',
71
+ coreScenarios: '核心场景',
72
+ successCriteria: '成功标准',
73
+ nonGoals: '非目标',
74
+ priorityPrinciples: '优先级原则',
75
+ prReviewPrinciples: 'PR 审核原则',
76
+ aiPurpose: 'AI 推断的项目用途',
77
+ capabilities: '当前能力',
78
+ architecture: '当前架构',
79
+ modules: '重要模块',
80
+ dataFlow: '数据 / 控制流',
81
+ goalSupport: '对项目目标的支撑',
82
+ gaps: '差距',
83
+ evidence: '证据',
84
+ featureDraft: 'AI 生成的功能草稿(待确认)。',
85
+ summaryProposed: '摘要(建议)',
86
+ goalImpact: '项目目标影响',
87
+ proposedOperations: '建议操作',
88
+ noEvidence: '(未报告)',
89
+ };
90
+ }
91
+
92
+ function renderGoalDraft(project, draft) {
93
+ const tpl = readTemplate('project-goal.md');
94
+ if (!tpl) return null;
95
+ const l = labels(project);
96
+ const today = todayIso();
97
+ const vars = {
98
+ PROJECT: project.slug,
99
+ DATE: today,
100
+ AUTHOR: process.env.USERNAME || process.env.USER || 'unknown',
101
+ };
102
+ let rendered = renderTemplate(tpl, vars);
103
+ // Replace the body placeholders with the draft content. The template still has
104
+ // "TODO —" markers; we keep them in the draft and append the AI proposal below.
105
+ const proposal = [
106
+ '',
107
+ `## ${l.aiProposal}`,
108
+ '',
109
+ `- **${l.oneSentenceGoal}**: ${draft.oneSentenceGoal || 'TODO'}`,
110
+ `- **${l.targetUsers}**: ${(draft.targetUsers || []).join(', ') || 'TODO'}`,
111
+ `- **${l.coreScenarios}**: ${(draft.coreScenarios || []).map(s => `- ${s}`).join('\n') || '- TODO'}`,
112
+ `- **${l.successCriteria}**: ${(draft.successCriteria || []).map(s => `- ${s}`).join('\n') || '- TODO'}`,
113
+ `- **${l.nonGoals}**: ${(draft.nonGoals || []).map(s => `- ${s}`).join('\n') || '- TODO'}`,
114
+ `- **${l.priorityPrinciples}**: ${(draft.priorityPrinciples || []).map(s => `- ${s}`).join('\n') || '- TODO'}`,
115
+ `- **${l.prReviewPrinciples}**: ${(draft.prReviewPrinciples || []).map(s => `- ${s}`).join('\n') || '- TODO'}`,
116
+ ].join('\n');
117
+ return rendered.replace('## Human Revision History', proposal + '\n\n## Human Revision History');
118
+ }
119
+
120
+ function renderAnalysisDraft(project, draft) {
121
+ const tpl = readTemplate('project-analysis.md');
122
+ if (!tpl) return null;
123
+ const l = labels(project);
124
+ const vars = { PROJECT: project.slug, DATE: todayIso() };
125
+ let rendered = renderTemplate(tpl, vars);
126
+ const proposal = [
127
+ '',
128
+ `## ${l.aiProposal}`,
129
+ '',
130
+ `- **${l.aiPurpose}**: ${draft.purpose || 'TODO'}`,
131
+ `- **${l.capabilities}**: ${(draft.capabilities || []).map(s => `- ${s}`).join('\n') || '- TODO'}`,
132
+ `- **${l.architecture}**: ${draft.architecture || 'TODO'}`,
133
+ `- **${l.modules}**: ${(draft.modules || []).map(m => `- ${m.slug}: ${m.role}`).join('\n') || '- TODO'}`,
134
+ `- **${l.dataFlow}**: ${draft.dataFlow || 'TODO'}`,
135
+ `- **${l.goalSupport}**: ${draft.goalSupport || 'TODO'}`,
136
+ `- **${l.gaps}**: ${(draft.gaps || []).map(s => `- ${s}`).join('\n') || `- ${l.noEvidence}`}`,
137
+ `- **${l.evidence}**: ${(draft.evidence || []).map(s => `- ${s}`).join('\n') || `- ${l.noEvidence}`}`,
138
+ ].join('\n');
139
+ return rendered.replace('## Evidence List', proposal + '\n\n## Evidence List');
140
+ }
141
+
142
+ function renderFeatureDraft(project, feature) {
143
+ const tpl = readTemplate('feature.md');
144
+ if (!tpl) return null;
145
+ const l = labels(project);
146
+ const vars = { PROJECT: project.slug, SLUG: feature.slug, DATE: todayIso() };
147
+ let rendered = renderTemplate(tpl, vars);
148
+ const body = [
149
+ '',
150
+ `> ${l.featureDraft}`,
151
+ '',
152
+ `## ${l.summaryProposed}`,
153
+ feature.summary || 'TODO',
154
+ '',
155
+ ].join('\n');
156
+ return rendered.replace('## What the Feature Does', body + '\n## What the Feature Does\n' + (feature.summary || 'TODO'));
157
+ }
158
+
159
+ async function runInitialAnalysis(project, options = {}) {
160
+ const slug = project.slug;
161
+ const kbPath = path.resolve(project.kbPath);
162
+ const aiProfileId = project.aiProfileId || 'mock-agent';
163
+ const adapter = getAdapter(aiProfileId);
164
+ if (!adapter) return { ok: false, status: 400, error: `unknown adapter: ${aiProfileId}` };
165
+ if (!fs.existsSync(kbPath)) return { ok: false, status: 400, error: 'project KB not initialized' };
166
+
167
+ const runId = `initial-${shortRunId(Date.now().toString(36) + Math.random().toString(36))}`;
168
+ const draftsDir = path.join(kbPath, '_ai', 'drafts', runId);
169
+ const runsDir = path.join(kbPath, '_ai', 'runs');
170
+ fs.mkdirSync(draftsDir, { recursive: true });
171
+ fs.mkdirSync(runsDir, { recursive: true });
172
+
173
+ const runRecord = {
174
+ schema: 'ai-run/v1',
175
+ runId,
176
+ type: 'initial',
177
+ project: slug,
178
+ aiProfileId,
179
+ knowledgeLanguage: knowledgeLanguage(project),
180
+ status: 'running',
181
+ startedAt: new Date().toISOString(),
182
+ outputPaths: [],
183
+ drafts: [],
184
+ goalAccepted: false,
185
+ headCommitAtRun: project.headCommit || null,
186
+ };
187
+
188
+ try {
189
+ // 1. Build context pack (no commits needed for initial)
190
+ const pack = await buildContextPack({
191
+ project,
192
+ runId,
193
+ trigger: 'initial',
194
+ commits: [],
195
+ });
196
+ runRecord.contextPackPath = path.relative(kbPath, path.join(kbPath, '_ai', 'context-packs', runId, 'context-pack.json'));
197
+
198
+ // 2. Run analyzer
199
+ const output = await adapter.analyzeInitialProject({ project, contextPack: pack });
200
+
201
+ // 3. Validate output
202
+ const validation = adapter.validateOutput(output);
203
+ if (!validation.valid) {
204
+ runRecord.status = 'failed';
205
+ runRecord.finishedAt = new Date().toISOString();
206
+ runRecord.error = 'invalid adapter output';
207
+ runRecord.validationErrors = validation.errors;
208
+ writeRun(runsDir, runRecord);
209
+ return { ok: false, status: 422, error: 'invalid adapter output', validation, runId, runRecord };
210
+ }
211
+
212
+ // 4. Render drafts to disk
213
+ const goalText = renderGoalDraft(project, output.goalDraft || {});
214
+ const analysisText = renderAnalysisDraft(project, output.analysisDraft || {});
215
+ const featureTexts = (output.features || []).map(f => ({ slug: f.slug, text: renderFeatureDraft(project, f) }));
216
+
217
+ const goalPath = path.join(draftsDir, 'project-goal.md');
218
+ const analysisPath = path.join(draftsDir, 'project-analysis.md');
219
+ fs.writeFileSync(goalPath, goalText || '', 'utf-8');
220
+ fs.writeFileSync(analysisPath, analysisText || '', 'utf-8');
221
+ runRecord.drafts.push({ op: 'create-file', path: 'project-goal.md', fromDraft: 'goalDraft' });
222
+ runRecord.drafts.push({ op: 'create-file', path: 'project-analysis.md', fromDraft: 'analysisDraft' });
223
+
224
+ for (const f of featureTexts) {
225
+ const featurePath = path.join(draftsDir, 'features', `${f.slug}.md`);
226
+ fs.mkdirSync(path.dirname(featurePath), { recursive: true });
227
+ fs.writeFileSync(featurePath, f.text || '', 'utf-8');
228
+ runRecord.drafts.push({ op: 'create-file', path: `features/${f.slug}.md`, fromDraft: 'feature' });
229
+ }
230
+ runRecord.outputPaths = runRecord.drafts.map(d => d.path);
231
+
232
+ runRecord.status = 'succeeded';
233
+ runRecord.finishedAt = new Date().toISOString();
234
+ runRecord.evidenceCount = (output.analysisDraft && output.analysisDraft.evidence) ? output.analysisDraft.evidence.length : 0;
235
+ writeRun(runsDir, runRecord);
236
+ return { ok: true, runId, runRecord };
237
+ } catch (e) {
238
+ runRecord.status = 'failed';
239
+ runRecord.finishedAt = new Date().toISOString();
240
+ runRecord.error = e.message;
241
+ writeRun(runsDir, runRecord);
242
+ return { ok: false, status: 500, error: e.message, runId, runRecord };
243
+ }
244
+ }
245
+
246
+ function renderChangeDraft(project, change, commit) {
247
+ const tpl = readTemplate('change.md');
248
+ if (!tpl) return null;
249
+ const l = labels(project);
250
+ const vars = {
251
+ PROJECT: project.slug,
252
+ COMMIT: commit.hash,
253
+ SHORTCOMMIT: (commit.short || commit.hash || '').slice(0, 7),
254
+ DATE: commit.date || todayIso(),
255
+ AUTHOR: commit.author || 'unknown',
256
+ SUBJECT: commit.subject || '',
257
+ TYPE: (commit.subject || '').match(/^[a-z]+/i) ? RegExp.lastMatch.toLowerCase() : 'chore',
258
+ CLASSIFICATION: change.classification,
259
+ };
260
+ let rendered = renderTemplate(tpl, vars);
261
+ const proposal = [
262
+ '',
263
+ `## ${l.aiProposal}`,
264
+ '',
265
+ `- **${l.goalImpact}**: ${change.goalImpact || 'TODO'}`,
266
+ `- **${l.evidence}**: ${(change.evidence || []).map(s => `- ${s}`).join('\n') || `- ${l.noEvidence}`}`,
267
+ `- **${l.proposedOperations}**: ${(change.proposedOps || []).map(o => `- ${o.op} ${o.path}${o.fromTemplate ? ` (from ${o.fromTemplate})` : ''}`).join('\n') || `- ${l.noEvidence}`}`,
268
+ ].join('\n');
269
+ return rendered.replace('## Reviewer Notes', proposal + '\n\n## Reviewer Notes');
270
+ }
271
+
272
+ function featureSlugFromSubject(subject) {
273
+ const s = (subject || '').replace(/^[^:]+:\s*/, '').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
274
+ return s.slice(0, 50) || 'change';
275
+ }
276
+
277
+ async function runCommitAnalysis(project, options = {}) {
278
+ const slug = project.slug;
279
+ const kbPath = path.resolve(project.kbPath);
280
+ const aiProfileId = project.aiProfileId || 'mock-agent';
281
+ const adapter = getAdapter(aiProfileId);
282
+ if (!adapter) return { ok: false, status: 400, error: `unknown adapter: ${aiProfileId}` };
283
+ if (!fs.existsSync(kbPath)) return { ok: false, status: 400, error: 'project KB not initialized' };
284
+
285
+ // Run the scanner to discover pending commits since lastAnalyzedCommit.
286
+ const scan = await scanProject({ slug, ...project }, { maxCommits: options.maxCommits || 200 });
287
+ if (scan.repoStatus !== 'ok') {
288
+ return { ok: false, status: 400, error: `git not ok: ${scan.repoStatus} (${scan.error || ''})` };
289
+ }
290
+ if (!scan.commits || scan.commits.length === 0) {
291
+ return { ok: true, noop: true, message: 'no pending commits', runId: null, scan };
292
+ }
293
+
294
+ const runId = `commits-${shortRunId(Date.now().toString(36) + Math.random().toString(36))}`;
295
+ const draftsDir = path.join(kbPath, '_ai', 'drafts', runId);
296
+ const runsDir = path.join(kbPath, '_ai', 'runs');
297
+ fs.mkdirSync(draftsDir, { recursive: true });
298
+ fs.mkdirSync(runsDir, { recursive: true });
299
+
300
+ const runRecord = {
301
+ schema: 'ai-run/v1',
302
+ runId,
303
+ type: 'commits',
304
+ project: slug,
305
+ aiProfileId,
306
+ knowledgeLanguage: knowledgeLanguage(project),
307
+ status: 'running',
308
+ startedAt: new Date().toISOString(),
309
+ mode: scan.mode,
310
+ range: scan.range,
311
+ commitCount: scan.commits.length,
312
+ headCommitAtRun: scan.headCommit || null,
313
+ lastAnalyzedCommitBefore: project.lastAnalyzedCommit || null,
314
+ drafts: [],
315
+ outputPaths: [],
316
+ };
317
+
318
+ try {
319
+ // 1. Build a commit-aware context pack
320
+ const pack = await buildContextPack({
321
+ project,
322
+ runId,
323
+ trigger: 'commits',
324
+ commits: scan.commits,
325
+ });
326
+ runRecord.contextPackPath = path.relative(kbPath, path.join(kbPath, '_ai', 'context-packs', runId, 'context-pack.json'));
327
+
328
+ // 2. Run analyzer
329
+ const output = await adapter.analyzeCommitBatch({ project, commits: scan.commits, contextPack: pack });
330
+
331
+ // 3. Validate
332
+ const validation = adapter.validateOutput(output);
333
+ if (!validation.valid) {
334
+ runRecord.status = 'failed';
335
+ runRecord.finishedAt = new Date().toISOString();
336
+ runRecord.error = 'invalid adapter output';
337
+ runRecord.validationErrors = validation.errors;
338
+ writeRun(runsDir, runRecord);
339
+ return { ok: false, status: 422, error: 'invalid adapter output', validation, runId, runRecord };
340
+ }
341
+
342
+ // 4. Render drafts
343
+ const changes = output.changes || [];
344
+ const changesByCommit = new Map(changes.map(c => [c.commit, c]));
345
+ let touchedGoal = false;
346
+
347
+ for (const commit of scan.commits) {
348
+ const change = changesByCommit.get(commit.hash);
349
+ if (!change) continue;
350
+ const shortCommit = (commit.short || commit.hash || '').slice(0, 7);
351
+ const draftChangePath = path.join(draftsDir, 'changes', `${shortCommit}.md`);
352
+ fs.mkdirSync(path.dirname(draftChangePath), { recursive: true });
353
+ fs.writeFileSync(draftChangePath, renderChangeDraft(project, change, commit), 'utf-8');
354
+ runRecord.drafts.push({ op: 'create-file', path: `changes/${shortCommit}.md`, fromDraft: 'change' });
355
+ runRecord.outputPaths.push(`changes/${shortCommit}.md`);
356
+
357
+ if (change.classification === 'new-feature') {
358
+ const featureSlug = featureSlugFromSubject(commit.subject);
359
+ const featurePath = path.join(draftsDir, 'features', `${featureSlug}.md`);
360
+ fs.mkdirSync(path.dirname(featurePath), { recursive: true });
361
+ const featureText = renderFeatureDraft(project, {
362
+ slug: featureSlug,
363
+ summary: knowledgeLanguage(project) === 'zh-CN'
364
+ ? `${shortCommit} 引入的新功能:${commit.subject}`
365
+ : `New feature introduced by ${shortCommit}: ${commit.subject}`,
366
+ });
367
+ fs.writeFileSync(featurePath, featureText, 'utf-8');
368
+ runRecord.drafts.push({ op: 'create-file', path: `features/${featureSlug}.md`, fromDraft: 'feature' });
369
+ runRecord.outputPaths.push(`features/${featureSlug}.md`);
370
+ } else if (change.classification === 'refactor' || change.classification === 'infrastructure') {
371
+ // Note a goal-impact line so the reviewer can see this changed the implementation shape.
372
+ touchedGoal = touchedGoal || (change.goalImpact && change.goalImpact.length > 0);
373
+ }
374
+ }
375
+
376
+ runRecord.touchedGoal = touchedGoal;
377
+ runRecord.status = 'succeeded';
378
+ runRecord.finishedAt = new Date().toISOString();
379
+ runRecord.evidenceTotal = changes.reduce((acc, c) => acc + (c.evidence ? c.evidence.length : 0), 0);
380
+ writeRun(runsDir, runRecord);
381
+
382
+ return { ok: true, runId, runRecord, scan };
383
+ } catch (e) {
384
+ runRecord.status = 'failed';
385
+ runRecord.finishedAt = new Date().toISOString();
386
+ runRecord.error = e.message;
387
+ writeRun(runsDir, runRecord);
388
+ return { ok: false, status: 500, error: e.message, runId, runRecord };
389
+ }
390
+ }
391
+
392
+ function writeRun(runsDir, runRecord) {
393
+ const p = path.join(runsDir, `${runRecord.runId}.json`);
394
+ fs.writeFileSync(p, JSON.stringify(runRecord, null, 2), 'utf-8');
395
+ return p;
396
+ }
397
+
398
+ function readRun(kbPath, runId) {
399
+ const p = path.join(kbPath, '_ai', 'runs', `${runId}.json`);
400
+ if (!fs.existsSync(p)) return null;
401
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
402
+ }
403
+
404
+ function listRuns(kbPath) {
405
+ const runsDir = path.join(kbPath, '_ai', 'runs');
406
+ if (!fs.existsSync(runsDir)) return [];
407
+ return fs.readdirSync(runsDir).filter(f => f.endsWith('.json')).map(f => {
408
+ try { return JSON.parse(fs.readFileSync(path.join(runsDir, f), 'utf-8')); } catch { return null; }
409
+ }).filter(Boolean);
410
+ }
411
+
412
+ function listDrafts(kbPath, runId) {
413
+ const dir = path.join(kbPath, '_ai', 'drafts', runId);
414
+ if (!fs.existsSync(dir)) return [];
415
+ const out = [];
416
+ const walk = (d) => {
417
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
418
+ const full = path.join(d, e.name);
419
+ if (e.isDirectory()) walk(full);
420
+ else out.push({ path: path.relative(dir, full).replace(/\\/g, '/'), size: fs.statSync(full).size });
421
+ }
422
+ };
423
+ walk(dir);
424
+ return out;
425
+ }
426
+
427
+ module.exports = {
428
+ runInitialAnalysis,
429
+ runCommitAnalysis,
430
+ readRun,
431
+ listRuns,
432
+ listDrafts,
433
+ };