@codewithjuber/forgekit 0.8.0

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 (140) hide show
  1. package/.claude-plugin/marketplace.json +12 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/.codex-plugin/plugin.json +29 -0
  4. package/.mcp.json +8 -0
  5. package/ARCHITECTURE.md +314 -0
  6. package/CHANGELOG.md +467 -0
  7. package/LICENSE +21 -0
  8. package/ONBOARDING.md +180 -0
  9. package/README.md +286 -0
  10. package/bin/claude-init.sh +90 -0
  11. package/bin/claude-taste.sh +33 -0
  12. package/bin/learn-consolidate.sh +51 -0
  13. package/brand.json +15 -0
  14. package/global/CLAUDE.md +31 -0
  15. package/global/crew/frontend-verifier.md +36 -0
  16. package/global/crew/independent-reviewer.md +29 -0
  17. package/global/crew/scout.md +24 -0
  18. package/global/crew/verifier.md +28 -0
  19. package/global/guards/_guardlib.sh +36 -0
  20. package/global/guards/cortex.sh +14 -0
  21. package/global/guards/cost-budget.sh +41 -0
  22. package/global/guards/doom-loop.sh +25 -0
  23. package/global/guards/format-on-edit.sh +32 -0
  24. package/global/guards/lean-guard.sh +20 -0
  25. package/global/guards/protect-paths.sh +45 -0
  26. package/global/guards/recall-load.sh +22 -0
  27. package/global/guards/secret-redact.sh +18 -0
  28. package/global/guards/session-learner.sh +72 -0
  29. package/global/recall/MEMORY.md +7 -0
  30. package/global/rules/self-correction.md +17 -0
  31. package/global/rules/stack-notes.md +24 -0
  32. package/global/rules/tech-currency.md +19 -0
  33. package/global/settings.template.json +183 -0
  34. package/global/statusline.sh +51 -0
  35. package/global/taste/brutalist.json +9 -0
  36. package/global/taste/brutalist.md +19 -0
  37. package/global/taste/corporate.json +9 -0
  38. package/global/taste/corporate.md +19 -0
  39. package/global/taste/editorial.json +9 -0
  40. package/global/taste/editorial.md +19 -0
  41. package/global/taste/minimalist.json +9 -0
  42. package/global/taste/minimalist.md +20 -0
  43. package/global/taste/playful.json +9 -0
  44. package/global/taste/playful.md +19 -0
  45. package/global/tools/atlas/SKILL.md +27 -0
  46. package/global/tools/code-modernization/SKILL.md +275 -0
  47. package/global/tools/code-modernization/references/cost-impact-preflight.md +54 -0
  48. package/global/tools/code-modernization/references/design-patterns-cheatsheet.md +24 -0
  49. package/global/tools/code-modernization/references/research-protocol.md +42 -0
  50. package/global/tools/code-modernization/scripts/preflight_scan.py +190 -0
  51. package/global/tools/cognitive-substrate/SKILL.md +56 -0
  52. package/global/tools/cognitive-substrate/references/capability-map.md +17 -0
  53. package/global/tools/cost-guard/SKILL.md +50 -0
  54. package/global/tools/design-md/SKILL.md +54 -0
  55. package/global/tools/dev-radar/SKILL.md +56 -0
  56. package/global/tools/explore-plan-code/SKILL.md +24 -0
  57. package/global/tools/lean/SKILL.md +41 -0
  58. package/global/tools/recall/SKILL.md +31 -0
  59. package/global/tools/reuse-first/SKILL.md +64 -0
  60. package/global/tools/self-improve/SKILL.md +44 -0
  61. package/global/tools/taste/SKILL.md +26 -0
  62. package/global/tools/tech-selector/SKILL.md +35 -0
  63. package/global/tools/ui-workflow/SKILL.md +44 -0
  64. package/hooks/hooks.json +107 -0
  65. package/install.sh +88 -0
  66. package/package.json +93 -0
  67. package/public/index.html +45 -0
  68. package/scripts/build-pages.mjs +180 -0
  69. package/scripts/bump.mjs +322 -0
  70. package/skills/cognitive-substrate/SKILL.md +56 -0
  71. package/skills/cognitive-substrate/references/capability-map.md +17 -0
  72. package/source/mcp.json +10 -0
  73. package/source/rules.json +106 -0
  74. package/source/substrate.json +41 -0
  75. package/src/adjudicate.js +84 -0
  76. package/src/anchor.js +210 -0
  77. package/src/atlas.js +487 -0
  78. package/src/brain.js +84 -0
  79. package/src/brand.js +25 -0
  80. package/src/cli.js +1509 -0
  81. package/src/context.js +273 -0
  82. package/src/cortex.js +251 -0
  83. package/src/cortex_distill.js +55 -0
  84. package/src/cortex_features.js +81 -0
  85. package/src/cortex_hook.js +197 -0
  86. package/src/cortex_hook_main.js +139 -0
  87. package/src/cortex_mcp.js +352 -0
  88. package/src/cost_report.js +271 -0
  89. package/src/dash.html +396 -0
  90. package/src/dash.js +220 -0
  91. package/src/diagnose.js +0 -0
  92. package/src/doctor.js +315 -0
  93. package/src/embed.js +244 -0
  94. package/src/emit/_shared.js +39 -0
  95. package/src/emit/aider.js +22 -0
  96. package/src/emit/claude.js +44 -0
  97. package/src/emit/codex.js +17 -0
  98. package/src/emit/continue.js +28 -0
  99. package/src/emit/copilot.js +12 -0
  100. package/src/emit/cursor.js +23 -0
  101. package/src/emit/gemini.js +40 -0
  102. package/src/emit/mcp.js +94 -0
  103. package/src/emit/windsurf.js +22 -0
  104. package/src/emit/zed.js +34 -0
  105. package/src/eval.js +47 -0
  106. package/src/extract.js +82 -0
  107. package/src/harden.js +44 -0
  108. package/src/imagine.js +301 -0
  109. package/src/init.js +178 -0
  110. package/src/lean.js +149 -0
  111. package/src/ledger.js +475 -0
  112. package/src/ledger_bridge.js +279 -0
  113. package/src/ledger_read.js +152 -0
  114. package/src/ledger_store.js +360 -0
  115. package/src/lessons.js +185 -0
  116. package/src/lessons_store.js +137 -0
  117. package/src/metrics.js +54 -0
  118. package/src/model_tiers.js +17 -0
  119. package/src/model_tiers.json +39 -0
  120. package/src/predictor.js +143 -0
  121. package/src/preflight.js +410 -0
  122. package/src/providers.js +320 -0
  123. package/src/recall.js +103 -0
  124. package/src/reuse.js +0 -0
  125. package/src/route.js +323 -0
  126. package/src/scope.js +122 -0
  127. package/src/skillgate.js +89 -0
  128. package/src/speclock.js +64 -0
  129. package/src/substrate.js +492 -0
  130. package/src/sync.js +132 -0
  131. package/src/taste.js +55 -0
  132. package/src/uicheck.js +96 -0
  133. package/src/uifingerprint.js +861 -0
  134. package/src/uivisual.js +334 -0
  135. package/src/util.js +71 -0
  136. package/src/verify.js +117 -0
  137. package/templates/project-layer/.claude/settings.json +22 -0
  138. package/templates/project-layer/.claude/skills/hostlelo-deploy/SKILL.md +38 -0
  139. package/templates/project-layer/AGENTS.md +28 -0
  140. package/templates/project-layer/CLAUDE.md +40 -0
package/src/anchor.js ADDED
@@ -0,0 +1,210 @@
1
+ // forge anchor — M4 goal-anchoring (the paper's continuous goal-drift check, made
2
+ // deterministic). Given the original objective, compare the files you've ACTUALLY
3
+ // changed (git) against the area the goal named. Flags work that has wandered off it.
4
+ // Advisory — a stated goal is re-read against real diffs, not trusted to stay in view.
5
+ import { execFileSync } from "node:child_process";
6
+ import { adjudicate, asText, buildRunner, llmEnabled } from "./adjudicate.js";
7
+ import { load as loadAtlas, query as queryAtlas } from "./atlas.js";
8
+ import { referencedEntities } from "./preflight.js";
9
+
10
+ const STOP = new Set(
11
+ "the a an to of in on for and or add fix make use update change new it its into with from this that be is are so all any".split(
12
+ " ",
13
+ ),
14
+ );
15
+
16
+ function goalKeywords(goal) {
17
+ const { symbols, files } = referencedEntities(goal);
18
+ // Strip path/file tokens (src/auth.js) so their generic parts (src, js) don't become
19
+ // keywords that match every file — the files themselves are matched via goalTargetFiles.
20
+ const prose = String(goal)
21
+ .toLowerCase()
22
+ .replace(/\S*[/.]\S*/g, " ");
23
+ const words = prose.match(/[a-z][a-z0-9_-]{2,}/g) || [];
24
+ const keywords = new Set(words.filter((w) => !STOP.has(w)));
25
+ for (const s of symbols) keywords.add(s.toLowerCase());
26
+ return { keywords: [...keywords], symbols, files };
27
+ }
28
+
29
+ // Machine-generated / tool-config noise that isn't the developer's own work: forge's
30
+ // cache and every config file `forge init` emits (dot-paths + AGENTS/CLAUDE).
31
+ // ponytail: this also hides drift in genuine dot-dir edits (.github/*) — fine for an
32
+ // advisory coarse check; widen to a managed-file manifest if that ever matters.
33
+ const NOISE = /(^|\/)\.forge\/|(^|\/)\.[^/]+\/|^\.[^/]+$|(^|\/)(AGENTS|CLAUDE)\.md$/i;
34
+
35
+ function gitFiles(root) {
36
+ const run = (args) => {
37
+ try {
38
+ return execFileSync("git", args, {
39
+ cwd: root,
40
+ encoding: "utf8",
41
+ stdio: ["ignore", "pipe", "ignore"],
42
+ });
43
+ } catch {
44
+ return "";
45
+ }
46
+ };
47
+ const out =
48
+ run(["diff", "--name-only", "HEAD"]) + run(["ls-files", "--others", "--exclude-standard"]);
49
+ return [
50
+ ...new Set(
51
+ out
52
+ .split("\n")
53
+ .map((s) => s.trim())
54
+ .filter(Boolean),
55
+ ),
56
+ ].filter((f) => !NOISE.test(f));
57
+ }
58
+
59
+ // Files the goal's named symbols actually live in, so a symbol-named goal anchors to its
60
+ // file (e.g. "change verifyToken" anchors to src/auth.js, which the name alone wouldn't match).
61
+ function goalTargetFiles(root, symbols, files) {
62
+ const targets = new Set(files.map((f) => f.replace(/^\.?\//, "")));
63
+ const atlas = loadAtlas(root);
64
+ if (atlas)
65
+ for (const s of symbols)
66
+ for (const hit of queryAtlas(atlas, s)) if (hit.name === s) targets.add(hit.file);
67
+ return [...targets];
68
+ }
69
+
70
+ // M4 goal-drift — LLM proposer. Semantically classifies the files the coarse keyword match
71
+ // flagged as off-goal. PROPOSER ONLY, and one-directional: the model may only move a file
72
+ // off→on (never on→off), and only with a reason that references the goal — so it can quiet a
73
+ // false drift flag but can never hide a real one. Preserves the "errs toward on-goal" invariant.
74
+ export function buildDriftPrompt(goal, offGoalFiles) {
75
+ const list = offGoalFiles
76
+ .slice(0, 40)
77
+ .map((f) => `- ${f}`)
78
+ .join("\n");
79
+ return `A developer's stated goal is: """${String(goal).slice(0, 400)}"""
80
+ These changed files did NOT obviously match the goal by name:
81
+ ${list}
82
+ For each, decide if the change is plausibly IN SERVICE of that goal. Answer with STRICT JSON and
83
+ nothing else, listing only files from above that genuinely serve the goal:
84
+ {"onGoal":[{"file":"<path>","reason":"<why it serves the goal>"}]}
85
+ Omit files that do not serve the goal. No text outside the JSON object.`;
86
+ }
87
+
88
+ export function parseDriftProposal(obj) {
89
+ const onGoal = Array.isArray(obj.onGoal)
90
+ ? obj.onGoal
91
+ .map((e) => ({ file: asText(e?.file, 240), reason: asText(e?.reason, 200) }))
92
+ .filter((e) => e.file && e.reason)
93
+ : [];
94
+ return { onGoal };
95
+ }
96
+
97
+ export function driftLLM(goal, offGoalFiles, { run = buildRunner() } = {}) {
98
+ if (!offGoalFiles.length) return { onGoal: [] };
99
+ return adjudicate({
100
+ prompt: buildDriftPrompt(goal, offGoalFiles),
101
+ parse: parseDriftProposal,
102
+ run,
103
+ });
104
+ }
105
+
106
+ /**
107
+ * @param {string} root
108
+ * @param {string} goal
109
+ * @param {{ changed?: string[], llm?:boolean, run?:(p:string)=>string, model?:string, timeoutMs?:number }} [opts]
110
+ * inject `changed` to skip git; inject `run` to stub the model (used in tests).
111
+ */
112
+ export function goalDrift(root, goal, opts = {}) {
113
+ const { keywords, symbols, files } = goalKeywords(goal);
114
+ const changedFiles = opts.changed || gitFiles(root);
115
+ const targets = goalTargetFiles(root, symbols, files).map((t) => t.toLowerCase());
116
+ const onGoal = [];
117
+ const offGoal = [];
118
+ for (const f of changedFiles) {
119
+ const lf = f.toLowerCase();
120
+ // ponytail: path/keyword match, not semantic — coarse on purpose (are you in the
121
+ // right AREA?), and errs toward "on-goal" so it never cries drift on a match.
122
+ const named = targets.some((t) => lf === t || lf.endsWith(`/${t}`));
123
+ (named || keywords.some((k) => lf.includes(k)) ? onGoal : offGoal).push(f);
124
+ }
125
+
126
+ // Opt-in semantic pass: rescue files the keyword match missed, but only off→on and only when
127
+ // the model gives a goal-referencing reason. Verified, not trusted; fail-safe on any error.
128
+ let provenance = { path: "deterministic" };
129
+ if (llmEnabled({ llm: opts.llm }) && offGoal.length) {
130
+ const goalTerms = new Set([...keywords, ...symbols.map((s) => s.toLowerCase())]);
131
+ const grounded = (reason) => {
132
+ const words = String(reason).toLowerCase();
133
+ return goalTerms.size === 0 || [...goalTerms].some((t) => words.includes(t));
134
+ };
135
+ const proposal = driftLLM(goal, offGoal, {
136
+ run: opts.run || buildRunner({ model: opts.model, timeoutMs: opts.timeoutMs }),
137
+ });
138
+ const rescued = new Set(
139
+ (proposal?.onGoal || [])
140
+ .filter((e) => offGoal.includes(e.file) && grounded(e.reason))
141
+ .map((e) => e.file),
142
+ );
143
+ if (rescued.size) {
144
+ for (const f of [...offGoal]) {
145
+ if (rescued.has(f)) {
146
+ offGoal.splice(offGoal.indexOf(f), 1);
147
+ onGoal.push(f);
148
+ }
149
+ }
150
+ provenance = { path: "llm-verified", rescued: [...rescued] };
151
+ } else if (proposal) {
152
+ provenance = { path: "llm-agreed" };
153
+ }
154
+ }
155
+
156
+ const drift = changedFiles.length > 0 && (offGoal.length > 0 || onGoal.length === 0);
157
+ return {
158
+ goal: String(goal),
159
+ keywords,
160
+ changed: changedFiles,
161
+ onGoal,
162
+ offGoal,
163
+ drift,
164
+ provenance,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * M4 — one-sided CUSUM control chart over a drift-signal series (spec §5:
170
+ * docs/plans/substrate-v2/06-faculties-and-mechanisms.md). A raw threshold on a
171
+ * single checkpoint's drift Dₜ is noisy — one exploratory step legitimately wanders.
172
+ * CUSUM accumulates only the excess over the allowance k (Cₜ = max(0, Cₜ₋₁ + Dₜ − k))
173
+ * and alarms at Cₜ > h, which detects SUSTAINED small drift with provably minimal
174
+ * detection delay for a given false-alarm rate (classical sequential analysis),
175
+ * while a single within-tolerance spike drains back to zero instead of alarming.
176
+ * Defaults k = 0.35, h = 1.0 per the spec (calibration lands in P8). Pure.
177
+ * @param {number[]} signals drift per checkpoint, Dₜ ∈ [0, 1] (non-numeric → 0)
178
+ * @param {{k?: number, h?: number}} [opts]
179
+ * @returns {{alarm: boolean, C: number[], firstAlarm: number}} firstAlarm = index of
180
+ * the first checkpoint whose statistic crossed h, or -1 if none did.
181
+ */
182
+ export function cusum(signals, { k = 0.35, h = 1.0 } = {}) {
183
+ const C = [];
184
+ let c = 0;
185
+ let firstAlarm = -1;
186
+ for (let i = 0; i < signals.length; i++) {
187
+ const d = Number(signals[i]);
188
+ c = Math.max(0, c + (Number.isFinite(d) ? d : 0) - k);
189
+ C.push(c);
190
+ if (firstAlarm < 0 && c > h) firstAlarm = i;
191
+ }
192
+ return { alarm: firstAlarm >= 0, C, firstAlarm };
193
+ }
194
+
195
+ export function renderAnchor(r) {
196
+ const lines = ["Forge anchor — goal-drift check", ""];
197
+ if (!r.changed.length)
198
+ return `${lines.join("\n")}\n no changes yet vs HEAD — nothing to check against the goal.`;
199
+ lines.push(
200
+ ` changed: ${r.changed.length} file(s) · on-goal ${r.onGoal.length} · off-goal ${r.offGoal.length}`,
201
+ );
202
+ if (r.offGoal.length) {
203
+ lines.push("", " off-goal (unrelated to the stated goal — intended, or drift?):");
204
+ for (const f of r.offGoal.slice(0, 12)) lines.push(` - ${f}`);
205
+ }
206
+ if (r.drift && !r.offGoal.length)
207
+ lines.push("", " ! no changed file matches the goal — are you working on the right thing?");
208
+ if (!r.drift) lines.push("", " ✓ on goal — every change maps to what you set out to do.");
209
+ return lines.join("\n");
210
+ }
package/src/atlas.js ADDED
@@ -0,0 +1,487 @@
1
+ // forge atlas — a portable code graph. Build once, then query definitions, membership,
2
+ // reverse dependents, and impact radius without asking a model to rediscover the repo.
3
+
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import { extname, join, relative } from "node:path";
6
+ import { adjudicate, asText, buildRunner, llmEnabled } from "./adjudicate.js";
7
+ import { CALL_RE } from "./extract.js";
8
+ import { contentHash, IGNORE_DIRS } from "./util.js";
9
+
10
+ const JS_RULES = [
11
+ { re: /(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g, kind: "function" },
12
+ { re: /(?:export\s+)?class\s+([A-Za-z_$][\w$]*)/g, kind: "class" },
13
+ { re: /(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g, kind: "const" },
14
+ ];
15
+ const RULES = {
16
+ ".js": JS_RULES,
17
+ ".jsx": JS_RULES,
18
+ ".ts": JS_RULES,
19
+ ".tsx": JS_RULES,
20
+ ".mjs": JS_RULES,
21
+ ".cjs": JS_RULES,
22
+ ".py": [
23
+ { re: /^\s*def\s+([A-Za-z_]\w*)/gm, kind: "function" },
24
+ { re: /^\s*class\s+([A-Za-z_]\w*)/gm, kind: "class" },
25
+ ],
26
+ ".go": [
27
+ { re: /^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/gm, kind: "function" },
28
+ { re: /^type\s+([A-Za-z_]\w*)/gm, kind: "type" },
29
+ ],
30
+ ".rs": [
31
+ { re: /\bfn\s+([A-Za-z_]\w*)/g, kind: "function" },
32
+ { re: /\b(?:struct|enum|trait)\s+([A-Za-z_]\w*)/g, kind: "type" },
33
+ ],
34
+ ".java": [{ re: /\b(?:class|interface|enum)\s+([A-Za-z_]\w*)/g, kind: "type" }],
35
+ };
36
+
37
+ const IMPORT_RE =
38
+ /(?:import\s+(?:[^"'\n]+\s+from\s+)?["']([^"']+)["']|require\(["']([^"']+)["']\)|^\s*(?:from\s+([\w.]+)\s+)?import\s+([\w*,\s]+))/gm;
39
+ const BUILTINS = new Set([
40
+ "if",
41
+ "for",
42
+ "while",
43
+ "switch",
44
+ "catch",
45
+ "function",
46
+ "return",
47
+ "console",
48
+ "String",
49
+ "Number",
50
+ "Boolean",
51
+ "Array",
52
+ "Object",
53
+ "Promise",
54
+ "Set",
55
+ "Map",
56
+ "Date",
57
+ "Error",
58
+ "RegExp",
59
+ "parseInt",
60
+ "parseFloat",
61
+ "setTimeout",
62
+ "clearTimeout",
63
+ "fetch",
64
+ "print",
65
+ "len",
66
+ "range",
67
+ "int",
68
+ "str",
69
+ "float",
70
+ "dict",
71
+ "list",
72
+ "set",
73
+ "super",
74
+ ]);
75
+
76
+ const hash = contentHash;
77
+
78
+ function walk(dir, files, cap) {
79
+ let entries;
80
+ try {
81
+ entries = readdirSync(dir);
82
+ } catch (err) {
83
+ if (process.env.FORGE_DEBUG === "1")
84
+ process.stderr.write(`forge atlas: skipping ${dir}: ${err?.message ?? err}\n`);
85
+ return;
86
+ }
87
+ for (const name of entries) {
88
+ if (IGNORE_DIRS.has(name) || name.startsWith(".")) continue;
89
+ const path = join(dir, name);
90
+ let st;
91
+ try {
92
+ st = statSync(path);
93
+ } catch {
94
+ continue;
95
+ }
96
+ if (st.isDirectory()) walk(path, files, cap);
97
+ else if (RULES[extname(name)] && files.length < cap) files.push(path);
98
+ }
99
+ }
100
+
101
+ function moduleId(rel) {
102
+ return rel.replace(/\.[^.]+$/, "").replace(/[/\\]/g, ".");
103
+ }
104
+
105
+ function lineOf(text, index) {
106
+ return text.slice(0, index).split("\n").length;
107
+ }
108
+
109
+ function nearestSource(nodes, line, fallback) {
110
+ let best = fallback;
111
+ for (const n of nodes) {
112
+ if (n.line <= line && (!best.line || n.line >= best.line)) best = n;
113
+ }
114
+ return best;
115
+ }
116
+
117
+ function extractFile(path, root, preRead) {
118
+ const ext = extname(path);
119
+ const rules = RULES[ext];
120
+ const rel = relative(root, path);
121
+ let text = preRead;
122
+ if (text == null) {
123
+ try {
124
+ text = readFileSync(path, "utf8");
125
+ } catch {
126
+ return { symbols: [], nodes: [], edges: [], hash: "" };
127
+ }
128
+ }
129
+
130
+ const mod = {
131
+ id: `module:${moduleId(rel)}`,
132
+ name: moduleId(rel),
133
+ kind: "module",
134
+ file: rel,
135
+ line: 1,
136
+ };
137
+ const symbols = [];
138
+ const nodes = [mod];
139
+ const edges = [];
140
+
141
+ for (const { re, kind } of rules) {
142
+ re.lastIndex = 0;
143
+ let m;
144
+ while ((m = re.exec(text))) {
145
+ const name = m[1];
146
+ const line = lineOf(text, m.index);
147
+ const node = {
148
+ id: `${rel}:${name}:${line}`,
149
+ qname: `${moduleId(rel)}.${name}`,
150
+ name,
151
+ kind,
152
+ file: rel,
153
+ line,
154
+ };
155
+ symbols.push({ name, kind, file: rel, line, id: node.id, qname: node.qname });
156
+ nodes.push(node);
157
+ edges.push({ source: mod.id, target: node.id, kind: "contains", confidence: 1, line });
158
+ }
159
+ }
160
+
161
+ // Inheritance edges — `class X extends Y` (JS/TS) and `class X(Base, …)` (Python). Without
162
+ // these the `inherits` edge weight was dead and a base-class change never appeared in blast
163
+ // radius. The base is a bare name; resolveEdges links it to a real node if one exists.
164
+ const classNodes = new Map(nodes.filter((n) => n.kind === "class").map((n) => [n.name, n]));
165
+ const INHERIT_RES = [
166
+ /\bclass\s+([A-Za-z_$][\w$]*)\s+extends\s+([A-Za-z_$][\w$.]*)/g, // JS/TS
167
+ /^\s*class\s+([A-Za-z_]\w*)\s*\(([^)]*)\)/gm, // Python
168
+ ];
169
+ for (const re of INHERIT_RES) {
170
+ re.lastIndex = 0;
171
+ let cm;
172
+ while ((cm = re.exec(text))) {
173
+ const child = classNodes.get(cm[1]);
174
+ if (!child) continue;
175
+ const line = lineOf(text, cm.index);
176
+ const bases = cm[2]
177
+ .split(",")
178
+ .map((b) => b.trim())
179
+ .filter((b) => b && !b.includes("=")) // drop Python kwargs like metaclass=ABCMeta
180
+ .map((b) => b.split(".").pop()) // module.Base → Base
181
+ .filter((b) => b && b !== cm[1] && b.toLowerCase() !== "object");
182
+ for (const base of bases)
183
+ edges.push({ source: child.id, target: base, kind: "inherits", confidence: 0.9, line });
184
+ }
185
+ }
186
+
187
+ IMPORT_RE.lastIndex = 0;
188
+ let im;
189
+ while ((im = IMPORT_RE.exec(text))) {
190
+ const target = im[1] || im[2] || im[3] || "";
191
+ const names = im[4]
192
+ ? im[4]
193
+ .split(",")
194
+ .map((s) => s.trim())
195
+ .filter(Boolean)
196
+ : [];
197
+ const line = lineOf(text, im.index);
198
+ const source = nearestSource(nodes.slice(1), line, mod);
199
+ if (target) edges.push({ source: source.id, target, kind: "imports", confidence: 0.85, line });
200
+ for (const name of names) {
201
+ if (name !== "*")
202
+ edges.push({
203
+ source: source.id,
204
+ target: target ? `${target}.${name}` : name,
205
+ kind: "imports",
206
+ confidence: 0.85,
207
+ line,
208
+ });
209
+ }
210
+ }
211
+
212
+ const lines = text.split("\n");
213
+ for (let index = 0; index < lines.length; index += 1) {
214
+ const lineText = lines[index];
215
+ CALL_RE.lastIndex = 0;
216
+ const line = index + 1;
217
+ let cm;
218
+ while ((cm = CALL_RE.exec(lineText))) {
219
+ const callee = cm[1];
220
+ if (BUILTINS.has(callee)) continue;
221
+ const source = nearestSource(nodes.slice(1), line, mod);
222
+ if (source.name === callee) continue;
223
+ edges.push({ source: source.id, target: callee, kind: "calls", confidence: 0.75, line });
224
+ }
225
+ }
226
+
227
+ return { symbols, nodes, edges, hash: hash(text) };
228
+ }
229
+
230
+ function resolveEdges(nodes, edges) {
231
+ const byName = new Map();
232
+ const byQname = new Map();
233
+ const idSet = new Set();
234
+ for (const n of nodes) {
235
+ idSet.add(n.id);
236
+ if (n.name) {
237
+ const arr = byName.get(n.name) || [];
238
+ arr.push(n);
239
+ byName.set(n.name, arr);
240
+ }
241
+ if (n.qname) byQname.set(n.qname, n);
242
+ }
243
+ return edges.map((edge) => {
244
+ // O(1) membership — this was a full nodes.some() scan per edge (O(E·N) on real repos).
245
+ if (idSet.has(edge.target)) return edge;
246
+ const direct = byQname.get(edge.target);
247
+ if (direct) return { ...edge, target: direct.id, resolved: true };
248
+ const short = String(edge.target).split(".").pop();
249
+ const matches = byName.get(short) || [];
250
+ if (matches.length === 1)
251
+ return { ...edge, target: matches[0].id, resolved: true, confidence: edge.confidence * 0.9 };
252
+ return { ...edge, unresolved: true };
253
+ });
254
+ }
255
+
256
+ const cachePath = (root) => join(root, ".forge", "atlas.cache.json");
257
+
258
+ function readCache(root) {
259
+ try {
260
+ return existsSync(cachePath(root)) ? JSON.parse(readFileSync(cachePath(root), "utf8")) : {};
261
+ } catch {
262
+ return {};
263
+ }
264
+ }
265
+
266
+ export function build({ root = process.cwd(), cap = 20000 } = {}) {
267
+ const files = [];
268
+ walk(root, files, cap);
269
+ // Incremental: reuse the prior per-file extraction when the content hash is unchanged, so a
270
+ // rebuild only re-parses edited files instead of re-running every regex over the whole repo.
271
+ const prev = readCache(root);
272
+ const cache = {};
273
+ const symbols = [];
274
+ const nodes = [];
275
+ const rawEdges = [];
276
+ const fileHashes = {};
277
+ for (const f of files) {
278
+ const rel = relative(root, f);
279
+ let text;
280
+ try {
281
+ text = readFileSync(f, "utf8");
282
+ } catch {
283
+ continue;
284
+ }
285
+ const h = hash(text);
286
+ const reused = prev[rel]?.hash === h ? prev[rel].data : null;
287
+ const data =
288
+ reused ||
289
+ (({ symbols, nodes, edges }) => ({ symbols, nodes, edges }))(extractFile(f, root, text));
290
+ cache[rel] = { hash: h, data };
291
+ symbols.push(...data.symbols);
292
+ nodes.push(...data.nodes);
293
+ rawEdges.push(...data.edges);
294
+ fileHashes[rel] = h;
295
+ }
296
+ const edges = resolveEdges(nodes, rawEdges);
297
+ const atlas = {
298
+ version: 2,
299
+ files: files.length,
300
+ symbols,
301
+ nodes,
302
+ edges,
303
+ fileHashes,
304
+ capped: files.length >= cap,
305
+ };
306
+ mkdirSync(join(root, ".forge"), { recursive: true });
307
+ writeFileSync(join(root, ".forge", "atlas.json"), JSON.stringify(atlas));
308
+ writeFileSync(cachePath(root), JSON.stringify(cache));
309
+ return atlas;
310
+ }
311
+
312
+ /** True if any tracked file's current content hash differs from the atlas (or a file vanished). */
313
+ export function isStale(root, atlas) {
314
+ if (!atlas?.fileHashes) return true;
315
+ for (const [rel, h] of Object.entries(atlas.fileHashes)) {
316
+ let text;
317
+ try {
318
+ text = readFileSync(join(root, rel), "utf8");
319
+ } catch {
320
+ return true; // a tracked file was deleted
321
+ }
322
+ if (hash(text) !== h) return true;
323
+ }
324
+ return false;
325
+ }
326
+
327
+ export function load(root = process.cwd()) {
328
+ const p = join(root, ".forge", "atlas.json");
329
+ return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : null;
330
+ }
331
+
332
+ export function query(atlas, term) {
333
+ const t = String(term).toLowerCase();
334
+ return (atlas.symbols || []).filter(
335
+ (s) =>
336
+ s.name.toLowerCase().includes(t) ||
337
+ String(s.qname || "")
338
+ .toLowerCase()
339
+ .includes(t),
340
+ );
341
+ }
342
+
343
+ export function has(atlas, name) {
344
+ return (atlas.symbols || []).some((s) => s.name === name || s.qname === name || s.id === name);
345
+ }
346
+
347
+ function targetIds(atlas, target) {
348
+ const t = String(target);
349
+ const nodes = atlas.nodes || [];
350
+ const matches = nodes.filter(
351
+ (n) => n.id === t || n.name === t || n.qname === t || n.file === t || n.file?.endsWith(`/${t}`),
352
+ );
353
+ return matches.map((n) => n.id);
354
+ }
355
+
356
+ const EDGE_WEIGHT = { calls: 0.95, imports: 0.85, inherits: 0.92, references: 0.7, contains: 0.45 };
357
+
358
+ // Reverse-adjacency (node id → incoming edges) + node lookup, built once per atlas and memoized.
359
+ // substrateCheck calls impact() up to 8× on the same atlas; without this each call rebuilt both.
360
+ const ADJ_CACHE = new WeakMap();
361
+ function adjacency(atlas) {
362
+ const cached = ADJ_CACHE.get(atlas);
363
+ if (cached) return cached;
364
+ const nodeById = new Map((atlas.nodes || []).map((n) => [n.id, n]));
365
+ const incoming = new Map();
366
+ for (const e of atlas.edges || []) {
367
+ if (e.unresolved) continue;
368
+ const arr = incoming.get(e.target) || [];
369
+ arr.push(e);
370
+ incoming.set(e.target, arr);
371
+ }
372
+ const built = { nodeById, incoming };
373
+ ADJ_CACHE.set(atlas, built);
374
+ return built;
375
+ }
376
+
377
+ // Imagination (§8) — LLM proposer for the edges the regex graph structurally misses: dynamic
378
+ // dispatch, DI, reflection, string-keyed lookups. PROPOSER ONLY. Every candidate is then
379
+ // verified twice — it must resolve to a REAL node in the graph AND (via the caller's `verify`
380
+ // predicate, a grep) actually reference the target in source. Unverifiable → dropped, never added.
381
+ export function buildImpactPrompt(atlas, target) {
382
+ const files = [...new Set((atlas.nodes || []).map((n) => n.file).filter(Boolean))].slice(0, 60);
383
+ return `A code symbol/file is about to change. Name the OTHER files in this repo that most
384
+ likely break or depend on it through edges a regex misses: dynamic dispatch, dependency
385
+ injection, reflection, string-keyed registries, event handlers.
386
+ Changing target: ${String(target).slice(0, 120)}
387
+ Files in repo:
388
+ ${files.map((f) => `- ${f}`).join("\n")}
389
+ Answer with STRICT JSON and nothing else, listing only files from the list above:
390
+ {"files":["<path>"...]}
391
+ No text outside the JSON object.`;
392
+ }
393
+
394
+ export function parseImpactProposal(obj) {
395
+ const files = Array.isArray(obj.files)
396
+ ? [...new Set(obj.files.map((f) => asText(f, 240)).filter(Boolean))].slice(0, 20)
397
+ : [];
398
+ return { files };
399
+ }
400
+
401
+ export function impactLLM(atlas, target, { run = buildRunner() } = {}) {
402
+ return adjudicate({ prompt: buildImpactPrompt(atlas, target), parse: parseImpactProposal, run });
403
+ }
404
+
405
+ /**
406
+ * @param {object} atlas
407
+ * @param {string} target
408
+ * @param {object} [opts]
409
+ * @param {number} [opts.threshold]
410
+ * @param {number} [opts.maxHops]
411
+ * @param {number} [opts.decay]
412
+ * @param {boolean} [opts.llm]
413
+ * @param {(p:string)=>string} [opts.run]
414
+ * @param {(file:string, target:string)=>boolean} [opts.verify]
415
+ */
416
+ export function impact(
417
+ atlas,
418
+ target,
419
+ { threshold = 0.1, maxHops = 6, decay = 0.85, llm, run, verify } = {},
420
+ ) {
421
+ const starts = targetIds(atlas, target);
422
+ const { nodeById, incoming } = adjacency(atlas);
423
+ const visited = new Map();
424
+ const queue = starts.map((id) => ({ id, confidence: 1, hop: 0, path: [id], edgeKinds: [] }));
425
+ while (queue.length) {
426
+ const current = queue.shift();
427
+ if (!current || current.hop >= maxHops) continue;
428
+ for (const edge of incoming.get(current.id) || []) {
429
+ if (starts.includes(edge.source)) continue;
430
+ const nextConfidence =
431
+ current.confidence * (EDGE_WEIGHT[edge.kind] || 0.5) * (edge.confidence ?? 1) * decay;
432
+ if (nextConfidence < threshold) continue;
433
+ const prev = visited.get(edge.source);
434
+ if (prev && prev.confidence >= nextConfidence) continue;
435
+ const item = {
436
+ id: edge.source,
437
+ node: nodeById.get(edge.source) || { id: edge.source, name: edge.source, kind: "unknown" },
438
+ confidence: Number(nextConfidence.toFixed(4)),
439
+ hopDistance: current.hop + 1,
440
+ path: [...current.path, edge.source],
441
+ edgeKinds: [...current.edgeKinds, edge.kind],
442
+ };
443
+ visited.set(edge.source, item);
444
+ queue.push({
445
+ id: edge.source,
446
+ confidence: nextConfidence,
447
+ hop: current.hop + 1,
448
+ path: item.path,
449
+ edgeKinds: item.edgeKinds,
450
+ });
451
+ }
452
+ }
453
+ const impacted = [...visited.values()].sort((a, b) => b.confidence - a.confidence);
454
+ const deterministicFiles = new Set(impacted.map((x) => x.node.file).filter(Boolean));
455
+
456
+ // Opt-in imagination pass: model proposes missed edges, but only VERIFIED ones are kept.
457
+ const llmImpacted = [];
458
+ if (llmEnabled({ llm }) && run) {
459
+ const knownFiles = new Set((atlas.nodes || []).map((n) => n.file).filter(Boolean));
460
+ const proposal = impactLLM(atlas, target, { run });
461
+ for (const file of proposal?.files || []) {
462
+ if (deterministicFiles.has(file)) continue; // already found deterministically
463
+ if (!knownFiles.has(file)) continue; // must be a real file in the graph
464
+ if (typeof verify === "function" && !verify(file, target)) continue; // must grep-confirm the ref
465
+ if (typeof verify !== "function") continue; // no external check available → never add blind
466
+ llmImpacted.push({
467
+ id: `llm:${file}`,
468
+ node: { id: `llm:${file}`, name: file, kind: "module", file },
469
+ confidence: Number((threshold * 0.9).toFixed(4)),
470
+ hopDistance: null,
471
+ source: "llm-verified",
472
+ });
473
+ }
474
+ }
475
+
476
+ const all = [...impacted, ...llmImpacted];
477
+ return {
478
+ target,
479
+ found: starts.length > 0,
480
+ threshold,
481
+ impacted: all,
482
+ impactedFiles: [...new Set(all.map((x) => x.node.file).filter(Boolean))].sort(),
483
+ llmVerified: llmImpacted.map((x) => x.node.file),
484
+ totalGraphNodes: (atlas.nodes || []).length,
485
+ totalGraphEdges: (atlas.edges || []).length,
486
+ };
487
+ }