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.
Files changed (37) hide show
  1. package/bin/engine/invariants.mjs +361 -0
  2. package/bin/hooks/pre-tool-use.mjs +182 -0
  3. package/bin/labels.json +10 -0
  4. package/bin/recall.mjs +540 -0
  5. package/commands/autopilot.md +957 -76
  6. package/commands/cleanup.md +52 -3
  7. package/commands/optimize.md +585 -0
  8. package/commands/orchestrate/phase-1-resolve.md +35 -25
  9. package/commands/orchestrate/phase-3-dependency.md +465 -34
  10. package/commands/orchestrate/phase-4-execution.md +447 -28
  11. package/commands/orchestrate/phase-6-report.md +198 -9
  12. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  13. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  14. package/commands/quality-gate.md +417 -0
  15. package/commands/review-pr.md +391 -13
  16. package/commands/spec-doctor.md +429 -0
  17. package/commands/validate.md +52 -0
  18. package/commands/work-on/build/architect.md +77 -0
  19. package/commands/work-on/build/context.md +357 -22
  20. package/commands/work-on/build/implement.md +2 -0
  21. package/commands/work-on/build/validate.md +28 -0
  22. package/commands/work-on/build.md +44 -0
  23. package/commands/work-on/close.md +621 -8
  24. package/commands/work-on/investigate.md +221 -0
  25. package/commands/work-on.md +15 -14
  26. package/docs/FORGE-PROTOCOL.md +101 -0
  27. package/docs/spec/forge-protocol-v1.md +97 -5
  28. package/docs/spec/label-state-machine.md +6 -1
  29. package/package.json +2 -2
  30. package/packages/protocol/src/cli.js +374 -84
  31. package/packages/protocol/src/types.js +75 -1
  32. package/scripts/build-knowledge-index.mjs +1761 -0
  33. package/scripts/calibration.mjs +996 -0
  34. package/scripts/danger-zones.mjs +782 -0
  35. package/scripts/doctor-pipeline-state.sh +88 -2
  36. package/scripts/select-fix-targets.sh +234 -0
  37. 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
+ });