forgedock 1.1.7 → 1.1.9

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 (97) hide show
  1. package/README.md +10 -33
  2. package/bin/cinema.mjs +23 -5
  3. package/bin/engine/invariants.mjs +361 -0
  4. package/bin/engine-cli.mjs +137 -11
  5. package/bin/env-detect.mjs +249 -0
  6. package/bin/forge-utils.mjs +364 -4
  7. package/bin/forgedock.mjs +1064 -135
  8. package/bin/hooks/pre-tool-use.mjs +182 -0
  9. package/bin/hooks/session-start.mjs +68 -3
  10. package/bin/init-detect.mjs +101 -12
  11. package/bin/journey.mjs +917 -16
  12. package/bin/labels.json +10 -0
  13. package/bin/recall.mjs +557 -0
  14. package/bin/registry.mjs +91 -3
  15. package/bin/runner.mjs +38 -2
  16. package/bin/tui.mjs +49 -3
  17. package/commands/adopt.md +1 -1
  18. package/commands/analytics.md +8 -8
  19. package/commands/audit.md +1 -1
  20. package/commands/autopilot.md +958 -77
  21. package/commands/ci-audit.md +1 -1
  22. package/commands/cleanup.md +139 -55
  23. package/commands/compat-audit.md +1 -1
  24. package/commands/deploy-info.md +1 -1
  25. package/commands/deploy-pr.md +1 -1
  26. package/commands/diagnose.md +1 -1
  27. package/commands/explain.md +1 -1
  28. package/commands/fix-ci.md +1 -1
  29. package/commands/forgedock-init.md +1 -1
  30. package/commands/incident-response.md +3 -3
  31. package/commands/issue.md +1 -1
  32. package/commands/optimize.md +586 -1
  33. package/commands/orchestrate/config.md +13 -3
  34. package/commands/orchestrate/phase-1-resolve.md +36 -26
  35. package/commands/orchestrate/phase-2-triage.md +1 -1
  36. package/commands/orchestrate/phase-2.5-synthesis.md +1 -1
  37. package/commands/orchestrate/phase-3-dependency.md +587 -48
  38. package/commands/orchestrate/phase-4-execution.md +1031 -57
  39. package/commands/orchestrate/phase-5-cleanup.md +1 -1
  40. package/commands/orchestrate/phase-6-report.md +248 -11
  41. package/commands/orchestrate/safety.md +2 -2
  42. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  43. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  44. package/commands/qa-sweep.md +1 -1
  45. package/commands/quality-gate.md +418 -1
  46. package/commands/recover-orphans.md +5 -5
  47. package/commands/replay.md +1 -1
  48. package/commands/review-pr-agents/api.md +2 -2
  49. package/commands/review-pr-agents/auth.md +2 -2
  50. package/commands/review-pr-agents/billing.md +2 -2
  51. package/commands/review-pr-agents/concurrency.md +2 -2
  52. package/commands/review-pr-agents/database.md +2 -2
  53. package/commands/review-pr-agents/frontend.md +2 -2
  54. package/commands/review-pr-agents/infra.md +2 -2
  55. package/commands/review-pr-agents/protocols.md +1 -1
  56. package/commands/review-pr-agents/scraper.md +2 -2
  57. package/commands/review-pr-agents/security.md +2 -2
  58. package/commands/review-pr-staging.md +41 -3
  59. package/commands/review-pr.md +462 -19
  60. package/commands/rollback.md +1 -1
  61. package/commands/scope.md +1 -1
  62. package/commands/security-audit.md +1 -1
  63. package/commands/spec-doctor.md +429 -0
  64. package/commands/test-gate.md +1 -1
  65. package/commands/validate.md +53 -1
  66. package/commands/work-on/build/architect.md +78 -1
  67. package/commands/work-on/build/context.md +360 -22
  68. package/commands/work-on/build/implement.md +3 -1
  69. package/commands/work-on/build/validate.md +29 -1
  70. package/commands/work-on/build.md +75 -6
  71. package/commands/work-on/close.md +624 -10
  72. package/commands/work-on/decompose.md +1 -1
  73. package/commands/work-on/investigate.md +235 -5
  74. package/commands/work-on/remediate.md +331 -0
  75. package/commands/work-on/review.md +1 -1
  76. package/commands/work-on-monolithic.md +1 -1
  77. package/commands/work-on.md +205 -66
  78. package/docs/CI.md +2 -2
  79. package/docs/CODEX.md +1 -1
  80. package/docs/CONFIG.md +186 -0
  81. package/docs/FORGE-PROTOCOL.md +101 -0
  82. package/docs/site/command-reference.md +31 -1
  83. package/docs/site/getting-started.md +9 -34
  84. package/docs/site/index.md +1 -1
  85. package/docs/spec/forge-protocol-v1.md +97 -5
  86. package/docs/spec/label-state-machine.md +6 -1
  87. package/package.json +3 -2
  88. package/packages/protocol/src/cli.js +374 -84
  89. package/packages/protocol/src/types.js +75 -1
  90. package/scripts/build-knowledge-index.mjs +1761 -0
  91. package/scripts/calibration.mjs +996 -0
  92. package/scripts/check-command-docs-drift.sh +4 -2
  93. package/scripts/danger-zones.mjs +789 -0
  94. package/scripts/doctor-pipeline-state.sh +88 -2
  95. package/scripts/select-fix-targets.sh +234 -0
  96. package/scripts/transition-label.sh +245 -6
  97. package/scripts/refresh-bot-token.sh +0 -97
package/bin/labels.json CHANGED
@@ -39,6 +39,11 @@
39
39
  "color": "5319E7",
40
40
  "description": "Pipeline: PR created, under review. Managed by ForgeDock."
41
41
  },
42
+ {
43
+ "name": "workflow:awaiting-merge",
44
+ "color": "FF8C00",
45
+ "description": "Pipeline: remediated + re-reviewed, awaiting a human merge decision. Managed by ForgeDock."
46
+ },
42
47
  {
43
48
  "name": "workflow:merged",
44
49
  "color": "0E8A16",
@@ -59,6 +64,11 @@
59
64
  "color": "E4E669",
60
65
  "description": "Pipeline stalled — requires human intervention. Managed by ForgeDock."
61
66
  },
67
+ {
68
+ "name": "blocked-on-human-merge",
69
+ "color": "006B75",
70
+ "description": "Dependent of a gated (needs-human/awaiting-merge) predecessor. Managed by ForgeDock."
71
+ },
62
72
  {
63
73
  "name": "review-finding",
64
74
  "color": "D93F0B",
package/bin/recall.mjs ADDED
@@ -0,0 +1,557 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /**
6
+ * bin/recall.mjs — Forge Ledger recall CLI.
7
+ *
8
+ * Queries the local knowledge index (~/.forge/index/) and returns relevant
9
+ * knowledge cards with issue citations.
10
+ *
11
+ * Query modes:
12
+ * --file <path> Exact file-path lookup (hash-map, O(1)); repeatable for multi-file union
13
+ * --symbol <name> Exact symbol lookup (hash-map, O(1))
14
+ * free text BM25-lite ranked search across all card fields
15
+ *
16
+ * Combined queries (--file + free text) take the union with exact-match boost.
17
+ * Multiple --file flags are unioned: cards matching any file receive a boost per
18
+ * matched file; results are deduped by card ID and ranked by combined score.
19
+ *
20
+ * Usage:
21
+ * forge recall <free-text query> [--file <path>] [--symbol <name>]
22
+ * [--k 5] [--min-score 0.3] [--kind pattern|investigation|decision]
23
+ * [--include-stale] [--json]
24
+ *
25
+ * Options:
26
+ * --file <path> Exact file path to look up cards for (repeatable — union across files)
27
+ * --symbol <name> Exact symbol name to look up
28
+ * --k <number> Maximum results to return (default: 5)
29
+ * --min-score <float> Minimum BM25 score threshold (default: 0.1)
30
+ * --kind <type> Filter by card kind (pattern|investigation|decision)
31
+ * --include-stale Include stale cards (excluded by default)
32
+ * --json Output JSON array instead of formatted text
33
+ * --index <dir> Override index directory (default: ~/.forge/index)
34
+ * --doctor Run divergence check (requires GitHub API access)
35
+ *
36
+ * Exit codes:
37
+ * 0 — results found (or empty index, not an error)
38
+ * 1 — fatal error (cannot read index, invalid args)
39
+ *
40
+ * Machine-readable JSON output format (--json):
41
+ * [{ id, kind, issue, score, paths, symbols, pattern, rootCause, prevention,
42
+ * decision, verdict, confidence, status, createdAt }]
43
+ *
44
+ * @module recall
45
+ */
46
+
47
+ import { existsSync, readFileSync } from 'fs';
48
+ import { homedir } from 'os';
49
+ import { join, resolve } from 'path';
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Constants
53
+ // ---------------------------------------------------------------------------
54
+
55
+ const DEFAULT_INDEX_DIR = join(homedir(), '.forge', 'index');
56
+ const CARDS_FILE = 'knowledge.jsonl';
57
+ const POSTINGS_FILE = 'postings.json';
58
+ const DEFAULT_K = 5;
59
+ const DEFAULT_MIN_SCORE = 0.1;
60
+ const AGE_DECAY_DAYS = 180;
61
+
62
+ // BM25 parameters (per FORGE:DESIGN_DECISION spec)
63
+ const BM25_K1 = 1.2;
64
+ const BM25_B = 0.75;
65
+
66
+ // Score boosts for exact matches (per spec)
67
+ const EXACT_FILE_BOOST = 5.0;
68
+ const EXACT_SYMBOL_BOOST = 3.0;
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Argument parsing
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function parseArgs(argv) {
75
+ const args = {
76
+ query: [],
77
+ files: [], // Array — accepts repeated --file flags (union query across multiple file paths)
78
+ symbol: null,
79
+ k: DEFAULT_K,
80
+ minScore: DEFAULT_MIN_SCORE,
81
+ kind: null,
82
+ includeStale: false,
83
+ json: false,
84
+ indexDir: DEFAULT_INDEX_DIR,
85
+ doctor: false,
86
+ };
87
+
88
+ for (let i = 2; i < argv.length; i++) {
89
+ switch (argv[i]) {
90
+ case '--file':
91
+ args.files.push(argv[++i]);
92
+ break;
93
+ case '--symbol':
94
+ args.symbol = argv[++i];
95
+ break;
96
+ case '--k':
97
+ args.k = parseInt(argv[++i], 10) || DEFAULT_K;
98
+ break;
99
+ case '--min-score':
100
+ args.minScore = parseFloat(argv[++i]) || DEFAULT_MIN_SCORE;
101
+ break;
102
+ case '--kind':
103
+ args.kind = argv[++i];
104
+ break;
105
+ case '--include-stale':
106
+ args.includeStale = true;
107
+ break;
108
+ case '--json':
109
+ args.json = true;
110
+ break;
111
+ case '--index':
112
+ args.indexDir = argv[++i];
113
+ break;
114
+ case '--doctor':
115
+ args.doctor = true;
116
+ break;
117
+ default:
118
+ if (!argv[i].startsWith('--')) {
119
+ args.query.push(argv[i]);
120
+ }
121
+ break;
122
+ }
123
+ }
124
+
125
+ args.queryText = args.query.join(' ').trim();
126
+ return args;
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Index loading
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /**
134
+ * Load cards from JSONL file.
135
+ */
136
+ function loadCards(indexDir) {
137
+ const cardsPath = join(indexDir, CARDS_FILE);
138
+ if (!existsSync(cardsPath)) return [];
139
+
140
+ try {
141
+ return readFileSync(cardsPath, 'utf8')
142
+ .split('\n')
143
+ .filter(l => l.trim())
144
+ .map(l => JSON.parse(l));
145
+ } catch (e) {
146
+ process.stderr.write(`[recall] WARNING: Could not read ${cardsPath}: ${e.message}\n`);
147
+ return [];
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Load postings (inverted) index.
153
+ */
154
+ function loadPostings(indexDir) {
155
+ const postingsPath = join(indexDir, POSTINGS_FILE);
156
+ if (!existsSync(postingsPath)) return {};
157
+
158
+ try {
159
+ return JSON.parse(readFileSync(postingsPath, 'utf8'));
160
+ } catch (_) {
161
+ return {};
162
+ }
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // BM25-lite tokenizer (must match build-knowledge-index.mjs)
167
+ // ---------------------------------------------------------------------------
168
+
169
+ function tokenize(text) {
170
+ if (!text) return [];
171
+ const tokens = new Set();
172
+
173
+ const raw = text.toLowerCase().split(/[^a-z0-9_]+/).filter(t => t.length >= 2);
174
+ for (const tok of raw) {
175
+ tokens.add(tok);
176
+ if (tok.includes('_')) {
177
+ for (const part of tok.split('_')) {
178
+ if (part.length >= 2) tokens.add(part);
179
+ }
180
+ }
181
+ }
182
+
183
+ const camelRe = /[A-Z][a-z]+/g;
184
+ const camelMatches = text.match(camelRe) || [];
185
+ for (const part of camelMatches) {
186
+ if (part.length >= 2) tokens.add(part.toLowerCase());
187
+ }
188
+
189
+ return Array.from(tokens);
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // BM25-lite scoring
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Compute BM25-lite score for a set of query terms against the postings index.
198
+ *
199
+ * score = Σ_{t∈q} idf(t) · fieldTf·(k1+1) / (fieldTf + k1·(1−b+b·len/avgLen))
200
+ * · exp(−ageDays/180)
201
+ * · (status == "stale" ? 0 : 1)
202
+ *
203
+ * @param {string[]} queryTerms
204
+ * @param {string} cardId
205
+ * @param {object} postings
206
+ * @param {number} N - total card count
207
+ * @param {number} avgLen - average card token count (approximated)
208
+ * @param {number} cardLen - this card's token count (approximated)
209
+ * @param {number} ageDays
210
+ * @param {string} status
211
+ * @returns {number}
212
+ */
213
+ function bm25Score(queryTerms, cardId, postings, N, avgLen, cardLen, ageDays, status) {
214
+ if (status === 'stale') return 0;
215
+
216
+ let score = 0;
217
+ const dfCache = {};
218
+
219
+ for (const term of queryTerms) {
220
+ if (!postings[term]) continue;
221
+
222
+ const df = Object.keys(postings[term]).length;
223
+ const fieldTf = postings[term][cardId] || 0;
224
+ if (fieldTf === 0) continue;
225
+
226
+ // IDF
227
+ const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
228
+
229
+ // BM25 numerator/denominator
230
+ const norm = fieldTf * (BM25_K1 + 1) / (fieldTf + BM25_K1 * (1 - BM25_B + BM25_B * cardLen / Math.max(avgLen, 1)));
231
+ score += idf * norm;
232
+ }
233
+
234
+ // Age decay: exp(−ageDays / 180)
235
+ const decay = Math.exp(-ageDays / AGE_DECAY_DAYS);
236
+ return score * decay;
237
+ }
238
+
239
+ /**
240
+ * Compute card age in days from createdAt field.
241
+ */
242
+ function cardAgeDays(card) {
243
+ if (!card.createdAt) return 0;
244
+ try {
245
+ const created = new Date(card.createdAt).getTime();
246
+ const now = Date.now();
247
+ return Math.max(0, (now - created) / (1000 * 60 * 60 * 24));
248
+ } catch (_) {
249
+ return 0;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Approximate token count for a card (used for BM25 length normalization).
255
+ */
256
+ function cardTokenCount(card) {
257
+ const text = [
258
+ card.pattern || '',
259
+ card.rootCause || '',
260
+ card.prevention || '',
261
+ card.decision || '',
262
+ (card.symbols || []).join(' '),
263
+ (card.paths || []).join(' '),
264
+ ].join(' ');
265
+ return tokenize(text).length;
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Rename map application
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /**
273
+ * Load rename log and build a map from old path → current path.
274
+ */
275
+ function buildRenameMap(indexDir) {
276
+ const renamesPath = join(indexDir, 'renames.jsonl');
277
+ if (!existsSync(renamesPath)) return {};
278
+
279
+ const map = {};
280
+ try {
281
+ const lines = readFileSync(renamesPath, 'utf8').split('\n').filter(l => l.trim());
282
+ for (const line of lines) {
283
+ const entry = JSON.parse(line);
284
+ // One-hop record only (last entry for a given `from` wins, matching
285
+ // append-only log semantics). Multi-hop chains (A->B->C) are resolved
286
+ // transitively at lookup time in applyRenameMap — see below.
287
+ if (entry && entry.from) map[entry.from] = entry.to;
288
+ }
289
+ } catch (_) {
290
+ // Return empty map on error
291
+ }
292
+ return map;
293
+ }
294
+
295
+ /**
296
+ * Apply rename map to query file path.
297
+ * Follows the rename chain to a fixed point so multi-hop renames
298
+ * (e.g. A -> B -> C) resolve to the final current path, not just the
299
+ * first hop. Guards against cycles (e.g. A -> B, B -> A) with a visited
300
+ * set so a malformed/cyclic rename log can't cause an infinite loop.
301
+ */
302
+ function applyRenameMap(queryPath, renameMap) {
303
+ let current = queryPath;
304
+ const seen = new Set([current]);
305
+ while (Object.prototype.hasOwnProperty.call(renameMap, current)) {
306
+ const next = renameMap[current];
307
+ if (next === undefined || next === current || seen.has(next)) break;
308
+ current = next;
309
+ seen.add(current);
310
+ }
311
+ return current;
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Query execution
316
+ // ---------------------------------------------------------------------------
317
+
318
+ /**
319
+ * Execute a recall query and return ranked results.
320
+ *
321
+ * @param {object} args - parsed CLI args
322
+ * @param {object[]} cards - all cards
323
+ * @param {object} postings - postings index
324
+ * @returns {Array<{card, score}>}
325
+ */
326
+ function executeQuery(args, cards, postings) {
327
+ const renameMap = buildRenameMap(args.indexDir);
328
+
329
+ // Normalize all query file paths through rename map (multi-file union support)
330
+ // args.files is an array; each entry may be a single path from repeated --file flags.
331
+ const effectiveFiles = (args.files || []).map(f => ({
332
+ original: f,
333
+ effective: applyRenameMap(f, renameMap),
334
+ basename: (applyRenameMap(f, renameMap).split('/').pop() || f),
335
+ }));
336
+
337
+ // Pre-filter cards
338
+ let candidates = cards.filter(card => {
339
+ if (!args.includeStale && card.status === 'stale') return false;
340
+ if (args.kind && card.kind !== args.kind) return false;
341
+ return true;
342
+ });
343
+
344
+ // BM25 setup
345
+ const N = candidates.length;
346
+ const tokenCounts = candidates.map(c => cardTokenCount(c));
347
+ const avgLen = tokenCounts.length > 0 ? tokenCounts.reduce((a, b) => a + b, 0) / tokenCounts.length : 1;
348
+
349
+ // Query tokenization: free text + all file paths + symbol
350
+ const queryTerms = args.queryText ? tokenize(args.queryText) : [];
351
+ for (const ef of effectiveFiles) queryTerms.push(...tokenize(ef.effective));
352
+ if (args.symbol) queryTerms.push(...tokenize(args.symbol));
353
+
354
+ // Use a Map for dedup: card.id → {card, score}
355
+ // When the same card matches multiple files, accumulate the highest score.
356
+ const hitMap = new Map();
357
+
358
+ for (let i = 0; i < candidates.length; i++) {
359
+ const card = candidates[i];
360
+ const ageDays = cardAgeDays(card);
361
+ const cardLen = tokenCounts[i];
362
+
363
+ let score = 0;
364
+
365
+ // BM25 text score
366
+ if (queryTerms.length > 0) {
367
+ score = bm25Score(queryTerms, card.id, postings, N, avgLen, cardLen, ageDays, card.status);
368
+ }
369
+
370
+ // Per-file exact match boost (applied once per matching file — accumulates across files)
371
+ let hasExactMatch = false;
372
+ for (const ef of effectiveFiles) {
373
+ const cardPaths = card.paths || [];
374
+ if (cardPaths.some(p => p === ef.effective || p === ef.original)) {
375
+ score += EXACT_FILE_BOOST;
376
+ hasExactMatch = true;
377
+ } else if (ef.basename && cardPaths.some(p => p.split('/').pop() === ef.basename)) {
378
+ score += EXACT_FILE_BOOST * 0.5; // Partial boost for basename match
379
+ // basename match does not set hasExactMatch (not an exact path match)
380
+ }
381
+ }
382
+
383
+ // Exact symbol match boost
384
+ if (args.symbol) {
385
+ const cardSymbols = card.symbols || [];
386
+ if (cardSymbols.some(s => s === args.symbol || s.toLowerCase() === args.symbol.toLowerCase())) {
387
+ score += EXACT_SYMBOL_BOOST;
388
+ hasExactMatch = true;
389
+ }
390
+ }
391
+
392
+ // Include if: score > threshold, OR exact file/symbol match (always include even if score=0)
393
+ if (score >= args.minScore || hasExactMatch) {
394
+ const existing = hitMap.get(card.id);
395
+ if (!existing || score > existing.score) {
396
+ hitMap.set(card.id, { card, score });
397
+ }
398
+ }
399
+ }
400
+
401
+ const results = Array.from(hitMap.values());
402
+
403
+ // Sort by score descending, then by recency
404
+ results.sort((a, b) => {
405
+ if (Math.abs(b.score - a.score) > 0.001) return b.score - a.score;
406
+ return (b.card.createdAt || '').localeCompare(a.card.createdAt || '');
407
+ });
408
+
409
+ return results.slice(0, args.k);
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Output formatting
414
+ // ---------------------------------------------------------------------------
415
+
416
+ function formatCard(card, score, showScore = true) {
417
+ const lines = [];
418
+ const cite = `#${card.issue}`;
419
+ const kind = typeof card.kind === 'string' && card.kind ? card.kind.toUpperCase() : 'UNKNOWN';
420
+ const scoreStr = showScore ? ` (score: ${score.toFixed(2)})` : '';
421
+ const staleStr = card.status === 'stale' ? ' [STALE]' : '';
422
+
423
+ lines.push(`── ${kind}${staleStr} from ${cite}${scoreStr} ──`);
424
+
425
+ if (card.kind === 'pattern') {
426
+ if (card.pattern) lines.push(`Pattern: ${card.pattern}`);
427
+ if (card.rootCause) lines.push(`Root Cause: ${card.rootCause}`);
428
+ if (card.prevention) lines.push(`Prevention: ${card.prevention}`);
429
+ } else if (card.kind === 'investigation') {
430
+ if (card.rootCause) lines.push(`Root Cause: ${card.rootCause}`);
431
+ if (card.prevention) lines.push(`Fix: ${card.prevention}`);
432
+ if (card.verdict) lines.push(`Verdict: ${card.verdict} (${card.confidence || '?'})`);
433
+ } else if (card.kind === 'decision') {
434
+ if (card.decision) lines.push(`Decision: ${card.decision}`);
435
+ if (card.anomalies && card.anomalies !== 'None') {
436
+ lines.push(`Anomalies: ${card.anomalies}`);
437
+ }
438
+ }
439
+
440
+ if (card.paths && card.paths.length > 0) {
441
+ lines.push(`Files: ${card.paths.slice(0, 5).join(', ')}${card.paths.length > 5 ? ` +${card.paths.length - 5} more` : ''}`);
442
+ }
443
+ if (card.symbols && card.symbols.length > 0) {
444
+ lines.push(`Symbols: ${card.symbols.slice(0, 8).join(', ')}${card.symbols.length > 8 ? ` +${card.symbols.length - 8} more` : ''}`);
445
+ }
446
+ if (card.severity) lines.push(`Severity: ${card.severity}`);
447
+ if (card.createdAt) lines.push(`Date: ${card.createdAt}`);
448
+ lines.push(`Cite: https://github.com/issues/${card.issue}`);
449
+
450
+ return lines.join('\n');
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // Main entry point
455
+ // ---------------------------------------------------------------------------
456
+
457
+ function main() {
458
+ const args = parseArgs(process.argv);
459
+ args.indexDir = resolve(args.indexDir);
460
+
461
+ // Validate
462
+ if (!args.queryText && args.files.length === 0 && !args.symbol && !args.doctor) {
463
+ process.stderr.write('Usage: forge recall <query> [--file <path>] [--symbol <name>] [--json] [--k N]\n');
464
+ process.stderr.write(' --file may be repeated for a multi-file union query\n');
465
+ process.stderr.write(' forge recall --doctor (check index health)\n');
466
+ process.exit(0); // Not an error — just no query
467
+ }
468
+
469
+ // Check index exists
470
+ const cardsPath = join(args.indexDir, CARDS_FILE);
471
+ if (!existsSync(cardsPath)) {
472
+ if (args.json) {
473
+ process.stdout.write('[]\n');
474
+ } else {
475
+ process.stderr.write(`[recall] Index not found at ${args.indexDir}\n`);
476
+ process.stderr.write('[recall] Run: node scripts/build-knowledge-index.mjs --full-rebuild\n');
477
+ }
478
+ process.exit(0); // Not an error — index just needs building
479
+ }
480
+
481
+ // Load index
482
+ const cards = loadCards(args.indexDir);
483
+ const postings = loadPostings(args.indexDir);
484
+
485
+ if (args.doctor) {
486
+ // Doctor mode: show index stats
487
+ const total = cards.length;
488
+ const stale = cards.filter(c => c.status === 'stale').length;
489
+ const byKind = {};
490
+ for (const c of cards) byKind[c.kind] = (byKind[c.kind] || 0) + 1;
491
+ const uniqueIssues = new Set(cards.map(c => c.issue)).size;
492
+
493
+ process.stdout.write(`Forge Ledger Index Health\n`);
494
+ process.stdout.write(`─────────────────────────\n`);
495
+ process.stdout.write(`Total cards: ${total}\n`);
496
+ process.stdout.write(`Stale: ${stale} (${total > 0 ? Math.round(stale / total * 100) : 0}%)\n`);
497
+ process.stdout.write(`Unique issues: ${uniqueIssues}\n`);
498
+ process.stdout.write(`By kind: ${JSON.stringify(byKind)}\n`);
499
+ process.stdout.write(`Index dir: ${args.indexDir}\n`);
500
+ process.exit(0);
501
+ }
502
+
503
+ if (cards.length === 0) {
504
+ if (args.json) {
505
+ process.stdout.write('[]\n');
506
+ } else {
507
+ process.stderr.write('[recall] Index is empty. Run: node scripts/build-knowledge-index.mjs --full-rebuild\n');
508
+ }
509
+ process.exit(0);
510
+ }
511
+
512
+ // Execute query
513
+ const results = executeQuery(args, cards, postings);
514
+
515
+ if (results.length === 0) {
516
+ if (args.json) {
517
+ process.stdout.write('[]\n');
518
+ } else {
519
+ process.stderr.write(`[recall] No results found for query: ${args.queryText || args.files.join(', ') || args.symbol}\n`);
520
+ }
521
+ process.exit(0);
522
+ }
523
+
524
+ // Output results
525
+ if (args.json) {
526
+ const output = results.map(({ card, score }) => ({
527
+ id: card.id,
528
+ kind: card.kind,
529
+ issue: card.issue,
530
+ score: Math.round(score * 1000) / 1000,
531
+ paths: card.paths || [],
532
+ symbols: card.symbols || [],
533
+ pattern: card.pattern || null,
534
+ rootCause: card.rootCause || null,
535
+ prevention: card.prevention || null,
536
+ decision: card.decision || null,
537
+ verdict: card.verdict || null,
538
+ confidence: card.confidence || null,
539
+ severity: card.severity || null,
540
+ status: card.status,
541
+ createdAt: card.createdAt || null,
542
+ }));
543
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
544
+ } else {
545
+ process.stdout.write(`Forge Recall — ${results.length} result(s)\n\n`);
546
+ for (const { card, score } of results) {
547
+ process.stdout.write(formatCard(card, score) + '\n\n');
548
+ }
549
+ }
550
+ }
551
+
552
+ try {
553
+ main();
554
+ } catch (e) {
555
+ process.stderr.write(`[recall] ERROR: ${e.message}\n`);
556
+ process.exit(1);
557
+ }