forgedock 1.1.7 → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/invariants.mjs +361 -0
- package/bin/hooks/pre-tool-use.mjs +182 -0
- package/bin/labels.json +10 -0
- package/bin/recall.mjs +540 -0
- package/commands/autopilot.md +957 -76
- package/commands/cleanup.md +52 -3
- package/commands/optimize.md +585 -0
- package/commands/orchestrate/phase-1-resolve.md +35 -25
- package/commands/orchestrate/phase-3-dependency.md +465 -34
- package/commands/orchestrate/phase-4-execution.md +447 -28
- package/commands/orchestrate/phase-6-report.md +198 -9
- package/commands/pipeline-health/phase-2-metrics.md +305 -1
- package/commands/pipeline-health/phase-4-proposals.md +127 -0
- package/commands/quality-gate.md +417 -0
- package/commands/review-pr.md +391 -13
- package/commands/spec-doctor.md +429 -0
- package/commands/validate.md +52 -0
- package/commands/work-on/build/architect.md +77 -0
- package/commands/work-on/build/context.md +357 -22
- package/commands/work-on/build/implement.md +2 -0
- package/commands/work-on/build/validate.md +28 -0
- package/commands/work-on/build.md +44 -0
- package/commands/work-on/close.md +621 -8
- package/commands/work-on/investigate.md +221 -0
- package/commands/work-on.md +15 -14
- package/docs/FORGE-PROTOCOL.md +101 -0
- package/docs/spec/forge-protocol-v1.md +97 -5
- package/docs/spec/label-state-machine.md +6 -1
- package/package.json +2 -2
- package/packages/protocol/src/cli.js +374 -84
- package/packages/protocol/src/types.js +75 -1
- package/scripts/build-knowledge-index.mjs +1761 -0
- package/scripts/calibration.mjs +996 -0
- package/scripts/danger-zones.mjs +782 -0
- package/scripts/doctor-pipeline-state.sh +88 -2
- package/scripts/select-fix-targets.sh +234 -0
- package/scripts/transition-label.sh +157 -6
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();
|