@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,360 @@
|
|
|
1
|
+
// forge ledger storage — the on-disk PCM ledger (docs/plans/substrate-v2/02-team-memory.md):
|
|
2
|
+
// one immutable canonical-JSON file per claim (sharded by id prefix, bytes = pure
|
|
3
|
+
// content so every replica writes the identical file), plus three append-only logs per
|
|
4
|
+
// claim — evidence, provenance, tombstones — that git union-merges without conflicts
|
|
5
|
+
// (`forge init` emits the .gitattributes rule). Everything author- or time-varying is
|
|
6
|
+
// a log line; nothing on disk is ever edited in place.
|
|
7
|
+
import {
|
|
8
|
+
appendFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
authorTrust,
|
|
19
|
+
canonicalize,
|
|
20
|
+
claimId,
|
|
21
|
+
DORMANT_VAL,
|
|
22
|
+
emptyState,
|
|
23
|
+
liveClaims,
|
|
24
|
+
mergeStates,
|
|
25
|
+
mintClaim,
|
|
26
|
+
ORACLES,
|
|
27
|
+
SECRET_RE,
|
|
28
|
+
sealRecord,
|
|
29
|
+
sortRecords,
|
|
30
|
+
val,
|
|
31
|
+
validOutcome,
|
|
32
|
+
} from "./ledger.js";
|
|
33
|
+
|
|
34
|
+
/** The canonical repo ledger. (recall's global store keeps its own sibling ledger.) */
|
|
35
|
+
export const repoLedger = (root = process.cwd()) => join(root, ".forge", "ledger");
|
|
36
|
+
|
|
37
|
+
/** The union-merge rule consumer repos need for conflict-free ledger merges —
|
|
38
|
+
* emitted into .gitattributes by `forge init` (see init.js). NOTE: .gitattributes
|
|
39
|
+
* supports full-line comments only, so the rule ships with a comment line above it. */
|
|
40
|
+
export const GITATTRIBUTES_RULE = [
|
|
41
|
+
"# PCM ledger logs are hash-deduped append-only sets - union merge is conflict-free (forge)",
|
|
42
|
+
".forge/ledger/*/*.log merge=union",
|
|
43
|
+
].join("\n");
|
|
44
|
+
|
|
45
|
+
const LOGS = ["evidence", "provenance", "tombstones"];
|
|
46
|
+
const claimPath = (dir, id) => join(dir, "claims", id.slice(0, 2), `${id}.json`);
|
|
47
|
+
const logPath = (dir, log, id) => join(dir, log, `${id}.log`);
|
|
48
|
+
|
|
49
|
+
/** Claim file bytes: pure content only. Identical for the same id on every replica. */
|
|
50
|
+
const claimBytes = (claim) =>
|
|
51
|
+
`${canonicalize({ body: claim.body, kind: claim.kind, scope: claim.scope ?? {}, v: claim.v ?? 1 })}\n`;
|
|
52
|
+
|
|
53
|
+
const readJson = (path) => {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
56
|
+
} catch {
|
|
57
|
+
return null; // one corrupt file must never take down the whole ledger
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Parse an append-only log: one canonical-JSON record per line, deduped by content
|
|
62
|
+
* hash, corrupt lines skipped. The single reader every log goes through. */
|
|
63
|
+
function readLog(dir, log, id) {
|
|
64
|
+
const path = logPath(dir, log, id);
|
|
65
|
+
if (!existsSync(path)) return [];
|
|
66
|
+
const records = [];
|
|
67
|
+
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
|
68
|
+
if (!line.trim()) continue;
|
|
69
|
+
try {
|
|
70
|
+
records.push(JSON.parse(line));
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
// sortRecords, not file order: after a git union merge the two replicas' logs hold
|
|
74
|
+
// the same set in different line orders — views must not depend on that.
|
|
75
|
+
return sortRecords(records);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Append one sealed record to a log iff its hash isn't already present. */
|
|
79
|
+
function appendRecord(dir, log, id, record) {
|
|
80
|
+
if (!record?.h) return { ok: false, reason: "record missing content hash" };
|
|
81
|
+
if (!existsSync(claimPath(dir, id)))
|
|
82
|
+
return { ok: false, reason: `no such claim in ledger: ${id}` };
|
|
83
|
+
if (readLog(dir, log, id).some((e) => e.h === record.h)) return { ok: true, deduped: true };
|
|
84
|
+
mkdirSync(join(dir, log), { recursive: true });
|
|
85
|
+
appendFileSync(logPath(dir, log, id), `${canonicalize(record)}\n`);
|
|
86
|
+
return { ok: true, deduped: false };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Walk every claim file: yields {id, path, raw, claim(valid-or-null)}. Shared by
|
|
90
|
+
* loadState (keep valid) and verify (report invalid) so the two can never drift. */
|
|
91
|
+
function* walkClaimFiles(dir) {
|
|
92
|
+
const claimsRoot = join(dir, "claims");
|
|
93
|
+
if (!existsSync(claimsRoot)) return;
|
|
94
|
+
for (const shard of readdirSync(claimsRoot).sort()) {
|
|
95
|
+
for (const f of readdirSync(join(claimsRoot, shard))
|
|
96
|
+
.filter((f) => f.endsWith(".json"))
|
|
97
|
+
.sort()) {
|
|
98
|
+
const path = join(claimsRoot, shard, f);
|
|
99
|
+
const id = f.replace(/\.json$/, "");
|
|
100
|
+
const parsed = readJson(path);
|
|
101
|
+
// Verify the address: a tampered/corrupt claim is surfaced as claim:null.
|
|
102
|
+
const valid = parsed && claimId(parsed.kind, parsed.body, parsed.scope) === id;
|
|
103
|
+
yield { id, path, raw: readFileSync(path, "utf8"), claim: valid ? { ...parsed, id } : null };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Persist a claim (idempotent — content-addressed). Bytes contain content only;
|
|
110
|
+
* the claim's provenance record (if any) is appended to the provenance log. A
|
|
111
|
+
* corrupt/truncated file at the claim's path is REPAIRED by rewriting the canonical
|
|
112
|
+
* bytes — a killed process must never leave a claim permanently unloadable.
|
|
113
|
+
* @returns {{ok:boolean, reason?:string, id?:string, existed?:boolean}}
|
|
114
|
+
*/
|
|
115
|
+
export function putClaim(dir, claim) {
|
|
116
|
+
if (!claim?.id || claim.id !== claimId(claim.kind, claim.body, claim.scope))
|
|
117
|
+
return { ok: false, reason: "claim id does not match canonical content hash" };
|
|
118
|
+
const text = claimBytes(claim);
|
|
119
|
+
if (SECRET_RE.test(text))
|
|
120
|
+
return { ok: false, reason: "refused: claim looks like it contains a secret/credential" };
|
|
121
|
+
const path = claimPath(dir, claim.id);
|
|
122
|
+
const already = existsSync(path);
|
|
123
|
+
const healthy = already && readJson(path) !== null && readFileSync(path, "utf8") === text;
|
|
124
|
+
if (!healthy) {
|
|
125
|
+
mkdirSync(join(dir, "claims", claim.id.slice(0, 2)), { recursive: true });
|
|
126
|
+
writeFileSync(path, text);
|
|
127
|
+
}
|
|
128
|
+
if (claim.provenance?.h) appendRecord(dir, "provenance", claim.id, claim.provenance);
|
|
129
|
+
return { ok: true, id: claim.id, existed: already && healthy };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Append one evidence outcome (deduped by its content hash — append is idempotent). */
|
|
133
|
+
export function appendEvidence(dir, id, outcome) {
|
|
134
|
+
if (!validOutcome(outcome)) return { ok: false, reason: "invalid outcome (use outcomeRecord)" };
|
|
135
|
+
return appendRecord(dir, "evidence", id, outcome);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** All evidence outcomes for a claim (corrupt lines skipped, duplicates dropped). */
|
|
139
|
+
export function readEvidence(dir, id) {
|
|
140
|
+
return readLog(dir, "evidence", id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Retract a claim — an append-only record, so two teammates retracting concurrently
|
|
144
|
+
* both survive the merge (the view shows the earliest deterministically). */
|
|
145
|
+
export function tombstone(dir, id, { author = "", reason = "", t = 0 } = {}) {
|
|
146
|
+
return appendRecord(dir, "tombstones", id, sealRecord({ author, reason, t }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Ratify a claim — the fahm→ḥikma promotion (08-dashboard-ux.md §2): mint a `decision`
|
|
151
|
+
* claim pointing at the ratified claim's full id. Promotion is HUMAN-ONLY by design:
|
|
152
|
+
* the caller supplies the author (a person's identity, via gitAuthor()); nothing in the
|
|
153
|
+
* substrate ever calls this automatically. Append-only and content-addressed, so
|
|
154
|
+
* ratifying the same claim twice converges on the same decision ({existed:true}).
|
|
155
|
+
* @param {string} dir
|
|
156
|
+
* @param {string} idPrefix
|
|
157
|
+
* @param {{author?: string, t?: number}} [opts]
|
|
158
|
+
* @returns {{ok:boolean, reason?:string, decisionId?:string, ratifies?:string, existed?:boolean}}
|
|
159
|
+
*/
|
|
160
|
+
export function ratify(dir, idPrefix, { author = "", t = 0 } = {}) {
|
|
161
|
+
const target = getClaimByPrefix(dir, idPrefix);
|
|
162
|
+
if (!target) return { ok: false, reason: `no claim matching ${idPrefix}` };
|
|
163
|
+
const minted = mintClaim({
|
|
164
|
+
kind: "decision",
|
|
165
|
+
body: { ratifies: target.id, note: "" },
|
|
166
|
+
provenance: { agent: "dash", author },
|
|
167
|
+
t,
|
|
168
|
+
});
|
|
169
|
+
if (!minted.ok) return { ok: false, reason: "reason" in minted ? minted.reason : "mint failed" };
|
|
170
|
+
const put = putClaim(dir, minted.claim);
|
|
171
|
+
if (!put.ok) return { ok: false, reason: put.reason ?? "could not persist the decision claim" };
|
|
172
|
+
return { ok: true, decisionId: minted.claim.id, ratifies: target.id, existed: put.existed };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Load the full ledger state {claims, evidence, provenance, tombstones}. */
|
|
176
|
+
export function loadState(dir) {
|
|
177
|
+
const state = emptyState();
|
|
178
|
+
for (const { id, claim } of walkClaimFiles(dir)) {
|
|
179
|
+
if (!claim) continue;
|
|
180
|
+
state.claims[id] = claim;
|
|
181
|
+
for (const log of LOGS) state[log][id] = readLog(dir, log, id);
|
|
182
|
+
}
|
|
183
|
+
return state;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** All claims with evidence/provenance/tombstone views attached (retrieval input). */
|
|
187
|
+
export function loadClaims(dir) {
|
|
188
|
+
return liveClaims(loadState(dir));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Find one claim by id prefix without scanning the whole ledger (ids are sharded by
|
|
192
|
+
* their first two hex chars, so any prefix ≥ 2 chars pins the shard). */
|
|
193
|
+
export function getClaimByPrefix(dir, prefix) {
|
|
194
|
+
if (!prefix || prefix.length < 2) return null;
|
|
195
|
+
const shardDir = join(dir, "claims", prefix.slice(0, 2));
|
|
196
|
+
if (!existsSync(shardDir)) return null;
|
|
197
|
+
const f = readdirSync(shardDir)
|
|
198
|
+
.filter((f) => f.endsWith(".json") && f.startsWith(prefix))
|
|
199
|
+
.sort()[0];
|
|
200
|
+
if (!f) return null;
|
|
201
|
+
const id = f.replace(/\.json$/, "");
|
|
202
|
+
const claim = readJson(join(shardDir, f));
|
|
203
|
+
if (!claim || claimId(claim.kind, claim.body, claim.scope) !== id) return null;
|
|
204
|
+
const state = emptyState();
|
|
205
|
+
state.claims[id] = { ...claim, id };
|
|
206
|
+
for (const log of LOGS) state[log][id] = readLog(dir, log, id);
|
|
207
|
+
return liveClaims(state)[0];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `forge ledger merge <path>` — semilattice merge of another on-disk ledger into
|
|
211
|
+
* this one. Idempotent and order-independent by the CRDT property, so merging a
|
|
212
|
+
* teammate's checkout, a backup, or a branch worktree is always safe. */
|
|
213
|
+
export function mergeDirs(dstDir, srcDir) {
|
|
214
|
+
return importState(dstDir, loadState(srcDir));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* `forge ledger blame <id-prefix>` — the full accountability view of one claim: who
|
|
219
|
+
* minted it (every author, via the provenance log), every evidence record in (t, h)
|
|
220
|
+
* order, retractions, and the per-author trust the ledger has earned (17:36's audit
|
|
221
|
+
* trail: every channel the agent used can be questioned).
|
|
222
|
+
*/
|
|
223
|
+
export function blame(dir, prefix, nowDay = 0) {
|
|
224
|
+
const claim = getClaimByPrefix(dir, prefix);
|
|
225
|
+
if (!claim) return null;
|
|
226
|
+
const trust = authorTrust(loadClaims(dir));
|
|
227
|
+
return {
|
|
228
|
+
id: claim.id,
|
|
229
|
+
kind: claim.kind,
|
|
230
|
+
body: claim.body,
|
|
231
|
+
scope: claim.scope,
|
|
232
|
+
minted: claim.provenanceAll,
|
|
233
|
+
evidence: claim.evidence,
|
|
234
|
+
tombstones: readLog(dir, "tombstones", claim.id),
|
|
235
|
+
val: val(claim, nowDay),
|
|
236
|
+
valTrustWeighted: val(claim, nowDay, { trust }),
|
|
237
|
+
trust: Object.fromEntries(
|
|
238
|
+
[
|
|
239
|
+
...new Set(
|
|
240
|
+
[...claim.provenanceAll, ...claim.evidence].map((r) => r.author).filter(Boolean),
|
|
241
|
+
),
|
|
242
|
+
].map((a) => [a, trust[a] ?? 1]),
|
|
243
|
+
),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Semilattice import: merge another ledger state into this directory (the mergeDirs
|
|
248
|
+
* core). Idempotent; safe to re-run. */
|
|
249
|
+
export function importState(dir, other) {
|
|
250
|
+
const merged = mergeStates(loadState(dir), other);
|
|
251
|
+
let claims = 0;
|
|
252
|
+
let records = 0;
|
|
253
|
+
for (const c of Object.values(merged.claims)) {
|
|
254
|
+
const r = putClaim(dir, c);
|
|
255
|
+
if (r.ok && !r.existed) claims++;
|
|
256
|
+
for (const log of LOGS) {
|
|
257
|
+
for (const rec of merged[log][c.id] ?? []) {
|
|
258
|
+
const a = appendRecord(dir, log, c.id, rec);
|
|
259
|
+
if (a.ok && !a.deduped) records++;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
reindex(dir);
|
|
264
|
+
return { claims, records };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Regenerate LEDGER.md — the human index (like recall's MEMORY.md). */
|
|
268
|
+
export function reindex(dir, nowDay = 0) {
|
|
269
|
+
const rows = loadClaims(dir)
|
|
270
|
+
.filter((c) => !c.tombstone)
|
|
271
|
+
.map((c) => `- \`${c.id.slice(0, 12)}\` ${c.kind} · val ${val(c, nowDay).toFixed(2)}`);
|
|
272
|
+
mkdirSync(dir, { recursive: true });
|
|
273
|
+
writeFileSync(
|
|
274
|
+
join(dir, "LEDGER.md"),
|
|
275
|
+
["# Proof-Carrying Memory ledger", "", ...rows, ""].join("\n"),
|
|
276
|
+
);
|
|
277
|
+
return rows.length;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Normal-form check (CI-friendly): every claim parses and matches its address; every
|
|
282
|
+
* log line parses, carries a TRUE content hash, and (for evidence) names a known
|
|
283
|
+
* oracle with the table weight; no secrets anywhere. Everything loadState silently
|
|
284
|
+
* skips, verify names.
|
|
285
|
+
* @returns {{ok:boolean, claims:number, outcomes:number, issues:string[]}}
|
|
286
|
+
*/
|
|
287
|
+
export function verify(dir) {
|
|
288
|
+
const issues = [];
|
|
289
|
+
let claims = 0;
|
|
290
|
+
let outcomes = 0;
|
|
291
|
+
const ids = [];
|
|
292
|
+
for (const { id, raw, claim } of walkClaimFiles(dir)) {
|
|
293
|
+
if (!claim) issues.push(`claim ${id}: unparseable or id mismatch`);
|
|
294
|
+
else {
|
|
295
|
+
claims++;
|
|
296
|
+
ids.push(id);
|
|
297
|
+
}
|
|
298
|
+
if (SECRET_RE.test(raw)) issues.push(`claim ${id}: contains secret-like content`);
|
|
299
|
+
}
|
|
300
|
+
for (const log of LOGS) {
|
|
301
|
+
const logRoot = join(dir, log);
|
|
302
|
+
if (!existsSync(logRoot)) continue;
|
|
303
|
+
for (const f of readdirSync(logRoot).filter((f) => f.endsWith(".log"))) {
|
|
304
|
+
const id = f.replace(/\.log$/, "");
|
|
305
|
+
for (const [n, line] of readFileSync(join(logRoot, f), "utf8").split(/\r?\n/).entries()) {
|
|
306
|
+
if (!line.trim()) continue;
|
|
307
|
+
const where = `${log} ${id}:${n + 1}`;
|
|
308
|
+
let o = null;
|
|
309
|
+
try {
|
|
310
|
+
o = JSON.parse(line);
|
|
311
|
+
} catch {}
|
|
312
|
+
if (!o?.h) {
|
|
313
|
+
issues.push(`${where}: unparseable or missing hash`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const { h, ...rest } = o;
|
|
317
|
+
if (sealRecord(rest).h !== h) {
|
|
318
|
+
issues.push(`${where}: content hash mismatch (forged/corrupt)`);
|
|
319
|
+
} else if (log === "evidence") {
|
|
320
|
+
if (!validOutcome(o)) issues.push(`${where}: invalid outcome (oracle/result/ref)`);
|
|
321
|
+
else if (o.w !== ORACLES[o.oracle].w)
|
|
322
|
+
issues.push(`${where}: recorded weight ${o.w} != oracle table ${ORACLES[o.oracle].w}`);
|
|
323
|
+
else outcomes++;
|
|
324
|
+
}
|
|
325
|
+
if (SECRET_RE.test(line)) issues.push(`${where}: secret-like content`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return { ok: issues.length === 0, claims, outcomes, issues };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Move dormant/tombstoned claim files to the attic (audit trail, never retrieved). */
|
|
333
|
+
export function pruneToAttic(dir, id) {
|
|
334
|
+
const from = claimPath(dir, id);
|
|
335
|
+
if (!existsSync(from)) return { ok: false, reason: "no such claim" };
|
|
336
|
+
mkdirSync(join(dir, "attic"), { recursive: true });
|
|
337
|
+
renameSync(from, join(dir, "attic", `${id}.json`));
|
|
338
|
+
return { ok: true };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Counts + val distribution for `forge ledger stats` and the dashboard. Buckets use
|
|
342
|
+
* the protocol's DORMANT_VAL threshold (and its mirror) — never a local literal. */
|
|
343
|
+
export function stats(dir, nowDay = 0) {
|
|
344
|
+
const claims = loadClaims(dir);
|
|
345
|
+
const byKind = {};
|
|
346
|
+
const buckets = { dormant: 0, uncertain: 0, trusted: 0 };
|
|
347
|
+
for (const c of claims) {
|
|
348
|
+
byKind[c.kind] = (byKind[c.kind] ?? 0) + 1;
|
|
349
|
+
const v = val(c, nowDay);
|
|
350
|
+
if (v < DORMANT_VAL) buckets.dormant++;
|
|
351
|
+
else if (v < 1 - DORMANT_VAL) buckets.uncertain++;
|
|
352
|
+
else buckets.trusted++;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
total: claims.length,
|
|
356
|
+
tombstoned: claims.filter((c) => c.tombstone).length,
|
|
357
|
+
byKind,
|
|
358
|
+
val: buckets,
|
|
359
|
+
};
|
|
360
|
+
}
|
package/src/lessons.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// forge lessons — the self-correcting core. PURE logic only (no fs/hooks here, so it's
|
|
2
|
+
// fully unit-testable): a mistake scorer that won't fire on normal iteration, a
|
|
3
|
+
// confidence lifecycle that lets wrong lessons die faster than right ones grow, and an
|
|
4
|
+
// injection selector that never overflows a tool's context cap.
|
|
5
|
+
//
|
|
6
|
+
// Design invariants (why this isn't just Reflexion):
|
|
7
|
+
// - A lesson is trusted because it keeps being RE-CONFIRMED against fresh outcomes,
|
|
8
|
+
// never because it exists. Injection is NOT confirmation — only the storage/hook
|
|
9
|
+
// layer feeds independent outcome signals in here, never the act of injecting.
|
|
10
|
+
// - A mistake is defined by a BAD OUTCOME, not by "differs from a lesson", so a
|
|
11
|
+
// developer changing their mind retires a lesson instead of fighting it.
|
|
12
|
+
|
|
13
|
+
/** Signal taxonomy: weight = prior that a real mistake occurred; family gates false positives. */
|
|
14
|
+
// `solo: true` = trustworthy enough to fire a lesson on its own (only an explicit human
|
|
15
|
+
// undo qualifies). Everything else needs a second signal from a different family — this is
|
|
16
|
+
// what stops a stray "no problem" (S5) or a lone thrash (S3) from minting a false lesson.
|
|
17
|
+
export const SIGNALS = {
|
|
18
|
+
S1: { weight: 0.45, family: "outcome" }, // test-fail then pass on same files
|
|
19
|
+
S2: { weight: 0.25, family: "behavioral" }, // agent edits code it just wrote
|
|
20
|
+
S3: { weight: 0.3, family: "behavioral" }, // repeated edits to same symbol
|
|
21
|
+
S4: { weight: 0.4, family: "outcome" }, // file/hunk reverted
|
|
22
|
+
S5: { weight: 0.35, family: "human" }, // user negative utterance (weak, noisy — never solo)
|
|
23
|
+
S6: { weight: 0.5, family: "human", solo: true }, // explicit undo (git revert/checkout) — trusted solo
|
|
24
|
+
S7: { weight: 0.35, family: "outcome" }, // lint/type/build regression introduced
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// One source of truth for scope weighting — the PCM ledger core owns it now, so the
|
|
28
|
+
// legacy injection path and Eq.-3 retrieval can never rank the same memory with
|
|
29
|
+
// silently different weights during the bridge window.
|
|
30
|
+
import { SCOPE_WEIGHT } from "./ledger.js";
|
|
31
|
+
|
|
32
|
+
export { SCOPE_WEIGHT };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Score whether a cluster of signals reflects a real mistake.
|
|
36
|
+
* noisy-OR (bounded in [0,1)) so three weak signals can't fake one strong one; a
|
|
37
|
+
* cross-family gate so a lone behavioral signal (thrash) never fires a lesson.
|
|
38
|
+
* @param {{signal:string, anti?:number}[]} events - anti in [0,1] scales a signal down.
|
|
39
|
+
* @returns {{p:number, fires:boolean, families:string[]}}
|
|
40
|
+
*/
|
|
41
|
+
export function scoreMistake(events) {
|
|
42
|
+
const valid = events.filter((e) => SIGNALS[e.signal]);
|
|
43
|
+
const product = valid.reduce((acc, e) => {
|
|
44
|
+
const w = SIGNALS[e.signal].weight * (e.anti ?? 1);
|
|
45
|
+
return acc * (1 - w);
|
|
46
|
+
}, 1);
|
|
47
|
+
const p = 1 - product;
|
|
48
|
+
const families = [...new Set(valid.map((e) => SIGNALS[e.signal].family))];
|
|
49
|
+
const soloOk = valid.some((e) => SIGNALS[e.signal].solo);
|
|
50
|
+
const fires = families.length >= 2 || soloOk;
|
|
51
|
+
return { p, fires, families };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Route a scored cluster: distill a lesson now, accumulate for later, or discard as variation. */
|
|
55
|
+
export function classify(events) {
|
|
56
|
+
const { p, fires } = scoreMistake(events);
|
|
57
|
+
if (fires && p >= 0.7) return "distill";
|
|
58
|
+
if (p >= 0.4) return "accumulate";
|
|
59
|
+
return "discard";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A fresh lesson candidate. confidence starts at 0.5 (evidence 0, contradiction 0 → α=β=1). */
|
|
63
|
+
export function newLesson(fields, nowDay = 0) {
|
|
64
|
+
return {
|
|
65
|
+
id: fields.id,
|
|
66
|
+
trigger: fields.trigger ?? {}, // {files:[glob], symbols:[], action, keywords:[]}
|
|
67
|
+
scope: fields.scope ?? "repo", // symbol | dir | repo | global
|
|
68
|
+
whatWentWrong: fields.whatWentWrong ?? "",
|
|
69
|
+
correctedBehavior: fields.correctedBehavior ?? "",
|
|
70
|
+
evidenceCount: 0,
|
|
71
|
+
contradictionCount: 0,
|
|
72
|
+
quarantineReconfirms: 0,
|
|
73
|
+
status: "candidate", // candidate | active | quarantined | retired
|
|
74
|
+
createdDay: nowDay,
|
|
75
|
+
lastConfirmedDay: nowDay,
|
|
76
|
+
halfLifeDays: fields.halfLifeDays ?? 45,
|
|
77
|
+
provenance: fields.provenance ?? { episodes: [], signals: [] },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validity — the paper's `val` term (Eq. retrieve, §7.1). The Laplace-smoothed Beta posterior
|
|
83
|
+
* mean over GROUND-TRUTH outcomes: how much independent evidence upholds this lesson vs.
|
|
84
|
+
* contradicts it, with NO time decay. This is what makes a memory pruned by whether its
|
|
85
|
+
* prediction later held (a test/commit), not by the model's own say-so.
|
|
86
|
+
*/
|
|
87
|
+
export function validity(lesson) {
|
|
88
|
+
const alpha = 1 + (lesson.evidenceCount ?? 0);
|
|
89
|
+
const beta = 1 + (lesson.contradictionCount ?? 0);
|
|
90
|
+
return alpha / (alpha + beta);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Freshness — the paper's `rec` term: exponential recency decay since last confirmation. */
|
|
94
|
+
export function freshness(lesson, nowDay) {
|
|
95
|
+
const age = Math.max(0, nowDay - lesson.lastConfirmedDay);
|
|
96
|
+
return 0.5 ** (age / lesson.halfLifeDays);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Time-decayed Beta posterior mean = freshness × validity. Unconfirmed lessons fade out. */
|
|
100
|
+
export function confidenceOf(lesson, nowDay) {
|
|
101
|
+
return freshness(lesson, nowDay) * validity(lesson);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Independent outcome re-confirmed this lesson. Raises confidence with diminishing returns. */
|
|
105
|
+
export function confirm(lesson, nowDay) {
|
|
106
|
+
const next = {
|
|
107
|
+
...lesson,
|
|
108
|
+
evidenceCount: lesson.evidenceCount + 1,
|
|
109
|
+
lastConfirmedDay: nowDay,
|
|
110
|
+
};
|
|
111
|
+
if (lesson.status === "quarantined") {
|
|
112
|
+
const reconfirms = lesson.quarantineReconfirms + 1;
|
|
113
|
+
const back = reconfirms >= 2;
|
|
114
|
+
return {
|
|
115
|
+
...next,
|
|
116
|
+
quarantineReconfirms: back ? 0 : reconfirms,
|
|
117
|
+
status: back ? "active" : "quarantined",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return { ...next, status: "active" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** An independent outcome contradicted this lesson. Contradiction is cheaper than confirmation. */
|
|
124
|
+
export function contradict(lesson, nowDay) {
|
|
125
|
+
const next = { ...lesson, contradictionCount: lesson.contradictionCount + 1 };
|
|
126
|
+
if (lesson.status === "quarantined") return { ...next, status: "retired" };
|
|
127
|
+
if (confidenceOf(next, nowDay) < 0.3) return { ...next, status: "quarantined" };
|
|
128
|
+
return next;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Compile a `*` / `**` glob to an anchored RegExp (no sentinel chars — single pass). */
|
|
132
|
+
const globToRe = (glob) => {
|
|
133
|
+
const body = glob
|
|
134
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
135
|
+
.replace(/\*\*|\*/g, (m) => (m === "**" ? ".*" : "[^/]*"));
|
|
136
|
+
return new RegExp(`^${body}$`);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Trigger overlap with the current context, in [0,1]: symbol hit beats file beats keyword. */
|
|
140
|
+
export function matchScore(lesson, context) {
|
|
141
|
+
const t = lesson.trigger;
|
|
142
|
+
if (t.symbols?.some((s) => context.symbols?.includes(s))) return 1.0;
|
|
143
|
+
if (t.files?.some((g) => context.files?.some((f) => globToRe(g).test(f)))) return 0.6;
|
|
144
|
+
if (t.keywords?.some((k) => context.keywords?.includes(k))) return 0.3;
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Select the lessons worth injecting for the current files/symbols — relevance-ranked and
|
|
150
|
+
* hard-capped. Overflow becomes a POINTER, never a silent drop (the #39811 cliff lesson).
|
|
151
|
+
* @returns {{selected:object[], overflow:number, block:string}}
|
|
152
|
+
*/
|
|
153
|
+
export function selectForInjection(lessons, context, { budget = 12, nowDay = 0 } = {}) {
|
|
154
|
+
const ranked = lessons
|
|
155
|
+
.filter((l) => l.status === "active")
|
|
156
|
+
.map((l) => ({ lesson: l, m: matchScore(l, context) }))
|
|
157
|
+
.filter((x) => x.m > 0)
|
|
158
|
+
.map((x) => {
|
|
159
|
+
// The paper's retrieval score, decomposed into named terms: relevance (match) ×
|
|
160
|
+
// freshness (rec) × validity (val, ground-truth outcomes) × scope, with a small recency
|
|
161
|
+
// boost. Making `val` explicit ranks outcome-confirmed lessons above merely-recent ones.
|
|
162
|
+
const rel = x.m;
|
|
163
|
+
const rec = freshness(x.lesson, nowDay);
|
|
164
|
+
const val = validity(x.lesson);
|
|
165
|
+
const scopeW = SCOPE_WEIGHT[x.lesson.scope] ?? 0.5;
|
|
166
|
+
const recencyBoost = 1 + (nowDay - x.lesson.lastConfirmedDay <= 14 ? 0.2 : 0);
|
|
167
|
+
return { lesson: x.lesson, score: rel * rec * val * scopeW * recencyBoost };
|
|
168
|
+
})
|
|
169
|
+
.sort((a, b) => b.score - a.score);
|
|
170
|
+
|
|
171
|
+
const selected = ranked.slice(0, budget).map((x) => x.lesson);
|
|
172
|
+
const overflow = Math.max(0, ranked.length - selected.length);
|
|
173
|
+
const rows = selected.map((l) => `- **${l.id}** — ${l.correctedBehavior}`.slice(0, 200));
|
|
174
|
+
if (overflow) rows.push(`- _(+${overflow} more matched lessons in .forge/lessons/ — not shown)_`);
|
|
175
|
+
const block = selected.length
|
|
176
|
+
? [
|
|
177
|
+
"## Lessons for the files in play (Forge Cortex)",
|
|
178
|
+
"Background context from past mistakes on this repo — verify before acting.",
|
|
179
|
+
"",
|
|
180
|
+
...rows,
|
|
181
|
+
"",
|
|
182
|
+
].join("\n")
|
|
183
|
+
: "";
|
|
184
|
+
return { selected, overflow, block };
|
|
185
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// forge lessons storage — persistence for the self-correcting core. Lessons are one
|
|
2
|
+
// file each under .forge/lessons/ (git-committable, human-auditable), episodes are an
|
|
3
|
+
// append-only JSONL. Flat front-matter, not YAML: zero runtime deps and still readable/
|
|
4
|
+
// diffable. Secret-refusal is reused from recall (never persist a credential).
|
|
5
|
+
import {
|
|
6
|
+
appendFileSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { SECRET_RE } from "./recall.js";
|
|
15
|
+
|
|
16
|
+
export const lessonsDir = (root = process.cwd()) => join(root, ".forge", "lessons");
|
|
17
|
+
|
|
18
|
+
const csv = (arr) => (arr ?? []).join(", ");
|
|
19
|
+
const listOf = (v) =>
|
|
20
|
+
v
|
|
21
|
+
? v
|
|
22
|
+
.split(",")
|
|
23
|
+
.map((s) => s.trim())
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
: [];
|
|
26
|
+
|
|
27
|
+
/** Pure: lesson object → file text (flat front-matter + markdown body). */
|
|
28
|
+
export function serialize(lesson) {
|
|
29
|
+
const t = lesson.trigger ?? {};
|
|
30
|
+
const fm = [
|
|
31
|
+
`id: ${lesson.id}`,
|
|
32
|
+
`status: ${lesson.status}`,
|
|
33
|
+
`scope: ${lesson.scope}`,
|
|
34
|
+
`trigger.files: ${csv(t.files)}`,
|
|
35
|
+
`trigger.symbols: ${csv(t.symbols)}`,
|
|
36
|
+
`trigger.keywords: ${csv(t.keywords)}`,
|
|
37
|
+
`trigger.action: ${t.action ?? ""}`,
|
|
38
|
+
`evidence: ${lesson.evidenceCount}`,
|
|
39
|
+
`contradiction: ${lesson.contradictionCount}`,
|
|
40
|
+
`quarantineReconfirms: ${lesson.quarantineReconfirms}`,
|
|
41
|
+
`created: ${lesson.createdDay}`,
|
|
42
|
+
`lastConfirmed: ${lesson.lastConfirmedDay}`,
|
|
43
|
+
`halfLifeDays: ${lesson.halfLifeDays}`,
|
|
44
|
+
`episodes: ${csv(lesson.provenance?.episodes)}`,
|
|
45
|
+
`signals: ${csv(lesson.provenance?.signals)}`,
|
|
46
|
+
].join("\n");
|
|
47
|
+
return `---\n${fm}\n---\n\n${lesson.whatWentWrong}\n\n**Fix:** ${lesson.correctedBehavior}\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Pure: file text → lesson object (inverse of serialize). */
|
|
51
|
+
export function parse(text) {
|
|
52
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
53
|
+
if (!m) throw new Error("invalid lesson file (no front-matter)");
|
|
54
|
+
const fm = {};
|
|
55
|
+
for (const line of m[1].split("\n")) {
|
|
56
|
+
const i = line.indexOf(":");
|
|
57
|
+
if (i > 0) fm[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
58
|
+
}
|
|
59
|
+
const body = m[2].trim();
|
|
60
|
+
const [whatWentWrong] = body.split(/\n\n\*\*Fix:\*\*/);
|
|
61
|
+
const fix = body.match(/\*\*Fix:\*\*\s*([\s\S]*)$/);
|
|
62
|
+
return {
|
|
63
|
+
id: fm.id,
|
|
64
|
+
status: fm.status,
|
|
65
|
+
scope: fm.scope,
|
|
66
|
+
trigger: {
|
|
67
|
+
files: listOf(fm["trigger.files"]),
|
|
68
|
+
symbols: listOf(fm["trigger.symbols"]),
|
|
69
|
+
keywords: listOf(fm["trigger.keywords"]),
|
|
70
|
+
action: fm["trigger.action"] || undefined,
|
|
71
|
+
},
|
|
72
|
+
whatWentWrong: whatWentWrong.trim(),
|
|
73
|
+
correctedBehavior: fix ? fix[1].trim() : "",
|
|
74
|
+
evidenceCount: Number(fm.evidence) || 0,
|
|
75
|
+
contradictionCount: Number(fm.contradiction) || 0,
|
|
76
|
+
quarantineReconfirms: Number(fm.quarantineReconfirms) || 0,
|
|
77
|
+
createdDay: Number(fm.created) || 0,
|
|
78
|
+
lastConfirmedDay: Number(fm.lastConfirmed) || 0,
|
|
79
|
+
halfLifeDays: Number(fm.halfLifeDays) || 45,
|
|
80
|
+
provenance: { episodes: listOf(fm.episodes), signals: listOf(fm.signals) },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Persist one lesson. Refuses secret-like content (store a pointer, never the value). */
|
|
85
|
+
export function save(root, lesson) {
|
|
86
|
+
const text = serialize(lesson);
|
|
87
|
+
if (SECRET_RE.test(text)) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: "refused: lesson looks like it contains a secret/credential",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const dir = lessonsDir(root);
|
|
94
|
+
mkdirSync(dir, { recursive: true });
|
|
95
|
+
writeFileSync(join(dir, `${lesson.id}.md`), text);
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Load every persisted lesson (episodes.jsonl is skipped — only .md files are lessons).
|
|
100
|
+
* A single malformed/half-written file is skipped, not fatal — one bad file must never take
|
|
101
|
+
* down memory retrieval, routing, and the pre-edit advisory (everywhere `load` is called). */
|
|
102
|
+
export function load(root) {
|
|
103
|
+
const dir = lessonsDir(root);
|
|
104
|
+
if (!existsSync(dir)) return [];
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const f of readdirSync(dir)
|
|
107
|
+
.filter((f) => f.endsWith(".md"))
|
|
108
|
+
.sort()) {
|
|
109
|
+
try {
|
|
110
|
+
out.push(parse(readFileSync(join(dir, f), "utf8")));
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (process.env.FORGE_DEBUG === "1")
|
|
113
|
+
process.stderr.write(`forge lessons: skipping ${f}: ${err?.message ?? err}\n`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Append a correction episode to the audit log (independent evidence, never overwritten). */
|
|
120
|
+
export function appendEpisode(root, episode) {
|
|
121
|
+
const dir = lessonsDir(root);
|
|
122
|
+
mkdirSync(dir, { recursive: true });
|
|
123
|
+
appendFileSync(join(dir, "episodes.jsonl"), `${JSON.stringify(episode)}\n`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function readEpisodes(root) {
|
|
127
|
+
const path = join(lessonsDir(root), "episodes.jsonl");
|
|
128
|
+
if (!existsSync(path)) return [];
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
131
|
+
if (!line) continue;
|
|
132
|
+
try {
|
|
133
|
+
out.push(JSON.parse(line)); // one corrupt JSONL line must not discard the whole log
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|