forgedock 1.1.6 → 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 (43) hide show
  1. package/README.md +3 -1
  2. package/bin/engine/invariants.mjs +361 -0
  3. package/bin/hooks/pre-tool-use.mjs +182 -0
  4. package/bin/journey.mjs +77 -7
  5. package/bin/labels.json +10 -0
  6. package/bin/recall.mjs +540 -0
  7. package/commands/autopilot.md +959 -78
  8. package/commands/cleanup.md +52 -3
  9. package/commands/optimize.md +585 -0
  10. package/commands/orchestrate/phase-1-resolve.md +35 -25
  11. package/commands/orchestrate/phase-3-dependency.md +465 -34
  12. package/commands/orchestrate/phase-4-execution.md +448 -29
  13. package/commands/orchestrate/phase-6-report.md +198 -9
  14. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  15. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  16. package/commands/quality-gate.md +417 -0
  17. package/commands/review-pr.md +391 -13
  18. package/commands/spec-doctor.md +429 -0
  19. package/commands/validate.md +52 -0
  20. package/commands/work-on/build/architect.md +77 -0
  21. package/commands/work-on/build/context.md +357 -22
  22. package/commands/work-on/build/implement.md +2 -0
  23. package/commands/work-on/build/validate.md +28 -0
  24. package/commands/work-on/build.md +44 -0
  25. package/commands/work-on/close.md +621 -8
  26. package/commands/work-on/investigate.md +221 -0
  27. package/commands/work-on.md +15 -14
  28. package/docs/FORGE-PROTOCOL.md +101 -0
  29. package/docs/design/awesome-list-submissions.md +105 -0
  30. package/docs/design/f5bot-monitoring-setup.md +43 -0
  31. package/docs/design/growth-strategy-2026.md +579 -0
  32. package/docs/spec/forge-protocol-v1.md +97 -5
  33. package/docs/spec/label-state-machine.md +6 -1
  34. package/package.json +2 -2
  35. package/packages/protocol/src/cli.js +374 -84
  36. package/packages/protocol/src/types.js +75 -1
  37. package/scripts/build-knowledge-index.mjs +1761 -0
  38. package/scripts/calibration.mjs +996 -0
  39. package/scripts/danger-zones.mjs +782 -0
  40. package/scripts/doctor-pipeline-state.sh +88 -2
  41. package/scripts/refresh-bot-token.sh +97 -0
  42. package/scripts/select-fix-targets.sh +234 -0
  43. package/scripts/transition-label.sh +157 -6
package/bin/journey.mjs CHANGED
@@ -238,13 +238,40 @@ export function detectDescription(cwd) {
238
238
  // Journey context (Task 5)
239
239
  // ---------------------------------------------------------------------------
240
240
 
241
- import { execFileSync } from "child_process";
241
+ import { execFileSync, spawnSync } from "child_process";
242
242
  import os from "os";
243
243
  import {
244
244
  renderMark, ember, shimmer, revealRows, moltenBar, fixCard,
245
245
  colorMode, motionEnabled, CHROME_STOPS, HERO_MARK, COMPACT_MARK, sleep,
246
246
  } from "./cinema.mjs";
247
247
 
248
+ // ---------------------------------------------------------------------------
249
+ // URL opener (default openFn implementation — injectable for tests)
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /**
253
+ * Open a URL in the system default browser. Best-effort: errors are swallowed
254
+ * so the caller's journey continues even when no browser is available.
255
+ *
256
+ * Platform dispatch:
257
+ * Linux → xdg-open
258
+ * macOS → open
259
+ * Windows → start (via shell)
260
+ */
261
+ export function openUrl(url) {
262
+ try {
263
+ if (process.platform === "win32") {
264
+ spawnSync("cmd", ["/c", "start", "", url], { stdio: "ignore", windowsHide: true });
265
+ } else if (process.platform === "darwin") {
266
+ spawnSync("open", [url], { detached: true, stdio: "ignore" });
267
+ } else {
268
+ spawnSync("xdg-open", [url], { detached: true, stdio: "ignore" });
269
+ }
270
+ } catch {
271
+ // Best-effort — if the browser can't be opened, the journey continues.
272
+ }
273
+ }
274
+
248
275
  /**
249
276
  * Build the shared journey context. Every act takes this as its first arg.
250
277
  * All process-touching values are injectable for tests.
@@ -255,6 +282,7 @@ export function makeCtx(overrides = {}) {
255
282
  const argv = overrides.argv ?? process.argv.slice(2);
256
283
  const nodeVersion = overrides.nodeVersion ?? process.versions.node;
257
284
  const enrichFn = overrides.enrichFn ?? enrich;
285
+ const openFn = overrides.openFn ?? openUrl;
258
286
  return {
259
287
  cwd: process.cwd(),
260
288
  home: env.HOME || env.USERPROFILE || os.homedir(),
@@ -267,6 +295,7 @@ export function makeCtx(overrides = {}) {
267
295
  nodeVersion,
268
296
  linkStrategy: "symlink",
269
297
  enrichFn,
298
+ openFn,
270
299
  exec: (cmd, args) =>
271
300
  execFileSync(cmd, args, {
272
301
  encoding: "utf-8",
@@ -755,7 +784,7 @@ export async function forge(ctx) {
755
784
 
756
785
  import { detectConfig } from "./init-detect.mjs";
757
786
  import { enrich } from "./init-enrich-api.mjs";
758
- import { annotatedReviewScreen, box } from "./tui.mjs";
787
+ import { annotatedReviewScreen, box, confirm } from "./tui.mjs";
759
788
 
760
789
  const badgeOf = (field) => field.confidence;
761
790
 
@@ -872,10 +901,11 @@ export function celebrate(ctx, summary) {
872
901
  w.write(
873
902
  box(
874
903
  [
875
- ` 1. run npx forgedock labels setup — bootstrap GitHub labels`,
876
- ` 2. run npx forgedock doctor — verify the install is green`,
877
- ` 3. open claude in this repo`,
878
- ` 4. run /issue <title> then /work-on <number>`,
904
+ ` 1. install GitHub App — github.com/apps/rapiercraft-forgedock`,
905
+ ` 2. run npx forgedock labels setup — bootstrap GitHub labels`,
906
+ ` 3. run npx forgedock doctor — verify the install is green`,
907
+ ` 4. open claude in this repo`,
908
+ ` 5. run /issue <title> then /work-on <number>`,
879
909
  ],
880
910
  { title: "what's next" },
881
911
  ),
@@ -883,6 +913,45 @@ export function celebrate(ctx, summary) {
883
913
  w.write(" " + dimLine(ctx, "docs: github.com/RapierCraftStudios/ForgeDock · ⭐ a star is the whole marketing budget") + "\n\n");
884
914
  }
885
915
 
916
+ // ---------------------------------------------------------------------------
917
+ // Act V.5 — Connect: GitHub App install prompt (Issue #1719)
918
+ // ---------------------------------------------------------------------------
919
+
920
+ const GITHUB_APP_URL = "https://github.com/apps/rapiercraft-forgedock/installations/new";
921
+
922
+ /**
923
+ * Act V.5: prompt the user to install the ForgeDock GitHub App on their
924
+ * account. Non-blocking — declining or non-interactive mode both continue
925
+ * the journey without error.
926
+ *
927
+ * Uses ctx.openFn (injectable for tests) to open the URL in the default
928
+ * browser. In non-TTY environments (piped stdin, --fast) confirm() resolves
929
+ * immediately to false (the defaultValue), so no prompt is shown.
930
+ *
931
+ * @returns {Promise<{ opened: boolean }>}
932
+ */
933
+ export async function connect(ctx) {
934
+ const { stdout: w } = ctx;
935
+ w.write("\n " + ember("Connect to GitHub", ctx.mode) + "\n\n");
936
+
937
+ const yes = await confirm(
938
+ "Install the ForgeDock GitHub App for automatic pipeline triggers?",
939
+ false,
940
+ );
941
+
942
+ if (yes) {
943
+ ctx.openFn(GITHUB_APP_URL);
944
+ w.write(
945
+ ` \x1b[38;2;255;179;71m✔\x1b[0m Opening ${GITHUB_APP_URL}\n` +
946
+ ` ${ctx.mode === "none" ? "" : "\x1b[2m"}Install account-wide (all repositories) to enable webhook triggers.\x1b[22m\n`,
947
+ );
948
+ return { opened: true };
949
+ }
950
+
951
+ w.write(` ${ctx.mode === "none" ? "" : "\x1b[2m"}Skipped — install anytime: ${GITHUB_APP_URL}\x1b[22m\n`);
952
+ return { opened: false };
953
+ }
954
+
886
955
  // ---------------------------------------------------------------------------
887
956
  // The full journey (Task 8)
888
957
  // ---------------------------------------------------------------------------
@@ -902,7 +971,8 @@ export async function runJourney(ctx) {
902
971
  const forged = await forge(ctx);
903
972
  const { draft, description } = await read(ctx);
904
973
  const reviewed = await review(ctx, draft, description);
905
- celebrate(ctx, { ...reviewed, total: forged.total, hookStatus: forged.hookStatus });
974
+ const connected = await connect(ctx);
975
+ celebrate(ctx, { ...reviewed, ...connected, total: forged.total, hookStatus: forged.hookStatus });
906
976
  return reviewed.aborted ? 1 : 0;
907
977
  } finally {
908
978
  process.removeListener("SIGINT", onSigint);
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,540 @@
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
+ // Chain renames: if `from` was already renamed, update through
285
+ const effectiveFrom = map[entry.from] !== undefined ? null : entry.from; // Don't double-chain
286
+ if (effectiveFrom) map[effectiveFrom] = entry.to;
287
+ }
288
+ } catch (_) {
289
+ // Return empty map on error
290
+ }
291
+ return map;
292
+ }
293
+
294
+ /**
295
+ * Apply rename map to query file path.
296
+ * Returns the current canonical path for a queried old path.
297
+ */
298
+ function applyRenameMap(queryPath, renameMap) {
299
+ return renameMap[queryPath] || queryPath;
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Query execution
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /**
307
+ * Execute a recall query and return ranked results.
308
+ *
309
+ * @param {object} args - parsed CLI args
310
+ * @param {object[]} cards - all cards
311
+ * @param {object} postings - postings index
312
+ * @returns {Array<{card, score}>}
313
+ */
314
+ function executeQuery(args, cards, postings) {
315
+ const renameMap = buildRenameMap(args.indexDir);
316
+
317
+ // Normalize all query file paths through rename map (multi-file union support)
318
+ // args.files is an array; each entry may be a single path from repeated --file flags.
319
+ const effectiveFiles = (args.files || []).map(f => ({
320
+ original: f,
321
+ effective: applyRenameMap(f, renameMap),
322
+ basename: (applyRenameMap(f, renameMap).split('/').pop() || f),
323
+ }));
324
+
325
+ // Pre-filter cards
326
+ let candidates = cards.filter(card => {
327
+ if (!args.includeStale && card.status === 'stale') return false;
328
+ if (args.kind && card.kind !== args.kind) return false;
329
+ return true;
330
+ });
331
+
332
+ // BM25 setup
333
+ const N = candidates.length;
334
+ const tokenCounts = candidates.map(c => cardTokenCount(c));
335
+ const avgLen = tokenCounts.length > 0 ? tokenCounts.reduce((a, b) => a + b, 0) / tokenCounts.length : 1;
336
+
337
+ // Query tokenization: free text + all file paths + symbol
338
+ const queryTerms = args.queryText ? tokenize(args.queryText) : [];
339
+ for (const ef of effectiveFiles) queryTerms.push(...tokenize(ef.effective));
340
+ if (args.symbol) queryTerms.push(...tokenize(args.symbol));
341
+
342
+ // Use a Map for dedup: card.id → {card, score}
343
+ // When the same card matches multiple files, accumulate the highest score.
344
+ const hitMap = new Map();
345
+
346
+ for (let i = 0; i < candidates.length; i++) {
347
+ const card = candidates[i];
348
+ const ageDays = cardAgeDays(card);
349
+ const cardLen = tokenCounts[i];
350
+
351
+ let score = 0;
352
+
353
+ // BM25 text score
354
+ if (queryTerms.length > 0) {
355
+ score = bm25Score(queryTerms, card.id, postings, N, avgLen, cardLen, ageDays, card.status);
356
+ }
357
+
358
+ // Per-file exact match boost (applied once per matching file — accumulates across files)
359
+ let hasExactMatch = false;
360
+ for (const ef of effectiveFiles) {
361
+ const cardPaths = card.paths || [];
362
+ if (cardPaths.some(p => p === ef.effective || p === ef.original)) {
363
+ score += EXACT_FILE_BOOST;
364
+ hasExactMatch = true;
365
+ } else if (ef.basename && cardPaths.some(p => p.split('/').pop() === ef.basename)) {
366
+ score += EXACT_FILE_BOOST * 0.5; // Partial boost for basename match
367
+ // basename match does not set hasExactMatch (not an exact path match)
368
+ }
369
+ }
370
+
371
+ // Exact symbol match boost
372
+ if (args.symbol) {
373
+ const cardSymbols = card.symbols || [];
374
+ if (cardSymbols.some(s => s === args.symbol || s.toLowerCase() === args.symbol.toLowerCase())) {
375
+ score += EXACT_SYMBOL_BOOST;
376
+ hasExactMatch = true;
377
+ }
378
+ }
379
+
380
+ // Include if: score > threshold, OR exact file/symbol match (always include even if score=0)
381
+ if (score >= args.minScore || hasExactMatch) {
382
+ const existing = hitMap.get(card.id);
383
+ if (!existing || score > existing.score) {
384
+ hitMap.set(card.id, { card, score });
385
+ }
386
+ }
387
+ }
388
+
389
+ const results = Array.from(hitMap.values());
390
+
391
+ // Sort by score descending, then by recency
392
+ results.sort((a, b) => {
393
+ if (Math.abs(b.score - a.score) > 0.001) return b.score - a.score;
394
+ return (b.card.createdAt || '').localeCompare(a.card.createdAt || '');
395
+ });
396
+
397
+ return results.slice(0, args.k);
398
+ }
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // Output formatting
402
+ // ---------------------------------------------------------------------------
403
+
404
+ function formatCard(card, score, showScore = true) {
405
+ const lines = [];
406
+ const cite = `#${card.issue}`;
407
+ const kind = card.kind.toUpperCase();
408
+ const scoreStr = showScore ? ` (score: ${score.toFixed(2)})` : '';
409
+ const staleStr = card.status === 'stale' ? ' [STALE]' : '';
410
+
411
+ lines.push(`── ${kind}${staleStr} from ${cite}${scoreStr} ──`);
412
+
413
+ if (card.kind === 'pattern') {
414
+ if (card.pattern) lines.push(`Pattern: ${card.pattern}`);
415
+ if (card.rootCause) lines.push(`Root Cause: ${card.rootCause}`);
416
+ if (card.prevention) lines.push(`Prevention: ${card.prevention}`);
417
+ } else if (card.kind === 'investigation') {
418
+ if (card.rootCause) lines.push(`Root Cause: ${card.rootCause}`);
419
+ if (card.prevention) lines.push(`Fix: ${card.prevention}`);
420
+ if (card.verdict) lines.push(`Verdict: ${card.verdict} (${card.confidence || '?'})`);
421
+ } else if (card.kind === 'decision') {
422
+ if (card.decision) lines.push(`Decision: ${card.decision}`);
423
+ if (card.anomalies && card.anomalies !== 'None') {
424
+ lines.push(`Anomalies: ${card.anomalies}`);
425
+ }
426
+ }
427
+
428
+ if (card.paths && card.paths.length > 0) {
429
+ lines.push(`Files: ${card.paths.slice(0, 5).join(', ')}${card.paths.length > 5 ? ` +${card.paths.length - 5} more` : ''}`);
430
+ }
431
+ if (card.symbols && card.symbols.length > 0) {
432
+ lines.push(`Symbols: ${card.symbols.slice(0, 8).join(', ')}${card.symbols.length > 8 ? ` +${card.symbols.length - 8} more` : ''}`);
433
+ }
434
+ if (card.severity) lines.push(`Severity: ${card.severity}`);
435
+ if (card.createdAt) lines.push(`Date: ${card.createdAt}`);
436
+ lines.push(`Cite: https://github.com/issues/${card.issue}`);
437
+
438
+ return lines.join('\n');
439
+ }
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // Main entry point
443
+ // ---------------------------------------------------------------------------
444
+
445
+ function main() {
446
+ const args = parseArgs(process.argv);
447
+ args.indexDir = resolve(args.indexDir);
448
+
449
+ // Validate
450
+ if (!args.queryText && args.files.length === 0 && !args.symbol && !args.doctor) {
451
+ process.stderr.write('Usage: forge recall <query> [--file <path>] [--symbol <name>] [--json] [--k N]\n');
452
+ process.stderr.write(' --file may be repeated for a multi-file union query\n');
453
+ process.stderr.write(' forge recall --doctor (check index health)\n');
454
+ process.exit(0); // Not an error — just no query
455
+ }
456
+
457
+ // Check index exists
458
+ const cardsPath = join(args.indexDir, CARDS_FILE);
459
+ if (!existsSync(cardsPath)) {
460
+ if (args.json) {
461
+ process.stdout.write('[]\n');
462
+ } else {
463
+ process.stderr.write(`[recall] Index not found at ${args.indexDir}\n`);
464
+ process.stderr.write('[recall] Run: node scripts/build-knowledge-index.mjs --full-rebuild\n');
465
+ }
466
+ process.exit(0); // Not an error — index just needs building
467
+ }
468
+
469
+ // Load index
470
+ const cards = loadCards(args.indexDir);
471
+ const postings = loadPostings(args.indexDir);
472
+
473
+ if (args.doctor) {
474
+ // Doctor mode: show index stats
475
+ const total = cards.length;
476
+ const stale = cards.filter(c => c.status === 'stale').length;
477
+ const byKind = {};
478
+ for (const c of cards) byKind[c.kind] = (byKind[c.kind] || 0) + 1;
479
+ const uniqueIssues = new Set(cards.map(c => c.issue)).size;
480
+
481
+ process.stdout.write(`Forge Ledger Index Health\n`);
482
+ process.stdout.write(`─────────────────────────\n`);
483
+ process.stdout.write(`Total cards: ${total}\n`);
484
+ process.stdout.write(`Stale: ${stale} (${total > 0 ? Math.round(stale / total * 100) : 0}%)\n`);
485
+ process.stdout.write(`Unique issues: ${uniqueIssues}\n`);
486
+ process.stdout.write(`By kind: ${JSON.stringify(byKind)}\n`);
487
+ process.stdout.write(`Index dir: ${args.indexDir}\n`);
488
+ process.exit(0);
489
+ }
490
+
491
+ if (cards.length === 0) {
492
+ if (args.json) {
493
+ process.stdout.write('[]\n');
494
+ } else {
495
+ process.stderr.write('[recall] Index is empty. Run: node scripts/build-knowledge-index.mjs --full-rebuild\n');
496
+ }
497
+ process.exit(0);
498
+ }
499
+
500
+ // Execute query
501
+ const results = executeQuery(args, cards, postings);
502
+
503
+ if (results.length === 0) {
504
+ if (args.json) {
505
+ process.stdout.write('[]\n');
506
+ } else {
507
+ process.stderr.write(`[recall] No results found for query: ${args.queryText || args.files.join(', ') || args.symbol}\n`);
508
+ }
509
+ process.exit(0);
510
+ }
511
+
512
+ // Output results
513
+ if (args.json) {
514
+ const output = results.map(({ card, score }) => ({
515
+ id: card.id,
516
+ kind: card.kind,
517
+ issue: card.issue,
518
+ score: Math.round(score * 1000) / 1000,
519
+ paths: card.paths || [],
520
+ symbols: card.symbols || [],
521
+ pattern: card.pattern || null,
522
+ rootCause: card.rootCause || null,
523
+ prevention: card.prevention || null,
524
+ decision: card.decision || null,
525
+ verdict: card.verdict || null,
526
+ confidence: card.confidence || null,
527
+ severity: card.severity || null,
528
+ status: card.status,
529
+ createdAt: card.createdAt || null,
530
+ }));
531
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
532
+ } else {
533
+ process.stdout.write(`Forge Recall — ${results.length} result(s)\n\n`);
534
+ for (const { card, score } of results) {
535
+ process.stdout.write(formatCard(card, score) + '\n\n');
536
+ }
537
+ }
538
+ }
539
+
540
+ main();