@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/context.js ADDED
@@ -0,0 +1,273 @@
1
+ // forge context — context assembly as a budgeted optimization with a completeness
2
+ // gate (docs/plans/substrate-v2/04-context-assembly.md). Two failures die here:
3
+ // over-stuffing (everything competes for the window on equal terms — P3 of the
4
+ // paper) and under-supplying (the agent edits a symbol without its callers, tests,
5
+ // or the team's lessons, then "assumes"). Selection gets an objective function
6
+ // (greedy knapsack by value density, with a compression ladder instead of silent
7
+ // drops) and sufficiency becomes a COMPUTED SET: required knowledge R(edit) from
8
+ // the atlas, missing = R \ covered — auto-fetched when resolvable, asked as a
9
+ // derived M2 question when not. Context insufficiency stops being a feeling.
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { basename, dirname, join } from "node:path";
12
+ import { has as atlasHas, query as atlasQuery, impact } from "./atlas.js";
13
+ import { claimText, val } from "./ledger.js";
14
+ import { loadClaims, repoLedger } from "./ledger_store.js";
15
+ import { referencedEntities } from "./preflight.js";
16
+
17
+ /** chars → tokens heuristic (calibrated in P8; consistent with the reuse estimator). */
18
+ export const tokensOf = (text) => Math.ceil(String(text).length / 3.6);
19
+
20
+ /** Lessons must be THIS trusted to enter the required set (spec §3: lessons*(S)). */
21
+ export const LESSON_REQUIRED_VAL = 0.8;
22
+ /** Per-source diminishing returns for optional items (spec §2). */
23
+ const SOURCE_DISCOUNT = 0.7;
24
+ /** Default assembly budget in tokens (callers pass the real per-tool cap). */
25
+ export const DEFAULT_BUDGET = 6000;
26
+
27
+ const readRel = (root, rel) => {
28
+ try {
29
+ return readFileSync(join(root, rel), "utf8");
30
+ } catch {
31
+ return null;
32
+ }
33
+ };
34
+
35
+ // Sibling-test detection (same heuristics family as substrate's predictFailingTests,
36
+ // local here to keep the import graph one-directional: substrate → context, never back).
37
+ const isTestFile = (f) => /(\.|_)(test|spec)\.[jt]sx?$|(^|\/)(tests?|__tests__)\//.test(f);
38
+ function siblingTests(root, file) {
39
+ const dir = dirname(file);
40
+ const base = basename(file).replace(/\.[^.]+$/, "");
41
+ const ext = file.match(/\.[^.]+$/)?.[0] ?? ".js";
42
+ const candidates = [
43
+ join(dir, `${base}.test${ext}`),
44
+ join(dir, `${base}.spec${ext}`),
45
+ join(dir, "__tests__", `${base}.test${ext}`),
46
+ join("test", `${base}.test${ext}`),
47
+ join("tests", `${base}.test${ext}`),
48
+ ];
49
+ return candidates.filter((c) => existsSync(join(root, c)));
50
+ }
51
+
52
+ /**
53
+ * The required-knowledge set R(edit) — computed, not vibes (spec §3):
54
+ * defs(S) ∪ blast₁(S) ∪ tests(S) ∪ lessons*(S)
55
+ * Each entry: { key, kind, name, resolvable } — unresolvable entries are exactly the
56
+ * derived clarifying questions.
57
+ */
58
+ export function requiredSet(root, task, { atlas = null, claims = [], nowDay = 0 } = {}) {
59
+ const entities = referencedEntities(String(task || ""));
60
+ const R = [];
61
+ const targetFiles = new Set();
62
+
63
+ for (const s of entities.symbols) {
64
+ const known = atlas ? atlasHas(atlas, s) : false;
65
+ R.push({ key: `def:${s}`, kind: "def", name: s, resolvable: known });
66
+ if (known)
67
+ for (const hit of atlasQuery(atlas, s))
68
+ if (hit.name === s || hit.qname === s) targetFiles.add(hit.file);
69
+ if (known) R.push({ key: `deps:${s}`, kind: "deps", name: s, resolvable: true });
70
+ }
71
+ for (const f of entities.files) {
72
+ const onDisk = existsSync(join(root, f));
73
+ R.push({ key: `file:${f}`, kind: "file", name: f, resolvable: onDisk });
74
+ if (onDisk) targetFiles.add(f);
75
+ }
76
+ for (const f of targetFiles) {
77
+ if (isTestFile(f)) continue;
78
+ for (const t of siblingTests(root, f))
79
+ R.push({ key: `tests:${t}`, kind: "tests", name: t, resolvable: true });
80
+ }
81
+ // Team lessons trusted past the floor, scope-matching the targets — required context:
82
+ // an agent editing without them repeats a mistake the ledger already paid for.
83
+ const names = new Set([...entities.symbols, ...entities.files.map((f) => basename(f))]);
84
+ for (const c of claims) {
85
+ if (c.kind !== "lesson" || c.tombstone || val(c, nowDay) < LESSON_REQUIRED_VAL) continue;
86
+ const trig = [...(c.body.trigger?.symbols ?? []), ...(c.body.trigger?.files ?? [])];
87
+ if (trig.some((t) => names.has(t) || names.has(basename(String(t)))))
88
+ R.push({ key: `lesson:${c.id.slice(0, 8)}`, kind: "lesson", name: c.id, resolvable: true });
89
+ }
90
+ // Dedupe by key, deterministic order.
91
+ const seen = new Set();
92
+ return R.filter((r) => (seen.has(r.key) ? false : seen.add(r.key))).sort((a, b) =>
93
+ a.key < b.key ? -1 : 1,
94
+ );
95
+ }
96
+
97
+ // An item is one injectable unit with a COMPRESSION LADDER: granularity variants from
98
+ // full text down to a one-line pointer. The optimizer may downgrade an item instead of
99
+ // dropping it — compression is a lossy move with a known cost, chosen explicitly,
100
+ // never by scroll-off (spec §2).
101
+ function fileItem(root, rel, { covers, source, score }) {
102
+ const text = readRel(root, rel);
103
+ if (text === null) return null;
104
+ const head = text.split("\n").slice(0, 25).join("\n");
105
+ const variants = [
106
+ { gran: "full", text: `// ${rel}\n${text}`, tokens: tokensOf(text) },
107
+ { gran: "head", text: `// ${rel} (first 25 lines)\n${head}`, tokens: tokensOf(head) },
108
+ { gran: "pointer", text: `- read ${rel}`, tokens: 8 },
109
+ ];
110
+ return { id: `${source}:${rel}`, source, covers, score, variants };
111
+ }
112
+
113
+ /**
114
+ * Assemble the context for a task: pinned required items (downgraded before dropped),
115
+ * optional items greedily by value density, and the missing set as derived questions.
116
+ * @param {string} root
117
+ * @param {string} task
118
+ * @param {{budget?:number, atlas?:any, claims?:any[], nowDay?:number}} [opts]
119
+ */
120
+ export function assemble(
121
+ root,
122
+ task,
123
+ { budget = DEFAULT_BUDGET, atlas = null, claims, nowDay = 0 } = {},
124
+ ) {
125
+ const ledgerDir = repoLedger(root);
126
+ const allClaims = claims ?? (existsSync(join(ledgerDir, "claims")) ? loadClaims(ledgerDir) : []);
127
+ const required = requiredSet(root, task, { atlas, claims: allClaims, nowDay });
128
+
129
+ // --- build candidate items, keyed by what they cover -------------------------------
130
+ const items = [];
131
+ for (const r of required) {
132
+ if (!r.resolvable) continue;
133
+ if (r.kind === "def") {
134
+ const hit = atlasQuery(atlas, r.name).find((s) => s.name === r.name || s.qname === r.name);
135
+ if (hit?.file) {
136
+ const it = fileItem(root, hit.file, { covers: [r.key], source: "def", score: 1 });
137
+ if (it) items.push(it);
138
+ }
139
+ } else if (r.kind === "file") {
140
+ const it = fileItem(root, r.name, { covers: [r.key], source: "def", score: 1 });
141
+ if (it) items.push(it);
142
+ } else if (r.kind === "tests") {
143
+ const it = fileItem(root, r.name, { covers: [r.key], source: "tests", score: 0.9 });
144
+ if (it) items.push(it);
145
+ } else if (r.kind === "deps" && atlas) {
146
+ const hop1 = impact(atlas, r.name, { maxHops: 1 })
147
+ .impacted.filter((x) => x.hopDistance === 1)
148
+ .slice(0, 12);
149
+ const text = hop1.length
150
+ ? [
151
+ `direct dependents of ${r.name} (edit these with it or verify them):`,
152
+ ...hop1.map((x) => ` - ${x.node.name} (${x.node.file}, via ${x.edgeKinds[0]})`),
153
+ ].join("\n")
154
+ : `no direct dependents of ${r.name} found in the atlas`;
155
+ items.push({
156
+ id: `deps:${r.name}`,
157
+ source: "deps",
158
+ covers: [r.key],
159
+ score: 1,
160
+ variants: [{ gran: "full", text, tokens: tokensOf(text) }],
161
+ });
162
+ } else if (r.kind === "lesson") {
163
+ const c = allClaims.find((x) => x.id === r.name);
164
+ if (c) {
165
+ const text = `lesson (val ${val(c, nowDay).toFixed(2)}): ${c.body.correctedBehavior}`;
166
+ items.push({
167
+ id: `lesson:${c.id.slice(0, 8)}`,
168
+ source: "lesson",
169
+ covers: [r.key],
170
+ score: 0.95,
171
+ variants: [{ gran: "full", text, tokens: tokensOf(text) }],
172
+ });
173
+ }
174
+ }
175
+ }
176
+ // Optional extras: trusted scope-matching facts (nice-to-have, never required).
177
+ for (const c of allClaims) {
178
+ if (c.kind !== "fact" || c.tombstone) continue;
179
+ const v = val(c, nowDay);
180
+ if (v < 0.5) continue;
181
+ const text = `fact: ${claimText(c)}`;
182
+ items.push({
183
+ id: `fact:${c.id.slice(0, 8)}`,
184
+ source: "fact",
185
+ covers: [],
186
+ score: 0.3 + 0.4 * v,
187
+ variants: [{ gran: "full", text, tokens: tokensOf(text) }],
188
+ });
189
+ }
190
+
191
+ // A symbol's definition and an explicitly named file often resolve to the SAME file —
192
+ // merge items by id (union of covered keys) so one span never gets injected twice.
193
+ const byId = new Map();
194
+ for (const it of items) {
195
+ const prev = byId.get(it.id);
196
+ if (prev) {
197
+ prev.covers = [...new Set([...prev.covers, ...it.covers])];
198
+ prev.score = Math.max(prev.score, it.score);
199
+ } else byId.set(it.id, it);
200
+ }
201
+ const merged = [...byId.values()];
202
+
203
+ // --- selection: pin required coverage, downgrade before dropping -------------------
204
+ const pinned = merged.filter((i) => i.covers.length);
205
+ const optional = merged
206
+ .filter((i) => !i.covers.length)
207
+ .sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : 1));
208
+ const chosen = pinned.map((i) => ({ item: i, v: 0 })); // v = variant index
209
+ const used = () => chosen.reduce((n, c) => n + c.item.variants[c.v].tokens, 0);
210
+ // Downgrade the largest pinned item one rung at a time until the pins fit the budget.
211
+ while (used() > budget) {
212
+ const cand = chosen
213
+ .filter((c) => c.v < c.item.variants.length - 1)
214
+ .sort((a, b) => b.item.variants[b.v].tokens - a.item.variants[a.v].tokens)[0];
215
+ if (!cand) break; // everything is already a pointer — required coverage beats budget
216
+ cand.v++;
217
+ }
218
+ // Greedy fill by value density with per-source diminishing returns.
219
+ const perSource = {};
220
+ for (const item of optional) {
221
+ const variant = item.variants[0];
222
+ const discount = SOURCE_DISCOUNT ** (perSource[item.source] ?? 0);
223
+ if (used() + variant.tokens > budget) continue;
224
+ chosen.push({ item, v: 0 });
225
+ perSource[item.source] = (perSource[item.source] ?? 0) + 1;
226
+ if (discount < 0.2) break; // fourth+ item from one source: value has decayed away
227
+ }
228
+
229
+ const covered = new Set(chosen.flatMap((c) => c.item.covers));
230
+ const missing = required.filter((r) => !r.resolvable || !covered.has(r.key));
231
+ const questions = missing
232
+ .filter((r) => !r.resolvable)
233
+ .map((r) =>
234
+ r.kind === "def"
235
+ ? `The task names \`${r.name}\` but the repo doesn't define it — which file implements it (or is it new)?`
236
+ : `The task names \`${r.name}\` but that file doesn't exist — where should this live?`,
237
+ );
238
+
239
+ return {
240
+ ok: missing.length === 0,
241
+ budget,
242
+ tokens: used(),
243
+ required: required.map((r) => r.key),
244
+ covered: [...covered].sort(),
245
+ missing: missing.map((r) => r.key),
246
+ questions,
247
+ selection: chosen.map((c) => ({
248
+ id: c.item.id,
249
+ source: c.item.source,
250
+ gran: c.item.variants[c.v].gran,
251
+ tokens: c.item.variants[c.v].tokens,
252
+ })),
253
+ block: chosen.map((c) => c.item.variants[c.v].text).join("\n\n"),
254
+ };
255
+ }
256
+
257
+ /** Human rendering for `forge context`. */
258
+ export function renderContext(r) {
259
+ const lines = ["Forge context — budgeted assembly + completeness gate", ""];
260
+ lines.push(
261
+ ` budget: ${r.tokens}/${r.budget} tokens · required ${r.required.length} · ${r.ok ? "COMPLETE" : "INCOMPLETE"}`,
262
+ );
263
+ for (const s of r.selection) lines.push(` + ${s.id} [${s.gran}] ${s.tokens}t`);
264
+ if (r.missing.length) {
265
+ lines.push("", " missing (computed, not a feeling):");
266
+ for (const m of r.missing) lines.push(` - ${m}`);
267
+ }
268
+ if (r.questions.length) {
269
+ lines.push("", " ask before acting:");
270
+ for (const q of r.questions) lines.push(` ? ${q}`);
271
+ }
272
+ return lines.join("\n");
273
+ }
package/src/cortex.js ADDED
@@ -0,0 +1,251 @@
1
+ // forge cortex — orchestration that ties the pure core (lessons.js) to storage
2
+ // (lessons_store.js). This is what the hooks (Layer 0) call: a scored signal-cluster
3
+ // becomes a created/confirmed lesson; an independent human reversal becomes a
4
+ // contradiction. Kept fs-thin and deterministic (day + ids passed in) so it's testable
5
+ // without any hook wiring.
6
+
7
+ import { recordLessonEvent, supersedeLessonClaim } from "./ledger_bridge.js";
8
+ import { mergedLessons } from "./ledger_read.js";
9
+ import {
10
+ confidenceOf,
11
+ confirm,
12
+ contradict,
13
+ matchScore,
14
+ newLesson,
15
+ scoreMistake,
16
+ selectForInjection,
17
+ } from "./lessons.js";
18
+ import { appendEpisode, load, readEpisodes, save } from "./lessons_store.js";
19
+ import { slug } from "./util.js";
20
+
21
+ const lessonIdFor = (ctx) => `lsn_${slug(ctx.symbols?.[0] || ctx.files?.[0] || "ctx") || "ctx"}`;
22
+
23
+ /** Two contexts overlap if they share any file or symbol. */
24
+ const overlaps = (a, b) => {
25
+ const files = new Set(a.files ?? []);
26
+ const symbols = new Set(a.symbols ?? []);
27
+ return (b.files ?? []).some((f) => files.has(f)) || (b.symbols ?? []).some((s) => symbols.has(s));
28
+ };
29
+
30
+ /** Lessons whose trigger overlaps the current context, in the given lifecycle states. */
31
+ export function matchingLessons(
32
+ lessons,
33
+ context,
34
+ statuses = ["active", "candidate", "quarantined"],
35
+ ) {
36
+ return lessons.filter((l) => statuses.includes(l.status) && matchScore(l, context) > 0);
37
+ }
38
+
39
+ // Deterministic fallback when no LLM distiller ran (the Stop-hook distiller replaces this).
40
+ function templateDistill(context) {
41
+ const sym = context.symbols?.[0];
42
+ const where = sym ? `\`${sym}\`` : context.files?.[0] ? `\`${context.files[0]}\`` : "this code";
43
+ return {
44
+ whatWentWrong: `An edit to ${where} was corrected (repeated / reverted / broke a check).`,
45
+ correctedBehavior: sym
46
+ ? `Before editing ${where}, check its callers and tests first.`
47
+ : `Slow down on ${where}: re-read and check impact before editing.`,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Record a correction episode. If the signals clear the mistake bar, create a new
53
+ * candidate lesson (first occurrence) or confirm the matching one (a recurrence — the
54
+ * only thing that promotes a lesson toward `active`). Always logs the episode.
55
+ * @param {string} root
56
+ * @param {{signals:{signal:string}[], context:object, nowDay:number, episodeId:string, distilled?:{whatWentWrong:string, correctedBehavior:string}}} opts
57
+ * @returns {{action:string, id?:string, status?:string, p:number, fires:boolean}}
58
+ */
59
+ export function recordMistake(root, { signals, context, nowDay, episodeId, distilled }) {
60
+ const { p, fires } = scoreMistake(signals);
61
+ // A weak-but-firing episode (0.4–0.7) only earns a lesson if the same context already
62
+ // misfired before — recurrence, not a single shot. Check BEFORE logging this episode.
63
+ const recurredWeak = readEpisodes(root).some(
64
+ (e) => e.kind === "mistake" && overlaps(e.context ?? {}, context),
65
+ );
66
+ appendEpisode(root, {
67
+ id: episodeId,
68
+ kind: "mistake",
69
+ signals: signals.map((s) => s.signal),
70
+ p,
71
+ context,
72
+ day: nowDay,
73
+ });
74
+ const strong = fires && p >= 0.7;
75
+ const accumulated = fires && p >= 0.4 && recurredWeak;
76
+ if (!strong && !accumulated) return { action: "logged", p, fires };
77
+
78
+ // Deliberately LEGACY-ONLY (not mergedLessons): this lookup decides confirm-vs-create
79
+ // for a LOCAL write. A teammate's merged ledger claim has no local file, and letting it
80
+ // match would swallow a fresh local mistake into confirm() — which edits a legacy file
81
+ // that doesn't exist — and hang the shadow evidence ref (#n<count>) off counters this
82
+ // repo never owned. Creating locally is correct AND convergent: the new lesson's shadow
83
+ // claim content-addresses to the teammate's claim, so both sides' evidence lands on ONE
84
+ // claim at the next `forge ledger merge`.
85
+ const existing = matchingLessons(load(root), context)[0];
86
+ if (existing) {
87
+ const c = confirm(existing, nowDay);
88
+ const updated = {
89
+ ...c,
90
+ provenance: {
91
+ ...c.provenance,
92
+ episodes: [...(c.provenance?.episodes ?? []), episodeId],
93
+ },
94
+ };
95
+ if (!save(root, updated).ok) return { action: "refused", p, fires };
96
+ // Shadow the confirmation into the PCM ledger (best-effort by design, never blocks
97
+ // the hook — reads are merged via ledger_read.js since P2). The evidence counter
98
+ // rides in the ref because episode ids reset per session (ep_m0_…) — without it,
99
+ // two same-day sessions confirming via the same file would hash identically and
100
+ // the second real confirmation would be silently deduped away.
101
+ recordLessonEvent(root, updated, {
102
+ result: "confirm",
103
+ ref: `episode:${episodeId}#n${updated.evidenceCount}`,
104
+ t: nowDay,
105
+ });
106
+ return {
107
+ action: "confirmed",
108
+ id: updated.id,
109
+ status: updated.status,
110
+ p,
111
+ fires,
112
+ };
113
+ }
114
+
115
+ const d = distilled ?? templateDistill(context);
116
+ const lesson = newLesson(
117
+ {
118
+ id: lessonIdFor(context),
119
+ trigger: {
120
+ symbols: context.symbols ?? [],
121
+ files: context.files ?? [],
122
+ keywords: context.keywords ?? [],
123
+ action: "edit",
124
+ },
125
+ scope: context.symbols?.length ? "symbol" : "repo",
126
+ whatWentWrong: d.whatWentWrong,
127
+ correctedBehavior: d.correctedBehavior,
128
+ provenance: {
129
+ episodes: [episodeId],
130
+ signals: signals.map((s) => s.signal),
131
+ },
132
+ },
133
+ nowDay,
134
+ );
135
+ // Report "created" only when the write actually landed — a refused save (e.g. secret-bearing
136
+ // body) must not make the hook believe a lesson exists and try to distill a phantom.
137
+ if (!save(root, lesson).ok) return { action: "refused", p, fires };
138
+ // Mint-only shadow: a freshly created lesson has zero evidence (creation is not
139
+ // confirmation — the ledger's val starts at the 0.5 prior, same as newLesson).
140
+ recordLessonEvent(root, lesson, { t: nowDay });
141
+ return { action: "created", id: lesson.id, status: lesson.status, p, fires };
142
+ }
143
+
144
+ /**
145
+ * An independent human reversal (e.g. `git revert` of what a lesson advised) contradicts
146
+ * every matching active/quarantined lesson — the anti-self-reinforcement path.
147
+ */
148
+ export function recordContradiction(root, { context, nowDay, episodeId }) {
149
+ appendEpisode(root, {
150
+ id: episodeId,
151
+ kind: "contradiction",
152
+ context,
153
+ day: nowDay,
154
+ });
155
+ // Legacy-only for the same reason as recordMistake: contradict() saves the legacy
156
+ // file, so only lessons that HAVE one are targets. A teammate's claim still converges
157
+ // — the same reversal, recurring locally, mints the local twin whose evidence merges.
158
+ const targets = matchingLessons(load(root), context, ["active", "quarantined"]);
159
+ const results = targets.map((l) => {
160
+ const updated = contradict(l, nowDay);
161
+ const saved = save(root, updated).ok;
162
+ // Shadowed at the conservative bridge weight, NOT human.revert (w=1.0): the hook's
163
+ // revert detection is regex-based and matches routine `git restore`s, and a
164
+ // full-weight contradiction would permanently anchor the claim near dormancy in an
165
+ // append-only log. The counter in the ref keeps distinct same-day events distinct.
166
+ if (saved)
167
+ recordLessonEvent(root, updated, {
168
+ result: "contradict",
169
+ ref: `episode:${episodeId}#c${updated.contradictionCount}`,
170
+ t: nowDay,
171
+ });
172
+ return { id: updated.id, status: updated.status, saved };
173
+ });
174
+ return { action: "contradicted", results };
175
+ }
176
+
177
+ /** The injection block for the current context — what a SessionStart/PreToolUse hook emits.
178
+ * Reads the MERGED view (P2 read flip): teammate lessons that arrived via
179
+ * `forge ledger merge` inject alongside local ones. */
180
+ export function lessonsForContext(root, context, opts = {}) {
181
+ return selectForInjection(mergedLessons(root, opts.nowDay ?? 0), context, opts);
182
+ }
183
+
184
+ /** Repo-wide top active lessons — what a SessionStart hook injects (no file context yet).
185
+ * Merged view: a teammate's outcome-confirmed lesson surfaces here too. */
186
+ export function startupBlock(root, nowDay = 0, budget = 8) {
187
+ const active = mergedLessons(root, nowDay).filter((l) => l.status === "active");
188
+ if (!active.length) return "";
189
+ const ranked = active
190
+ .map((l) => ({ lesson: l, conf: confidenceOf(l, nowDay) }))
191
+ .sort((a, b) => b.conf - a.conf);
192
+ const shown = ranked.slice(0, budget);
193
+ const rows = shown.map((x) =>
194
+ `- **${x.lesson.id}** — ${x.lesson.correctedBehavior}`.slice(0, 200),
195
+ );
196
+ const overflow = active.length - shown.length;
197
+ if (overflow > 0) rows.push(`- _(+${overflow} more active lessons — run \`forge cortex\`)_`);
198
+ return [
199
+ "## Lessons learned on this repo (Forge Cortex)",
200
+ "Background context from past corrections on this repo — verify before acting, don't blindly obey.",
201
+ "",
202
+ ...rows,
203
+ "",
204
+ ].join("\n");
205
+ }
206
+
207
+ /** Replace a lesson's body with a model-distilled version (false if not found or the save was
208
+ * refused — e.g. the distilled text tripped secret-refusal). */
209
+ export function applyDistillation(root, lessonId, distilled) {
210
+ if (!distilled) return false;
211
+ // Legacy-only read: distillation EDITS the legacy file, so only file-backed lessons apply.
212
+ const lesson = load(root).find((l) => l.id === lessonId);
213
+ if (!lesson) return false;
214
+ const updated = {
215
+ ...lesson,
216
+ whatWentWrong: distilled.whatWentWrong,
217
+ correctedBehavior: distilled.correctedBehavior,
218
+ };
219
+ const ok = save(root, updated).ok;
220
+ // A body rewrite changes the content-addressed claim id — supersede in the ledger
221
+ // (mint the distilled claim, carry the evidence over, tombstone the template claim)
222
+ // or the lesson's history splits across two disjoint claims.
223
+ if (ok) supersedeLessonClaim(root, lesson, updated);
224
+ return ok;
225
+ }
226
+
227
+ /** The lessons block to inline into AGENTS.md so non-Claude tools see them (empty if none). */
228
+ export function cortexBlock(targetRoot = process.cwd()) {
229
+ return startupBlock(targetRoot, Math.floor(Date.now() / 86400000));
230
+ }
231
+
232
+ /** Auditable snapshot for `forge cortex status` — merged view, like every read surface. */
233
+ export function summary(root, nowDay = 0) {
234
+ const lessons = mergedLessons(root, nowDay);
235
+ const by = (s) => lessons.filter((l) => l.status === s).length;
236
+ return {
237
+ total: lessons.length,
238
+ active: by("active"),
239
+ candidate: by("candidate"),
240
+ quarantined: by("quarantined"),
241
+ retired: by("retired"),
242
+ topActive: lessons
243
+ .filter((l) => l.status === "active")
244
+ .map((l) => ({
245
+ id: l.id,
246
+ confidence: Number(confidenceOf(l, nowDay).toFixed(2)),
247
+ }))
248
+ .sort((a, b) => b.confidence - a.confidence)
249
+ .slice(0, 10),
250
+ };
251
+ }
@@ -0,0 +1,55 @@
1
+ // forge cortex distiller — turn a raw correction episode into a real, useful lesson via a
2
+ // cheap model call, instead of the deterministic template. OPT-IN (ENABLE_CORTEX_DISTILL=1)
3
+ // and fail-safe: any failure returns null and the caller keeps the template. Zero deps — it
4
+ // shells out to the `claude` CLI via the shared adjudicate runner (same primitive every other
5
+ // faculty uses); the runner is injectable so the pure prompt/parse logic is testable without it.
6
+ import { buildRunner } from "./adjudicate.js";
7
+ import { SECRET_RE } from "./recall.js";
8
+
9
+ /**
10
+ * Pure: build the distillation prompt from an episode.
11
+ * @param {{context?:{symbols?:string[], files?:string[]}, signals?:string[]}} episode
12
+ */
13
+ export function buildPrompt({ context = {}, signals = [] }) {
14
+ const where = context.symbols?.[0] || context.files?.[0] || "some code";
15
+ return `A coding agent just made and then corrected a mistake on this repository.
16
+ Observed correction signals: ${signals.join(", ") || "n/a"}.
17
+ Location: ${where}.
18
+ Write ONE short, durable lesson that would stop this mistake recurring. Respond with STRICT
19
+ JSON and nothing else:
20
+ {"whatWentWrong":"<concrete, <=140 chars>","correctedBehavior":"<an imperative rule, <=140 chars>"}
21
+ Do not include secrets, tokens, keys, or PII. No text outside the JSON object.`;
22
+ }
23
+
24
+ /** Pure: extract {whatWentWrong, correctedBehavior} from model output, or null if unusable. */
25
+ export function parseDistilled(text) {
26
+ if (!text) return null;
27
+ const match = String(text).match(/\{[\s\S]*\}/);
28
+ if (!match) return null;
29
+ let obj;
30
+ try {
31
+ obj = JSON.parse(match[0]);
32
+ } catch {
33
+ return null;
34
+ }
35
+ const whatWentWrong = String(obj.whatWentWrong ?? "")
36
+ .trim()
37
+ .slice(0, 200);
38
+ const correctedBehavior = String(obj.correctedBehavior ?? "")
39
+ .trim()
40
+ .slice(0, 200);
41
+ if (!whatWentWrong || !correctedBehavior) return null;
42
+ if (SECRET_RE.test(`${whatWentWrong} ${correctedBehavior}`)) return null; // never persist a secret
43
+ return { whatWentWrong, correctedBehavior };
44
+ }
45
+
46
+ const claudeRun = (prompt, opts = {}) => buildRunner(opts)(prompt);
47
+
48
+ /** Distill an episode into a lesson body. Returns null on any failure (caller keeps template). */
49
+ export function distill(episode, { run = claudeRun } = {}) {
50
+ try {
51
+ return parseDistilled(run(buildPrompt(episode)));
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
@@ -0,0 +1,81 @@
1
+ // forge cortex features — turn a real edit into the predictor's feature vector. The pure
2
+ // computeFeatures() is fully testable; featuresForEdit() fills it from actual repo state
3
+ // (lessons + git) with graceful degradation. caller_fanout / no_caller_update are SEAMS: a
4
+ // zero-dep grep gives a rough fan-out today; adopting a graph MCP (agent-lsp/Serena) drops
5
+ // a precise call graph straight in without touching the predictor.
6
+ import { execFileSync } from "node:child_process";
7
+ import { confidenceOf, matchScore } from "./lessons.js";
8
+ import { load } from "./lessons_store.js";
9
+
10
+ /**
11
+ * Pure: edit + gathered signals → the predictor feature vector (all features in [0,1]).
12
+ * @param {{file?:string, symbol?:string}} edit
13
+ * @param {{callerCount?:number, churnCommits?:number, hasTest?:boolean, signatureChange?:boolean, callersInDiff?:boolean, activeLessons?:object[], nowDay?:number}} deps
14
+ */
15
+ export function computeFeatures(edit, deps = {}) {
16
+ const {
17
+ callerCount = 0,
18
+ churnCommits = 0,
19
+ hasTest = true,
20
+ signatureChange = false,
21
+ callersInDiff = true,
22
+ activeLessons = [],
23
+ nowDay = 0,
24
+ } = deps;
25
+ const ctx = {
26
+ files: edit.file ? [edit.file] : [],
27
+ symbols: edit.symbol ? [edit.symbol] : [],
28
+ };
29
+ const matched = activeLessons.filter((l) => matchScore(l, ctx) > 0);
30
+ const lessonMatch = matched.reduce((m, l) => Math.max(m, confidenceOf(l, nowDay)), 0);
31
+ return {
32
+ caller_fanout: Math.min(1, callerCount / 10), // >10 callers = maxed out
33
+ lesson_match: lessonMatch,
34
+ churn: Math.min(1, churnCommits / 10),
35
+ test_coverage_gap: hasTest ? 0 : 1,
36
+ signature_change: signatureChange ? 1 : 0,
37
+ no_caller_update: signatureChange && !callersInDiff ? 1 : 0,
38
+ past_mistake_here: matched.some((l) => (l.evidenceCount ?? 0) > 0) ? 1 : 0,
39
+ };
40
+ }
41
+
42
+ const tryExec = (bin, args, root) => {
43
+ try {
44
+ return execFileSync(bin, args, {
45
+ cwd: root,
46
+ encoding: "utf8",
47
+ timeout: 1500, // bound latency — this runs in a PreToolUse hook on every edit
48
+ stdio: ["ignore", "pipe", "ignore"],
49
+ });
50
+ } catch {
51
+ return "";
52
+ }
53
+ };
54
+
55
+ /** How many commits recently touched this file (git churn) — 0 if not a git repo. */
56
+ export function gitChurn(root, file) {
57
+ if (!file) return 0;
58
+ const out = tryExec("git", ["log", "--oneline", "-n", "50", "--", file], root);
59
+ return out ? out.trim().split("\n").filter(Boolean).length : 0;
60
+ }
61
+
62
+ /** Rough fan-out: how many files mention the symbol (grep). SEAM for a real call graph. */
63
+ export function grepFanout(root, symbol) {
64
+ if (!symbol) return 0;
65
+ const out = tryExec("git", ["grep", "-l", "--", symbol], root);
66
+ return out ? out.trim().split("\n").filter(Boolean).length : 0;
67
+ }
68
+
69
+ /** Build the feature vector for a real edit from actual repo state (best-effort, degrades). */
70
+ export function featuresForEdit(root, edit, { nowDay = 0 } = {}) {
71
+ const activeLessons = load(root).filter((l) => l.status === "active");
72
+ const callerCount = grepFanout(root, edit.symbol);
73
+ return computeFeatures(edit, {
74
+ activeLessons,
75
+ nowDay,
76
+ callerCount,
77
+ churnCommits: gitChurn(root, edit.file),
78
+ // hasTest / signatureChange / callersInDiff need the diff or a graph — left at safe
79
+ // defaults until a graph MCP is wired; the predictor already handles missing signal.
80
+ });
81
+ }