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.
- package/CHANGELOG.md +41 -0
- package/README.md +184 -58
- package/_site/_test/ai-profile-test.js +59 -1
- package/_site/_test/baseline-schema-test.js +4 -3
- package/_site/_test/claude-workbench-test.js +72 -0
- package/_site/_test/draft-apply-test.js +12 -6
- package/_site/_test/kb-v2-templates-test.js +31 -43
- package/_site/_test/knowledge-store-logs-supervision-test.js +143 -0
- package/_site/_test/project-control-panel-task14-test.js +151 -0
- package/_site/_test/task15-20-integration-test.js +194 -0
- package/_site/_test/task15-20-ui-flow-test.js +144 -0
- package/_site/_test/ui-smoke-test.js +2 -2
- package/_site/index.html +1640 -90
- package/_site/lib/ai-adapter.js +3 -3
- package/_site/lib/ai-workspace.js +120 -0
- package/_site/lib/analysis-orchestrator.js +117 -32
- package/_site/lib/claude-cli-runner.js +862 -0
- package/_site/lib/context-pack-builder.js +19 -11
- package/_site/lib/draft-apply.js +80 -31
- package/_site/lib/index-builder.js +100 -0
- package/_site/lib/job-orchestrator.js +12 -9
- package/_site/lib/kb-v3.js +188 -0
- package/_site/lib/kb-validator.js +84 -0
- package/_site/lib/knowledge-store.js +141 -0
- package/_site/lib/llm-client.js +103 -56
- package/_site/lib/prompt-registry.js +102 -0
- package/_site/lib/structured-logger.js +120 -0
- package/_site/lib/supervision.js +103 -0
- package/_site/server.js +835 -19
- package/_site/vendor/tailwind-browser.js +947 -0
- package/_site/vendor/vue.global.prod.js +9 -0
- package/ai-profiles.json +12 -10
- package/docs/development-progress.md +141 -0
- 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
|
|
125
|
-
const
|
|
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
|
|
191
|
-
if (fs.existsSync(path.join(projectRoot, '
|
|
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
|
|
196
|
-
if (fs.existsSync(path.join(projectRoot, '
|
|
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 =
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
package/_site/lib/draft-apply.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
if (
|
|
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
|
|
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
|
|
185
|
-
const runPath =
|
|
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
|
|
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
|
|
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 ||
|
|
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 ||
|
|
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
|
+
};
|