@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.
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.mcp.json +8 -0
- package/ARCHITECTURE.md +314 -0
- package/CHANGELOG.md +467 -0
- package/LICENSE +21 -0
- package/ONBOARDING.md +180 -0
- package/README.md +286 -0
- package/bin/claude-init.sh +90 -0
- package/bin/claude-taste.sh +33 -0
- package/bin/learn-consolidate.sh +51 -0
- package/brand.json +15 -0
- package/global/CLAUDE.md +31 -0
- package/global/crew/frontend-verifier.md +36 -0
- package/global/crew/independent-reviewer.md +29 -0
- package/global/crew/scout.md +24 -0
- package/global/crew/verifier.md +28 -0
- package/global/guards/_guardlib.sh +36 -0
- package/global/guards/cortex.sh +14 -0
- package/global/guards/cost-budget.sh +41 -0
- package/global/guards/doom-loop.sh +25 -0
- package/global/guards/format-on-edit.sh +32 -0
- package/global/guards/lean-guard.sh +20 -0
- package/global/guards/protect-paths.sh +45 -0
- package/global/guards/recall-load.sh +22 -0
- package/global/guards/secret-redact.sh +18 -0
- package/global/guards/session-learner.sh +72 -0
- package/global/recall/MEMORY.md +7 -0
- package/global/rules/self-correction.md +17 -0
- package/global/rules/stack-notes.md +24 -0
- package/global/rules/tech-currency.md +19 -0
- package/global/settings.template.json +183 -0
- package/global/statusline.sh +51 -0
- package/global/taste/brutalist.json +9 -0
- package/global/taste/brutalist.md +19 -0
- package/global/taste/corporate.json +9 -0
- package/global/taste/corporate.md +19 -0
- package/global/taste/editorial.json +9 -0
- package/global/taste/editorial.md +19 -0
- package/global/taste/minimalist.json +9 -0
- package/global/taste/minimalist.md +20 -0
- package/global/taste/playful.json +9 -0
- package/global/taste/playful.md +19 -0
- package/global/tools/atlas/SKILL.md +27 -0
- package/global/tools/code-modernization/SKILL.md +275 -0
- package/global/tools/code-modernization/references/cost-impact-preflight.md +54 -0
- package/global/tools/code-modernization/references/design-patterns-cheatsheet.md +24 -0
- package/global/tools/code-modernization/references/research-protocol.md +42 -0
- package/global/tools/code-modernization/scripts/preflight_scan.py +190 -0
- package/global/tools/cognitive-substrate/SKILL.md +56 -0
- package/global/tools/cognitive-substrate/references/capability-map.md +17 -0
- package/global/tools/cost-guard/SKILL.md +50 -0
- package/global/tools/design-md/SKILL.md +54 -0
- package/global/tools/dev-radar/SKILL.md +56 -0
- package/global/tools/explore-plan-code/SKILL.md +24 -0
- package/global/tools/lean/SKILL.md +41 -0
- package/global/tools/recall/SKILL.md +31 -0
- package/global/tools/reuse-first/SKILL.md +64 -0
- package/global/tools/self-improve/SKILL.md +44 -0
- package/global/tools/taste/SKILL.md +26 -0
- package/global/tools/tech-selector/SKILL.md +35 -0
- package/global/tools/ui-workflow/SKILL.md +44 -0
- package/hooks/hooks.json +107 -0
- package/install.sh +88 -0
- package/package.json +93 -0
- package/public/index.html +45 -0
- package/scripts/build-pages.mjs +180 -0
- package/scripts/bump.mjs +322 -0
- package/skills/cognitive-substrate/SKILL.md +56 -0
- package/skills/cognitive-substrate/references/capability-map.md +17 -0
- package/source/mcp.json +10 -0
- package/source/rules.json +106 -0
- package/source/substrate.json +41 -0
- package/src/adjudicate.js +84 -0
- package/src/anchor.js +210 -0
- package/src/atlas.js +487 -0
- package/src/brain.js +84 -0
- package/src/brand.js +25 -0
- package/src/cli.js +1509 -0
- package/src/context.js +273 -0
- package/src/cortex.js +251 -0
- package/src/cortex_distill.js +55 -0
- package/src/cortex_features.js +81 -0
- package/src/cortex_hook.js +197 -0
- package/src/cortex_hook_main.js +139 -0
- package/src/cortex_mcp.js +352 -0
- package/src/cost_report.js +271 -0
- package/src/dash.html +396 -0
- package/src/dash.js +220 -0
- package/src/diagnose.js +0 -0
- package/src/doctor.js +315 -0
- package/src/embed.js +244 -0
- package/src/emit/_shared.js +39 -0
- package/src/emit/aider.js +22 -0
- package/src/emit/claude.js +44 -0
- package/src/emit/codex.js +17 -0
- package/src/emit/continue.js +28 -0
- package/src/emit/copilot.js +12 -0
- package/src/emit/cursor.js +23 -0
- package/src/emit/gemini.js +40 -0
- package/src/emit/mcp.js +94 -0
- package/src/emit/windsurf.js +22 -0
- package/src/emit/zed.js +34 -0
- package/src/eval.js +47 -0
- package/src/extract.js +82 -0
- package/src/harden.js +44 -0
- package/src/imagine.js +301 -0
- package/src/init.js +178 -0
- package/src/lean.js +149 -0
- package/src/ledger.js +475 -0
- package/src/ledger_bridge.js +279 -0
- package/src/ledger_read.js +152 -0
- package/src/ledger_store.js +360 -0
- package/src/lessons.js +185 -0
- package/src/lessons_store.js +137 -0
- package/src/metrics.js +54 -0
- package/src/model_tiers.js +17 -0
- package/src/model_tiers.json +39 -0
- package/src/predictor.js +143 -0
- package/src/preflight.js +410 -0
- package/src/providers.js +320 -0
- package/src/recall.js +103 -0
- package/src/reuse.js +0 -0
- package/src/route.js +323 -0
- package/src/scope.js +122 -0
- package/src/skillgate.js +89 -0
- package/src/speclock.js +64 -0
- package/src/substrate.js +492 -0
- package/src/sync.js +132 -0
- package/src/taste.js +55 -0
- package/src/uicheck.js +96 -0
- package/src/uifingerprint.js +861 -0
- package/src/uivisual.js +334 -0
- package/src/util.js +71 -0
- package/src/verify.js +117 -0
- package/templates/project-layer/.claude/settings.json +22 -0
- package/templates/project-layer/.claude/skills/hostlelo-deploy/SKILL.md +38 -0
- package/templates/project-layer/AGENTS.md +28 -0
- 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
|
+
};
|
package/src/emit/mcp.js
ADDED
|
@@ -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
|
+
};
|
package/src/emit/zed.js
ADDED
|
@@ -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
|
+
}
|