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
@@ -10,6 +10,8 @@ const fs = require('fs');
10
10
  const path = require('path');
11
11
  const crypto = require('crypto');
12
12
  const { execGit } = require('./git-runner');
13
+ const aiWorkspace = require('./ai-workspace');
14
+ const { isV3Kb } = require('./kb-v3');
13
15
 
14
16
  const MAX_FILE_BYTES = 200 * 1024; // Skip files larger than 200 KB
15
17
  const MAX_EXCERPT_BYTES = 8 * 1024; // Excerpt limited to 8 KB
@@ -121,9 +123,8 @@ function findRelatedDocs(projectRoot, changedFiles) {
121
123
  // that intersect the changed files is "related". If frontmatter does not exist
122
124
  // (legacy docs), fall back to filename match.
123
125
  const out = new Set();
124
- const featuresDir = path.join(projectRoot, 'features');
125
- const modulesDir = path.join(projectRoot, 'modules');
126
- for (const dir of [featuresDir, modulesDir]) {
126
+ const dirs = [path.join(projectRoot, 'features'), path.join(projectRoot, 'modules'), path.join(projectRoot, 'changes')];
127
+ for (const dir of dirs) {
127
128
  if (!fs.existsSync(dir)) continue;
128
129
  let entries;
129
130
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
@@ -187,13 +188,17 @@ async function buildContextPack({ project, runId, trigger, commits = [], options
187
188
  seenAbs.add(abs);
188
189
  }
189
190
 
190
- // 1. project-goal.md (highest-priority context)
191
- if (fs.existsSync(path.join(projectRoot, 'project-goal.md'))) {
191
+ // 1. project goal (highest-priority context)
192
+ if (fs.existsSync(path.join(projectRoot, 'GOAL.md'))) {
193
+ addEntry('GOAL.md', 'goal', 'highest-priority human-controlled truth');
194
+ } else if (fs.existsSync(path.join(projectRoot, 'project-goal.md'))) {
192
195
  addEntry('project-goal.md', 'goal', 'highest-priority human-controlled truth');
193
196
  }
194
197
 
195
- // 2. project-analysis.md
196
- if (fs.existsSync(path.join(projectRoot, 'project-analysis.md'))) {
198
+ // 2. architecture / project analysis
199
+ if (fs.existsSync(path.join(projectRoot, 'ARCHITECTURE.md'))) {
200
+ addEntry('ARCHITECTURE.md', 'analysis', 'current architecture and project description');
201
+ } else if (fs.existsSync(path.join(projectRoot, 'project-analysis.md'))) {
197
202
  addEntry('project-analysis.md', 'analysis', 'current AI-generated description');
198
203
  }
199
204
 
@@ -258,6 +263,10 @@ async function buildContextPack({ project, runId, trigger, commits = [], options
258
263
  createdAt: new Date().toISOString(),
259
264
  trigger: trigger || 'initial',
260
265
  gitPath,
266
+ kbPath: projectRoot,
267
+ sourceBranch: project.currentBranch || null,
268
+ sourceDefaultBranch: project.defaultBranch || null,
269
+ sourceRemote: project.remoteUrl || null,
261
270
  range,
262
271
  diffStat,
263
272
  commitCount: commits.length,
@@ -271,10 +280,9 @@ async function buildContextPack({ project, runId, trigger, commits = [], options
271
280
  };
272
281
 
273
282
  // 8. Write to disk
274
- const outDir = path.join(projectRoot, '_ai', 'context-packs', pack.runId);
275
- if (!isSafePath(projectRoot, path.relative(projectRoot, outDir))) {
276
- throw new Error('unsafe output path');
277
- }
283
+ const outDir = (project.kbSchemaVersion === 'v3' || isV3Kb(projectRoot))
284
+ ? aiWorkspace.contextPackDir(slug, pack.runId)
285
+ : path.join(projectRoot, '_ai', 'context-packs', pack.runId);
278
286
  fs.mkdirSync(outDir, { recursive: true });
279
287
  fs.writeFileSync(path.join(outDir, 'context-pack.json'), JSON.stringify(pack, null, 2), 'utf-8');
280
288
 
@@ -14,6 +14,9 @@
14
14
 
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
+ const aiWorkspace = require('./ai-workspace');
18
+ const { isV3Kb, applyPolicyForPath } = require('./kb-v3');
19
+ const { regenerateIndexes } = require('./index-builder');
17
20
 
18
21
  const TRUSTED_GOAL_REL = 'project-goal.md';
19
22
 
@@ -42,8 +45,32 @@ function isSafeApplyPath(kbPath, rel) {
42
45
  return true;
43
46
  }
44
47
 
48
+ function parseFrontmatter(text) {
49
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(text || ''));
50
+ if (!match) return { meta: {}, body: String(text || '') };
51
+ const meta = {};
52
+ for (const line of match[1].split(/\r?\n/)) {
53
+ const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line.trim());
54
+ if (m) meta[m[1]] = m[2].trim();
55
+ }
56
+ return { meta, body: String(text || '').slice(match[0].length) };
57
+ }
58
+
59
+ function withStampedFrontmatter(content, stamp) {
60
+ const parsed = parseFrontmatter(content);
61
+ const meta = { ...parsed.meta };
62
+ for (const [key, value] of Object.entries(stamp || {})) {
63
+ if (value != null && value !== '') meta[key] = value;
64
+ }
65
+ const lines = ['---'];
66
+ for (const [key, value] of Object.entries(meta)) lines.push(`${key}: ${String(value).replace(/\r?\n/g, ' ')}`);
67
+ lines.push('---', '');
68
+ return lines.join('\n') + parsed.body.replace(/^\r?\n/, '');
69
+ }
70
+
45
71
  function listDraftFiles(kbPath, runId) {
46
- const dir = path.join(kbPath, '_ai', 'drafts', runId);
72
+ const slug = path.basename(kbPath);
73
+ const dir = aiWorkspace.findExistingDraftDir({ slug, kbPath, runId });
47
74
  if (!fs.existsSync(dir)) return [];
48
75
  const out = [];
49
76
  const walk = (d) => {
@@ -87,21 +114,32 @@ function applyDrafts({ kbPath, slug, runId, drafts, allowGoalEdit, headCommitAtR
87
114
  // 1. Validate every draft before touching anything
88
115
  const errors = [];
89
116
  const prepared = [];
117
+ const v3 = isV3Kb(kbPath);
90
118
  for (const d of drafts) {
91
119
  const v = validateDraftSchema(d);
92
120
  if (!v.valid) { errors.push({ draft: d && d.path, errors: v.errors }); continue; }
93
121
  if (!isSafeApplyPath(kbPath, d.path)) { errors.push({ draft: d.path, errors: ['unsafe path'] }); continue; }
94
- if (d.path === TRUSTED_GOAL_REL && !allowGoalEdit) {
122
+ if (v3) {
123
+ const policy = applyPolicyForPath(d.path, allowGoalEdit);
124
+ if (!policy.ok) {
125
+ errors.push({ draft: d.path, status: policy.status || 422, reviewRequired: !!policy.reviewRequired, errors: [policy.reason] });
126
+ continue;
127
+ }
128
+ } else if (d.path === TRUSTED_GOAL_REL && !allowGoalEdit) {
95
129
  errors.push({ draft: d.path, errors: ['refusing to overwrite project-goal.md without allowGoalEdit=true'] });
96
130
  continue;
97
131
  }
98
132
  prepared.push(d);
99
133
  }
100
- if (errors.length) return { ok: false, status: 422, error: 'invalid drafts', errors };
134
+ if (errors.length) {
135
+ const status = errors.some(item => item.status === 409 || item.reviewRequired) ? 409 : 422;
136
+ return { ok: false, status, error: 'invalid drafts', errors };
137
+ }
101
138
  if (prepared.length === 0) return { ok: false, status: 400, error: 'no valid drafts after validation' };
102
139
 
103
140
  // 2. Backup everything we will overwrite, into _ai/backups/<runId>/
104
- const backupsDir = path.join(kbPath, '_ai', 'backups', runId);
141
+ const aiRoot = aiWorkspace.ensureProjectAIPath(slug);
142
+ const backupsDir = path.join(aiRoot, 'backups', runId);
105
143
  fs.mkdirSync(backupsDir, { recursive: true });
106
144
  const backups = [];
107
145
  for (const d of prepared) {
@@ -116,7 +154,12 @@ function applyDrafts({ kbPath, slug, runId, drafts, allowGoalEdit, headCommitAtR
116
154
  const dest = path.join(kbPath, d.path);
117
155
  try {
118
156
  fs.mkdirSync(path.dirname(dest), { recursive: true });
119
- fs.writeFileSync(dest, d.content, 'utf-8');
157
+ const stamp = {
158
+ sourceBranch: d.sourceBranch || undefined,
159
+ sourceHeadCommit: d.sourceHeadCommit || undefined,
160
+ };
161
+ const content = stamp.sourceBranch || stamp.sourceHeadCommit ? withStampedFrontmatter(d.content, stamp) : d.content;
162
+ fs.writeFileSync(dest, content, 'utf-8');
120
163
  writes.push(d.path);
121
164
  } catch (e) {
122
165
  failed.push({ path: d.path, error: e.message });
@@ -136,34 +179,39 @@ function applyDrafts({ kbPath, slug, runId, drafts, allowGoalEdit, headCommitAtR
136
179
  return { ok: false, status: 500, error: 'partial write failure, rolled back', failed, backups };
137
180
  }
138
181
 
139
- // 4. Update manifest
140
- const manifest = readManifest(kbPath) || { schema: 'kb-manifest/v1', project: slug, trustedKnowledge: [], draftAreas: [] };
182
+ // 4. Update manifest for legacy KBs; V3 relies on structure and regenerated indexes.
183
+ let manifest = null;
141
184
  const today = new Date().toISOString().slice(0, 10);
142
- if (!manifest.trustedKnowledge.includes('README.md')) manifest.trustedKnowledge.push('README.md');
143
- for (const d of prepared) {
144
- if (d.path === TRUSTED_GOAL_REL) {
145
- manifest.goal = { path: d.path, status: 'accepted', updatedAt: today };
146
- } else if (d.path === 'project-analysis.md') {
147
- manifest.analysis = manifest.analysis || {};
148
- manifest.analysis.path = d.path;
149
- manifest.analysis.updatedAt = today;
150
- if (headCommitAtRun) manifest.analysis.lastAnalyzedCommit = headCommitAtRun;
151
- } else if (d.path.startsWith('features/')) {
152
- if (!manifest.trustedKnowledge.includes('features/')) manifest.trustedKnowledge.push('features/');
153
- } else if (d.path.startsWith('modules/')) {
154
- if (!manifest.trustedKnowledge.includes('modules/')) manifest.trustedKnowledge.push('modules/');
155
- } else if (d.path.startsWith('changes/')) {
156
- if (!manifest.trustedKnowledge.includes('changes/')) manifest.trustedKnowledge.push('changes/');
157
- } else if (d.path.startsWith('architecture/')) {
158
- if (!manifest.trustedKnowledge.includes('architecture/')) manifest.trustedKnowledge.push('architecture/');
185
+ let indexes = null;
186
+ if (v3) {
187
+ indexes = regenerateIndexes(kbPath);
188
+ } else {
189
+ manifest = readManifest(kbPath) || { schema: 'kb-manifest/v1', project: slug, trustedKnowledge: [], draftAreas: [] };
190
+ if (!manifest.trustedKnowledge.includes('README.md')) manifest.trustedKnowledge.push('README.md');
191
+ for (const d of prepared) {
192
+ if (d.path === TRUSTED_GOAL_REL) {
193
+ manifest.goal = { path: d.path, status: 'accepted', updatedAt: today };
194
+ } else if (d.path === 'project-analysis.md') {
195
+ manifest.analysis = manifest.analysis || {};
196
+ manifest.analysis.path = d.path;
197
+ manifest.analysis.updatedAt = today;
198
+ if (headCommitAtRun) manifest.analysis.lastAnalyzedCommit = headCommitAtRun;
199
+ } else if (d.path.startsWith('features/')) {
200
+ if (!manifest.trustedKnowledge.includes('features/')) manifest.trustedKnowledge.push('features/');
201
+ } else if (d.path.startsWith('modules/')) {
202
+ if (!manifest.trustedKnowledge.includes('modules/')) manifest.trustedKnowledge.push('modules/');
203
+ } else if (d.path.startsWith('changes/')) {
204
+ if (!manifest.trustedKnowledge.includes('changes/')) manifest.trustedKnowledge.push('changes/');
205
+ } else if (d.path.startsWith('architecture/')) {
206
+ if (!manifest.trustedKnowledge.includes('architecture/')) manifest.trustedKnowledge.push('architecture/');
207
+ }
159
208
  }
209
+ writeManifest(kbPath, manifest);
160
210
  }
161
- writeManifest(kbPath, manifest);
162
211
 
163
212
  // 5. Mark the run as applied (caller is responsible for advancing
164
213
  // lastAnalyzedCommit since they know the commit-batch size).
165
- const runsDir = path.join(kbPath, '_ai', 'runs');
166
- const runPath = path.join(runsDir, `${runId}.json`);
214
+ const runPath = aiWorkspace.findExistingRunPath({ slug, kbPath, runId });
167
215
  if (fs.existsSync(runPath)) {
168
216
  try {
169
217
  const run = JSON.parse(fs.readFileSync(runPath, 'utf-8'));
@@ -176,13 +224,13 @@ function applyDrafts({ kbPath, slug, runId, drafts, allowGoalEdit, headCommitAtR
176
224
  } catch {}
177
225
  }
178
226
 
179
- return { ok: true, applied: writes, backups, manifest };
227
+ return { ok: true, applied: writes, backups, manifest, indexes };
180
228
  }
181
229
 
182
230
  function rejectDrafts({ kbPath, runId, reason }) {
183
231
  if (!kbPath || !runId) return { ok: false, status: 400, error: 'kbPath and runId required' };
184
- const runsDir = path.join(kbPath, '_ai', 'runs');
185
- const runPath = path.join(runsDir, `${runId}.json`);
232
+ const slug = path.basename(kbPath);
233
+ const runPath = aiWorkspace.findExistingRunPath({ slug, kbPath, runId });
186
234
  if (!fs.existsSync(runPath)) return { ok: false, status: 404, error: 'run not found' };
187
235
  const run = JSON.parse(fs.readFileSync(runPath, 'utf-8'));
188
236
  run.applyStatus = 'rejected';
@@ -197,7 +245,8 @@ function readDraftContent(kbPath, runId, rel) {
197
245
  if (path.isAbsolute(rel)) return null;
198
246
  const norm = rel.replace(/\\/g, '/');
199
247
  if (norm.startsWith('../') || norm.includes('/../') || norm.endsWith('/..')) return null;
200
- const dir = path.join(kbPath, '_ai', 'drafts', runId);
248
+ const slug = path.basename(kbPath);
249
+ const dir = aiWorkspace.findExistingDraftDir({ slug, kbPath, runId });
201
250
  const target = path.join(dir, rel);
202
251
  // Reject if the resolved target escapes the drafts dir
203
252
  const relPath = path.relative(dir, target);
@@ -0,0 +1,100 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function parseFrontmatter(text) {
5
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(text || ''));
6
+ if (!match) return {};
7
+ const meta = {};
8
+ for (const line of match[1].split(/\r?\n/)) {
9
+ const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line.trim());
10
+ if (!m) continue;
11
+ let value = m[2].trim();
12
+ if (/^\[.*\]$/.test(value)) {
13
+ value = value.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
14
+ }
15
+ meta[m[1]] = value;
16
+ }
17
+ return meta;
18
+ }
19
+
20
+ function listMarkdown(dir) {
21
+ if (!fs.existsSync(dir)) return [];
22
+ return fs.readdirSync(dir, { withFileTypes: true })
23
+ .filter(entry => entry.isFile() && entry.name.endsWith('.md') && entry.name !== '00-index.md')
24
+ .map(entry => {
25
+ const rel = entry.name;
26
+ const full = path.join(dir, entry.name);
27
+ const text = fs.readFileSync(full, 'utf-8');
28
+ const meta = parseFrontmatter(text);
29
+ const title = meta.title || text.match(/^#\s+(.+)$/m)?.[1] || entry.name.replace(/\.md$/, '');
30
+ return { rel, full, meta, title, updatedAt: meta.updatedAt || meta.period || '' };
31
+ });
32
+ }
33
+
34
+ function tagLine(items) {
35
+ const tags = new Set();
36
+ for (const item of items) {
37
+ const raw = item.meta.tags;
38
+ if (Array.isArray(raw)) raw.forEach(t => tags.add(t));
39
+ else if (raw) String(raw).split(',').map(s => s.trim()).filter(Boolean).forEach(t => tags.add(t));
40
+ }
41
+ return [...tags].sort();
42
+ }
43
+
44
+ function writeModuleIndex(kbPath) {
45
+ const dir = path.join(kbPath, 'modules');
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ const items = listMarkdown(dir).sort((a, b) => a.title.localeCompare(b.title));
48
+ const tags = tagLine(items);
49
+ const lines = [
50
+ '# Modules Index',
51
+ '',
52
+ tags.length ? `Tags: ${tags.map(t => `\`${t}\``).join(', ')}` : 'Tags: none',
53
+ '',
54
+ '| Module | Tags | Updated |',
55
+ '|---|---|---|',
56
+ ];
57
+ for (const item of items) {
58
+ const tagsText = Array.isArray(item.meta.tags) ? item.meta.tags.join(', ') : (item.meta.tags || '');
59
+ lines.push(`| [${item.title}](./${item.rel}) | ${tagsText || '-'} | ${item.updatedAt || '-'} |`);
60
+ }
61
+ if (!items.length) lines.push('| No modules yet | - | - |');
62
+ fs.writeFileSync(path.join(dir, '00-index.md'), lines.join('\n') + '\n', 'utf-8');
63
+ return { path: 'modules/00-index.md', count: items.length };
64
+ }
65
+
66
+ function writeChangesIndex(kbPath) {
67
+ const dir = path.join(kbPath, 'changes');
68
+ fs.mkdirSync(dir, { recursive: true });
69
+ const items = listMarkdown(dir).sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)) || a.title.localeCompare(b.title));
70
+ const tags = tagLine(items);
71
+ const lines = [
72
+ '# Changes Index',
73
+ '',
74
+ tags.length ? `Tags: ${tags.map(t => `\`${t}\``).join(', ')}` : 'Tags: none',
75
+ '',
76
+ '| Change | Tags | Period / Updated |',
77
+ '|---|---|---|',
78
+ ];
79
+ for (const item of items) {
80
+ const tagsText = Array.isArray(item.meta.tags) ? item.meta.tags.join(', ') : (item.meta.tags || '');
81
+ lines.push(`| [${item.title}](./${item.rel}) | ${tagsText || '-'} | ${item.updatedAt || '-'} |`);
82
+ }
83
+ if (!items.length) lines.push('| No changes yet | - | - |');
84
+ fs.writeFileSync(path.join(dir, '00-index.md'), lines.join('\n') + '\n', 'utf-8');
85
+ return { path: 'changes/00-index.md', count: items.length };
86
+ }
87
+
88
+ function regenerateIndexes(kbPath) {
89
+ return {
90
+ modules: writeModuleIndex(kbPath),
91
+ changes: writeChangesIndex(kbPath),
92
+ };
93
+ }
94
+
95
+ module.exports = {
96
+ parseFrontmatter,
97
+ regenerateIndexes,
98
+ writeModuleIndex,
99
+ writeChangesIndex,
100
+ };
@@ -89,7 +89,7 @@ function projectList(projects, slug) {
89
89
  .map(s => projects[s]);
90
90
  }
91
91
 
92
- function defaultProjectKbPath(slug) {
92
+ function legacyDefaultProjectKbPath(slug) {
93
93
  return path.join('D:\\SanQian.Xu\\project-knowledge-base', 'projects', slug);
94
94
  }
95
95
 
@@ -112,11 +112,12 @@ async function runScan(projects, slug, job) {
112
112
  return { scanned, errors, total: list.length };
113
113
  }
114
114
 
115
- async function runInitial(projects, slug, job) {
115
+ async function runInitial(projects, slug, job, options = {}) {
116
+ const resolveKbPath = options.defaultProjectKbPath || legacyDefaultProjectKbPath;
116
117
  const list = projectList(projects, slug);
117
118
  let ran = 0, skipped = 0, failed = 0;
118
119
  for (const p of list) {
119
- const kbPath = p.kbPath || defaultProjectKbPath(p.slug);
120
+ const kbPath = p.kbPath || resolveKbPath(p.slug);
120
121
  if (!fs.existsSync(kbPath)) { skipped++; appendLine(job, `[analyze-initial] ${p.slug} → skipped (no KB)`); continue; }
121
122
  const goalPath = path.join(kbPath, 'project-goal.md');
122
123
  if (fs.existsSync(goalPath)) { skipped++; appendLine(job, `[analyze-initial] ${p.slug} → skipped (goal already exists)`); continue; }
@@ -127,11 +128,12 @@ async function runInitial(projects, slug, job) {
127
128
  return { ran, skipped, failed, total: list.length };
128
129
  }
129
130
 
130
- async function runCommits(projects, slug, job) {
131
+ async function runCommits(projects, slug, job, options = {}) {
132
+ const resolveKbPath = options.defaultProjectKbPath || legacyDefaultProjectKbPath;
131
133
  const list = projectList(projects, slug);
132
134
  let ran = 0, noop = 0, failed = 0;
133
135
  for (const p of list) {
134
- const kbPath = p.kbPath || defaultProjectKbPath(p.slug);
136
+ const kbPath = p.kbPath || resolveKbPath(p.slug);
135
137
  if (!fs.existsSync(kbPath)) { appendLine(job, `[analyze-commits] ${p.slug} → skipped (no KB)`); continue; }
136
138
  const result = await runCommitAnalysis({ slug: p.slug, ...p, kbPath });
137
139
  if (result.ok) {
@@ -154,7 +156,8 @@ function runLegacyScript(slug, job) {
154
156
  });
155
157
  }
156
158
 
157
- async function runJob({ job, projects, projectsPath, jobsLogPath, writeProjects }) {
159
+ async function runJob({ job, projects, projectsPath, jobsLogPath, writeProjects, defaultProjectKbPath }) {
160
+ const runOptions = { defaultProjectKbPath };
158
161
  if (!job) throw new Error('job required');
159
162
  if (!KNOWN_MODES.has(job.mode)) {
160
163
  job.status = 'failed';
@@ -181,17 +184,17 @@ async function runJob({ job, projects, projectsPath, jobsLogPath, writeProjects
181
184
  job.status = summary.errors > 0 ? 'partial' : 'success';
182
185
  exitCode = summary.errors > 0 ? 1 : 0;
183
186
  } else if (job.mode === 'analyze-initial') {
184
- summary = await runInitial(projects, slug, job);
187
+ summary = await runInitial(projects, slug, job, runOptions);
185
188
  job.status = summary.failed > 0 ? 'partial' : 'success';
186
189
  exitCode = summary.failed > 0 ? 1 : 0;
187
190
  } else if (job.mode === 'analyze-commits') {
188
- summary = await runCommits(projects, slug, job);
191
+ summary = await runCommits(projects, slug, job, runOptions);
189
192
  job.status = summary.failed > 0 ? 'partial' : 'success';
190
193
  exitCode = summary.failed > 0 ? 1 : 0;
191
194
  } else if (job.mode === 'safe') {
192
195
  // scan → analyze-commits. Crucially, no apply step.
193
196
  const scanSummary = await runScan(projects, slug, job);
194
- const commitSummary = await runCommits(projects, slug, job);
197
+ const commitSummary = await runCommits(projects, slug, job, runOptions);
195
198
  summary = { scan: scanSummary, commits: commitSummary, applied: 0 };
196
199
  const totalFailed = scanSummary.errors + commitSummary.failed;
197
200
  job.status = totalFailed > 0 ? 'partial' : 'success';
@@ -0,0 +1,188 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { migrateAIWorkspace, ensureProjectAIPath } = require('./ai-workspace');
4
+ const { regenerateIndexes } = require('./index-builder');
5
+
6
+ const V3_TOP_LEVEL = ['README.md', 'GOAL.md', 'ARCHITECTURE.md', 'modules', 'changes'];
7
+ const TRUSTED_AUTO_PATHS = ['README.md', 'modules/', 'modules/00-index.md', 'changes/', 'changes/00-index.md'];
8
+ const REVIEW_REQUIRED_PATHS = ['GOAL.md', 'ARCHITECTURE.md'];
9
+ const NEVER_TOUCH_PREFIXES = ['.git/', '.gitignore', '_meta/', '_ai/'];
10
+
11
+ function todayIso() {
12
+ return new Date().toISOString().slice(0, 10);
13
+ }
14
+
15
+ function frontmatter(fields) {
16
+ const lines = ['---'];
17
+ for (const [key, value] of Object.entries(fields)) {
18
+ if (value == null || value === '') continue;
19
+ if (Array.isArray(value)) lines.push(`${key}: [${value.join(', ')}]`);
20
+ else lines.push(`${key}: ${String(value).replace(/\r?\n/g, ' ')}`);
21
+ }
22
+ lines.push('---', '');
23
+ return lines.join('\n');
24
+ }
25
+
26
+ function ensureFile(file, content, created) {
27
+ if (fs.existsSync(file)) return;
28
+ fs.mkdirSync(path.dirname(file), { recursive: true });
29
+ fs.writeFileSync(file, content, 'utf-8');
30
+ if (created) created.push(path.relative(path.dirname(path.dirname(file)), file).replace(/\\/g, '/'));
31
+ }
32
+
33
+ function initV3ProjectDirs(slug, kbPath) {
34
+ const base = path.resolve(kbPath);
35
+ const created = [];
36
+ fs.mkdirSync(path.join(base, 'modules'), { recursive: true });
37
+ fs.mkdirSync(path.join(base, 'changes'), { recursive: true });
38
+ ensureFile(path.join(base, 'README.md'), `# ${slug}\n\nThis knowledge base is managed by Project Knowledge Base.\n\n- [Goal](./GOAL.md)\n- [Architecture](./ARCHITECTURE.md)\n- [Modules](./modules/00-index.md)\n- [Changes](./changes/00-index.md)\n`, created);
39
+ ensureFile(path.join(base, 'GOAL.md'), `${frontmatter({ title: `${slug} Goal`, status: 'draft', updatedAt: todayIso() })}# ${slug} Goal\n\nTODO: confirm the project goal.\n`, created);
40
+ ensureFile(path.join(base, 'ARCHITECTURE.md'), `${frontmatter({ title: `${slug} Architecture`, updatedAt: todayIso() })}# ${slug} Architecture\n\nTODO: summarize the project architecture.\n`, created);
41
+ regenerateIndexes(base);
42
+ ensureProjectAIPath(slug);
43
+ return { created, basePath: base, kbSchemaVersion: 'v3', topLevel: V3_TOP_LEVEL.slice() };
44
+ }
45
+
46
+ function readIfExists(file) {
47
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
48
+ }
49
+
50
+ function visibleTopLevel(kbPath) {
51
+ if (!fs.existsSync(kbPath)) return [];
52
+ return fs.readdirSync(kbPath).filter(name => !name.startsWith('.')).sort();
53
+ }
54
+
55
+ function isV3Kb(kbPath) {
56
+ return fs.existsSync(path.join(kbPath, 'GOAL.md')) &&
57
+ fs.existsSync(path.join(kbPath, 'ARCHITECTURE.md')) &&
58
+ fs.existsSync(path.join(kbPath, 'modules')) &&
59
+ fs.existsSync(path.join(kbPath, 'changes'));
60
+ }
61
+
62
+ function consolidateLegacyCommits({ slug, kbPath }) {
63
+ const commitsDir = path.join(kbPath, 'commits');
64
+ const aiRoot = ensureProjectAIPath(slug);
65
+ const backupDir = path.join(aiRoot, 'legacy-commits');
66
+ const result = { found: 0, created: [], backupDir };
67
+ if (!fs.existsSync(commitsDir)) return result;
68
+ const files = fs.readdirSync(commitsDir).filter(f => f.endsWith('.md') && f !== '00-index.md').sort();
69
+ result.found = files.length;
70
+ if (!files.length) return result;
71
+ fs.mkdirSync(backupDir, { recursive: true });
72
+ for (const file of files) fs.copyFileSync(path.join(commitsDir, file), path.join(backupDir, file));
73
+ const bucketCount = Math.min(10, Math.max(1, Math.ceil(files.length / 4)));
74
+ const bucketSize = Math.ceil(files.length / bucketCount);
75
+ const changesDir = path.join(kbPath, 'changes');
76
+ fs.mkdirSync(changesDir, { recursive: true });
77
+ for (let i = 0; i < bucketCount; i++) {
78
+ const bucket = files.slice(i * bucketSize, (i + 1) * bucketSize);
79
+ if (!bucket.length) continue;
80
+ const rel = `legacy-change-${String(i + 1).padStart(2, '0')}.md`;
81
+ const title = `Legacy Change ${i + 1}`;
82
+ const body = [
83
+ frontmatter({
84
+ title,
85
+ tags: ['legacy-migration'],
86
+ updatedAt: todayIso(),
87
+ aggregatedFrom: `[${bucket.map(f => `commits/${f}`).join(', ')}]`,
88
+ }),
89
+ `# ${title}`,
90
+ '',
91
+ 'This document consolidates legacy per-commit knowledge during the KB v3 migration.',
92
+ '',
93
+ '## Aggregated Commits',
94
+ '',
95
+ ...bucket.map(file => `- \`commits/${file}\``),
96
+ '',
97
+ ].join('\n');
98
+ fs.writeFileSync(path.join(changesDir, rel), body, 'utf-8');
99
+ result.created.push(`changes/${rel}`);
100
+ }
101
+ return result;
102
+ }
103
+
104
+ function removeV2OnlyItems(kbPath) {
105
+ const removed = [];
106
+ const names = [
107
+ '_ai', 'kb-manifest.json', 'project-goal.md', 'project-analysis.md', 'framework.md',
108
+ 'architecture', 'commits', 'features', 'operations', 'quality', 'requirements', 'references',
109
+ ];
110
+ for (const name of names) {
111
+ const target = path.join(kbPath, name);
112
+ if (fs.existsSync(target)) {
113
+ fs.rmSync(target, { recursive: true, force: true });
114
+ removed.push(name);
115
+ }
116
+ }
117
+ return removed;
118
+ }
119
+
120
+ function migrateToV3({ slug, kbPath, preserveLegacyAI = true }) {
121
+ const base = path.resolve(kbPath);
122
+ fs.mkdirSync(base, { recursive: true });
123
+ const aiMigration = migrateAIWorkspace({ slug, kbPath: base, preserveOriginal: preserveLegacyAI });
124
+
125
+ const oldGoal = readIfExists(path.join(base, 'project-goal.md'));
126
+ const oldFramework = readIfExists(path.join(base, 'framework.md'));
127
+ const oldArch = readIfExists(path.join(base, 'architecture', 'overview.md'));
128
+ const oldAnalysis = readIfExists(path.join(base, 'project-analysis.md'));
129
+
130
+ const init = initV3ProjectDirs(slug, base);
131
+ if (oldGoal && !oldGoal.includes('TODO: confirm the project goal')) {
132
+ fs.writeFileSync(path.join(base, 'GOAL.md'), oldGoal, 'utf-8');
133
+ }
134
+ const archParts = [
135
+ oldFramework && `## Migrated Framework\n\n${oldFramework}`,
136
+ oldArch && `## Migrated Architecture Overview\n\n${oldArch}`,
137
+ oldAnalysis && `## Migrated Project Analysis\n\n${oldAnalysis}`,
138
+ ].filter(Boolean);
139
+ if (archParts.length) {
140
+ fs.writeFileSync(path.join(base, 'ARCHITECTURE.md'), `${frontmatter({ title: `${slug} Architecture`, updatedAt: todayIso() })}# ${slug} Architecture\n\n${archParts.join('\n\n')}\n`, 'utf-8');
141
+ }
142
+ const commits = consolidateLegacyCommits({ slug, kbPath: base });
143
+ const removed = removeV2OnlyItems(base);
144
+ regenerateIndexes(base);
145
+ return {
146
+ ok: true,
147
+ slug,
148
+ kbPath: base,
149
+ kbSchemaVersion: 'v3',
150
+ init,
151
+ aiMigration,
152
+ legacyCommits: commits,
153
+ removed,
154
+ topLevel: visibleTopLevel(base),
155
+ };
156
+ }
157
+
158
+ function normalizeApplyPath(rel) {
159
+ return String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '');
160
+ }
161
+
162
+ function applyPolicyForPath(rel, allowGoalEdit = false) {
163
+ const norm = normalizeApplyPath(rel);
164
+ if (!norm || norm.includes('../') || norm.startsWith('../')) return { ok: false, reason: 'unsafe path' };
165
+ if (NEVER_TOUCH_PREFIXES.some(prefix => norm === prefix.replace(/\/$/, '') || norm.startsWith(prefix))) {
166
+ return { ok: false, reason: 'path is reserved and cannot be written by AI' };
167
+ }
168
+ if (REVIEW_REQUIRED_PATHS.includes(norm)) {
169
+ return allowGoalEdit ? { ok: true, reviewRequired: true } : { ok: false, status: 409, reviewRequired: true, reason: `${norm} requires human review` };
170
+ }
171
+ if (norm === 'README.md') return { ok: true, autoApply: true };
172
+ if (norm.startsWith('modules/') || norm.startsWith('changes/')) return { ok: true, autoApply: true };
173
+ return { ok: false, reason: `path is outside KB v3 trusted allowlist: ${norm}` };
174
+ }
175
+
176
+ module.exports = {
177
+ V3_TOP_LEVEL,
178
+ TRUSTED_AUTO_PATHS,
179
+ REVIEW_REQUIRED_PATHS,
180
+ NEVER_TOUCH_PREFIXES,
181
+ initV3ProjectDirs,
182
+ migrateToV3,
183
+ consolidateLegacyCommits,
184
+ isV3Kb,
185
+ visibleTopLevel,
186
+ applyPolicyForPath,
187
+ frontmatter,
188
+ };