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,231 @@
1
+ // Job Orchestrator (TASK-010)
2
+ //
3
+ // Run modes for the server-side scheduler. Each mode produces a job record
4
+ // that is persisted to <KB_ROOT>/.jobs-log.json so the user can audit what ran
5
+ // and when.
6
+ //
7
+ // Modes:
8
+ // * `legacy` — runs the existing PowerShell gen-commit-doc.ps1.
9
+ // Kept for backward compatibility. The script writes
10
+ // docs directly to the KB; this is the historical path.
11
+ // * `scan` — read-only: for each enabled project, call scanProject
12
+ // to update headCommit/lastSeenCommit/lastScanAt. Never
13
+ // invokes AI and never writes any drafts.
14
+ // * `analyze-initial`— for each enabled project that has a v2 KB but no
15
+ // trusted project-goal.md, run the initial-analysis
16
+ // orchestrator. Drafts land under _ai/drafts/.
17
+ // * `analyze-commits`— for each enabled project with pending commits
18
+ // (scan.pendingCount > 0), run incremental commit
19
+ // analysis. Drafts land under _ai/drafts/.
20
+ // * `safe` — composite mode: scan + analyze-commits. This is the
21
+ // new default for the scheduled task. Crucially, it
22
+ // never calls apply, so AI drafts cannot become
23
+ // trusted knowledge without a human at the Drafts tab.
24
+ //
25
+ // All modes honor `slug` to scope a run to one project, or `ALL` (default)
26
+ // to iterate every enabled project.
27
+ //
28
+ // Job records are JSON-serializable and streamed to the caller via the job
29
+ // object passed in by the route handler (so the existing `runningJobs` map
30
+ // can be repurposed as a live status view).
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+ const { spawn } = require('child_process');
35
+ const { scanProject, applyScanResult } = require('./scanner');
36
+ const { runInitialAnalysis, runCommitAnalysis } = require('./analysis-orchestrator');
37
+
38
+ const LEGACY_SCRIPT = 'D:\\SanQian.Xu\\project-knowledge-base\\scripts\\gen-commit-doc.ps1';
39
+
40
+ const KNOWN_MODES = new Set(['legacy', 'scan', 'analyze-initial', 'analyze-commits', 'safe']);
41
+
42
+ function appendLine(job, line) {
43
+ job.output = (job.output || '') + line + '\n';
44
+ if (job.output.length > 200_000) {
45
+ job.output = job.output.slice(-200_000);
46
+ }
47
+ }
48
+
49
+ function appendJobLog(jobsLogPath, entry) {
50
+ let arr = [];
51
+ try { arr = JSON.parse(fs.readFileSync(jobsLogPath, 'utf-8')); } catch {}
52
+ if (!Array.isArray(arr)) arr = [];
53
+ arr.push(entry);
54
+ // Keep at most the most recent 100 entries
55
+ if (arr.length > 100) arr = arr.slice(-100);
56
+ try { fs.writeFileSync(jobsLogPath, JSON.stringify(arr, null, 2) + '\n', 'utf-8'); } catch {}
57
+ }
58
+
59
+ function readJobLog(jobsLogPath) {
60
+ if (!fs.existsSync(jobsLogPath)) return [];
61
+ try { return JSON.parse(fs.readFileSync(jobsLogPath, 'utf-8')) || []; }
62
+ catch { return []; }
63
+ }
64
+
65
+ function makeJob({ mode, slug }) {
66
+ return {
67
+ jobId: 'job-' + Date.now() + '-' + Math.floor(Math.random() * 10000),
68
+ mode,
69
+ slug: slug || 'ALL',
70
+ startTime: new Date().toISOString(),
71
+ endTime: null,
72
+ status: 'running',
73
+ exitCode: null,
74
+ output: '',
75
+ summary: null,
76
+ };
77
+ }
78
+
79
+ function projectList(projects, slug) {
80
+ // Return the live project objects (with a `slug` field guaranteed). The caller
81
+ // may mutate them — `runScan` does, via `applyScanResult` — and those changes
82
+ // should be visible in the projects map the caller passed in.
83
+ if (slug && slug !== 'ALL') {
84
+ if (!projects[slug]) throw new Error(`unknown slug: ${slug}`);
85
+ return [{ slug, ...projects[slug] }];
86
+ }
87
+ return Object.keys(projects)
88
+ .filter(s => projects[s].enabled !== false)
89
+ .map(s => projects[s]);
90
+ }
91
+
92
+ function defaultProjectKbPath(slug) {
93
+ return path.join('D:\\SanQian.Xu\\project-knowledge-base', 'projects', slug);
94
+ }
95
+
96
+ async function runScan(projects, slug, job) {
97
+ const list = projectList(projects, slug);
98
+ let scanned = 0, errors = 0;
99
+ for (const p of list) {
100
+ const scan = await scanProject(p, { maxCommits: 200 });
101
+ if (scan.error && scan.repoStatus !== 'ok' && scan.repoStatus !== 'empty') {
102
+ appendLine(job, `[scan] ${p.slug} → ${scan.repoStatus}: ${scan.error}`);
103
+ errors++;
104
+ } else {
105
+ // Mutate the original in the projects map so the caller sees the update.
106
+ const target = projects[p.slug] || p;
107
+ applyScanResult(target, scan);
108
+ appendLine(job, `[scan] ${p.slug} → head ${(scan.headCommit || '?').slice(0, 7)} · ${scan.pendingCount} pending · mode ${scan.mode || '?'}`);
109
+ scanned++;
110
+ }
111
+ }
112
+ return { scanned, errors, total: list.length };
113
+ }
114
+
115
+ async function runInitial(projects, slug, job) {
116
+ const list = projectList(projects, slug);
117
+ let ran = 0, skipped = 0, failed = 0;
118
+ for (const p of list) {
119
+ const kbPath = p.kbPath || defaultProjectKbPath(p.slug);
120
+ if (!fs.existsSync(kbPath)) { skipped++; appendLine(job, `[analyze-initial] ${p.slug} → skipped (no KB)`); continue; }
121
+ const goalPath = path.join(kbPath, 'project-goal.md');
122
+ if (fs.existsSync(goalPath)) { skipped++; appendLine(job, `[analyze-initial] ${p.slug} → skipped (goal already exists)`); continue; }
123
+ const result = await runInitialAnalysis({ slug: p.slug, ...p, kbPath });
124
+ if (result.ok) { ran++; appendLine(job, `[analyze-initial] ${p.slug} → run ${result.runId} (${(result.runRecord.drafts || []).length} drafts)`); }
125
+ else { failed++; appendLine(job, `[analyze-initial] ${p.slug} → failed: ${result.error}`); }
126
+ }
127
+ return { ran, skipped, failed, total: list.length };
128
+ }
129
+
130
+ async function runCommits(projects, slug, job) {
131
+ const list = projectList(projects, slug);
132
+ let ran = 0, noop = 0, failed = 0;
133
+ for (const p of list) {
134
+ const kbPath = p.kbPath || defaultProjectKbPath(p.slug);
135
+ if (!fs.existsSync(kbPath)) { appendLine(job, `[analyze-commits] ${p.slug} → skipped (no KB)`); continue; }
136
+ const result = await runCommitAnalysis({ slug: p.slug, ...p, kbPath });
137
+ if (result.ok) {
138
+ if (result.noop) { noop++; appendLine(job, `[analyze-commits] ${p.slug} → no pending commits`); }
139
+ else { ran++; appendLine(job, `[analyze-commits] ${p.slug} → run ${result.runId} (${result.runRecord.commitCount} commits)`); }
140
+ } else { failed++; appendLine(job, `[analyze-commits] ${p.slug} → failed: ${result.error}`); }
141
+ }
142
+ return { ran, noop, failed, total: list.length };
143
+ }
144
+
145
+ function runLegacyScript(slug, job) {
146
+ return new Promise((resolve) => {
147
+ const p = spawn('powershell', ['-ExecutionPolicy', 'Bypass', '-File', LEGACY_SCRIPT, '-ProjectSlug', slug], {
148
+ windowsHide: true,
149
+ });
150
+ p.stdout.on('data', d => appendLine(job, d.toString('utf-8')));
151
+ p.stderr.on('data', d => appendLine(job, d.toString('utf-8')));
152
+ p.on('close', code => resolve({ exitCode: code, status: code === 0 ? 'success' : 'failed' }));
153
+ p.on('error', e => resolve({ exitCode: 1, status: 'failed', error: e.message }));
154
+ });
155
+ }
156
+
157
+ async function runJob({ job, projects, projectsPath, jobsLogPath, writeProjects }) {
158
+ if (!job) throw new Error('job required');
159
+ if (!KNOWN_MODES.has(job.mode)) {
160
+ job.status = 'failed';
161
+ job.endTime = new Date().toISOString();
162
+ job.exitCode = 2;
163
+ job.summary = { error: `unknown mode: ${job.mode}` };
164
+ appendLine(job, `Unknown mode: ${job.mode}. Known: ${[...KNOWN_MODES].join(', ')}`);
165
+ appendJobLog(jobsLogPath, job);
166
+ return job;
167
+ }
168
+ const slug = job.slug || 'ALL';
169
+ appendLine(job, `[start] mode=${job.mode} slug=${slug} at ${job.startTime}`);
170
+
171
+ let summary = null;
172
+ let exitCode = 0;
173
+ try {
174
+ if (job.mode === 'legacy') {
175
+ const r = await runLegacyScript(slug, job);
176
+ exitCode = r.exitCode;
177
+ job.status = r.status;
178
+ summary = { kind: 'legacy' };
179
+ } else if (job.mode === 'scan') {
180
+ summary = await runScan(projects, slug, job);
181
+ job.status = summary.errors > 0 ? 'partial' : 'success';
182
+ exitCode = summary.errors > 0 ? 1 : 0;
183
+ } else if (job.mode === 'analyze-initial') {
184
+ summary = await runInitial(projects, slug, job);
185
+ job.status = summary.failed > 0 ? 'partial' : 'success';
186
+ exitCode = summary.failed > 0 ? 1 : 0;
187
+ } else if (job.mode === 'analyze-commits') {
188
+ summary = await runCommits(projects, slug, job);
189
+ job.status = summary.failed > 0 ? 'partial' : 'success';
190
+ exitCode = summary.failed > 0 ? 1 : 0;
191
+ } else if (job.mode === 'safe') {
192
+ // scan → analyze-commits. Crucially, no apply step.
193
+ const scanSummary = await runScan(projects, slug, job);
194
+ const commitSummary = await runCommits(projects, slug, job);
195
+ summary = { scan: scanSummary, commits: commitSummary, applied: 0 };
196
+ const totalFailed = scanSummary.errors + commitSummary.failed;
197
+ job.status = totalFailed > 0 ? 'partial' : 'success';
198
+ exitCode = totalFailed > 0 ? 1 : 0;
199
+ appendLine(job, `[safe] complete. applied drafts: 0 (review required).`);
200
+ }
201
+ } catch (e) {
202
+ job.status = 'failed';
203
+ exitCode = 1;
204
+ summary = { error: e.message };
205
+ appendLine(job, `[error] ${e.message}`);
206
+ }
207
+
208
+ job.endTime = new Date().toISOString();
209
+ job.exitCode = exitCode;
210
+ job.summary = summary;
211
+ appendLine(job, `[end] status=${job.status} exitCode=${exitCode}`);
212
+
213
+ // Persist any project-state changes (e.g. scan updated headCommit).
214
+ if (writeProjects) {
215
+ try { writeProjects(); } catch (e) { appendLine(job, `[warn] could not persist projects.json: ${e.message}`); }
216
+ }
217
+ appendJobLog(jobsLogPath, job);
218
+ return job;
219
+ }
220
+
221
+ module.exports = {
222
+ KNOWN_MODES,
223
+ makeJob,
224
+ runJob,
225
+ readJobLog,
226
+ appendJobLog,
227
+ runScan,
228
+ runInitial,
229
+ runCommits,
230
+ runLegacyScript,
231
+ };
@@ -0,0 +1,224 @@
1
+ // KB Validator (TASK-011)
2
+ //
3
+ // Validates that a project's knowledge base is in a state where a downstream
4
+ // consumer (e.g. the future PR-review project) can rely on it.
5
+ //
6
+ // What is "valid"?
7
+ // * `<kbPath>/kb-manifest.json` exists and parses with `schema: "kb-manifest/v1"`.
8
+ // * `manifest.trustedKnowledge` lists directories the consumer is allowed to
9
+ // read from as authoritative. They are not required to exist, but at least
10
+ // the seed entry `README.md` must be listed (and exist).
11
+ // * `manifest.goal.path` points to a file that exists, OR is `null`/`""` (i.e.
12
+ // the project has not been goal-edited yet). The validator reports a clear
13
+ // "no goal" state instead of failing the project.
14
+ // * `manifest.analysis.path` points to a file that exists, OR is `null`/`""`.
15
+ // * If `manifest.trustedKnowledge` lists a directory, the directory either
16
+ // exists or is intentionally absent (a v2 KB starts with only the indexes).
17
+ // Missing `features/` etc. is NOT a hard error — the contract only
18
+ // promises that anything listed is reachable.
19
+ // * `manifest.draftAreas` is always a list and always includes `_ai/drafts/`,
20
+ // `_ai/runs/`, and `_ai/context-packs/`. These are the explicit "do not
21
+ // trust without human review" zones.
22
+ // * `_ai/` is present and contains `drafts/`, `runs/`, and `context-packs/`
23
+ // subdirectories (the consumer must never accidentally treat drafts as
24
+ // authoritative).
25
+ //
26
+ // What is "invalid"?
27
+ // * The manifest JSON does not parse.
28
+ // * The manifest schema is not `kb-manifest/v1`.
29
+ // * `manifest.goal.path` is set but the file does not exist.
30
+ // * `manifest.analysis.path` is set but the file does not exist.
31
+ // * Any entry in `manifest.trustedKnowledge` is non-empty and does not
32
+ // resolve to an existing file or directory.
33
+ // * A path listed in `manifest.trustedKnowledge` is actually inside the AI
34
+ // draft area (e.g. `_ai/`). That would mean someone tried to elevate a
35
+ // draft to trusted knowledge without going through apply — the validator
36
+ // refuses to certify the project in that case.
37
+
38
+ const fs = require('fs');
39
+ const path = require('path');
40
+
41
+ const MANIFEST_SCHEMA = 'kb-manifest/v1';
42
+ const REQUIRED_DRAFT_AREAS = ['_ai/drafts/', '_ai/runs/', '_ai/context-packs/'];
43
+
44
+ function readJsonSafe(p) {
45
+ try { return JSON.parse(fs.readFileSync(p, 'utf-8')); }
46
+ catch (e) { return { __error: e.message, __path: p }; }
47
+ }
48
+
49
+ function existsAny(root, rel) {
50
+ if (!rel) return false;
51
+ const abs = path.resolve(root, rel);
52
+ return fs.existsSync(abs);
53
+ }
54
+
55
+ function isInsideDraftArea(rel) {
56
+ const norm = String(rel || '').replace(/\\/g, '/');
57
+ return norm === '_ai' || norm.startsWith('_ai/');
58
+ }
59
+
60
+ function validateKb(kbPath) {
61
+ const errors = [];
62
+ const warnings = [];
63
+ const info = {
64
+ kbPath,
65
+ manifestExists: false,
66
+ goalStatus: 'unknown',
67
+ analysisStatus: 'unknown',
68
+ trustedKnowledgeEntries: 0,
69
+ draftAreasEntries: 0,
70
+ aiSubdirsPresent: [],
71
+ };
72
+
73
+ if (!kbPath || !fs.existsSync(kbPath)) {
74
+ return { ok: false, status: 400, error: `kbPath does not exist: ${kbPath}` };
75
+ }
76
+ if (!fs.statSync(kbPath).isDirectory()) {
77
+ return { ok: false, status: 400, error: `kbPath is not a directory: ${kbPath}` };
78
+ }
79
+
80
+ const manifestPath = path.join(kbPath, 'kb-manifest.json');
81
+ if (!fs.existsSync(manifestPath)) {
82
+ return { ok: false, status: 422, error: 'kb-manifest.json missing', info, errors: ['manifest missing'] };
83
+ }
84
+ info.manifestExists = true;
85
+
86
+ const manifest = readJsonSafe(manifestPath);
87
+ if (manifest.__error) {
88
+ return { ok: false, status: 422, error: `kb-manifest.json could not be parsed: ${manifest.__error}`, info, errors: ['manifest unparseable'] };
89
+ }
90
+
91
+ if (manifest.schema !== MANIFEST_SCHEMA) {
92
+ errors.push(`manifest.schema must be ${MANIFEST_SCHEMA}, got ${JSON.stringify(manifest.schema)}`);
93
+ }
94
+
95
+ // --- goal ---
96
+ if (manifest.goal && manifest.goal.path) {
97
+ if (isInsideDraftArea(manifest.goal.path)) {
98
+ errors.push(`manifest.goal.path is inside _ai/ (${manifest.goal.path}) — drafts must not be trusted knowledge`);
99
+ } else if (!existsAny(kbPath, manifest.goal.path)) {
100
+ errors.push(`manifest.goal.path (${manifest.goal.path}) does not exist on disk`);
101
+ } else {
102
+ info.goalStatus = manifest.goal.status || 'accepted';
103
+ }
104
+ } else {
105
+ info.goalStatus = 'not-created';
106
+ }
107
+
108
+ // --- analysis ---
109
+ if (manifest.analysis && manifest.analysis.path) {
110
+ if (isInsideDraftArea(manifest.analysis.path)) {
111
+ errors.push(`manifest.analysis.path is inside _ai/ (${manifest.analysis.path})`);
112
+ } else if (!existsAny(kbPath, manifest.analysis.path)) {
113
+ errors.push(`manifest.analysis.path (${manifest.analysis.path}) does not exist on disk`);
114
+ } else {
115
+ info.analysisStatus = 'present';
116
+ }
117
+ } else {
118
+ info.analysisStatus = 'not-created';
119
+ }
120
+
121
+ // --- trustedKnowledge ---
122
+ if (!Array.isArray(manifest.trustedKnowledge)) {
123
+ errors.push('manifest.trustedKnowledge must be an array');
124
+ } else {
125
+ info.trustedKnowledgeEntries = manifest.trustedKnowledge.length;
126
+ if (!manifest.trustedKnowledge.includes('README.md')) {
127
+ warnings.push('manifest.trustedKnowledge should include README.md');
128
+ }
129
+ for (const rel of manifest.trustedKnowledge) {
130
+ if (!rel || typeof rel !== 'string') { errors.push(`trustedKnowledge entry is not a non-empty string: ${JSON.stringify(rel)}`); continue; }
131
+ if (isInsideDraftArea(rel)) {
132
+ errors.push(`trustedKnowledge entry is inside _ai/: ${rel}`);
133
+ continue;
134
+ }
135
+ // Trust-entries that end with `/` are directories. Others are files.
136
+ const expectsDir = rel.endsWith('/');
137
+ if (!existsAny(kbPath, rel)) {
138
+ warnings.push(`trustedKnowledge entry does not exist on disk: ${rel}`);
139
+ continue;
140
+ }
141
+ const abs = path.resolve(kbPath, rel);
142
+ const stat = fs.statSync(abs);
143
+ if (expectsDir && !stat.isDirectory()) warnings.push(`trustedKnowledge entry is a file but listed as a dir: ${rel}`);
144
+ if (!expectsDir && stat.isDirectory()) warnings.push(`trustedKnowledge entry is a dir but listed as a file: ${rel}`);
145
+ }
146
+ }
147
+
148
+ // --- draftAreas ---
149
+ if (!Array.isArray(manifest.draftAreas)) {
150
+ errors.push('manifest.draftAreas must be an array');
151
+ } else {
152
+ info.draftAreasEntries = manifest.draftAreas.length;
153
+ for (const required of REQUIRED_DRAFT_AREAS) {
154
+ if (!manifest.draftAreas.includes(required)) {
155
+ warnings.push(`manifest.draftAreas should include ${required}`);
156
+ }
157
+ }
158
+ for (const rel of manifest.draftAreas) {
159
+ if (isInsideDraftArea(rel)) continue;
160
+ warnings.push(`draftArea entry is not inside _ai/: ${rel}`);
161
+ }
162
+ }
163
+
164
+ // --- _ai/ subdirs on disk ---
165
+ for (const sub of REQUIRED_DRAFT_AREAS) {
166
+ const abs = path.join(kbPath, sub);
167
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
168
+ info.aiSubdirsPresent.push(sub);
169
+ }
170
+ }
171
+
172
+ return {
173
+ ok: errors.length === 0,
174
+ status: errors.length ? 422 : 200,
175
+ info,
176
+ errors,
177
+ warnings,
178
+ manifest: errors.length ? null : manifest,
179
+ };
180
+ }
181
+
182
+ function buildPrContextPack(kbPath) {
183
+ // Build a small, JSON-serializable context pack a PR-review tool can
184
+ // consume directly: the project goal, the analysis, and the indexes. The
185
+ // pack never reads from _ai/; only from manifest.trustedKnowledge.
186
+ const v = validateKb(kbPath);
187
+ if (!v.ok) return { ok: false, status: v.status || 422, error: 'kb invalid', validation: v };
188
+ const manifest = v.manifest;
189
+ const read = rel => {
190
+ if (!rel || !existsAny(kbPath, rel)) return null;
191
+ const abs = path.resolve(kbPath, rel);
192
+ return { path: rel, content: fs.readFileSync(abs, 'utf-8'), size: fs.statSync(abs).size };
193
+ };
194
+ const pack = {
195
+ schema: 'pr-context-pack/v1',
196
+ generatedAt: new Date().toISOString(),
197
+ project: manifest.project,
198
+ goal: read(manifest.goal && manifest.goal.path),
199
+ analysis: read(manifest.analysis && manifest.analysis.path),
200
+ indexes: {},
201
+ trustedKnowledge: [],
202
+ };
203
+ if (manifest.indexes) {
204
+ for (const [k, rel] of Object.entries(manifest.indexes)) {
205
+ const got = read(rel);
206
+ if (got) pack.indexes[k] = got;
207
+ }
208
+ }
209
+ if (Array.isArray(manifest.trustedKnowledge)) {
210
+ for (const rel of manifest.trustedKnowledge) {
211
+ if (!rel || rel.endsWith('/')) continue; // directories
212
+ const got = read(rel);
213
+ if (got) pack.trustedKnowledge.push(got);
214
+ }
215
+ }
216
+ return { ok: true, pack };
217
+ }
218
+
219
+ module.exports = {
220
+ MANIFEST_SCHEMA,
221
+ REQUIRED_DRAFT_AREAS,
222
+ validateKb,
223
+ buildPrContextPack,
224
+ };
@@ -0,0 +1,126 @@
1
+ // _site/lib/llm-client.js
2
+ //
3
+ // Tiny Anthropic Messages API client. Zero npm deps; uses node:https.
4
+ // Reads connection settings from env (so the same adapter works against
5
+ // the real Anthropic API, the system-level proxy at api.minimaxi.com,
6
+ // or any other Anthropic-compatible endpoint):
7
+ //
8
+ // ANTHROPIC_BASE_URL default "https://api.anthropic.com"
9
+ // ANTHROPIC_AUTH_TOKEN required
10
+ // ANTHROPIC_MODEL default "claude-haiku-4-5"
11
+ // ANTHROPIC_VERSION default "2023-06-01"
12
+ //
13
+ // The client returns { text, usage, raw }. Callers are responsible for
14
+ // parsing the model output (usually as JSON) and for any retries.
15
+
16
+ const https = require('https');
17
+ const { URL } = require('url');
18
+
19
+ const DEFAULT_BASE = 'https://api.anthropic.com';
20
+ const DEFAULT_VERSION = '2023-06-01';
21
+ const DEFAULT_MODEL = 'claude-haiku-4-5';
22
+
23
+ function readConfig() {
24
+ return {
25
+ baseUrl: process.env.ANTHROPIC_BASE_URL || DEFAULT_BASE,
26
+ apiKey: process.env.ANTHROPIC_AUTH_TOKEN || '',
27
+ model: process.env.ANTHROPIC_MODEL || DEFAULT_MODEL,
28
+ version: process.env.ANTHROPIC_VERSION || DEFAULT_VERSION,
29
+ };
30
+ }
31
+
32
+ function postJson({ baseUrl, path: p, body, apiKey, version, timeoutMs = 60_000 }) {
33
+ return new Promise((resolve, reject) => {
34
+ let url;
35
+ try {
36
+ // Allow baseUrl to contain a path prefix (e.g. "https://api.x.com/anthropic").
37
+ // We resolve `p` relative to that base, but the result must be treated
38
+ // as the final URL — no further prefix insertion.
39
+ const base = new URL(baseUrl);
40
+ // base.pathname is already a valid path; we replace naively if p starts with /
41
+ let fullPath;
42
+ if (p.startsWith('/')) {
43
+ // Strip a trailing slash from base.pathname, then concatenate.
44
+ const basePath = base.pathname.endsWith('/') ? base.pathname.slice(0, -1) : base.pathname;
45
+ fullPath = basePath + p;
46
+ } else {
47
+ fullPath = p;
48
+ }
49
+ const data = JSON.stringify(body);
50
+ const req = https.request({
51
+ host: base.host,
52
+ port: base.port || 443,
53
+ path: fullPath,
54
+ method: 'POST',
55
+ headers: {
56
+ 'Content-Type': 'application/json',
57
+ 'x-api-key': apiKey,
58
+ 'anthropic-version': version,
59
+ 'Content-Length': Buffer.byteLength(data),
60
+ },
61
+ timeout: timeoutMs,
62
+ }, res => {
63
+ const chunks = [];
64
+ res.on('data', c => chunks.push(c));
65
+ res.on('end', () => {
66
+ const text = Buffer.concat(chunks).toString('utf-8');
67
+ if (res.statusCode < 200 || res.statusCode >= 300) {
68
+ return reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 500)}`));
69
+ }
70
+ try { resolve(JSON.parse(text)); }
71
+ catch (e) { reject(new Error(`bad JSON: ${e.message}; body head: ${text.slice(0, 200)}`)); }
72
+ });
73
+ });
74
+ req.on('error', reject);
75
+ req.on('timeout', () => req.destroy(new Error(`timeout after ${timeoutMs}ms`)));
76
+ req.write(data);
77
+ req.end();
78
+ } catch (e) { return reject(new Error(`bad baseUrl: ${baseUrl}: ${e.message}`)); }
79
+ });
80
+ }
81
+
82
+ async function completeJson({ system, user, schema, maxTokens = 2048, temperature = 0.2, model }) {
83
+ const cfg = readConfig();
84
+ if (!cfg.apiKey) throw new Error('ANTHROPIC_AUTH_TOKEN not set');
85
+ const useModel = model || cfg.model;
86
+ // Wrap the user content in a directive that asks for strict JSON. The
87
+ // adapter is also told the schema; it must return JSON that the KB
88
+ // orchestrator's validateOutput can check.
89
+ const schemaHint = schema ? `\n\nThe output MUST be a JSON object matching this schema:\n${schema}\n` : '';
90
+ const finalUser = `${user}${schemaHint}\n\nRespond with ONLY the JSON object, no prose, no markdown fences.`;
91
+ const messages = [{ role: 'user', content: finalUser }];
92
+ const body = {
93
+ model: useModel,
94
+ max_tokens: maxTokens,
95
+ temperature,
96
+ messages,
97
+ };
98
+ if (system) body.system = system;
99
+ const raw = await postJson({
100
+ baseUrl: cfg.baseUrl,
101
+ path: '/v1/messages',
102
+ body,
103
+ apiKey: cfg.apiKey,
104
+ version: cfg.version,
105
+ });
106
+ // The Messages API returns content as an array of blocks; we expect one text block.
107
+ const text = (raw.content || [])
108
+ .filter(b => b && b.type === 'text' && typeof b.text === 'string')
109
+ .map(b => b.text)
110
+ .join('\n')
111
+ .trim();
112
+ let parsed = null;
113
+ let parseError = null;
114
+ // First try direct parse; if that fails, strip a leading ```json fence.
115
+ const candidates = [
116
+ text,
117
+ text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, ''),
118
+ ];
119
+ for (const c of candidates) {
120
+ if (!c) continue;
121
+ try { parsed = JSON.parse(c); parseError = null; break; } catch (e) { parseError = e; }
122
+ }
123
+ return { text, parsed, parseError, raw };
124
+ }
125
+
126
+ module.exports = { completeJson, readConfig };
@@ -0,0 +1,94 @@
1
+ // Shared scanner used by the server and the analysis orchestrator.
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { execGit } = require('./git-runner');
5
+
6
+ async function scanProject(project, options = {}) {
7
+ const { maxCommits = 200 } = options;
8
+ const result = {
9
+ slug: project && project.slug,
10
+ repoStatus: 'unknown',
11
+ headCommit: null,
12
+ lastSeenCommit: project ? project.lastSeenCommit : null,
13
+ lastAnalyzedCommit: project ? project.lastAnalyzedCommit : null,
14
+ pendingCount: 0,
15
+ mode: null,
16
+ range: null,
17
+ commits: [],
18
+ error: null,
19
+ };
20
+ if (!project) { result.error = 'no project'; return result; }
21
+
22
+ const targetPath = project.gitPath || project.localPath;
23
+ if (!targetPath) {
24
+ result.repoStatus = 'missing-path';
25
+ result.error = 'no git path configured';
26
+ return result;
27
+ }
28
+ if (!fs.existsSync(targetPath)) {
29
+ result.repoStatus = 'missing-path';
30
+ result.error = `path not found: ${targetPath}`;
31
+ return result;
32
+ }
33
+ const inside = await execGit(targetPath, ['rev-parse', '--is-inside-work-tree']);
34
+ if (!inside.ok || (inside.stdout || '').trim() !== 'true') {
35
+ result.repoStatus = 'not-git';
36
+ result.error = 'not a git repository';
37
+ return result;
38
+ }
39
+ const head = await execGit(targetPath, ['rev-parse', 'HEAD']);
40
+ if (!head.ok) {
41
+ result.repoStatus = 'empty';
42
+ result.error = 'repository has no commits';
43
+ return result;
44
+ }
45
+ result.headCommit = (head.stdout || '').trim() || null;
46
+ result.repoStatus = 'ok';
47
+
48
+ if (!project.lastAnalyzedCommit) {
49
+ result.mode = 'initial';
50
+ const logArgs = ['log', '--no-merges', `--max-count=${maxCommits}`, '--pretty=format:%H|%h|%ad|%an|%s', '--date=short'];
51
+ const log = await execGit(targetPath, logArgs);
52
+ if (log.ok) {
53
+ const lines = (log.stdout || '').split('\n').filter(l => l.includes('|'));
54
+ for (const line of lines) {
55
+ const [hash, short, date, author, ...rest] = line.split('|');
56
+ result.commits.push({ hash, short, date, author, subject: rest.join('|') });
57
+ }
58
+ } else {
59
+ result.error = (log.stderr || log.error || 'git log failed').toString();
60
+ }
61
+ result.range = `HEAD~${result.commits.length}..HEAD`;
62
+ } else {
63
+ result.mode = 'incremental';
64
+ const range = `${project.lastAnalyzedCommit}..${result.headCommit}`;
65
+ result.range = range;
66
+ const logArgs = ['log', '--no-merges', range, '--pretty=format:%H|%h|%ad|%an|%s', '--date=short'];
67
+ const log = await execGit(targetPath, logArgs);
68
+ if (log.ok) {
69
+ const lines = (log.stdout || '').split('\n').filter(l => l.includes('|'));
70
+ for (const line of lines) {
71
+ const [hash, short, date, author, ...rest] = line.split('|');
72
+ result.commits.push({ hash, short, date, author, subject: rest.join('|') });
73
+ }
74
+ } else {
75
+ result.error = (log.stderr || log.error || 'git log failed').toString();
76
+ }
77
+ }
78
+
79
+ result.pendingCount = result.commits.length;
80
+ return result;
81
+ }
82
+
83
+ async function applyScanResult(project, scan) {
84
+ project.headCommit = scan.headCommit;
85
+ project.repoStatus = scan.repoStatus;
86
+ project.lastSeenCommit = scan.headCommit || project.lastSeenCommit;
87
+ project.lastScanAt = new Date().toISOString();
88
+ project.lastScanPendingCount = scan.pendingCount;
89
+ project.lastScanMode = scan.mode;
90
+ project.lastScanError = scan.error || null;
91
+ return project;
92
+ }
93
+
94
+ module.exports = { scanProject, applyScanResult };