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.
- package/README.md +3 -1
- package/bin/engine/invariants.mjs +361 -0
- package/bin/hooks/pre-tool-use.mjs +182 -0
- package/bin/journey.mjs +77 -7
- package/bin/labels.json +10 -0
- package/bin/recall.mjs +540 -0
- package/commands/autopilot.md +959 -78
- 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 +448 -29
- 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/design/awesome-list-submissions.md +105 -0
- package/docs/design/f5bot-monitoring-setup.md +43 -0
- package/docs/design/growth-strategy-2026.md +579 -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/refresh-bot-token.sh +97 -0
- package/scripts/select-fix-targets.sh +234 -0
- 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.
|
|
876
|
-
` 2. run npx forgedock
|
|
877
|
-
` 3.
|
|
878
|
-
` 4.
|
|
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
|
-
|
|
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();
|