@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/embed.js ADDED
@@ -0,0 +1,244 @@
1
+ // forge embed — the OPTIONAL embeddings tier (ROADMAP "Next", ADR-0005). MinHash is
2
+ // weak on very short specs (a few words hash to too few shingles — the documented
3
+ // honest limit); a configured embedding provider replaces the lexical `rel` term in
4
+ // Eq. 3 retrieval and the reuse cache's near-match with semantic cosine similarity.
5
+ //
6
+ // ADR-0005 compliance, condition by condition:
7
+ // 1. Core stays stdlib — this file is node stdlib only (spawnSync + the provider's
8
+ // own process); ledger.js never imports it. MinHash remains the reference path.
9
+ // 2. Graceful absence — no FORGE_EMBED, a crashed provider, a timeout, or garbage
10
+ // output all yield `null`, and every caller keeps the MinHash path unchanged.
11
+ // 3. Named tiers — `dependencies` stays empty; the provider is an opt-in env config
12
+ // (`FORGE_EMBED`), not a package at all.
13
+ // 4. Vetting — no third-party code runs unless the USER points FORGE_EMBED at it.
14
+ //
15
+ // Providers (FORGE_EMBED):
16
+ // cmd:<shell-command> spawn it, write {"texts":[..]} to stdin, read
17
+ // {"vectors":[[..]]} from stdout. The universal escape hatch:
18
+ // any local model, any script, any language.
19
+ // http:<url> OpenAI-compatible POST {input, model: $FORGE_EMBED_MODEL}
20
+ // with Authorization: Bearer $FORGE_EMBED_KEY. The fetch runs
21
+ // in a spawned node child (global fetch, node ≥20 stdlib) so
22
+ // this module stays synchronous like every other forge stage.
23
+ // The key travels only via the child's environment — it is
24
+ // never logged, never put in argv.
25
+ import { spawnSync } from "node:child_process";
26
+ import {
27
+ appendFileSync,
28
+ existsSync,
29
+ mkdirSync,
30
+ readFileSync,
31
+ statSync,
32
+ writeFileSync,
33
+ } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { contentHash } from "./util.js";
36
+
37
+ /** Hard ceilings — a misbehaving provider must never hang or flood a forge command. */
38
+ const DEFAULT_TIMEOUT_MS = 15000;
39
+ const MAX_TEXT_CHARS = 4000; // specs/claim texts are short; embed a stable prefix
40
+ const MAX_INPUT_BYTES = 2 * 1024 * 1024;
41
+ const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
42
+ /** Cache cap — see appendCache for the truncate-oldest policy. */
43
+ export const CACHE_MAX_BYTES = 4 * 1024 * 1024;
44
+
45
+ export const embedCachePath = (root = process.cwd()) => join(root, ".forge", "embed-cache.jsonl");
46
+
47
+ // Memoized per env value (not per process) so tests and long-lived callers that flip
48
+ // FORGE_EMBED re-resolve, while hot paths pay the parse once.
49
+ let memo = { env: /** @type {string|undefined} */ (undefined), provider: null };
50
+
51
+ /**
52
+ * Resolve the provider from FORGE_EMBED. Unset/unknown scheme → null (callers keep
53
+ * MinHash — the always-working default).
54
+ * @returns {{kind:"cmd", cmd:string}|{kind:"http", url:string}|null}
55
+ */
56
+ export function getProvider() {
57
+ const env = process.env.FORGE_EMBED ?? "";
58
+ if (memo.env === env) return memo.provider;
59
+ /** @type {any} */
60
+ let provider = null;
61
+ if (env.startsWith("cmd:") && env.length > 4) provider = { kind: "cmd", cmd: env.slice(4) };
62
+ else if (/^https?:\/\//.test(env)) provider = { kind: "http", url: env };
63
+ else if (env.startsWith("http:") && env.length > 5)
64
+ provider = { kind: "http", url: env.slice(5) };
65
+ memo = { env, provider };
66
+ return provider;
67
+ }
68
+
69
+ /** Cosine similarity in [-1,1]; zero-norm or empty vectors → 0 (never NaN). */
70
+ export function cosine(a, b) {
71
+ const n = Math.min(a?.length ?? 0, b?.length ?? 0);
72
+ if (!n) return 0;
73
+ let dot = 0;
74
+ let na = 0;
75
+ let nb = 0;
76
+ for (let i = 0; i < n; i++) {
77
+ dot += a[i] * b[i];
78
+ na += a[i] * a[i];
79
+ nb += b[i] * b[i];
80
+ }
81
+ return na && nb ? dot / Math.sqrt(na * nb) : 0;
82
+ }
83
+
84
+ const isVec = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => Number.isFinite(x));
85
+
86
+ /** Cache keys include the provider identity + model — switching backends must never
87
+ * serve another model's vectors. */
88
+ const providerId = (p) =>
89
+ `${p.kind}:${p.kind === "cmd" ? p.cmd : p.url}:${process.env.FORGE_EMBED_MODEL ?? ""}`;
90
+
91
+ /** Corrupt-line-tolerant cache reader (same discipline as metrics.js — a bad line is
92
+ * skipped, never fatal). @returns {Map<string, number[]>} */
93
+ function readCache(root) {
94
+ const out = new Map();
95
+ const path = embedCachePath(root);
96
+ if (!existsSync(path)) return out;
97
+ try {
98
+ for (const line of readFileSync(path, "utf8").split("\n")) {
99
+ if (!line.trim()) continue;
100
+ try {
101
+ const e = JSON.parse(line);
102
+ if (typeof e.k === "string" && isVec(e.v)) out.set(e.k, e.v);
103
+ } catch {}
104
+ }
105
+ } catch {}
106
+ return out;
107
+ }
108
+
109
+ /** Append new vectors; when the file outgrows CACHE_MAX_BYTES, rewrite it keeping the
110
+ * NEWEST lines that fit in half the cap (truncate-oldest — embeddings are
111
+ * re-derivable, so losing an old entry only costs one re-embed, never correctness).
112
+ * Best-effort: a cache write failure must never fail the query it was accelerating. */
113
+ function appendCache(root, entries) {
114
+ if (!entries.length) return;
115
+ const path = embedCachePath(root);
116
+ try {
117
+ mkdirSync(join(root, ".forge"), { recursive: true });
118
+ appendFileSync(path, `${entries.map((e) => JSON.stringify(e)).join("\n")}\n`);
119
+ if (statSync(path).size > CACHE_MAX_BYTES) {
120
+ const lines = readFileSync(path, "utf8").split("\n").filter(Boolean);
121
+ const keep = [];
122
+ let bytes = 0;
123
+ for (let i = lines.length - 1; i >= 0; i--) {
124
+ bytes += lines[i].length + 1;
125
+ if (bytes > CACHE_MAX_BYTES / 2) break;
126
+ keep.unshift(lines[i]);
127
+ }
128
+ writeFileSync(path, keep.length ? `${keep.join("\n")}\n` : "");
129
+ }
130
+ } catch {}
131
+ }
132
+
133
+ // Runs in a spawned node child: reads {url, model, texts} from stdin, POSTs the
134
+ // OpenAI-compatible payload, prints {vectors}. The Authorization header is built from
135
+ // the CHILD's env — the key never appears in argv, logs, or error output.
136
+ const HTTP_CHILD = `let raw="";process.stdin.on("data",(d)=>{raw+=d;});process.stdin.on("end",async()=>{try{const{url,model,texts}=JSON.parse(raw);const headers={"content-type":"application/json"};if(process.env.FORGE_EMBED_KEY)headers.authorization="Bearer "+process.env.FORGE_EMBED_KEY;const res=await fetch(url,{method:"POST",headers,body:JSON.stringify(model?{input:texts,model}:{input:texts})});if(!res.ok){console.error("embed: http "+res.status);process.exit(1);}const data=await res.json();process.stdout.write(JSON.stringify({vectors:(data.data??[]).map((d)=>d.embedding)}));}catch(e){console.error("embed: request failed");process.exit(1);}});`;
137
+
138
+ /** One provider round-trip. Any failure (spawn error, non-zero exit, timeout, bad
139
+ * JSON, wrong count, malformed vectors) → null. Never throws, never logs the key. */
140
+ function callProvider(provider, texts) {
141
+ const timeout = Number(process.env.FORGE_EMBED_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS;
142
+ const input =
143
+ provider.kind === "cmd"
144
+ ? JSON.stringify({ texts })
145
+ : JSON.stringify({ url: provider.url, model: process.env.FORGE_EMBED_MODEL, texts });
146
+ if (input.length > MAX_INPUT_BYTES) return null;
147
+ try {
148
+ const r =
149
+ provider.kind === "cmd"
150
+ ? spawnSync(provider.cmd, {
151
+ shell: true,
152
+ input,
153
+ encoding: "utf8",
154
+ timeout,
155
+ maxBuffer: MAX_OUTPUT_BYTES,
156
+ })
157
+ : spawnSync(process.execPath, ["-e", HTTP_CHILD], {
158
+ input,
159
+ encoding: "utf8",
160
+ timeout,
161
+ maxBuffer: MAX_OUTPUT_BYTES,
162
+ });
163
+ if (r.error || r.status !== 0 || !r.stdout) return null;
164
+ const vectors = JSON.parse(r.stdout).vectors;
165
+ if (!Array.isArray(vectors) || vectors.length !== texts.length) return null;
166
+ return vectors.map((v) => (isVec(v) ? v : null));
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Embed texts through the configured provider, via the content-hash-keyed disk cache
174
+ * (`.forge/embed-cache.jsonl` under `root`) so repeated retrieval doesn't re-pay the
175
+ * provider. Returns an array aligned with `texts` where an entry may be null (that
176
+ * one text couldn't be embedded — per-candidate MinHash fallback), or null overall
177
+ * when no provider is configured / nothing could be embedded.
178
+ * @param {string[]} texts
179
+ * @param {{root?: string}} [opts]
180
+ * @returns {(number[]|null)[]|null}
181
+ */
182
+ export function embed(texts, { root = process.cwd() } = {}) {
183
+ const provider = getProvider();
184
+ if (!provider || !Array.isArray(texts) || !texts.length) return null;
185
+ const pid = providerId(provider);
186
+ const trimmed = texts.map((t) => String(t).slice(0, MAX_TEXT_CHARS));
187
+ const keys = trimmed.map((t) => contentHash(`${pid}\n${t}`));
188
+ const cache = readCache(root);
189
+ const out = keys.map((k) => cache.get(k) ?? null);
190
+
191
+ const missIdx = out.flatMap((v, i) => (v === null ? [i] : []));
192
+ if (missIdx.length) {
193
+ const missTexts = [...new Set(missIdx.map((i) => trimmed[i]))];
194
+ const vecs = callProvider(provider, missTexts);
195
+ if (vecs) {
196
+ const byText = new Map(missTexts.map((t, i) => [t, vecs[i]]));
197
+ const fresh = new Map();
198
+ for (const i of missIdx) {
199
+ const v = byText.get(trimmed[i]) ?? null;
200
+ out[i] = v;
201
+ if (v) fresh.set(keys[i], v);
202
+ }
203
+ appendCache(
204
+ root,
205
+ [...fresh].map(([k, v]) => ({ k, v })),
206
+ );
207
+ }
208
+ }
209
+ return out.some(Boolean) ? out : null;
210
+ }
211
+
212
+ /**
213
+ * Build the optional `sim` function callers inject into ledger.retrieve/score and
214
+ * reuse.lookup (those cores stay embed-free). Query + every candidate text go through
215
+ * ONE embed() call (cache-deduped); the returned sim maps a claim to
216
+ * cosine(query, claim) or null when that claim's vector is missing (per-candidate
217
+ * MinHash fallback). Returns null when no provider is configured, the candidate list
218
+ * is empty, or the query itself couldn't be embedded — the caller's MinHash path is
219
+ * then byte-for-byte unchanged.
220
+ * @param {string} root repo root (hosts the disk cache)
221
+ * @param {string} query
222
+ * @param {any[]} claims
223
+ * @param {(claim:any)=>string} textOf
224
+ * @returns {((query:any, claim:any)=>number|null)|null}
225
+ */
226
+ export function claimSim(root, query, claims, textOf) {
227
+ if (!getProvider() || !claims.length) return null;
228
+ const texts = [String(query), ...claims.map((c) => String(textOf(c)))];
229
+ const vectors = embed(texts, { root });
230
+ const qv = vectors?.[0];
231
+ if (!qv) return null;
232
+ const byText = new Map();
233
+ for (let i = 1; i < texts.length; i++) if (vectors[i]) byText.set(texts[i], vectors[i]);
234
+ return (_query, claim) => {
235
+ const v = byText.get(String(textOf(claim)));
236
+ return v ? cosine(qv, v) : null;
237
+ };
238
+ }
239
+
240
+ /** The explainability line every query prints: which similarity backend served it. */
241
+ export function simLabel(sim) {
242
+ const p = getProvider();
243
+ return sim && p ? `embed(${p.kind})` : "minhash";
244
+ }
@@ -0,0 +1,39 @@
1
+ // Shared helpers for emitters. Every generated file carries a `forge:sync:<hash>`
2
+ // marker so re-running is idempotent (skip identical content) and drift is
3
+ // detectable (forge doctor compares the marker to the current source hash).
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+
8
+ export const MARKER = "forge:sync";
9
+
10
+ export const hashContent = (str) => createHash("sha256").update(str).digest("hex").slice(0, 12);
11
+
12
+ export const markerString = (hash) => `${MARKER}:${hash}`;
13
+
14
+ export const mdHeader = (hash) =>
15
+ `<!-- GENERATED by \`forge sync\` — edit source/rules.json, not this file. ${markerString(hash)} -->`;
16
+
17
+ export const yamlHeader = (hash) =>
18
+ `# GENERATED by \`forge sync\` — edit source/rules.json, not this file. ${markerString(hash)}`;
19
+
20
+ export const readIfExists = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null);
21
+
22
+ export const isManaged = (text) => typeof text === "string" && text.includes(`${MARKER}:`);
23
+
24
+ export function extractHash(text) {
25
+ const m = typeof text === "string" && text.match(new RegExp(`${MARKER}:([a-f0-9]{12})`));
26
+ return m ? m[1] : null;
27
+ }
28
+
29
+ // Write only when the content's marker differs from what's on disk.
30
+ export function writeIfChanged(absPath, content) {
31
+ const existing = readIfExists(absPath);
32
+ if (existing !== null && extractHash(existing) === extractHash(content)) return "unchanged";
33
+ mkdirSync(dirname(absPath), { recursive: true });
34
+ writeFileSync(absPath, content);
35
+ return "written";
36
+ }
37
+
38
+ export const writeManaged = (absPath, header, body) =>
39
+ writeIfChanged(absPath, `${header}\n${body}\n`);
@@ -0,0 +1,22 @@
1
+ // Aider loads extra context only via `read:` in .aider.conf.yml. Emit a managed
2
+ // conf that reads AGENTS.md; never clobber a user's own unmanaged conf.
3
+ import { join } from "node:path";
4
+
5
+ export default {
6
+ tool: "Aider",
7
+ emit(ctx) {
8
+ const path = join(ctx.targetRoot, ".aider.conf.yml");
9
+ const existing = ctx.shared.readIfExists(path);
10
+ if (existing !== null && !ctx.shared.isManaged(existing)) {
11
+ return {
12
+ tool: this.tool,
13
+ target: ".aider.conf.yml",
14
+ action: "skipped",
15
+ note: "existing conf — add `read: AGENTS.md` yourself",
16
+ };
17
+ }
18
+ const body = ["read:", " - AGENTS.md"].join("\n");
19
+ const action = ctx.shared.writeManaged(path, ctx.shared.yamlHeader(ctx.hash), body);
20
+ return { tool: this.tool, target: ".aider.conf.yml", action, note: "read: AGENTS.md" };
21
+ },
22
+ };
@@ -0,0 +1,44 @@
1
+ // Claude Code reads CLAUDE.md, not AGENTS.md. Emit a thin CLAUDE.md that imports
2
+ // the shared source via @AGENTS.md (first line, Windows-safe). If the repo already has its OWN
3
+ // CLAUDE.md, ADOPT it rather than skip: prepend the one-line @AGENTS.md import (idempotent) so
4
+ // Forge's shared rules actually reach Claude Code, while preserving every line the user wrote.
5
+ import { writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ const IMPORTS_AGENTS = /^\s*@AGENTS\.md\b/m;
9
+
10
+ export default {
11
+ tool: "Claude Code",
12
+ emit(ctx) {
13
+ const path = join(ctx.targetRoot, "CLAUDE.md");
14
+ const existing = ctx.shared.readIfExists(path);
15
+ // A user's own (unmanaged) CLAUDE.md: adopt it by wiring in the shared import, non-destructively.
16
+ if (existing !== null && !ctx.shared.isManaged(existing)) {
17
+ if (IMPORTS_AGENTS.test(existing)) {
18
+ return {
19
+ tool: this.tool,
20
+ target: "CLAUDE.md",
21
+ action: "unchanged",
22
+ note: "your CLAUDE.md already imports @AGENTS.md",
23
+ };
24
+ }
25
+ writeFileSync(path, `@AGENTS.md\n\n${existing.replace(/^/, "")}`);
26
+ return {
27
+ tool: this.tool,
28
+ target: "CLAUDE.md",
29
+ action: "adopted",
30
+ note: "prepended @AGENTS.md import to your existing CLAUDE.md (content preserved)",
31
+ };
32
+ }
33
+ const content = [
34
+ "@AGENTS.md",
35
+ "",
36
+ ctx.shared.mdHeader(ctx.hash),
37
+ "",
38
+ "> Claude reads the shared rules via the import above. Add Claude-only or repo-specific notes below.",
39
+ "",
40
+ ].join("\n");
41
+ const action = ctx.shared.writeIfChanged(path, content);
42
+ return { tool: this.tool, target: "CLAUDE.md", action, note: "@AGENTS.md import" };
43
+ },
44
+ };
@@ -0,0 +1,17 @@
1
+ // Codex reads AGENTS.md natively but hard-truncates past 32 KiB (project_doc_max_bytes).
2
+ const CAP = 32 * 1024;
3
+
4
+ export default {
5
+ tool: "Codex",
6
+ emit(ctx) {
7
+ const over = ctx.bytes > CAP;
8
+ return {
9
+ tool: this.tool,
10
+ target: "AGENTS.md",
11
+ action: over ? "warn" : "relies-on-agents",
12
+ note: over
13
+ ? `${ctx.bytes} B exceeds 32 KiB cap — will truncate`
14
+ : `native (${ctx.bytes}/${CAP} B)`,
15
+ };
16
+ },
17
+ };
@@ -0,0 +1,28 @@
1
+ // Continue does NOT read AGENTS.md — it loads .continue/rules/*.md (verified 2026-07).
2
+ // So emit the canonical rules there, so Continue gets the same single source as
3
+ // every other tool. Never clobber a user's own unmanaged rules file.
4
+ import { join } from "node:path";
5
+
6
+ export default {
7
+ tool: "Continue",
8
+ emit(ctx) {
9
+ const rel = ".continue/rules/00-forge.md";
10
+ const path = join(ctx.targetRoot, ".continue", "rules", "00-forge.md");
11
+ const existing = ctx.shared.readIfExists(path);
12
+ if (existing !== null && !ctx.shared.isManaged(existing)) {
13
+ return {
14
+ tool: this.tool,
15
+ target: rel,
16
+ action: "skipped",
17
+ note: "existing unmanaged rules file",
18
+ };
19
+ }
20
+ const action = ctx.shared.writeManaged(path, ctx.shared.mdHeader(ctx.hash), ctx.canonical);
21
+ return {
22
+ tool: this.tool,
23
+ target: rel,
24
+ action,
25
+ note: ".continue/rules/ (no AGENTS.md)",
26
+ };
27
+ },
28
+ };
@@ -0,0 +1,12 @@
1
+ // GitHub Copilot's coding agent reads the root AGENTS.md natively (since 2025-08-28).
2
+ export default {
3
+ tool: "Copilot",
4
+ emit(ctx) {
5
+ return {
6
+ tool: this.tool,
7
+ target: "AGENTS.md",
8
+ action: "relies-on-agents",
9
+ note: "coding agent reads root AGENTS.md",
10
+ };
11
+ },
12
+ };
@@ -0,0 +1,23 @@
1
+ // Cursor reads AGENTS.md natively; a legacy root .cursorrules can shadow it.
2
+ import { join } from "node:path";
3
+
4
+ export default {
5
+ tool: "Cursor",
6
+ emit(ctx) {
7
+ const legacy = join(ctx.targetRoot, ".cursorrules");
8
+ if (ctx.shared.readIfExists(legacy) !== null) {
9
+ return {
10
+ tool: this.tool,
11
+ target: "AGENTS.md",
12
+ action: "warn",
13
+ note: "legacy .cursorrules present — remove it so AGENTS.md wins",
14
+ };
15
+ }
16
+ return {
17
+ tool: this.tool,
18
+ target: "AGENTS.md",
19
+ action: "relies-on-agents",
20
+ note: "native (+ .cursor/rules/*.mdc for scoping)",
21
+ };
22
+ },
23
+ };
@@ -0,0 +1,40 @@
1
+ // Gemini CLI reads GEMINI.md by default and only reads AGENTS.md when
2
+ // settings.json context.fileName lists it. Merge that opt-in without a 2nd copy.
3
+ import { join } from "node:path";
4
+
5
+ export default {
6
+ tool: "Gemini CLI",
7
+ emit(ctx) {
8
+ const path = join(ctx.targetRoot, ".gemini/settings.json");
9
+ let settings = {};
10
+ const existing = ctx.shared.readIfExists(path);
11
+ if (existing) {
12
+ try {
13
+ settings = JSON.parse(existing);
14
+ } catch {
15
+ return {
16
+ tool: this.tool,
17
+ target: ".gemini/settings.json",
18
+ action: "skipped",
19
+ note: "existing settings.json is invalid JSON — left as-is",
20
+ };
21
+ }
22
+ }
23
+ const context = settings.context || (settings.context = {});
24
+ const current = Array.isArray(context.fileName)
25
+ ? context.fileName
26
+ : context.fileName
27
+ ? [context.fileName]
28
+ : [];
29
+ const had = current.includes("AGENTS.md");
30
+ context.fileName = [...new Set([...current, "AGENTS.md"])];
31
+ settings._forge = ctx.shared.markerString(ctx.hash);
32
+ const action = ctx.shared.writeIfChanged(path, JSON.stringify(settings, null, 2) + "\n");
33
+ return {
34
+ tool: this.tool,
35
+ target: ".gemini/settings.json",
36
+ action,
37
+ note: had ? "AGENTS.md already in context.fileName" : "added AGENTS.md to context.fileName",
38
+ };
39
+ },
40
+ };
@@ -0,0 +1,94 @@
1
+ // Emit the canonical MCP server set (source/mcp.json) into each tool's MCP config,
2
+ // in that tool's REAL format (all verified 2026-07). Always MERGE — never clobber a
3
+ // user's own servers. JSON tools differ only by their top-level key; Codex is TOML and
4
+ // Continue is a YAML block file. (Windsurf is global-only — no per-repo file to emit.)
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+
8
+ const JSON_TARGETS = [
9
+ { tool: "Claude Code", file: ".mcp.json", key: "mcpServers" },
10
+ { tool: "Cursor", file: ".cursor/mcp.json", key: "mcpServers" },
11
+ { tool: "Gemini CLI", file: ".gemini/settings.json", key: "mcpServers" },
12
+ { tool: "Roo Code", file: ".roo/mcp.json", key: "mcpServers" },
13
+ { tool: "Zed", file: ".zed/settings.json", key: "context_servers" },
14
+ { tool: "VS Code / Copilot", file: ".vscode/mcp.json", key: "servers" },
15
+ ];
16
+
17
+ function mergeJson(path, key, servers) {
18
+ let obj = {};
19
+ if (existsSync(path)) {
20
+ try {
21
+ obj = JSON.parse(readFileSync(path, "utf8"));
22
+ } catch {
23
+ return { action: "skipped", note: "invalid JSON — left as-is" };
24
+ }
25
+ }
26
+ const bucket = obj[key] || (obj[key] = {});
27
+ let added = 0;
28
+ for (const [name, def] of Object.entries(servers)) {
29
+ if (!bucket[name]) {
30
+ bucket[name] = def;
31
+ added += 1;
32
+ }
33
+ }
34
+ if (!added) return { action: "unchanged", note: "present" };
35
+ mkdirSync(dirname(path), { recursive: true });
36
+ writeFileSync(path, JSON.stringify(obj, null, 2) + "\n");
37
+ return { action: "written", note: `+${added} server(s)` };
38
+ }
39
+
40
+ function emitCodexToml(path, servers) {
41
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
42
+ let blocks = "";
43
+ for (const [name, def] of Object.entries(servers)) {
44
+ if (existing.includes(`[mcp_servers.${name}]`)) continue;
45
+ const args = (def.args || []).map((a) => JSON.stringify(a)).join(", ");
46
+ blocks += `\n[mcp_servers.${name}]\ncommand = ${JSON.stringify(def.command)}\nargs = [${args}]\n`;
47
+ }
48
+ if (!blocks) return { action: "unchanged", note: "present" };
49
+ mkdirSync(dirname(path), { recursive: true });
50
+ writeFileSync(path, existing + blocks);
51
+ return { action: "written", note: "TOML block appended" };
52
+ }
53
+
54
+ function emitContinueYaml(dir, servers) {
55
+ const path = join(dir, "forge-mcp.yaml");
56
+ const lines = ["name: Forge MCP", "version: 0.0.1", "schema: v1", "mcpServers:"];
57
+ for (const [name, def] of Object.entries(servers)) {
58
+ lines.push(` - name: ${name}`, " type: stdio", ` command: ${def.command}`, " args:");
59
+ for (const a of def.args || []) lines.push(` - ${JSON.stringify(a)}`);
60
+ }
61
+ const content = lines.join("\n") + "\n";
62
+ if (existsSync(path) && readFileSync(path, "utf8") === content)
63
+ return { action: "unchanged", note: "present" };
64
+ mkdirSync(dir, { recursive: true });
65
+ writeFileSync(path, content);
66
+ return { action: "written", note: "YAML block file" };
67
+ }
68
+
69
+ export function emitMcp({ targetRoot, servers }) {
70
+ const rows = JSON_TARGETS.map((t) => {
71
+ const r = mergeJson(join(targetRoot, t.file), t.key, servers);
72
+ return {
73
+ tool: `${t.tool} MCP`,
74
+ target: t.file,
75
+ action: r.action,
76
+ note: r.note,
77
+ };
78
+ });
79
+ const codex = emitCodexToml(join(targetRoot, ".codex", "config.toml"), servers);
80
+ rows.push({
81
+ tool: "Codex MCP",
82
+ target: ".codex/config.toml",
83
+ action: codex.action,
84
+ note: codex.note,
85
+ });
86
+ const cont = emitContinueYaml(join(targetRoot, ".continue", "mcpServers"), servers);
87
+ rows.push({
88
+ tool: "Continue MCP",
89
+ target: ".continue/mcpServers/forge-mcp.yaml",
90
+ action: cont.action,
91
+ note: cont.note,
92
+ });
93
+ return rows;
94
+ }
@@ -0,0 +1,22 @@
1
+ // Windsurf/Devin auto-discovers AGENTS.md but caps combined rules at ~12k chars.
2
+ // The rules dir is mid-rebrand (.windsurf -> .devin); detect which exists.
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ const CHAR_CAP = 12 * 1024;
7
+
8
+ export default {
9
+ tool: "Windsurf/Devin",
10
+ emit(ctx) {
11
+ const dir = existsSync(join(ctx.targetRoot, ".devin")) ? ".devin" : ".windsurf";
12
+ const over = ctx.chars > CHAR_CAP;
13
+ return {
14
+ tool: this.tool,
15
+ target: "AGENTS.md",
16
+ action: over ? "warn" : "relies-on-agents",
17
+ note: over
18
+ ? `${ctx.chars} chars exceeds ~12k cap — trim rules`
19
+ : `native (${dir}; ${ctx.chars} chars)`,
20
+ };
21
+ },
22
+ };
@@ -0,0 +1,34 @@
1
+ // Zed uses the FIRST match of a precedence list; several legacy files outrank
2
+ // AGENTS.md. Warn if any of them exist so AGENTS.md actually wins.
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ const EARLIER = [
7
+ ".rules",
8
+ ".cursorrules",
9
+ ".windsurfrules",
10
+ ".clinerules",
11
+ ".github/copilot-instructions.md",
12
+ "AGENT.md",
13
+ ];
14
+
15
+ export default {
16
+ tool: "Zed",
17
+ emit(ctx) {
18
+ const shadow = EARLIER.filter((f) => existsSync(join(ctx.targetRoot, f)));
19
+ if (shadow.length) {
20
+ return {
21
+ tool: this.tool,
22
+ target: "AGENTS.md",
23
+ action: "warn",
24
+ note: `shadowed by ${shadow.join(", ")} (Zed uses first match)`,
25
+ };
26
+ }
27
+ return {
28
+ tool: this.tool,
29
+ target: "AGENTS.md",
30
+ action: "relies-on-agents",
31
+ note: "AGENTS.md wins precedence",
32
+ };
33
+ },
34
+ };
package/src/eval.js ADDED
@@ -0,0 +1,47 @@
1
+ // forge eval — a small, honest evaluation harness for the impact oracle. The Python prototype
2
+ // used mutation testing against a real suite; this ships the deterministic core of that idea so
3
+ // the atlas/impact quality claim is CHECKABLE in CI: for a set of {target → files that truly
4
+ // depend on it} cases, score the oracle's precision/recall/F1 and compare it to the naive
5
+ // "edited-file-only" baseline the paper measured against. Pure; no repo/network.
6
+ import { impact } from "./atlas.js";
7
+
8
+ /** Pure: precision/recall/F1 of a predicted set against ground truth. */
9
+ export function score(predicted, groundTruth) {
10
+ const P = new Set(predicted);
11
+ const G = new Set(groundTruth);
12
+ let tp = 0;
13
+ for (const g of G) if (P.has(g)) tp += 1;
14
+ const precision = P.size ? tp / P.size : G.size ? 0 : 1;
15
+ const recall = G.size ? tp / G.size : 1;
16
+ const f1 = precision + recall ? (2 * precision * recall) / (precision + recall) : 0;
17
+ return { precision, recall, f1, tp, predicted: P.size, groundTruth: G.size };
18
+ }
19
+
20
+ const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
21
+
22
+ /**
23
+ * Evaluate the impact oracle over labeled cases and against the edited-file-only baseline.
24
+ * @param {object} atlas
25
+ * @param {{target:string, expected:string[], editedFile?:string}[]} cases
26
+ * @returns {{oracle:{precision:number,recall:number,f1:number}, baseline:{recall:number}, n:number, perCase:object[]}}
27
+ */
28
+ export function evalImpact(atlas, cases, opts = {}) {
29
+ const perCase = cases.map((c) => {
30
+ const predicted = impact(atlas, c.target, opts).impactedFiles;
31
+ const oracle = score(predicted, c.expected);
32
+ // Baseline: an agent that only "knows" the file it edited breaks nothing else it can see.
33
+ const baselineHits = c.editedFile && c.expected.includes(c.editedFile) ? [c.editedFile] : [];
34
+ const baseline = score(baselineHits, c.expected);
35
+ return { target: c.target, oracle, baseline };
36
+ });
37
+ return {
38
+ n: cases.length,
39
+ oracle: {
40
+ precision: mean(perCase.map((p) => p.oracle.precision)),
41
+ recall: mean(perCase.map((p) => p.oracle.recall)),
42
+ f1: mean(perCase.map((p) => p.oracle.f1)),
43
+ },
44
+ baseline: { recall: mean(perCase.map((p) => p.baseline.recall)) },
45
+ perCase,
46
+ };
47
+ }