@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
|
@@ -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
|
+
}
|