@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
@@ -0,0 +1,279 @@
1
+ // forge ledger bridge — the P1 migration seam between the legacy stores (lessons/*.md,
2
+ // recall facts) and the PCM ledger. The ledger shadows every write as the new
3
+ // canonical, and `forge ledger import` back-fills pre-ledger history. P2 (shipped —
4
+ // see ledger_read.js) flipped READS to a merged view (legacy ∪ ledger); the legacy
5
+ // files remain the canonical LOCAL state until full retirement.
6
+ // Spec: docs/plans/substrate-v2/01-pcm-protocol.md §7.
7
+ //
8
+ // Every entry point here is BEST-EFFORT by design: the legacy store is still
9
+ // canonical for local state, so a bridge failure returns {ok:false} and must never
10
+ // break a hook or CLI write that already succeeded.
11
+ import { mintClaim, outcomeRecord } from "./ledger.js";
12
+ import {
13
+ appendEvidence,
14
+ loadClaims,
15
+ putClaim,
16
+ readEvidence,
17
+ reindex,
18
+ repoLedger,
19
+ tombstone,
20
+ } from "./ledger_store.js";
21
+ import { load as loadLessons } from "./lessons_store.js";
22
+ // listStored, NOT list: the bridge reconciles the FILE store against the ledger. The
23
+ // merged `list` (P2 read flip) includes ledger-only teammate facts, which have no file
24
+ // and would read here as "deleted from the store".
25
+ import { listStored as listFacts, readFact } from "./recall.js";
26
+ import { epochDay, gitAuthor } from "./util.js";
27
+
28
+ /** One best-effort policy for the whole bridge (never throws into a caller). */
29
+ const bestEffort = (fn) => {
30
+ try {
31
+ return fn();
32
+ } catch (err) {
33
+ if (process.env.FORGE_DEBUG === "1")
34
+ process.stderr.write(`forge ledger bridge: ${err?.message ?? err}\n`);
35
+ return { ok: false, reason: String(err?.message ?? err) };
36
+ }
37
+ };
38
+
39
+ /** A lesson's claim: body = the CONTENT (trigger + texts) only. Counts/status are
40
+ * evidence-derived and the legacy file id rides in PROVENANCE (excluded from the
41
+ * content address) — so teammates who learn the same lesson mint the same id even
42
+ * if their legacy filenames differ, and confirm/contradict never re-mints.
43
+ * @returns {{ok:boolean, reason?:string, claim?:any}} */
44
+ export function lessonClaim(lesson, t = 0) {
45
+ return mintClaim({
46
+ kind: "lesson",
47
+ body: {
48
+ correctedBehavior: lesson.correctedBehavior ?? "",
49
+ trigger: {
50
+ action: lesson.trigger?.action ?? "",
51
+ files: lesson.trigger?.files ?? [],
52
+ keywords: lesson.trigger?.keywords ?? [],
53
+ symbols: lesson.trigger?.symbols ?? [],
54
+ },
55
+ whatWentWrong: lesson.whatWentWrong ?? "",
56
+ },
57
+ scope: { level: lesson.scope ?? "repo" },
58
+ provenance: { agent: "cortex", author: gitAuthor(), task: lesson.id ?? "" },
59
+ t,
60
+ });
61
+ }
62
+
63
+ /** A recall fact's claim. Name/text are trimmed so the shadow-write path and the
64
+ * file-parse import path mint the SAME id for the same fact.
65
+ * @returns {{ok:boolean, reason?:string, claim?:any}} */
66
+ export function factClaim(name, text, t = 0) {
67
+ return mintClaim({
68
+ kind: "fact",
69
+ body: { name: String(name).trim(), text: String(text).trim() },
70
+ scope: { level: "repo" },
71
+ provenance: { agent: "recall", author: gitAuthor() },
72
+ t,
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Shadow-write one lesson event into the repo ledger.
78
+ * @param {string} root repo root
79
+ * @param {object} lesson the (already saved) lesson object
80
+ * @param {{result?:"confirm"|"contradict", oracle?:string, ref?:string, t?:number}} ev
81
+ * evidence for this event; omit result for mint-only (a freshly created lesson has
82
+ * zero evidence — creation is not confirmation). `ref` is required whenever a
83
+ * result is given (outcomeRecord enforces it).
84
+ */
85
+ export function recordLessonEvent(root, lesson, ev = {}) {
86
+ return bestEffort(() => {
87
+ const dir = repoLedger(root);
88
+ const minted = lessonClaim(lesson, ev.t ?? 0);
89
+ if (!minted.ok) return { ok: false, reason: minted.reason };
90
+ const put = putClaim(dir, minted.claim);
91
+ if (!put.ok) return put;
92
+ if (ev.result) {
93
+ const o = outcomeRecord({
94
+ oracle: ev.oracle ?? "cortex.episode",
95
+ result: ev.result,
96
+ ref: ev.ref,
97
+ author: gitAuthor(),
98
+ t: ev.t ?? 0,
99
+ });
100
+ if (!o.ok) return { ok: false, reason: "reason" in o ? o.reason : "invalid outcome" };
101
+ const a = appendEvidence(dir, minted.claim.id, o.outcome);
102
+ if (!a.ok) return a;
103
+ }
104
+ return { ok: true, id: minted.claim.id };
105
+ });
106
+ }
107
+
108
+ /**
109
+ * A lesson's body was rewritten (distillation) — content addressing means a NEW claim
110
+ * id, so carry the history across: mint the new claim, copy the old claim's evidence
111
+ * to it, and tombstone the old claim as superseded. Without this, evidence splits
112
+ * between an orphaned template claim and the distilled one.
113
+ */
114
+ export function supersedeLessonClaim(root, before, after, t = epochDay()) {
115
+ return bestEffort(() => {
116
+ const dir = repoLedger(root);
117
+ const oldC = lessonClaim(before, t);
118
+ const newC = lessonClaim(after, t);
119
+ if (!newC.ok) return { ok: false, reason: newC.reason };
120
+ const put = putClaim(dir, newC.claim);
121
+ if (!put.ok) return put;
122
+ if (oldC.ok && oldC.claim.id !== newC.claim.id) {
123
+ for (const o of readEvidence(dir, oldC.claim.id)) appendEvidence(dir, newC.claim.id, o);
124
+ tombstone(dir, oldC.claim.id, {
125
+ author: gitAuthor(),
126
+ reason: `superseded-by:${newC.claim.id}`,
127
+ t,
128
+ });
129
+ }
130
+ return { ok: true, id: newC.claim.id };
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Shadow one fact into a ledger, superseding any live fact claim with the same name
136
+ * but different content — so `forge remember api-base <new>` retires the old value
137
+ * instead of leaving a stale phantom the P2 read-flip would resurrect.
138
+ * @returns {{ok:boolean, reason?:string, id?:string, existed?:boolean}}
139
+ */
140
+ export function shadowFact(ledgerDir, name, text, t = epochDay()) {
141
+ return bestEffort(() => {
142
+ const minted = factClaim(name, text, t);
143
+ if (!minted.ok) return { ok: false, reason: minted.reason };
144
+ const put = putClaim(ledgerDir, minted.claim);
145
+ if (!put.ok) return put;
146
+ for (const c of loadClaims(ledgerDir)) {
147
+ if (
148
+ c.kind === "fact" &&
149
+ !c.tombstone &&
150
+ c.id !== minted.claim.id &&
151
+ c.body?.name === minted.claim.body.name
152
+ )
153
+ tombstone(ledgerDir, c.id, {
154
+ author: gitAuthor(),
155
+ reason: `superseded-by:${minted.claim.id}`,
156
+ t,
157
+ });
158
+ }
159
+ reindex(ledgerDir, t);
160
+ return { ...put, id: minted.claim.id };
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Re-align a ledger's fact claims with the store they shadow: any live fact claim
166
+ * whose (name, text) no longer exists as a stored fact is tombstoned. Called after
167
+ * `forge recall consolidate` (which rm's duplicate files) so deleted memories don't
168
+ * survive as live claims.
169
+ */
170
+ export function reconcileFacts(store, ledgerDir, t = epochDay()) {
171
+ return bestEffort(() => {
172
+ const current = new Set();
173
+ for (const slug of listFacts(store)) {
174
+ const f = readFact(store, slug);
175
+ if (f) {
176
+ const minted = factClaim(f.name, f.text, t);
177
+ if (minted.ok) current.add(minted.claim.id);
178
+ }
179
+ }
180
+ let removed = 0;
181
+ for (const c of loadClaims(ledgerDir)) {
182
+ // Only reconcile claims THIS author minted: a locally-authored claim with no
183
+ // backing file means the file was deleted (consolidate rm'd a duplicate). A
184
+ // teammate's claim (arrived via `forge ledger merge`) never had a local file —
185
+ // with the P2 read flip it IS the readable fact, and tombstoning it here would
186
+ // silently delete team knowledge on every consolidate.
187
+ const mine = (c.provenance?.author ?? "") === gitAuthor();
188
+ if (c.kind === "fact" && !c.tombstone && mine && !current.has(c.id)) {
189
+ tombstone(ledgerDir, c.id, { author: gitAuthor(), reason: "removed-from-store", t });
190
+ removed++;
191
+ }
192
+ }
193
+ if (removed) reindex(ledgerDir, t);
194
+ return { ok: true, removed };
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Import every fact in a recall/brain store into a ledger (idempotent; supersedes
200
+ * stale same-name claims via shadowFact).
201
+ * @returns {{facts:number, refused:string[]}}
202
+ */
203
+ export function importFacts(store, ledgerDir, nowDay = 0) {
204
+ const refused = [];
205
+ let facts = 0;
206
+ for (const slug of listFacts(store)) {
207
+ const f = readFact(store, slug);
208
+ if (!f) {
209
+ refused.push(`fact ${slug}: unreadable`);
210
+ continue;
211
+ }
212
+ const r = shadowFact(ledgerDir, f.name, f.text, nowDay);
213
+ if (r.ok && !r.existed) facts++;
214
+ else if (!r.ok) refused.push(`fact ${slug}: ${r.reason}`);
215
+ }
216
+ return { facts, refused };
217
+ }
218
+
219
+ /**
220
+ * One-shot back-fill of PRE-LEDGER history. A lesson whose claim already exists in
221
+ * the ledger is skipped entirely — its live shadow-writes are already tracking it,
222
+ * and re-synthesizing evidence from the (still-moving) legacy counters would double-
223
+ * count and break idempotence. Only never-seen claims get their aggregate counts
224
+ * expanded into conservative `legacy.import` outcomes.
225
+ * @param {string} root repo root (lessons + repo ledger)
226
+ * @param {{recallStore?:string, recallLedger?:string, nowDay?:number}} opts
227
+ * @returns {{lessons:number, facts:number, outcomes:number, refused:string[]}}
228
+ */
229
+ export function importLegacy(root, { recallStore, recallLedger, nowDay = 0 } = {}) {
230
+ const dir = repoLedger(root);
231
+ const refused = [];
232
+ let lessons = 0;
233
+ let outcomes = 0;
234
+
235
+ for (const lesson of loadLessons(root)) {
236
+ const minted = lessonClaim(lesson, lesson.createdDay ?? 0);
237
+ if (!minted.ok) {
238
+ refused.push(`lesson ${lesson.id}: ${minted.reason}`);
239
+ continue;
240
+ }
241
+ const put = putClaim(dir, minted.claim);
242
+ if (!put.ok) {
243
+ refused.push(`lesson ${lesson.id}: ${put.reason}`);
244
+ continue;
245
+ }
246
+ if (put.existed) continue; // already tracked live — never re-synthesize
247
+ lessons++;
248
+ // Aggregate counts become individual, decay-dated outcomes: confirms carry the
249
+ // last-confirmed day (what freshness keyed on), contradictions the created day
250
+ // (the legacy store kept no per-contradiction date — documented approximation).
251
+ /** @type {{result:"confirm"|"contradict", t:number, n:number}[]} */
252
+ const synth = [];
253
+ for (let i = 0; i < (lesson.evidenceCount ?? 0); i++)
254
+ synth.push({ result: "confirm", t: lesson.lastConfirmedDay ?? 0, n: i });
255
+ for (let i = 0; i < (lesson.contradictionCount ?? 0); i++)
256
+ synth.push({ result: "contradict", t: lesson.createdDay ?? 0, n: i });
257
+ for (const s of synth) {
258
+ const o = outcomeRecord({
259
+ oracle: "legacy.import",
260
+ result: s.result,
261
+ ref: `legacy:${lesson.id}#${s.result}${s.n}`,
262
+ t: s.t,
263
+ });
264
+ if (!o.ok) continue;
265
+ const a = appendEvidence(dir, minted.claim.id, o.outcome);
266
+ if (a.ok && !a.deduped) outcomes++;
267
+ }
268
+ }
269
+
270
+ let facts = 0;
271
+ if (recallStore) {
272
+ const r = importFacts(recallStore, recallLedger ?? dir, nowDay);
273
+ facts = r.facts;
274
+ refused.push(...r.refused);
275
+ }
276
+
277
+ reindex(dir, nowDay);
278
+ return { lessons, facts, outcomes, refused };
279
+ }
@@ -0,0 +1,152 @@
1
+ // forge ledger read — the P2 read-path flip (docs/plans/substrate-v2/01-pcm-protocol.md §7).
2
+ // Since P1 the PCM ledger has been the convergent WRITE store while the legacy stores
3
+ // (lessons/*.md, recall/brain facts) served every read. This module turns reads into a
4
+ // MERGED VIEW (legacy ∪ ledger) so knowledge that arrives via `forge ledger merge`
5
+ // actually reaches injection and retrieval. The legacy file is still the canonical
6
+ // LOCAL state — the ledger only ADDS what teammates know — so the merge dedupes by the
7
+ // legacy id with the legacy record winning. Retiring the legacy formats entirely is the
8
+ // next step (ROADMAP.md).
9
+ //
10
+ // Everything here is READ-ONLY and best-effort by design: hooks call these on every
11
+ // session start / pre-edit, so a missing or corrupt ledger degrades to legacy-only —
12
+ // never an error, never a write.
13
+ import { DEFAULT_HALF_LIFE_DAYS, val, validOutcome } from "./ledger.js";
14
+ import { loadClaims, repoLedger } from "./ledger_store.js";
15
+ import { load } from "./lessons_store.js";
16
+ import { slug } from "./util.js";
17
+
18
+ /**
19
+ * Map a ledger `lesson` claim onto the legacy lesson shape (lessons.js), so every
20
+ * existing consumer — matchScore, selectForInjection, confidenceOf, summary — can rank
21
+ * a teammate's claim without knowing the ledger exists. Inverse-ish of
22
+ * ledger_bridge.lessonClaim: body carries trigger/whatWentWrong/correctedBehavior, and
23
+ * the legacy lesson id rides in provenance.task (it is excluded from the content
24
+ * address so teammates converge on one claim id).
25
+ *
26
+ * The legacy lifecycle (candidate/active/quarantined/retired) is local mutable state
27
+ * the ledger deliberately does not store, so status is DERIVED from evidence:
28
+ *
29
+ * | claim state | derived status | rationale |
30
+ * |--------------------------------------|----------------|-----------|
31
+ * | tombstoned | "retired" | a retraction is the ledger's retirement |
32
+ * | val(claim, nowDay) ≥ 0.6 | "active" | one fresh confirm (bridge oracle w=0.5 → val 0.6) clears it — mirrors confirm()'s promote-on-recurrence |
33
+ * | val < 0.45 and ≥ 1 contradiction | "quarantined" | net-negative outcome evidence — mirrors contradict()'s demotion |
34
+ * | otherwise | "candidate" | a fresh claim sits at the 0.5 prior, exactly newLesson() |
35
+ *
36
+ * Count/date fields are rebuilt from the evidence log: evidenceCount = valid confirm
37
+ * outcomes, contradictionCount = valid contradict outcomes, lastConfirmedDay = latest
38
+ * confirm t (else the mint day), createdDay = the mint day (provenance.t).
39
+ * @param {any} claim a materialized lesson claim (ledger_store.loadClaims view)
40
+ * @param {number} [nowDay] epoch day used for the val() decay clock
41
+ * @returns {object} a legacy-shaped lesson
42
+ */
43
+ export function claimToLesson(claim, nowDay = 0) {
44
+ const body = claim.body ?? {};
45
+ const evidence = (claim.evidence ?? []).filter(validOutcome);
46
+ const confirms = evidence.filter((e) => e.result === "confirm");
47
+ const contradictions = evidence.length - confirms.length;
48
+ const createdDay = claim.provenance?.t ?? 0;
49
+ const v = val(claim, nowDay);
50
+ const status = claim.tombstone
51
+ ? "retired"
52
+ : v >= 0.6
53
+ ? "active"
54
+ : v < 0.45 && contradictions >= 1
55
+ ? "quarantined"
56
+ : "candidate";
57
+ return {
58
+ id: String(claim.provenance?.task || "") || `lsn_${claim.id.slice(0, 8)}`,
59
+ trigger: {
60
+ files: body.trigger?.files ?? [],
61
+ symbols: body.trigger?.symbols ?? [],
62
+ keywords: body.trigger?.keywords ?? [],
63
+ action: body.trigger?.action || undefined,
64
+ },
65
+ scope: claim.scope?.level ?? "repo",
66
+ whatWentWrong: body.whatWentWrong ?? "",
67
+ correctedBehavior: body.correctedBehavior ?? "",
68
+ evidenceCount: confirms.length,
69
+ contradictionCount: contradictions,
70
+ quarantineReconfirms: 0,
71
+ status,
72
+ createdDay,
73
+ lastConfirmedDay: confirms.length ? Math.max(...confirms.map((e) => e.t ?? 0)) : createdDay,
74
+ halfLifeDays: DEFAULT_HALF_LIFE_DAYS,
75
+ // The claim id is the audit pointer back to the ledger (`forge ledger blame <id>`).
76
+ provenance: { episodes: [], signals: [], claim: claim.id },
77
+ };
78
+ }
79
+
80
+ /** Every lesson claim of the repo ledger, mapped to legacy shape. [] when there is no
81
+ * ledger or it is unreadable (best-effort — hooks call this on every event). */
82
+ export function ledgerLessons(root, nowDay = 0) {
83
+ try {
84
+ return loadClaims(repoLedger(root))
85
+ .filter((c) => c.kind === "lesson")
86
+ .map((c) => claimToLesson(c, nowDay));
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
92
+ // Within the ledger, two claims can map to one legacy id (a distillation supersedes its
93
+ // template claim; both carry the same provenance.task). Prefer the live one, then the
94
+ // most recently confirmed; ties keep the first in claim-id order (loadClaims is sorted),
95
+ // so the pick is deterministic across replicas.
96
+ const preferred = (a, b) => {
97
+ if ((a.status === "retired") !== (b.status === "retired")) return a.status === "retired" ? b : a;
98
+ return b.lastConfirmedDay > a.lastConfirmedDay ? b : a;
99
+ };
100
+
101
+ /**
102
+ * The merged lesson read: legacy `load(root)` ∪ ledger lessons, deduped by the LEGACY
103
+ * id with the legacy file winning — the legacy store is still the canonical local
104
+ * state (recordMistake/confirm/contradict edit it), so a local lesson's own shadow
105
+ * claim (same provenance.task) is invisible here, while a teammate's claim (different
106
+ * task, no local file) surfaces. Order is deterministic: legacy lessons in file order,
107
+ * then ledger-only lessons sorted by lesson id.
108
+ * @param {string} root
109
+ * @param {number} [nowDay]
110
+ * @returns {object[]}
111
+ */
112
+ export function mergedLessons(root, nowDay = 0) {
113
+ const legacy = load(root);
114
+ const local = new Set(legacy.map((l) => l.id));
115
+ const byId = new Map();
116
+ for (const l of ledgerLessons(root, nowDay)) {
117
+ if (local.has(l.id)) continue; // legacy wins — the local file is canonical
118
+ const prev = byId.get(l.id);
119
+ byId.set(l.id, prev ? preferred(prev, l) : l);
120
+ }
121
+ return [...legacy, ...[...byId.values()].sort((a, b) => (a.id < b.id ? -1 : 1))];
122
+ }
123
+
124
+ /** Live (non-tombstoned) `fact` claims of one ledger as {slug, name, text} — the fact
125
+ * counterpart of ledgerLessons. Best-effort: [] when the ledger is missing/corrupt. */
126
+ export function ledgerFacts(dir) {
127
+ try {
128
+ return loadClaims(dir)
129
+ .filter((c) => c.kind === "fact" && !c.tombstone)
130
+ .map((c) => ({
131
+ slug: slug(c.body?.name ?? "") || "fact",
132
+ name: String(c.body?.name ?? ""),
133
+ text: String(c.body?.text ?? ""),
134
+ }));
135
+ } catch {
136
+ return [];
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Merged fact slugs for a file store + its ledger: the file store's slugs win on
142
+ * collision (a stored file is the canonical local value; shadowFact tombstones stale
143
+ * same-name claims, so a surviving collision means the file is newer). Sorted, unique.
144
+ * @param {string[]} fileSlugs slugs of the file-backed facts (already the store's truth)
145
+ * @param {string} dir the ledger directory that shadows this store
146
+ * @returns {string[]}
147
+ */
148
+ export function mergeFactSlugs(fileSlugs, dir) {
149
+ const seen = new Set(fileSlugs);
150
+ for (const f of ledgerFacts(dir)) seen.add(f.slug);
151
+ return [...seen].sort();
152
+ }