forgedock 1.1.7 → 1.1.8
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/bin/engine/invariants.mjs +361 -0
- package/bin/hooks/pre-tool-use.mjs +182 -0
- package/bin/labels.json +10 -0
- package/bin/recall.mjs +540 -0
- package/commands/autopilot.md +957 -76
- package/commands/cleanup.md +52 -3
- package/commands/optimize.md +585 -0
- package/commands/orchestrate/phase-1-resolve.md +35 -25
- package/commands/orchestrate/phase-3-dependency.md +465 -34
- package/commands/orchestrate/phase-4-execution.md +447 -28
- package/commands/orchestrate/phase-6-report.md +198 -9
- package/commands/pipeline-health/phase-2-metrics.md +305 -1
- package/commands/pipeline-health/phase-4-proposals.md +127 -0
- package/commands/quality-gate.md +417 -0
- package/commands/review-pr.md +391 -13
- package/commands/spec-doctor.md +429 -0
- package/commands/validate.md +52 -0
- package/commands/work-on/build/architect.md +77 -0
- package/commands/work-on/build/context.md +357 -22
- package/commands/work-on/build/implement.md +2 -0
- package/commands/work-on/build/validate.md +28 -0
- package/commands/work-on/build.md +44 -0
- package/commands/work-on/close.md +621 -8
- package/commands/work-on/investigate.md +221 -0
- package/commands/work-on.md +15 -14
- package/docs/FORGE-PROTOCOL.md +101 -0
- package/docs/spec/forge-protocol-v1.md +97 -5
- package/docs/spec/label-state-machine.md +6 -1
- package/package.json +2 -2
- package/packages/protocol/src/cli.js +374 -84
- package/packages/protocol/src/types.js +75 -1
- package/scripts/build-knowledge-index.mjs +1761 -0
- package/scripts/calibration.mjs +996 -0
- package/scripts/danger-zones.mjs +782 -0
- package/scripts/doctor-pipeline-state.sh +88 -2
- package/scripts/select-fix-targets.sh +234 -0
- package/scripts/transition-label.sh +157 -6
|
@@ -0,0 +1,1761 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* build-knowledge-index.mjs — Forge Ledger incremental knowledge indexer.
|
|
7
|
+
*
|
|
8
|
+
* Fetches closed GitHub issues since a watermark timestamp, parses FORGE
|
|
9
|
+
* annotations via packages/protocol/src/parse.js, extracts knowledge cards
|
|
10
|
+
* (pattern, investigation, decision), and writes:
|
|
11
|
+
* - ~/.forge/index/knowledge.jsonl — card records (one JSON per line)
|
|
12
|
+
* - ~/.forge/index/postings.json — BM25-lite inverted index (term → card IDs)
|
|
13
|
+
* - ~/.forge/index/manifest.json — issue → content-hash map + watermark
|
|
14
|
+
* - ~/.forge/index/renames.jsonl — git rename log entries
|
|
15
|
+
*
|
|
16
|
+
* Also mirrors the index to orphan branch `forge-knowledge` (non-blocking).
|
|
17
|
+
*
|
|
18
|
+
* Card ID scheme: `{issue}/{kind}/{seq}` — provenance-based, never path-based.
|
|
19
|
+
* Paths are attributes maintained by the indexer. Renames are metadata updates.
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* node scripts/build-knowledge-index.mjs [options]
|
|
23
|
+
*
|
|
24
|
+
* Options:
|
|
25
|
+
* --repo <owner/repo> GitHub repository (default: reads forge.yaml)
|
|
26
|
+
* --incremental Only fetch issues updated since watermark (default)
|
|
27
|
+
* --full-rebuild Ignore watermark; re-index all closed issues
|
|
28
|
+
* --issue <number> Index only a specific issue (for close-phase integration)
|
|
29
|
+
* --output <dir> Override index directory (default: ~/.forge/index)
|
|
30
|
+
* --no-mirror Skip orphan branch mirror
|
|
31
|
+
* --dry-run Parse and output without writing
|
|
32
|
+
* --verbose Verbose logging
|
|
33
|
+
* --with-danger-zones After indexing, run danger-zones.mjs to refresh risk data (non-blocking)
|
|
34
|
+
* --pull-feeds Pull subscribed pattern cards from exchange repos into the local ledger.
|
|
35
|
+
* Reads forge.yaml → pattern_feeds. Pinned-ref only — no floating HEAD.
|
|
36
|
+
* All imported cards are tagged origin:<feed-slug> and priority:LOW.
|
|
37
|
+
* --feed <slug> (with --pull-feeds) Only pull the feed with this slug.
|
|
38
|
+
*
|
|
39
|
+
* Exit codes: 0 = success, 1 = fatal error, 2 = partial (some issues failed)
|
|
40
|
+
*
|
|
41
|
+
* Requires: Node.js >=18, gh CLI authenticated
|
|
42
|
+
*
|
|
43
|
+
* @module build-knowledge-index
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { createHash } from 'crypto';
|
|
47
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, readdirSync } from 'fs';
|
|
48
|
+
import { execSync, spawnSync } from 'child_process';
|
|
49
|
+
import { homedir } from 'os';
|
|
50
|
+
import { dirname, join, resolve } from 'path';
|
|
51
|
+
import { fileURLToPath } from 'url';
|
|
52
|
+
|
|
53
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
54
|
+
const __dirname = dirname(__filename);
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Constants
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
const DEFAULT_INDEX_DIR = join(homedir(), '.forge', 'index');
|
|
61
|
+
const CARDS_FILE = 'knowledge.jsonl';
|
|
62
|
+
const POSTINGS_FILE = 'postings.json';
|
|
63
|
+
const MANIFEST_FILE = 'manifest.json';
|
|
64
|
+
const RENAMES_FILE = 'renames.jsonl';
|
|
65
|
+
const COST_PRIORS_FILE = 'cost-priors.json'; // economic scheduling — keyed by task_type:module
|
|
66
|
+
const MIRROR_BRANCH = 'forge-knowledge';
|
|
67
|
+
|
|
68
|
+
// Annotation types that produce knowledge cards
|
|
69
|
+
const CARD_PRODUCING_TYPES = new Set([
|
|
70
|
+
'INVESTIGATOR',
|
|
71
|
+
'TRAJECTORY',
|
|
72
|
+
'REVIEWER',
|
|
73
|
+
'CONTEXT',
|
|
74
|
+
'ARCHITECT',
|
|
75
|
+
'BUILDER',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// BM25 parameters (per FORGE:DESIGN_DECISION spec)
|
|
79
|
+
const BM25_K1 = 1.2;
|
|
80
|
+
const BM25_B = 0.75;
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Argument parsing
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function parseArgs(argv) {
|
|
87
|
+
const args = {
|
|
88
|
+
repo: null,
|
|
89
|
+
incremental: true,
|
|
90
|
+
fullRebuild: false,
|
|
91
|
+
issue: null,
|
|
92
|
+
outputDir: DEFAULT_INDEX_DIR,
|
|
93
|
+
noMirror: false,
|
|
94
|
+
dryRun: false,
|
|
95
|
+
verbose: false,
|
|
96
|
+
withDangerZones: false,
|
|
97
|
+
// Pattern exchange subscription pull (forge#1746)
|
|
98
|
+
pullFeeds: false,
|
|
99
|
+
feedFilter: null,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (let i = 2; i < argv.length; i++) {
|
|
103
|
+
switch (argv[i]) {
|
|
104
|
+
case '--repo':
|
|
105
|
+
args.repo = argv[++i];
|
|
106
|
+
break;
|
|
107
|
+
case '--incremental':
|
|
108
|
+
args.incremental = true;
|
|
109
|
+
args.fullRebuild = false;
|
|
110
|
+
break;
|
|
111
|
+
case '--full-rebuild':
|
|
112
|
+
args.fullRebuild = true;
|
|
113
|
+
args.incremental = false;
|
|
114
|
+
break;
|
|
115
|
+
case '--issue':
|
|
116
|
+
args.issue = parseInt(argv[++i], 10);
|
|
117
|
+
if (isNaN(args.issue)) throw new Error('--issue requires a numeric argument');
|
|
118
|
+
break;
|
|
119
|
+
case '--output':
|
|
120
|
+
args.outputDir = argv[++i];
|
|
121
|
+
break;
|
|
122
|
+
case '--no-mirror':
|
|
123
|
+
args.noMirror = true;
|
|
124
|
+
break;
|
|
125
|
+
case '--dry-run':
|
|
126
|
+
args.dryRun = true;
|
|
127
|
+
break;
|
|
128
|
+
case '--verbose':
|
|
129
|
+
args.verbose = true;
|
|
130
|
+
break;
|
|
131
|
+
case '--with-danger-zones':
|
|
132
|
+
// After indexing completes, run danger-zones.mjs to refresh risk data.
|
|
133
|
+
// Non-blocking: a failure here does not affect the knowledge index.
|
|
134
|
+
args.withDangerZones = true;
|
|
135
|
+
break;
|
|
136
|
+
// Pattern exchange subscription pull (forge#1746)
|
|
137
|
+
case '--pull-feeds':
|
|
138
|
+
args.pullFeeds = true;
|
|
139
|
+
break;
|
|
140
|
+
case '--feed':
|
|
141
|
+
args.feedFilter = argv[++i];
|
|
142
|
+
break;
|
|
143
|
+
default:
|
|
144
|
+
// Ignore unknown flags for forward compatibility
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return args;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Logging
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
function log(msg, verbose = false) {
|
|
157
|
+
if (!verbose || globalThis._VERBOSE) {
|
|
158
|
+
process.stderr.write(`[ledger] ${msg}\n`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function debug(msg) {
|
|
163
|
+
if (globalThis._VERBOSE) {
|
|
164
|
+
process.stderr.write(`[ledger:debug] ${msg}\n`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Forge.yaml reader
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
function readForgeYaml(repoPath) {
|
|
173
|
+
const yamlPath = join(repoPath, 'forge.yaml');
|
|
174
|
+
if (!existsSync(yamlPath)) return null;
|
|
175
|
+
try {
|
|
176
|
+
const result = spawnSync('yq', ['-r', '.project.owner + "/" + .project.repo', yamlPath], {
|
|
177
|
+
encoding: 'utf8',
|
|
178
|
+
timeout: 5000,
|
|
179
|
+
});
|
|
180
|
+
if (result.status === 0 && result.stdout.trim()) {
|
|
181
|
+
return result.stdout.trim();
|
|
182
|
+
}
|
|
183
|
+
} catch (_) {
|
|
184
|
+
// ignore
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Index directory management
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
function ensureIndexDir(indexDir) {
|
|
194
|
+
mkdirSync(indexDir, { recursive: true });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readManifest(indexDir) {
|
|
198
|
+
const manifestPath = join(indexDir, MANIFEST_FILE);
|
|
199
|
+
if (!existsSync(manifestPath)) {
|
|
200
|
+
return { watermark: null, issues: {}, schemaVersion: 1 };
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
204
|
+
} catch (_) {
|
|
205
|
+
return { watermark: null, issues: {}, schemaVersion: 1 };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function writeManifest(indexDir, manifest) {
|
|
210
|
+
writeFileSync(join(indexDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2), 'utf8');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// GitHub API helpers
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
function ghApi(path, flags = []) {
|
|
218
|
+
const result = spawnSync('gh', ['api', path, ...flags], {
|
|
219
|
+
encoding: 'utf8',
|
|
220
|
+
timeout: 30000,
|
|
221
|
+
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
|
222
|
+
});
|
|
223
|
+
if (result.status !== 0) {
|
|
224
|
+
throw new Error(`gh api ${path} failed: ${result.stderr || result.stdout}`);
|
|
225
|
+
}
|
|
226
|
+
return result.stdout;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Fetch all closed issues updated since the watermark.
|
|
231
|
+
* Paginates through all pages.
|
|
232
|
+
*/
|
|
233
|
+
function fetchIssuesSince(repo, since) {
|
|
234
|
+
const issues = [];
|
|
235
|
+
let page = 1;
|
|
236
|
+
const perPage = 100;
|
|
237
|
+
|
|
238
|
+
while (true) {
|
|
239
|
+
let url = `repos/${repo}/issues?state=closed&per_page=${perPage}&page=${page}&sort=updated&direction=asc`;
|
|
240
|
+
if (since) {
|
|
241
|
+
url += `&since=${encodeURIComponent(since)}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let raw;
|
|
245
|
+
try {
|
|
246
|
+
raw = ghApi(url);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
log(`WARNING: GitHub API error on page ${page}: ${e.message}`);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let batch;
|
|
253
|
+
try {
|
|
254
|
+
batch = JSON.parse(raw);
|
|
255
|
+
} catch (_) {
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!Array.isArray(batch) || batch.length === 0) break;
|
|
260
|
+
|
|
261
|
+
for (const item of batch) {
|
|
262
|
+
// Filter: only issues (not PRs) — PRs have pull_request field
|
|
263
|
+
if (!item.pull_request) {
|
|
264
|
+
issues.push(item);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (batch.length < perPage) break;
|
|
269
|
+
page++;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return issues;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Fetch a single issue with its comments.
|
|
277
|
+
*/
|
|
278
|
+
function fetchIssue(repo, number) {
|
|
279
|
+
let issue;
|
|
280
|
+
try {
|
|
281
|
+
issue = JSON.parse(ghApi(`repos/${repo}/issues/${number}`));
|
|
282
|
+
} catch (e) {
|
|
283
|
+
throw new Error(`Failed to fetch issue #${number}: ${e.message}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let comments = [];
|
|
287
|
+
try {
|
|
288
|
+
const commentsRaw = ghApi(`repos/${repo}/issues/${number}/comments?per_page=100`);
|
|
289
|
+
comments = JSON.parse(commentsRaw);
|
|
290
|
+
} catch (_) {
|
|
291
|
+
// Comments optional
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { issue, comments };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Protocol parser (dynamic import to avoid ESM issues with older Node)
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
let _parseAnnotation = null;
|
|
302
|
+
|
|
303
|
+
async function getParser() {
|
|
304
|
+
if (_parseAnnotation) return _parseAnnotation;
|
|
305
|
+
|
|
306
|
+
// Resolve the packages/protocol/src/parse.js relative to repo root
|
|
307
|
+
// This script lives at scripts/build-knowledge-index.mjs
|
|
308
|
+
const repoRoot = dirname(__dirname);
|
|
309
|
+
const parsePath = join(repoRoot, 'packages', 'protocol', 'src', 'parse.js');
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const mod = await import(parsePath);
|
|
313
|
+
_parseAnnotation = mod.parse;
|
|
314
|
+
return _parseAnnotation;
|
|
315
|
+
} catch (e) {
|
|
316
|
+
throw new Error(`Cannot load packages/protocol/src/parse.js: ${e.message}\nEnsure the packages/protocol package is present in the repository.`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Card extraction
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Extract knowledge cards from a parsed FORGE:INVESTIGATOR annotation.
|
|
326
|
+
*
|
|
327
|
+
* @param {object} annotation - ParsedAnnotation
|
|
328
|
+
* @param {number} issueNumber
|
|
329
|
+
* @param {object} issueData - raw GitHub issue object
|
|
330
|
+
* @returns {Array<object>} cards
|
|
331
|
+
*/
|
|
332
|
+
function extractInvestigatorCards(annotation, issueNumber, issueData) {
|
|
333
|
+
const cards = [];
|
|
334
|
+
const fields = annotation.fields || {};
|
|
335
|
+
|
|
336
|
+
const verdict = fields['Verdict'] || null;
|
|
337
|
+
const confidence = fields['Confidence'] || null;
|
|
338
|
+
const severity = fields['Severity'] || null;
|
|
339
|
+
const taskType = fields['Task Type'] || null;
|
|
340
|
+
|
|
341
|
+
// Extract Pattern Metadata blocks from body
|
|
342
|
+
// Pattern blocks appear as: **Pattern**: text, **Root Cause**: text, etc.
|
|
343
|
+
const body = annotation.body || '';
|
|
344
|
+
|
|
345
|
+
// Extract root cause section
|
|
346
|
+
const rootCauseMatch = body.match(/### Root Cause\s*\n([\s\S]*?)(?=\n###|\n##|$)/);
|
|
347
|
+
const rootCause = rootCauseMatch ? rootCauseMatch[1].trim().replace(/\n+/g, ' ').slice(0, 400) : null;
|
|
348
|
+
|
|
349
|
+
// Extract affected files
|
|
350
|
+
const affectedFilesMatch = body.match(/### Affected Files\s*\n([\s\S]*?)(?=\n###|\n##|$)/);
|
|
351
|
+
const affectedFilesText = affectedFilesMatch ? affectedFilesMatch[1] : '';
|
|
352
|
+
const paths = extractFilePaths(affectedFilesText);
|
|
353
|
+
|
|
354
|
+
// Extract prevention/recommendation
|
|
355
|
+
const recommendationMatch = body.match(/### Recommendation\s*\n([\s\S]*?)(?=\n###|\n##|$)/);
|
|
356
|
+
const prevention = recommendationMatch ? recommendationMatch[1].trim().replace(/\n+/g, ' ').slice(0, 400) : null;
|
|
357
|
+
|
|
358
|
+
// Extract symbols from History Findings and pickaxe hits
|
|
359
|
+
const symbols = extractSymbols(body);
|
|
360
|
+
|
|
361
|
+
if (verdict === 'CONFIRMED' || verdict === 'PARTIAL') {
|
|
362
|
+
const card = {
|
|
363
|
+
schemaVersion: 1,
|
|
364
|
+
id: `${issueNumber}/investigation/0`,
|
|
365
|
+
kind: 'investigation',
|
|
366
|
+
issue: issueNumber,
|
|
367
|
+
pr: null,
|
|
368
|
+
sourceCommentId: null,
|
|
369
|
+
verdict,
|
|
370
|
+
confidence,
|
|
371
|
+
severity,
|
|
372
|
+
taskType,
|
|
373
|
+
rootCause,
|
|
374
|
+
prevention,
|
|
375
|
+
paths,
|
|
376
|
+
symbols,
|
|
377
|
+
anchor: buildAnchor(paths, symbols, issueNumber),
|
|
378
|
+
status: 'fresh',
|
|
379
|
+
outcome: {
|
|
380
|
+
fixMerged: issueData.state === 'closed',
|
|
381
|
+
},
|
|
382
|
+
createdAt: (issueData.updated_at || issueData.created_at || '').slice(0, 10),
|
|
383
|
+
};
|
|
384
|
+
cards.push(card);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Extract Pattern Metadata blocks (structured pattern cards)
|
|
388
|
+
const patternBlocks = extractPatternMetadataBlocks(body);
|
|
389
|
+
for (let i = 0; i < patternBlocks.length; i++) {
|
|
390
|
+
const pb = patternBlocks[i];
|
|
391
|
+
const card = {
|
|
392
|
+
schemaVersion: 1,
|
|
393
|
+
id: `${issueNumber}/pattern/${i}`,
|
|
394
|
+
kind: 'pattern',
|
|
395
|
+
issue: issueNumber,
|
|
396
|
+
pr: null,
|
|
397
|
+
sourceCommentId: null,
|
|
398
|
+
pattern: pb.pattern,
|
|
399
|
+
rootCause: pb.rootCause || rootCause,
|
|
400
|
+
prevention: pb.prevention || prevention,
|
|
401
|
+
severity: pb.severity || severity,
|
|
402
|
+
verdict,
|
|
403
|
+
confidence,
|
|
404
|
+
outcome: { fixMerged: issueData.state === 'closed' },
|
|
405
|
+
paths: pb.paths.length > 0 ? pb.paths : paths,
|
|
406
|
+
symbols: pb.symbols.length > 0 ? pb.symbols : symbols,
|
|
407
|
+
anchor: buildAnchor(pb.paths.length > 0 ? pb.paths : paths, pb.symbols.length > 0 ? pb.symbols : symbols, issueNumber),
|
|
408
|
+
status: 'fresh',
|
|
409
|
+
createdAt: (issueData.updated_at || issueData.created_at || '').slice(0, 10),
|
|
410
|
+
};
|
|
411
|
+
cards.push(card);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return cards;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Extract knowledge cards from a FORGE:TRAJECTORY annotation.
|
|
419
|
+
*/
|
|
420
|
+
function extractTrajectoryCards(annotation, issueNumber, issueData) {
|
|
421
|
+
const body = annotation.body || '';
|
|
422
|
+
|
|
423
|
+
// Extract decisions from Decisions field
|
|
424
|
+
const decisionsMatch = body.match(/\*\*Decisions\*\*:\s*(.+)/);
|
|
425
|
+
const decisions = decisionsMatch ? decisionsMatch[1].trim() : null;
|
|
426
|
+
|
|
427
|
+
if (!decisions || decisions === 'None') return [];
|
|
428
|
+
|
|
429
|
+
const card = {
|
|
430
|
+
schemaVersion: 1,
|
|
431
|
+
id: `${issueNumber}/decision/0`,
|
|
432
|
+
kind: 'decision',
|
|
433
|
+
issue: issueNumber,
|
|
434
|
+
pr: null,
|
|
435
|
+
sourceCommentId: null,
|
|
436
|
+
decision: decisions.slice(0, 400),
|
|
437
|
+
anomalies: (body.match(/\*\*Anomalies\*\*:\s*(.+)/) || [])[1]?.trim() || null,
|
|
438
|
+
paths: extractFilePaths(body),
|
|
439
|
+
symbols: extractSymbols(body),
|
|
440
|
+
anchor: buildAnchor(extractFilePaths(body), extractSymbols(body), issueNumber),
|
|
441
|
+
status: 'fresh',
|
|
442
|
+
createdAt: (issueData.updated_at || issueData.created_at || '').slice(0, 10),
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
return [card];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Extract file paths from markdown text.
|
|
450
|
+
* Captures backtick-quoted paths and paths in numbered lists.
|
|
451
|
+
*/
|
|
452
|
+
function extractFilePaths(text) {
|
|
453
|
+
const paths = new Set();
|
|
454
|
+
|
|
455
|
+
// Backtick-quoted paths: `path/to/file.ext`
|
|
456
|
+
const backtickRe = /`([a-zA-Z][^`]*\.[a-zA-Z]{1,6})`/g;
|
|
457
|
+
let m;
|
|
458
|
+
while ((m = backtickRe.exec(text)) !== null) {
|
|
459
|
+
const p = m[1];
|
|
460
|
+
// Filter: must look like a file path (contains / or has extension)
|
|
461
|
+
if (p.includes('/') || /\.[a-zA-Z]{1,6}$/.test(p)) {
|
|
462
|
+
paths.add(p.replace(/^\//, '')); // strip leading slash
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Numbered list entries: "1. `path/file.py` — ..."
|
|
467
|
+
const listRe = /^\d+\.\s+`([^`]+)`/gm;
|
|
468
|
+
while ((m = listRe.exec(text)) !== null) {
|
|
469
|
+
const p = m[1];
|
|
470
|
+
if (p.includes('/') || /\.[a-zA-Z]{1,6}$/.test(p)) {
|
|
471
|
+
paths.add(p.replace(/^\//, ''));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return Array.from(paths).slice(0, 20); // cap at 20 paths
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Extract symbol names from text (function names, class names, etc.)
|
|
480
|
+
*/
|
|
481
|
+
function extractSymbols(text) {
|
|
482
|
+
const symbols = new Set();
|
|
483
|
+
|
|
484
|
+
// Bold items often name functions/symbols: **scrubEnv**, **spawnAgent**
|
|
485
|
+
const boldRe = /\*\*([a-zA-Z_][a-zA-Z0-9_]*)\*\*/g;
|
|
486
|
+
let m;
|
|
487
|
+
while ((m = boldRe.exec(text)) !== null) {
|
|
488
|
+
const sym = m[1];
|
|
489
|
+
// Skip common prose words
|
|
490
|
+
if (sym.length >= 3 && !PROSE_WORDS.has(sym.toLowerCase())) {
|
|
491
|
+
symbols.add(sym);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Backtick symbols (camelCase or snake_case identifiers without path separators)
|
|
496
|
+
const codeRe = /`([a-zA-Z_][a-zA-Z0-9_]{2,}(?:\(\))?)`/g;
|
|
497
|
+
while ((m = codeRe.exec(text)) !== null) {
|
|
498
|
+
const sym = m[1].replace(/\(\)$/, '');
|
|
499
|
+
if (!sym.includes('/') && !sym.includes('.') && !PROSE_WORDS.has(sym.toLowerCase())) {
|
|
500
|
+
symbols.add(sym);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return Array.from(symbols).slice(0, 30);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const PROSE_WORDS = new Set([
|
|
508
|
+
'the', 'and', 'for', 'not', 'with', 'from', 'that', 'this', 'are', 'was',
|
|
509
|
+
'has', 'have', 'had', 'will', 'can', 'should', 'must', 'may', 'but', 'all',
|
|
510
|
+
'skip', 'note', 'see', 'add', 'run', 'use', 'fix', 'set', 'get', 'new',
|
|
511
|
+
'old', 'any', 'each', 'one', 'two', 'via', 'per', 'its', 'our', 'their',
|
|
512
|
+
'when', 'then', 'else', 'also', 'both', 'only', 'just', 'now', 'here',
|
|
513
|
+
'forge', 'issue', 'phase', 'step', 'file', 'path', 'type', 'name', 'value',
|
|
514
|
+
'none', 'true', 'false', 'null', 'main', 'base', 'head', 'body', 'data',
|
|
515
|
+
]);
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Extract Pattern Metadata blocks from annotation body.
|
|
519
|
+
* These are structured blocks with **Pattern**: / **Root Cause**: / **Prevention**: fields.
|
|
520
|
+
*/
|
|
521
|
+
function extractPatternMetadataBlocks(body) {
|
|
522
|
+
const blocks = [];
|
|
523
|
+
|
|
524
|
+
// Split on "Pattern Metadata" headers or bold Pattern: lines
|
|
525
|
+
const patternRe = /(?:Pattern Metadata|##\s*Pattern)\s*[\n:]+/g;
|
|
526
|
+
const parts = body.split(patternRe);
|
|
527
|
+
|
|
528
|
+
for (const part of parts.slice(1)) {
|
|
529
|
+
const patternMatch = part.match(/\*\*Pattern\*\*:\s*(.+)/);
|
|
530
|
+
const rootCauseMatch = part.match(/\*\*Root\s*Cause\*\*:\s*(.+)/);
|
|
531
|
+
const preventionMatch = part.match(/\*\*Prevention\*\*:\s*(.+)/);
|
|
532
|
+
const severityMatch = part.match(/\*\*Severity\*\*:\s*(.+)/);
|
|
533
|
+
const pathsText = (part.match(/\*\*(?:Files?|Paths?)\*\*:\s*([\s\S]*?)(?=\*\*|$)/) || [])[1] || '';
|
|
534
|
+
const symbolsText = (part.match(/\*\*Symbols?\*\*:\s*(.+)/) || [])[1] || '';
|
|
535
|
+
|
|
536
|
+
if (patternMatch || rootCauseMatch) {
|
|
537
|
+
blocks.push({
|
|
538
|
+
pattern: (patternMatch?.[1] || '').trim().slice(0, 200),
|
|
539
|
+
rootCause: (rootCauseMatch?.[1] || '').trim().slice(0, 400),
|
|
540
|
+
prevention: (preventionMatch?.[1] || '').trim().slice(0, 400),
|
|
541
|
+
severity: (severityMatch?.[1] || '').trim(),
|
|
542
|
+
paths: extractFilePaths(pathsText),
|
|
543
|
+
symbols: symbolsText ? symbolsText.split(/[,\s]+/).filter(s => s.length >= 2) : [],
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return blocks;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Build a lightweight anchor for a card.
|
|
553
|
+
* The anchor tracks the primary path and symbol for staleness detection.
|
|
554
|
+
*/
|
|
555
|
+
function buildAnchor(paths, symbols, issueNumber) {
|
|
556
|
+
if (paths.length === 0 && symbols.length === 0) return null;
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
path: paths[0] || null,
|
|
560
|
+
symbol: symbols[0] || null,
|
|
561
|
+
blobSha: null, // populated during staleness check phase
|
|
562
|
+
snippetHash: null, // populated during staleness check phase
|
|
563
|
+
citedAtIssue: issueNumber,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// Card extraction dispatcher
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Parse a best-effort `cost_usd: <number>` telemetry line from an annotation body.
|
|
573
|
+
* Emitted per-phase by work-on (validate.md writes `cost_usd: ${PHASE_COST_USD}`
|
|
574
|
+
* into FORGE:INVESTIGATOR / FORGE:BUILDER / FORGE:REVIEWER annotations).
|
|
575
|
+
* Returns a finite positive number, or null if absent/unparseable.
|
|
576
|
+
*/
|
|
577
|
+
function parseCostUsd(body) {
|
|
578
|
+
const m = (body || '').match(/cost_usd:\s*([0-9]+(?:\.[0-9]+)?)/);
|
|
579
|
+
if (!m) return null;
|
|
580
|
+
const v = parseFloat(m[1]);
|
|
581
|
+
return isFinite(v) && v > 0 ? v : null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function extractCardsFromIssue(issueNumber, issueData, comments) {
|
|
585
|
+
const parse = await getParser();
|
|
586
|
+
const cards = [];
|
|
587
|
+
|
|
588
|
+
// Accumulate per-phase actual spend across all annotations on this issue so it
|
|
589
|
+
// can be attached to the issue's primary card as `costUsd` — the field
|
|
590
|
+
// aggregateCostPriors() reads to build economic-scheduling priors. Without this
|
|
591
|
+
// the field is never populated and cost-priors.json is always written empty.
|
|
592
|
+
const phaseCost = { investigation: null, build: null, review: null };
|
|
593
|
+
|
|
594
|
+
const allBodies = [
|
|
595
|
+
issueData.body || '',
|
|
596
|
+
...comments.map(c => c.body || ''),
|
|
597
|
+
];
|
|
598
|
+
|
|
599
|
+
for (const body of allBodies) {
|
|
600
|
+
if (!body) continue;
|
|
601
|
+
|
|
602
|
+
let annotations;
|
|
603
|
+
try {
|
|
604
|
+
annotations = parse(body);
|
|
605
|
+
} catch (_) {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
for (const annotation of annotations) {
|
|
610
|
+
if (!CARD_PRODUCING_TYPES.has(annotation.type)) continue;
|
|
611
|
+
if (annotation.sentinelState === 'interrupted') continue; // incomplete
|
|
612
|
+
|
|
613
|
+
// Capture per-phase actual spend (best-effort) from phase annotations.
|
|
614
|
+
const annCost = parseCostUsd(annotation.body);
|
|
615
|
+
if (annCost !== null) {
|
|
616
|
+
if (annotation.type === 'INVESTIGATOR') phaseCost.investigation = annCost;
|
|
617
|
+
else if (annotation.type === 'BUILDER') phaseCost.build = annCost;
|
|
618
|
+
else if (annotation.type === 'REVIEWER') phaseCost.review = annCost;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
switch (annotation.type) {
|
|
623
|
+
case 'INVESTIGATOR': {
|
|
624
|
+
const investigatorCards = extractInvestigatorCards(annotation, issueNumber, issueData);
|
|
625
|
+
cards.push(...investigatorCards);
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
case 'TRAJECTORY': {
|
|
629
|
+
const trajectoryCards = extractTrajectoryCards(annotation, issueNumber, issueData);
|
|
630
|
+
cards.push(...trajectoryCards);
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
// REVIEWER, CONTEXT, ARCHITECT, BUILDER — extract paths/symbols for cross-reference
|
|
634
|
+
default: {
|
|
635
|
+
const paths = extractFilePaths(annotation.body || '');
|
|
636
|
+
const symbols = extractSymbols(annotation.body || '');
|
|
637
|
+
if (paths.length > 0 || symbols.length > 0) {
|
|
638
|
+
// These don't produce first-class cards but enrich path/symbol coverage
|
|
639
|
+
// They are merged into the INVESTIGATOR card paths if one exists
|
|
640
|
+
const existingInvCard = cards.find(c => c.kind === 'investigation' || c.kind === 'pattern');
|
|
641
|
+
if (existingInvCard) {
|
|
642
|
+
const mergedPaths = new Set([...existingInvCard.paths, ...paths]);
|
|
643
|
+
const mergedSymbols = new Set([...existingInvCard.symbols, ...symbols]);
|
|
644
|
+
existingInvCard.paths = Array.from(mergedPaths).slice(0, 20);
|
|
645
|
+
existingInvCard.symbols = Array.from(mergedSymbols).slice(0, 30);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
} catch (e) {
|
|
652
|
+
debug(`Error extracting cards from ${annotation.type} in issue #${issueNumber}: ${e.message}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Attach accumulated actual spend to the issue's primary card so downstream
|
|
658
|
+
// aggregateCostPriors() can bucket it by task_type:module. total is the sum of
|
|
659
|
+
// whichever phase costs were present (null when none were reported).
|
|
660
|
+
if (phaseCost.investigation !== null || phaseCost.build !== null || phaseCost.review !== null) {
|
|
661
|
+
const total = (phaseCost.investigation || 0) + (phaseCost.build || 0) + (phaseCost.review || 0);
|
|
662
|
+
const primaryCard = cards.find(c => c.kind === 'investigation' || c.kind === 'pattern') || cards[0];
|
|
663
|
+
if (primaryCard) {
|
|
664
|
+
primaryCard.costUsd = {
|
|
665
|
+
investigation: phaseCost.investigation,
|
|
666
|
+
build: phaseCost.build,
|
|
667
|
+
review: phaseCost.review,
|
|
668
|
+
total: total > 0 ? total : null,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return cards;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
// Content hashing
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
function hashCards(cards) {
|
|
681
|
+
const stable = JSON.stringify(cards.map(c => ({ ...c, status: undefined })).sort((a, b) => a.id < b.id ? -1 : 1));
|
|
682
|
+
return createHash('sha256').update(stable).digest('hex').slice(0, 16);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
// Staleness detection
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Run staleness detection on a card's anchor.
|
|
691
|
+
* Tier 1: blob sha match (cheapest)
|
|
692
|
+
* Tier 2: symbol still present in file
|
|
693
|
+
* Tier 3/4/5: handled offline — mark fresh by default for new cards
|
|
694
|
+
*
|
|
695
|
+
* Decision cards use 'needs-review' (not 'stale') when their anchor is dead —
|
|
696
|
+
* this signals human review is required before the ADR is retired, rather than
|
|
697
|
+
* silently treating the decision as invalidated. <!-- Added: forge#1737 -->
|
|
698
|
+
*/
|
|
699
|
+
function checkStaleness(card, repoPath) {
|
|
700
|
+
if (!card.anchor) return 'fresh';
|
|
701
|
+
if (!card.anchor.path) return 'fresh';
|
|
702
|
+
|
|
703
|
+
const isDecision = card.kind === 'decision';
|
|
704
|
+
const deadStatus = isDecision ? 'needs-review' : 'stale';
|
|
705
|
+
|
|
706
|
+
const filePath = join(repoPath, card.anchor.path);
|
|
707
|
+
if (!existsSync(filePath)) {
|
|
708
|
+
return deadStatus; // Anchor file no longer exists
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Tier 2: symbol check (regex-class, not AST)
|
|
712
|
+
if (card.anchor.symbol) {
|
|
713
|
+
try {
|
|
714
|
+
const content = readFileSync(filePath, 'utf8');
|
|
715
|
+
const symbolRe = new RegExp(`\\b${escapeRegExp(card.anchor.symbol)}\\b`);
|
|
716
|
+
if (!symbolRe.test(content)) {
|
|
717
|
+
return deadStatus; // Anchor symbol no longer present
|
|
718
|
+
}
|
|
719
|
+
} catch (_) {
|
|
720
|
+
// If we can't read the file, assume fresh (don't break on binary files)
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return 'fresh';
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Find the ADR file path for a decision card, if one has been written by close.md Phase C5.3.
|
|
729
|
+
* Returns the repo-relative path (e.g. 'devdocs/decisions/1737-auto-adrs-slug.md') or null.
|
|
730
|
+
* <!-- Added: forge#1737 -->
|
|
731
|
+
*/
|
|
732
|
+
function findADRPath(issueNumber, repoPath) {
|
|
733
|
+
const decisionsDir = join(repoPath, 'devdocs', 'decisions');
|
|
734
|
+
if (!existsSync(decisionsDir)) return null;
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
const files = readdirSync(decisionsDir);
|
|
738
|
+
const prefix = `${issueNumber}-`;
|
|
739
|
+
const match = files.find(f => f.startsWith(prefix) && f.endsWith('.md'));
|
|
740
|
+
return match ? `devdocs/decisions/${match}` : null;
|
|
741
|
+
} catch (_) {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Update a staleness-detected ADR file's frontmatter status to 'needs-review'.
|
|
748
|
+
* Non-blocking: logs and returns on any error.
|
|
749
|
+
* <!-- Added: forge#1737 -->
|
|
750
|
+
*/
|
|
751
|
+
function updateADRStatus(adrPath, repoPath) {
|
|
752
|
+
const absPath = join(repoPath, adrPath);
|
|
753
|
+
if (!existsSync(absPath)) return;
|
|
754
|
+
|
|
755
|
+
try {
|
|
756
|
+
const content = readFileSync(absPath, 'utf8');
|
|
757
|
+
// Only update if status is currently 'fresh' (avoid overwriting manual changes)
|
|
758
|
+
if (!content.includes('status: fresh')) return;
|
|
759
|
+
|
|
760
|
+
const updated = content.replace(/^status: fresh$/m, 'status: needs-review');
|
|
761
|
+
if (updated !== content) {
|
|
762
|
+
writeFileSync(absPath, updated, 'utf8');
|
|
763
|
+
log(`[ADR] Flipped status to needs-review: ${adrPath}`);
|
|
764
|
+
}
|
|
765
|
+
} catch (e) {
|
|
766
|
+
log(`WARNING: Failed to update ADR status for ${adrPath}: ${e.message}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function escapeRegExp(str) {
|
|
771
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ---------------------------------------------------------------------------
|
|
775
|
+
// Rename log
|
|
776
|
+
// ---------------------------------------------------------------------------
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Append git rename entries since the watermark commit to renames.jsonl.
|
|
780
|
+
*/
|
|
781
|
+
function updateRenames(indexDir, repoPath, since) {
|
|
782
|
+
const renamesPath = join(indexDir, RENAMES_FILE);
|
|
783
|
+
let sinceArg = '';
|
|
784
|
+
if (since) {
|
|
785
|
+
// Convert ISO timestamp to a git --after argument
|
|
786
|
+
sinceArg = `--after=${since}`;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
const result = spawnSync('git', [
|
|
791
|
+
'log',
|
|
792
|
+
'--diff-filter=R',
|
|
793
|
+
'--name-status',
|
|
794
|
+
'--format=%H %ai',
|
|
795
|
+
sinceArg,
|
|
796
|
+
'--',
|
|
797
|
+
].filter(Boolean), {
|
|
798
|
+
cwd: repoPath,
|
|
799
|
+
encoding: 'utf8',
|
|
800
|
+
timeout: 10000,
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
if (result.status !== 0) return;
|
|
804
|
+
|
|
805
|
+
const lines = result.stdout.split('\n');
|
|
806
|
+
const entries = [];
|
|
807
|
+
let currentCommit = null;
|
|
808
|
+
let currentDate = null;
|
|
809
|
+
|
|
810
|
+
for (const line of lines) {
|
|
811
|
+
if (!line.trim()) continue;
|
|
812
|
+
const commitMatch = line.match(/^([a-f0-9]{40})\s+(.+)$/);
|
|
813
|
+
if (commitMatch) {
|
|
814
|
+
currentCommit = commitMatch[1];
|
|
815
|
+
currentDate = commitMatch[2];
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
const renameMatch = line.match(/^R\d*\t(.+)\t(.+)$/);
|
|
819
|
+
if (renameMatch && currentCommit) {
|
|
820
|
+
entries.push(JSON.stringify({
|
|
821
|
+
commit: currentCommit,
|
|
822
|
+
date: currentDate,
|
|
823
|
+
from: renameMatch[1],
|
|
824
|
+
to: renameMatch[2],
|
|
825
|
+
}));
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (entries.length > 0) {
|
|
830
|
+
appendFileSync(renamesPath, entries.join('\n') + '\n', 'utf8');
|
|
831
|
+
}
|
|
832
|
+
} catch (_) {
|
|
833
|
+
// Non-blocking
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
// BM25-lite tokenizer and postings builder
|
|
839
|
+
// ---------------------------------------------------------------------------
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Tokenize text for BM25-lite indexing.
|
|
843
|
+
* - Lowercase
|
|
844
|
+
* - Split on non-alphanumeric
|
|
845
|
+
* - Split camelCase and snake_case (keep both forms)
|
|
846
|
+
*
|
|
847
|
+
* @param {string} text
|
|
848
|
+
* @returns {string[]}
|
|
849
|
+
*/
|
|
850
|
+
function tokenize(text) {
|
|
851
|
+
if (!text) return [];
|
|
852
|
+
const tokens = new Set();
|
|
853
|
+
|
|
854
|
+
// Split on non-alphanumeric, lowercase everything
|
|
855
|
+
const raw = text.toLowerCase().split(/[^a-z0-9_]+/).filter(t => t.length >= 2);
|
|
856
|
+
|
|
857
|
+
for (const tok of raw) {
|
|
858
|
+
tokens.add(tok);
|
|
859
|
+
|
|
860
|
+
// Split snake_case
|
|
861
|
+
if (tok.includes('_')) {
|
|
862
|
+
for (const part of tok.split('_')) {
|
|
863
|
+
if (part.length >= 2) tokens.add(part);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Also split camelCase from original text
|
|
869
|
+
const camelRe = /[A-Z][a-z]+/g;
|
|
870
|
+
const camelMatches = text.match(camelRe) || [];
|
|
871
|
+
for (const part of camelMatches) {
|
|
872
|
+
if (part.length >= 2) tokens.add(part.toLowerCase());
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return Array.from(tokens);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Compute per-field term frequencies for a card (BM25-lite field weighting).
|
|
880
|
+
*
|
|
881
|
+
* Field weights (per FORGE:DESIGN_DECISION):
|
|
882
|
+
* pattern field: weight 3
|
|
883
|
+
* rootCause ∪ symbols: weight 2
|
|
884
|
+
* paths basenames: weight 2
|
|
885
|
+
* prevention ∪ prose: weight 1
|
|
886
|
+
*
|
|
887
|
+
* Returns: Map<term, weighted_tf>
|
|
888
|
+
*/
|
|
889
|
+
function cardFieldTf(card) {
|
|
890
|
+
const tfMap = new Map();
|
|
891
|
+
|
|
892
|
+
function addTokens(text, weight) {
|
|
893
|
+
const tokens = tokenize(text || '');
|
|
894
|
+
for (const tok of tokens) {
|
|
895
|
+
tfMap.set(tok, (tfMap.get(tok) || 0) + weight);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Weight 3: pattern
|
|
900
|
+
addTokens(card.pattern || '', 3);
|
|
901
|
+
|
|
902
|
+
// Weight 2: rootCause + symbols
|
|
903
|
+
addTokens(card.rootCause || '', 2);
|
|
904
|
+
for (const sym of card.symbols || []) {
|
|
905
|
+
addTokens(sym, 2);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Weight 2: path basenames
|
|
909
|
+
for (const p of card.paths || []) {
|
|
910
|
+
const basename = p.split('/').pop() || p;
|
|
911
|
+
addTokens(basename, 2);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Weight 1: prevention + decision + anomalies
|
|
915
|
+
addTokens(card.prevention || '', 1);
|
|
916
|
+
addTokens(card.decision || '', 1);
|
|
917
|
+
addTokens(card.anomalies || '', 1);
|
|
918
|
+
addTokens(card.verdict || '', 1);
|
|
919
|
+
addTokens(card.taskType || '', 1);
|
|
920
|
+
|
|
921
|
+
return tfMap;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Build BM25-lite postings index from card JSONL.
|
|
926
|
+
*
|
|
927
|
+
* @param {object[]} cards
|
|
928
|
+
* @returns {object} postings — { term: { cardId: weightedTf, ... }, ... }
|
|
929
|
+
*/
|
|
930
|
+
function buildPostings(cards) {
|
|
931
|
+
const postings = {};
|
|
932
|
+
|
|
933
|
+
for (const card of cards) {
|
|
934
|
+
const tfMap = cardFieldTf(card);
|
|
935
|
+
for (const [term, tf] of tfMap) {
|
|
936
|
+
if (!postings[term]) postings[term] = {};
|
|
937
|
+
postings[term][card.id] = tf;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return postings;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// ---------------------------------------------------------------------------
|
|
945
|
+
// JSONL reader
|
|
946
|
+
// ---------------------------------------------------------------------------
|
|
947
|
+
|
|
948
|
+
function readJsonl(filePath) {
|
|
949
|
+
if (!existsSync(filePath)) return [];
|
|
950
|
+
try {
|
|
951
|
+
return readFileSync(filePath, 'utf8')
|
|
952
|
+
.split('\n')
|
|
953
|
+
.filter(l => l.trim())
|
|
954
|
+
.map(l => JSON.parse(l));
|
|
955
|
+
} catch (_) {
|
|
956
|
+
return [];
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ---------------------------------------------------------------------------
|
|
961
|
+
// Mirror to orphan branch
|
|
962
|
+
// ---------------------------------------------------------------------------
|
|
963
|
+
|
|
964
|
+
function mirrorToOrphanBranch(indexDir, repoPath) {
|
|
965
|
+
try {
|
|
966
|
+
// Check if forge-knowledge branch exists
|
|
967
|
+
const branchCheck = spawnSync('git', ['show-ref', '--quiet', `refs/heads/${MIRROR_BRANCH}`], {
|
|
968
|
+
cwd: repoPath,
|
|
969
|
+
encoding: 'utf8',
|
|
970
|
+
timeout: 5000,
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
if (branchCheck.status !== 0) {
|
|
974
|
+
// Create orphan branch
|
|
975
|
+
const initResult = spawnSync('git', ['checkout', '--orphan', MIRROR_BRANCH], {
|
|
976
|
+
cwd: repoPath,
|
|
977
|
+
encoding: 'utf8',
|
|
978
|
+
timeout: 10000,
|
|
979
|
+
});
|
|
980
|
+
if (initResult.status !== 0) {
|
|
981
|
+
log(`WARNING: Could not create ${MIRROR_BRANCH} branch — skipping mirror`);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
// Remove all tracked files from orphan branch working tree
|
|
985
|
+
spawnSync('git', ['rm', '-rf', '--quiet', '.'], { cwd: repoPath, encoding: 'utf8', timeout: 10000 });
|
|
986
|
+
} else {
|
|
987
|
+
// Switch to the branch. A checkout aborts on a dirty working tree; if it
|
|
988
|
+
// fails we must NOT proceed — otherwise the index files get written to the
|
|
989
|
+
// repo root and committed onto the CURRENT branch instead of the mirror. <!-- forge#1838 review SEC-1 -->
|
|
990
|
+
const switchResult = spawnSync('git', ['checkout', MIRROR_BRANCH], { cwd: repoPath, encoding: 'utf8', timeout: 10000 });
|
|
991
|
+
if (switchResult.status !== 0) {
|
|
992
|
+
log(`WARNING: Could not switch to ${MIRROR_BRANCH} (working tree dirty?) — skipping mirror`);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Copy index files to repo root (they will be committed to the orphan branch)
|
|
998
|
+
const files = [CARDS_FILE, POSTINGS_FILE, MANIFEST_FILE, RENAMES_FILE];
|
|
999
|
+
for (const f of files) {
|
|
1000
|
+
const src = join(indexDir, f);
|
|
1001
|
+
if (existsSync(src)) {
|
|
1002
|
+
const dest = join(repoPath, f);
|
|
1003
|
+
writeFileSync(dest, readFileSync(src));
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Stage and commit
|
|
1008
|
+
spawnSync('git', ['add', ...files.filter(f => existsSync(join(repoPath, f)))], {
|
|
1009
|
+
cwd: repoPath, encoding: 'utf8', timeout: 10000,
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
const commitResult = spawnSync('git', [
|
|
1013
|
+
'commit', '-m', `chore(ledger): update knowledge index [skip ci]`,
|
|
1014
|
+
'--allow-empty',
|
|
1015
|
+
], { cwd: repoPath, encoding: 'utf8', timeout: 10000 });
|
|
1016
|
+
|
|
1017
|
+
if (commitResult.status === 0) {
|
|
1018
|
+
// Push to remote (non-blocking)
|
|
1019
|
+
spawnSync('git', ['push', 'origin', MIRROR_BRANCH, '--force-with-lease'], {
|
|
1020
|
+
cwd: repoPath, encoding: 'utf8', timeout: 30000,
|
|
1021
|
+
});
|
|
1022
|
+
log(`Mirror: pushed to ${MIRROR_BRANCH} branch`);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Return to original branch
|
|
1026
|
+
spawnSync('git', ['checkout', '-'], { cwd: repoPath, encoding: 'utf8', timeout: 10000 });
|
|
1027
|
+
|
|
1028
|
+
} catch (e) {
|
|
1029
|
+
log(`WARNING: Mirror to ${MIRROR_BRANCH} failed: ${e.message} — continuing`);
|
|
1030
|
+
// Non-blocking: attempt to restore branch
|
|
1031
|
+
try {
|
|
1032
|
+
spawnSync('git', ['checkout', '-'], { cwd: repoPath, encoding: 'utf8', timeout: 5000 });
|
|
1033
|
+
} catch (_) { /* ignore */ }
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ---------------------------------------------------------------------------
|
|
1038
|
+
// Doctor check — divergence detection
|
|
1039
|
+
// ---------------------------------------------------------------------------
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Sample K random indexed issues and recompute their card hashes.
|
|
1043
|
+
* Report mismatches (but do not auto-repair — full rebuild is the recovery path).
|
|
1044
|
+
*
|
|
1045
|
+
* @param {object} manifest
|
|
1046
|
+
* @param {string} repo
|
|
1047
|
+
* @param {number} k - sample size
|
|
1048
|
+
*/
|
|
1049
|
+
async function doctorCheck(manifest, repo, k = 5) {
|
|
1050
|
+
const issuePairs = Object.entries(manifest.issues);
|
|
1051
|
+
if (issuePairs.length === 0) return;
|
|
1052
|
+
|
|
1053
|
+
const sample = issuePairs.sort(() => Math.random() - 0.5).slice(0, k);
|
|
1054
|
+
const mismatches = [];
|
|
1055
|
+
|
|
1056
|
+
for (const [issueNum, storedHash] of sample) {
|
|
1057
|
+
try {
|
|
1058
|
+
const { issue, comments } = fetchIssue(repo, issueNum);
|
|
1059
|
+
const cards = await extractCardsFromIssue(parseInt(issueNum, 10), issue, comments);
|
|
1060
|
+
const computedHash = hashCards(cards);
|
|
1061
|
+
if (computedHash !== storedHash) {
|
|
1062
|
+
mismatches.push({ issue: issueNum, stored: storedHash, computed: computedHash });
|
|
1063
|
+
}
|
|
1064
|
+
} catch (_) {
|
|
1065
|
+
// Skip on API error
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (mismatches.length > 0) {
|
|
1070
|
+
log(`DOCTOR: ${mismatches.length}/${k} sampled issues have hash mismatches — consider --full-rebuild`);
|
|
1071
|
+
for (const m of mismatches) {
|
|
1072
|
+
log(` Issue #${m.issue}: stored=${m.stored} computed=${m.computed}`);
|
|
1073
|
+
}
|
|
1074
|
+
} else {
|
|
1075
|
+
log(`DOCTOR: ${k} sampled issues passed divergence check`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ---------------------------------------------------------------------------
|
|
1080
|
+
// Cost-prior aggregation (economic scheduling — forge#1743)
|
|
1081
|
+
// ---------------------------------------------------------------------------
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Aggregate historical FORGE:CARD spend into cost priors keyed by
|
|
1085
|
+
* `task_type:module` (where module = the basename of the primary affected
|
|
1086
|
+
* file, lower-cased, without extension).
|
|
1087
|
+
*
|
|
1088
|
+
* Input: `cards` array from knowledge.jsonl (all cards in the index).
|
|
1089
|
+
* Each card may carry a `costUsd` object (populated by close-phase
|
|
1090
|
+
* indexing of FORGE:TRAJECTORY actual-spend fields):
|
|
1091
|
+
* card.costUsd = { investigation: number|null, build: number|null, review: number|null, total: number|null }
|
|
1092
|
+
*
|
|
1093
|
+
* Output written to `~/.forge/index/cost-priors.json`:
|
|
1094
|
+
* {
|
|
1095
|
+
* schemaVersion: 1,
|
|
1096
|
+
* generatedAt: ISO-string,
|
|
1097
|
+
* sampleCount: N, // total spend samples across all keys
|
|
1098
|
+
* priors: {
|
|
1099
|
+
* "<task_type>:<module>": {
|
|
1100
|
+
* n: number, // sample count
|
|
1101
|
+
* mean: number, // mean total_usd
|
|
1102
|
+
* variance: number, // sample variance (0 when n<2)
|
|
1103
|
+
* stddev: number, // sqrt(variance)
|
|
1104
|
+
* min: number,
|
|
1105
|
+
* max: number,
|
|
1106
|
+
* p50: number, // median
|
|
1107
|
+
* },
|
|
1108
|
+
* ...
|
|
1109
|
+
* }
|
|
1110
|
+
* }
|
|
1111
|
+
*
|
|
1112
|
+
* Cards that have no costUsd total are skipped (counted as absent, not zero).
|
|
1113
|
+
* Keys with only 1 sample have variance=0, stddev=0.
|
|
1114
|
+
*
|
|
1115
|
+
* @param {object[]} cards — full knowledge card array
|
|
1116
|
+
* @param {string} indexDir — output directory for cost-priors.json
|
|
1117
|
+
* @param {boolean} dryRun — if true, print priors to stdout, don't write
|
|
1118
|
+
*/
|
|
1119
|
+
function aggregateCostPriors(cards, indexDir, dryRun = false) {
|
|
1120
|
+
// Group total_usd values by task_type:module key
|
|
1121
|
+
// task_type from card.taskType (e.g. "Feature", "Bug Fix", "Refactor")
|
|
1122
|
+
// module = basename (no ext, lowercase) of card.paths[0], or "_unknown"
|
|
1123
|
+
const buckets = new Map(); // key → number[]
|
|
1124
|
+
|
|
1125
|
+
for (const card of cards) {
|
|
1126
|
+
const total = card.costUsd?.total ?? null;
|
|
1127
|
+
if (total === null || typeof total !== 'number' || !isFinite(total) || total <= 0) continue;
|
|
1128
|
+
|
|
1129
|
+
const rawTaskType = (card.taskType || 'unknown').trim().toLowerCase().replace(/\s+/g, '-');
|
|
1130
|
+
const primaryPath = card.paths?.[0] || '';
|
|
1131
|
+
const moduleName = primaryPath
|
|
1132
|
+
? primaryPath.split('/').pop().replace(/\.[^.]+$/, '').toLowerCase() || '_unknown'
|
|
1133
|
+
: '_unknown';
|
|
1134
|
+
|
|
1135
|
+
const key = `${rawTaskType}:${moduleName}`;
|
|
1136
|
+
if (!buckets.has(key)) buckets.set(key, []);
|
|
1137
|
+
buckets.get(key).push(total);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Compute statistics per bucket
|
|
1141
|
+
const priors = {};
|
|
1142
|
+
let totalSampleCount = 0;
|
|
1143
|
+
|
|
1144
|
+
for (const [key, values] of buckets) {
|
|
1145
|
+
const n = values.length;
|
|
1146
|
+
totalSampleCount += n;
|
|
1147
|
+
|
|
1148
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
1149
|
+
const mean = values.reduce((s, v) => s + v, 0) / n;
|
|
1150
|
+
const variance = n >= 2
|
|
1151
|
+
? values.reduce((s, v) => s + (v - mean) ** 2, 0) / (n - 1)
|
|
1152
|
+
: 0;
|
|
1153
|
+
const stddev = Math.sqrt(variance);
|
|
1154
|
+
const p50 = n % 2 === 0
|
|
1155
|
+
? (sorted[n / 2 - 1] + sorted[n / 2]) / 2
|
|
1156
|
+
: sorted[Math.floor(n / 2)];
|
|
1157
|
+
|
|
1158
|
+
priors[key] = {
|
|
1159
|
+
n,
|
|
1160
|
+
mean: Math.round(mean * 10000) / 10000, // 4 decimal places (sub-cent precision)
|
|
1161
|
+
variance: Math.round(variance * 10000) / 10000,
|
|
1162
|
+
stddev: Math.round(stddev * 10000) / 10000,
|
|
1163
|
+
min: Math.round(sorted[0] * 10000) / 10000,
|
|
1164
|
+
max: Math.round(sorted[n - 1] * 10000) / 10000,
|
|
1165
|
+
p50: Math.round(p50 * 10000) / 10000,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const output = {
|
|
1170
|
+
schemaVersion: 1,
|
|
1171
|
+
generatedAt: new Date().toISOString(),
|
|
1172
|
+
sampleCount: totalSampleCount,
|
|
1173
|
+
priors,
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
if (dryRun) {
|
|
1177
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
1178
|
+
return output;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
writeFileSync(join(indexDir, COST_PRIORS_FILE), JSON.stringify(output, null, 2), 'utf8');
|
|
1182
|
+
log(`Cost priors: wrote ${Object.keys(priors).length} keys (${totalSampleCount} samples) to ${COST_PRIORS_FILE}`);
|
|
1183
|
+
return output;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// ---------------------------------------------------------------------------
|
|
1187
|
+
// Pattern exchange subscription pull (forge#1746)
|
|
1188
|
+
// Reads forge.yaml → pattern_feeds, fetches subscribed card files from
|
|
1189
|
+
// exchange repos at pinned refs via gh api, and merges them into the local
|
|
1190
|
+
// ledger as LOW-priority priors tagged by origin feed slug.
|
|
1191
|
+
// ---------------------------------------------------------------------------
|
|
1192
|
+
|
|
1193
|
+
/** Read a yq scalar from forge.yaml in the repo root. Returns '' on error. */
|
|
1194
|
+
function yqScalar(repoPath, expr) {
|
|
1195
|
+
const result = spawnSync('yq', [expr, join(repoPath, 'forge.yaml')], {
|
|
1196
|
+
encoding: 'utf8',
|
|
1197
|
+
timeout: 5000,
|
|
1198
|
+
});
|
|
1199
|
+
if (result.status === 0 && result.stdout.trim()) return result.stdout.trim();
|
|
1200
|
+
return '';
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Sanitize an external-sourced identifier (feed slug from forge.yaml, card slug
|
|
1205
|
+
* from an exchange repo's `# Pattern:` header) before using it as a filesystem
|
|
1206
|
+
* path component. Collapses any character outside [A-Za-z0-9._-] to '-' so that
|
|
1207
|
+
* path-separator or traversal sequences (`/`, `..`) cannot escape LEDGER_ROOT.
|
|
1208
|
+
*/
|
|
1209
|
+
function sanitizePathComponent(value) {
|
|
1210
|
+
return String(value || '').replace(/[^A-Za-z0-9._-]/g, '-').replace(/^\.+/, '');
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** Read a JSON value from forge.yaml via yq -o json. Returns null on error. */
|
|
1214
|
+
function yqJsonVal(repoPath, expr) {
|
|
1215
|
+
const result = spawnSync('yq', ['-o', 'json', expr, join(repoPath, 'forge.yaml')], {
|
|
1216
|
+
encoding: 'utf8',
|
|
1217
|
+
timeout: 5000,
|
|
1218
|
+
});
|
|
1219
|
+
if (result.status !== 0 || !result.stdout.trim() || result.stdout.trim() === 'null') return null;
|
|
1220
|
+
try { return JSON.parse(result.stdout.trim()); } catch { return null; }
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Fetch a file from a GitHub repo at a specific pinned ref via gh api.
|
|
1225
|
+
* Returns decoded string content, or null on error.
|
|
1226
|
+
*/
|
|
1227
|
+
function ghFetchFileContent(repo, ref, filePath) {
|
|
1228
|
+
const endpoint = `repos/${repo}/contents/${filePath}?ref=${ref}`;
|
|
1229
|
+
try {
|
|
1230
|
+
const result = spawnSync('gh', ['api', endpoint, '--jq', '.content'], {
|
|
1231
|
+
encoding: 'utf8',
|
|
1232
|
+
timeout: 15000,
|
|
1233
|
+
});
|
|
1234
|
+
if (result.status !== 0 || !result.stdout.trim() || result.stdout.trim() === 'null') return null;
|
|
1235
|
+
// GitHub encodes content as base64 with embedded newlines
|
|
1236
|
+
return Buffer.from(result.stdout.trim().replace(/\n/g, ''), 'base64').toString('utf8');
|
|
1237
|
+
} catch {
|
|
1238
|
+
return null;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* List directory contents from a GitHub repo at a pinned ref.
|
|
1244
|
+
* Returns array of { path, type } objects, or [] on error.
|
|
1245
|
+
*/
|
|
1246
|
+
function ghListContents(repo, ref, dirPath) {
|
|
1247
|
+
const endpoint = `repos/${repo}/contents/${dirPath}?ref=${ref}`;
|
|
1248
|
+
try {
|
|
1249
|
+
const result = spawnSync('gh', ['api', endpoint, '--jq', '[.[] | {path: .path, type: .type}]'], {
|
|
1250
|
+
encoding: 'utf8',
|
|
1251
|
+
timeout: 15000,
|
|
1252
|
+
});
|
|
1253
|
+
if (result.status !== 0 || !result.stdout.trim() || result.stdout.trim() === 'null') return [];
|
|
1254
|
+
return JSON.parse(result.stdout.trim());
|
|
1255
|
+
} catch {
|
|
1256
|
+
return [];
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Parse a pattern card markdown file from the exchange repo schema.
|
|
1262
|
+
* See forge.yaml.example § PATTERN_FEEDS for the card format.
|
|
1263
|
+
* Returns a card object or null if malformed.
|
|
1264
|
+
*/
|
|
1265
|
+
function parseExchangeCard(markdown, filePath) {
|
|
1266
|
+
const lines = markdown.split('\n');
|
|
1267
|
+
|
|
1268
|
+
function extractSection(heading) {
|
|
1269
|
+
const start = lines.findIndex(l => l.trim() === `## ${heading}`);
|
|
1270
|
+
if (start === -1) return '';
|
|
1271
|
+
const end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
|
|
1272
|
+
const sectionLines = end === -1 ? lines.slice(start + 1) : lines.slice(start + 1, end);
|
|
1273
|
+
return sectionLines.join('\n').trim();
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const headerLine = lines.find(l => /^#\s+Pattern:\s+\S/.test(l));
|
|
1277
|
+
if (!headerLine) {
|
|
1278
|
+
log(`Exchange card at ${filePath}: missing "# Pattern: {slug}" header — skipping`);
|
|
1279
|
+
return null;
|
|
1280
|
+
}
|
|
1281
|
+
const slug = headerLine.replace(/^#\s+Pattern:\s+/, '').trim();
|
|
1282
|
+
if (!slug) { log(`Exchange card at ${filePath}: empty slug — skipping`); return null; }
|
|
1283
|
+
|
|
1284
|
+
const stacksRaw = extractSection('Stacks');
|
|
1285
|
+
const stacks = stacksRaw.split(/[\n,]/).map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
1286
|
+
if (stacks.length === 0) {
|
|
1287
|
+
log(`Exchange card "${slug}": no stacks — skipping (required by schema)`);
|
|
1288
|
+
return null;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const rootCauseShape = extractSection('Root Cause Shape');
|
|
1292
|
+
const prevention = extractSection('Prevention Rule');
|
|
1293
|
+
const summary = extractSection('Summary');
|
|
1294
|
+
|
|
1295
|
+
// Optional gate.d check template — bash code block under "## gate.d Check Template"
|
|
1296
|
+
let gateTemplate = '';
|
|
1297
|
+
const gateSectionIdx = lines.findIndex(l => l.trim() === '## gate.d Check Template');
|
|
1298
|
+
if (gateSectionIdx !== -1) {
|
|
1299
|
+
const codeStart = lines.findIndex((l, i) => i > gateSectionIdx && l.trim() === '```bash');
|
|
1300
|
+
if (codeStart !== -1) {
|
|
1301
|
+
const codeEnd = lines.findIndex((l, i) => i > codeStart && l.trim() === '```');
|
|
1302
|
+
if (codeEnd !== -1) {
|
|
1303
|
+
gateTemplate = lines.slice(codeStart + 1, codeEnd).join('\n').trim();
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
return { slug, stacks, root_cause_shape: rootCauseShape, prevention, summary, gate_template: gateTemplate };
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Pull all subscribed pattern feeds and merge into the local ledger.
|
|
1313
|
+
* Entry point for --pull-feeds mode.
|
|
1314
|
+
*
|
|
1315
|
+
* Security model:
|
|
1316
|
+
* - Only pinned commit SHAs are accepted as refs (7–40 hex chars)
|
|
1317
|
+
* - Fetched content is stored as data (JSON); never executed at import time
|
|
1318
|
+
* - All imported cards carry origin:<feedSlug> and priority:LOW
|
|
1319
|
+
* - Local validated findings (no origin field) always outrank imported cards
|
|
1320
|
+
* - Unsubscribing from a feed removes stale cards on next run
|
|
1321
|
+
*/
|
|
1322
|
+
async function pullFeeds(repoPath, args) {
|
|
1323
|
+
log('');
|
|
1324
|
+
log('=== build-knowledge-index --pull-feeds ===');
|
|
1325
|
+
log(`Run at: ${new Date().toISOString()}`);
|
|
1326
|
+
if (args.dryRun) log('Mode: DRY RUN — no files written');
|
|
1327
|
+
if (args.feedFilter) log(`Feed filter: --feed ${args.feedFilter}`);
|
|
1328
|
+
log('');
|
|
1329
|
+
|
|
1330
|
+
// Read pattern_feeds config from forge.yaml
|
|
1331
|
+
const feedsEnabled = yqScalar(repoPath, '.pattern_feeds.enabled // "false"').toLowerCase();
|
|
1332
|
+
if (feedsEnabled !== 'true') {
|
|
1333
|
+
log('pattern_feeds.enabled is not true in forge.yaml — nothing to do.');
|
|
1334
|
+
log('Set pattern_feeds.enabled: true and configure at least one feed.');
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const feeds = yqJsonVal(repoPath, '.pattern_feeds.feeds // []');
|
|
1339
|
+
if (!Array.isArray(feeds) || feeds.length === 0) {
|
|
1340
|
+
log('No feeds in forge.yaml → pattern_feeds.feeds — nothing to do.');
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
const ledgerRelPath = yqScalar(repoPath, '.pattern_feeds.ledger_path // ".forge/ledger"') || '.forge/ledger';
|
|
1345
|
+
const LEDGER_ROOT = resolve(repoPath, ledgerRelPath);
|
|
1346
|
+
|
|
1347
|
+
// Discover local validated finding slugs (no origin = local)
|
|
1348
|
+
const LOCAL_SLUGS = new Set();
|
|
1349
|
+
if (existsSync(LEDGER_ROOT)) {
|
|
1350
|
+
try {
|
|
1351
|
+
const walkDir = (dir) => {
|
|
1352
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1353
|
+
const full = join(dir, entry.name);
|
|
1354
|
+
if (entry.isDirectory()) walkDir(full);
|
|
1355
|
+
else if (entry.name.endsWith('.json')) {
|
|
1356
|
+
try {
|
|
1357
|
+
const data = JSON.parse(readFileSync(full, 'utf8'));
|
|
1358
|
+
if ((!data.origin || data.origin === 'local') && data.slug) {
|
|
1359
|
+
LOCAL_SLUGS.add(data.slug);
|
|
1360
|
+
}
|
|
1361
|
+
} catch { /* malformed — skip */ }
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
walkDir(LEDGER_ROOT);
|
|
1366
|
+
} catch { /* ledger unreadable — ok */ }
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
const PINNED_REF_RE = /^[0-9a-f]{7,40}$/i;
|
|
1370
|
+
const stats = { feeds_processed: 0, feeds_skipped: 0, cards_imported: 0, cards_skipped: 0, cards_removed: 0, errors: [] };
|
|
1371
|
+
|
|
1372
|
+
for (const feed of feeds) {
|
|
1373
|
+
const { slug: feedSlug, repo, ref, path: cardPath = 'cards', stacks: feedStacks } = feed ?? {};
|
|
1374
|
+
|
|
1375
|
+
if (!feedSlug || !repo || !ref) {
|
|
1376
|
+
log(`[WARN] Feed entry missing required fields (slug, repo, ref) — skipping: ${JSON.stringify(feed)}`);
|
|
1377
|
+
stats.feeds_skipped++;
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
if (args.feedFilter && feedSlug !== args.feedFilter) {
|
|
1382
|
+
debug(`Skipping feed ${feedSlug} (--feed filter)`);
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// Security gate: reject floating refs
|
|
1387
|
+
if (!PINNED_REF_RE.test(ref)) {
|
|
1388
|
+
log(`[WARN] Feed ${feedSlug}: ref "${ref}" is not a pinned commit SHA (7–40 hex chars). Floating refs rejected for supply-chain safety. Update forge.yaml → pattern_feeds.feeds → ref.`);
|
|
1389
|
+
stats.feeds_skipped++;
|
|
1390
|
+
stats.errors.push(`${feedSlug}: non-pinned ref rejected`);
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
log(`--- Feed: ${feedSlug} (${repo} @ ${ref.slice(0, 8)}…) ---`);
|
|
1395
|
+
stats.feeds_processed++;
|
|
1396
|
+
|
|
1397
|
+
// Discover card files: list subdirectories (stacks), then files within each
|
|
1398
|
+
const cardFiles = [];
|
|
1399
|
+
const contents = ghListContents(repo, ref, cardPath);
|
|
1400
|
+
|
|
1401
|
+
if (contents.length === 0) {
|
|
1402
|
+
log(`Feed ${feedSlug}: empty or inaccessible path ${cardPath} — skipping`);
|
|
1403
|
+
stats.feeds_skipped++;
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const subdirs = contents.filter(e => e.type === 'dir');
|
|
1408
|
+
const topFiles = contents.filter(e => e.type === 'file' && e.path.endsWith('.md'));
|
|
1409
|
+
|
|
1410
|
+
if (subdirs.length === 0) {
|
|
1411
|
+
// Flat layout: files directly under cardPath
|
|
1412
|
+
cardFiles.push(...topFiles.map(f => f.path));
|
|
1413
|
+
} else {
|
|
1414
|
+
for (const subdir of subdirs) {
|
|
1415
|
+
const stackName = subdir.path.split('/').pop();
|
|
1416
|
+
if (feedStacks && Array.isArray(feedStacks) && feedStacks.length > 0) {
|
|
1417
|
+
if (!feedStacks.includes(stackName)) {
|
|
1418
|
+
debug(`Feed ${feedSlug}: skipping stack dir ${stackName} (not in feed stacks filter)`);
|
|
1419
|
+
continue;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
const subContents = ghListContents(repo, ref, subdir.path);
|
|
1423
|
+
cardFiles.push(...subContents.filter(e => e.type === 'file' && e.path.endsWith('.md')).map(e => e.path));
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if (cardFiles.length === 0) {
|
|
1428
|
+
log(`Feed ${feedSlug}: no card files found — skipping`);
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
log(`Feed ${feedSlug}: found ${cardFiles.length} card file(s)`);
|
|
1433
|
+
const importedSlugs = new Set();
|
|
1434
|
+
|
|
1435
|
+
for (const filePath of cardFiles) {
|
|
1436
|
+
const markdown = ghFetchFileContent(repo, ref, filePath);
|
|
1437
|
+
if (!markdown) {
|
|
1438
|
+
log(`[WARN] Feed ${feedSlug}: could not fetch ${filePath} — skipping`);
|
|
1439
|
+
stats.cards_skipped++;
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const card = parseExchangeCard(markdown, filePath);
|
|
1444
|
+
if (!card) { stats.cards_skipped++; continue; }
|
|
1445
|
+
|
|
1446
|
+
const { slug } = card;
|
|
1447
|
+
|
|
1448
|
+
if (LOCAL_SLUGS.has(slug)) {
|
|
1449
|
+
debug(`Feed ${feedSlug}: slug "${slug}" exists as local validated finding — skipping import`);
|
|
1450
|
+
stats.cards_skipped++;
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Apply feed-level stack filter at card level
|
|
1455
|
+
if (feedStacks && Array.isArray(feedStacks) && feedStacks.length > 0) {
|
|
1456
|
+
const overlap = card.stacks.filter(s => feedStacks.includes(s));
|
|
1457
|
+
if (overlap.length === 0) {
|
|
1458
|
+
debug(`Feed ${feedSlug}: card "${slug}" stacks [${card.stacks}] do not overlap feed filter — skipping`);
|
|
1459
|
+
stats.cards_skipped++;
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const ledgerEntry = {
|
|
1465
|
+
schema_version: '1',
|
|
1466
|
+
slug,
|
|
1467
|
+
stacks: card.stacks,
|
|
1468
|
+
root_cause_shape: card.root_cause_shape,
|
|
1469
|
+
prevention: card.prevention,
|
|
1470
|
+
summary: card.summary,
|
|
1471
|
+
gate_template: card.gate_template || null,
|
|
1472
|
+
origin: feedSlug,
|
|
1473
|
+
source_repo: repo,
|
|
1474
|
+
source_ref: ref,
|
|
1475
|
+
source_path: filePath,
|
|
1476
|
+
priority: 'LOW', // always LOW for imported cards — never overridden
|
|
1477
|
+
imported_at: new Date().toISOString(),
|
|
1478
|
+
};
|
|
1479
|
+
|
|
1480
|
+
// Sanitize external-sourced slugs before building filesystem paths so a
|
|
1481
|
+
// crafted `# Pattern:` header or feed slug cannot traverse out of LEDGER_ROOT.
|
|
1482
|
+
const safeFeedSlug = sanitizePathComponent(feedSlug);
|
|
1483
|
+
const safeSlug = sanitizePathComponent(slug);
|
|
1484
|
+
const feedLedgerDir = join(LEDGER_ROOT, safeFeedSlug);
|
|
1485
|
+
const ledgerFilePath = join(feedLedgerDir, `${safeSlug}.json`);
|
|
1486
|
+
importedSlugs.add(safeSlug);
|
|
1487
|
+
|
|
1488
|
+
if (args.dryRun) {
|
|
1489
|
+
log(` DRY RUN: would write ${ledgerFilePath}`);
|
|
1490
|
+
if (args.verbose) log(` ${JSON.stringify(ledgerEntry)}`);
|
|
1491
|
+
stats.cards_imported++;
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// Idempotency: skip if same ref already imported
|
|
1496
|
+
if (existsSync(ledgerFilePath)) {
|
|
1497
|
+
try {
|
|
1498
|
+
const existing = JSON.parse(readFileSync(ledgerFilePath, 'utf8'));
|
|
1499
|
+
if (existing.source_ref === ref && existing.slug === slug) {
|
|
1500
|
+
debug(` SKIP ${slug} — already at ref ${ref.slice(0, 8)}`);
|
|
1501
|
+
stats.cards_skipped++;
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
} catch { /* overwrite malformed */ }
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
mkdirSync(feedLedgerDir, { recursive: true });
|
|
1508
|
+
writeFileSync(ledgerFilePath, JSON.stringify(ledgerEntry, null, 2) + '\n', 'utf8');
|
|
1509
|
+
log(` IMPORTED ${slug} → ${ledgerFilePath}`);
|
|
1510
|
+
stats.cards_imported++;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Reconcile: remove cards no longer present upstream (clean unsubscribe)
|
|
1514
|
+
if (!args.dryRun) {
|
|
1515
|
+
const feedLedgerDir = join(LEDGER_ROOT, sanitizePathComponent(feedSlug));
|
|
1516
|
+
if (existsSync(feedLedgerDir)) {
|
|
1517
|
+
for (const file of readdirSync(feedLedgerDir)) {
|
|
1518
|
+
if (!file.endsWith('.json')) continue;
|
|
1519
|
+
const existingSlug = file.replace(/\.json$/, '');
|
|
1520
|
+
if (!importedSlugs.has(existingSlug)) {
|
|
1521
|
+
const { unlinkSync } = await import('node:fs');
|
|
1522
|
+
unlinkSync(join(feedLedgerDir, file));
|
|
1523
|
+
log(` REMOVED stale card ${existingSlug} (no longer in upstream @ ${ref.slice(0, 8)})`);
|
|
1524
|
+
stats.cards_removed++;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
log('');
|
|
1532
|
+
log('=== Pull Feeds Summary ===');
|
|
1533
|
+
log(` Feeds processed: ${stats.feeds_processed}`);
|
|
1534
|
+
log(` Feeds skipped: ${stats.feeds_skipped}`);
|
|
1535
|
+
log(` Cards imported: ${stats.cards_imported}`);
|
|
1536
|
+
log(` Cards skipped: ${stats.cards_skipped}`);
|
|
1537
|
+
log(` Cards removed: ${stats.cards_removed}`);
|
|
1538
|
+
if (stats.errors.length > 0) {
|
|
1539
|
+
log(` Errors (${stats.errors.length}):`);
|
|
1540
|
+
for (const e of stats.errors) log(` - ${e}`);
|
|
1541
|
+
}
|
|
1542
|
+
if (args.dryRun) log('');
|
|
1543
|
+
if (args.dryRun) log('DRY RUN: no files written. Remove --dry-run to apply.');
|
|
1544
|
+
log('');
|
|
1545
|
+
|
|
1546
|
+
if (stats.feeds_skipped > 0 && stats.feeds_processed === 0) {
|
|
1547
|
+
process.exit(1);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// ---------------------------------------------------------------------------
|
|
1552
|
+
// Main entry point
|
|
1553
|
+
// ---------------------------------------------------------------------------
|
|
1554
|
+
|
|
1555
|
+
async function main() {
|
|
1556
|
+
const args = parseArgs(process.argv);
|
|
1557
|
+
globalThis._VERBOSE = args.verbose;
|
|
1558
|
+
|
|
1559
|
+
const repoPath = process.env.FORGE_REPO_PATH || resolve(dirname(__dirname));
|
|
1560
|
+
const indexDir = resolve(args.outputDir);
|
|
1561
|
+
|
|
1562
|
+
// Pattern exchange feed pull mode (forge#1746)
|
|
1563
|
+
// Invoked as: node scripts/build-knowledge-index.mjs --pull-feeds [--dry-run] [--feed <slug>]
|
|
1564
|
+
if (args.pullFeeds) {
|
|
1565
|
+
await pullFeeds(repoPath, args);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// Resolve GitHub repo from args or forge.yaml
|
|
1570
|
+
let repo = args.repo;
|
|
1571
|
+
if (!repo) {
|
|
1572
|
+
repo = readForgeYaml(repoPath);
|
|
1573
|
+
}
|
|
1574
|
+
if (!repo) {
|
|
1575
|
+
process.stderr.write('[ledger] ERROR: Cannot determine GitHub repo. Pass --repo owner/repo or ensure forge.yaml is present.\n');
|
|
1576
|
+
process.exit(1);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
log(`Forge Ledger indexer — repo: ${repo}, output: ${indexDir}`);
|
|
1580
|
+
|
|
1581
|
+
if (args.dryRun) {
|
|
1582
|
+
log('DRY RUN: will parse and display cards without writing');
|
|
1583
|
+
} else {
|
|
1584
|
+
ensureIndexDir(indexDir);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Load manifest
|
|
1588
|
+
const manifest = readManifest(indexDir);
|
|
1589
|
+
const watermark = args.fullRebuild ? null : manifest.watermark;
|
|
1590
|
+
|
|
1591
|
+
if (watermark) {
|
|
1592
|
+
log(`Incremental: fetching issues updated since ${watermark}`);
|
|
1593
|
+
} else {
|
|
1594
|
+
log('Full rebuild: fetching all closed issues');
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// Determine which issues to process
|
|
1598
|
+
let issuesToProcess = [];
|
|
1599
|
+
|
|
1600
|
+
if (args.issue) {
|
|
1601
|
+
// Single-issue mode (close phase integration)
|
|
1602
|
+
log(`Single-issue mode: indexing issue #${args.issue}`);
|
|
1603
|
+
const { issue, comments } = fetchIssue(repo, args.issue);
|
|
1604
|
+
issuesToProcess = [{ issue, comments }];
|
|
1605
|
+
} else {
|
|
1606
|
+
// Batch mode: fetch issues since watermark
|
|
1607
|
+
const rawIssues = fetchIssuesSince(repo, watermark);
|
|
1608
|
+
log(`Fetched ${rawIssues.length} issues to process`);
|
|
1609
|
+
|
|
1610
|
+
for (const issue of rawIssues) {
|
|
1611
|
+
let comments = [];
|
|
1612
|
+
try {
|
|
1613
|
+
const commentsRaw = ghApi(`repos/${repo}/issues/${issue.number}/comments?per_page=100`);
|
|
1614
|
+
comments = JSON.parse(commentsRaw);
|
|
1615
|
+
} catch (_) { /* empty comments */ }
|
|
1616
|
+
issuesToProcess.push({ issue, comments });
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// Read existing cards (for non-full-rebuild mode)
|
|
1621
|
+
let allCards = args.fullRebuild ? [] : readJsonl(join(indexDir, CARDS_FILE));
|
|
1622
|
+
|
|
1623
|
+
// Track new watermark
|
|
1624
|
+
let newWatermark = watermark;
|
|
1625
|
+
|
|
1626
|
+
// Process each issue
|
|
1627
|
+
let successCount = 0;
|
|
1628
|
+
let errorCount = 0;
|
|
1629
|
+
|
|
1630
|
+
for (const { issue, comments } of issuesToProcess) {
|
|
1631
|
+
const issueNumber = issue.number;
|
|
1632
|
+
debug(`Processing issue #${issueNumber}: ${issue.title}`);
|
|
1633
|
+
|
|
1634
|
+
try {
|
|
1635
|
+
const cards = await extractCardsFromIssue(issueNumber, issue, comments);
|
|
1636
|
+
|
|
1637
|
+
// Run staleness detection and link ADR files for decision cards
|
|
1638
|
+
// <!-- Added: forge#1737 — ADR path linking + needs-review for dead-anchor decisions -->
|
|
1639
|
+
for (const card of cards) {
|
|
1640
|
+
card.status = checkStaleness(card, repoPath);
|
|
1641
|
+
|
|
1642
|
+
if (card.kind === 'decision') {
|
|
1643
|
+
// Link the ADR file if one was written by close.md Phase C5.3
|
|
1644
|
+
const adrPath = findADRPath(card.issue, repoPath);
|
|
1645
|
+
if (adrPath) {
|
|
1646
|
+
card.adrPath = adrPath;
|
|
1647
|
+
}
|
|
1648
|
+
// If anchor is dead, update ADR frontmatter status to needs-review
|
|
1649
|
+
if (card.status === 'needs-review' && card.adrPath) {
|
|
1650
|
+
updateADRStatus(card.adrPath, repoPath);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
const hash = hashCards(cards);
|
|
1656
|
+
|
|
1657
|
+
if (!args.dryRun) {
|
|
1658
|
+
// Remove existing cards for this issue (re-index idempotency)
|
|
1659
|
+
allCards = allCards.filter(c => c.issue !== issueNumber);
|
|
1660
|
+
allCards.push(...cards);
|
|
1661
|
+
|
|
1662
|
+
// Update manifest
|
|
1663
|
+
manifest.issues[String(issueNumber)] = hash;
|
|
1664
|
+
} else {
|
|
1665
|
+
// Dry run: print cards
|
|
1666
|
+
for (const card of cards) {
|
|
1667
|
+
process.stdout.write(JSON.stringify(card) + '\n');
|
|
1668
|
+
}
|
|
1669
|
+
// Accumulate for dry-run cost-prior output (see end of main)
|
|
1670
|
+
allCards.push(...cards);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Advance watermark
|
|
1674
|
+
const updatedAt = issue.updated_at || issue.created_at;
|
|
1675
|
+
if (updatedAt && (!newWatermark || updatedAt > newWatermark)) {
|
|
1676
|
+
newWatermark = updatedAt;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
successCount++;
|
|
1680
|
+
if (cards.length > 0) {
|
|
1681
|
+
debug(`Issue #${issueNumber}: extracted ${cards.length} card(s), hash=${hash}`);
|
|
1682
|
+
}
|
|
1683
|
+
} catch (e) {
|
|
1684
|
+
log(`WARNING: Failed to process issue #${issueNumber}: ${e.message}`);
|
|
1685
|
+
errorCount++;
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
if (!args.dryRun) {
|
|
1690
|
+
// Write JSONL
|
|
1691
|
+
writeFileSync(
|
|
1692
|
+
join(indexDir, CARDS_FILE),
|
|
1693
|
+
allCards.map(c => JSON.stringify(c)).join('\n') + (allCards.length > 0 ? '\n' : ''),
|
|
1694
|
+
'utf8',
|
|
1695
|
+
);
|
|
1696
|
+
|
|
1697
|
+
// Build and write postings
|
|
1698
|
+
const postings = buildPostings(allCards);
|
|
1699
|
+
writeFileSync(join(indexDir, POSTINGS_FILE), JSON.stringify(postings), 'utf8');
|
|
1700
|
+
|
|
1701
|
+
// Aggregate cost priors for economic scheduling (forge#1743)
|
|
1702
|
+
aggregateCostPriors(allCards, indexDir, false);
|
|
1703
|
+
|
|
1704
|
+
// Update watermark in manifest
|
|
1705
|
+
manifest.watermark = newWatermark;
|
|
1706
|
+
manifest.schemaVersion = 1;
|
|
1707
|
+
manifest.lastIndexed = new Date().toISOString();
|
|
1708
|
+
manifest.cardCount = allCards.length;
|
|
1709
|
+
writeManifest(indexDir, manifest);
|
|
1710
|
+
|
|
1711
|
+
// Update rename log
|
|
1712
|
+
updateRenames(indexDir, repoPath, watermark);
|
|
1713
|
+
|
|
1714
|
+
log(`Indexed ${successCount} issues, ${allCards.length} cards total${errorCount > 0 ? ` (${errorCount} errors)` : ''}`);
|
|
1715
|
+
|
|
1716
|
+
// Mirror to orphan branch (non-blocking)
|
|
1717
|
+
if (!args.noMirror) {
|
|
1718
|
+
log('Mirroring to forge-knowledge branch...');
|
|
1719
|
+
mirrorToOrphanBranch(indexDir, repoPath);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Update danger-zones index (non-blocking) — refreshes per-file finding stats
|
|
1723
|
+
// and co-change matrix so Layer 5 and CHURN_CONTEXT consumers get fresh risk data.
|
|
1724
|
+
if (args.withDangerZones) {
|
|
1725
|
+
log('Updating danger-zones index (non-blocking)...');
|
|
1726
|
+
const dangerZonesScript = join(__dirname, 'danger-zones.mjs');
|
|
1727
|
+
if (existsSync(dangerZonesScript)) {
|
|
1728
|
+
try {
|
|
1729
|
+
const dzResult = spawnSync(
|
|
1730
|
+
process.execPath,
|
|
1731
|
+
[dangerZonesScript, '--output', indexDir, '--repo-path', repoPath],
|
|
1732
|
+
{ encoding: 'utf8', timeout: 120000, stdio: 'pipe' },
|
|
1733
|
+
);
|
|
1734
|
+
if (dzResult.status !== 0) {
|
|
1735
|
+
log(`WARNING: danger-zones.mjs exited with ${dzResult.status} — ${dzResult.stderr || dzResult.stdout || '(no output)'}`);
|
|
1736
|
+
} else {
|
|
1737
|
+
log('danger-zones index updated');
|
|
1738
|
+
}
|
|
1739
|
+
} catch (e) {
|
|
1740
|
+
log(`WARNING: danger-zones.mjs failed to run — ${e.message}`);
|
|
1741
|
+
}
|
|
1742
|
+
} else {
|
|
1743
|
+
log(`WARNING: --with-danger-zones flag set but ${dangerZonesScript} not found — skipping`);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
if (args.dryRun) {
|
|
1749
|
+
// Dry run: print cost priors to stdout
|
|
1750
|
+
aggregateCostPriors(allCards, indexDir, true);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
if (errorCount > 0 && successCount === 0) {
|
|
1754
|
+
process.exit(2);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
main().catch(e => {
|
|
1759
|
+
process.stderr.write(`[ledger] FATAL: ${e.message}\n${e.stack}\n`);
|
|
1760
|
+
process.exit(1);
|
|
1761
|
+
});
|