From 3ed9d57e0ec6e79454d4565df16d9a05ced983d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:43:27 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20flip=20ledger=20reads=20to=20a=20me?= =?UTF-8?q?rged=20view=20(legacy=20=E2=88=AA=20ledger)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PCM ledger has been the convergent write store since P1 while the legacy stores served every read, so knowledge arriving via `forge ledger merge` never reached injection. New src/ledger_read.js maps lesson claims onto the legacy lesson shape (status derived from evidence: tombstoned → retired, val ≥ 0.6 → active, val < 0.45 with a contradiction → quarantined, else candidate) and exposes merged lesson/fact reads deduped by legacy id/slug with the local file winning — best-effort, so a missing or corrupt ledger degrades to legacy-only. Flipped read surfaces: cortex lessonsForContext/startupBlock/summary, substrate advisory, route past-mistake density, recall list/MEMORY.md, brain AGENTS.md index. Write paths (recordMistake's confirm-vs-create lookup, recordContradiction, applyDistillation) deliberately keep reading the legacy files they edit; convergence comes from content-addressed claim ids. reconcileFacts now only tombstones locally-authored claims so a merged teammate fact survives `forge recall consolidate`. Legacy formats are still written — full retirement is the next step (ROADMAP updated). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Fc2MvWJbQ1cyNQ76ymv4hs --- CHANGELOG.md | 18 +++ ROADMAP.md | 9 +- src/brain.js | 34 +++-- src/cortex.js | 31 +++-- src/ledger_bridge.js | 26 ++-- src/ledger_read.js | 152 ++++++++++++++++++++ src/recall.js | 16 ++- src/route.js | 7 +- src/substrate.js | 5 +- test/ledger_bridge.test.js | 28 ++++ test/ledger_read.test.js | 277 +++++++++++++++++++++++++++++++++++++ 11 files changed, 569 insertions(+), 34 deletions(-) create mode 100644 src/ledger_read.js create mode 100644 test/ledger_read.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 05dd0cc..4f1fb83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- **Ledger read-path flip (P2).** Reads are now a merged view (legacy ∪ ledger) via the + new `src/ledger_read.js`, so teammate knowledge that arrives with `forge ledger merge` + actually reaches injection and retrieval: cortex lesson surfaces + (`lessonsForContext`, `startupBlock`, `summary`, the substrate advisory and routing + past-mistake density) map ledger `lesson` claims onto the legacy lesson shape with an + evidence-derived status (tombstoned → retired, val ≥ 0.6 → active, val < 0.45 with a + contradiction → quarantined, else candidate), and fact surfaces (`recall list`/ + `MEMORY.md`, brain's `AGENTS.md` index) include live ledger `fact` claims — always + deduped by legacy id/slug with the local file winning, and best-effort (a missing or + corrupt ledger degrades to legacy-only). Write paths (`recordMistake`'s + confirm-vs-create lookup, `recordContradiction`, `applyDistillation`) deliberately + keep reading the legacy store they edit; convergence comes from content-addressed + claim ids. `reconcileFacts` now only tombstones locally-authored claims, so a merged + teammate fact survives `forge recall consolidate`. Legacy formats are still written — + full retirement is the next step. + ## [0.6.0] - 2026-07-07 ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 2580d04..3ee4e27 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,9 +26,12 @@ confidence only from independent oracles, and merges across teammates conflict-f (git-native CRDT ledger). ## Next -- **Ledger read-path flip** — the ledger is the convergent *write* store today while - the legacy stores (`lessons/`, `recall`, `brain`) still serve reads; flip reads to be - ledger-first, then retire the legacy formats. +- **Legacy store retirement** — the read-path flip has shipped: every read surface + (cortex injection/status, the substrate advisory, routing, `recall list`, brain's + AGENTS.md index) is now a merged view (legacy ∪ ledger) via `src/ledger_read.js`, + so teammate knowledge from `forge ledger merge` reaches injection. The legacy + formats (`lessons/*.md`, recall/brain fact files) are still written as the canonical + local state; the remaining step is retiring them so the ledger is the only store. - **Embeddings tier** — optional vector backend (ADR-0005 dependency tier, stdlib fallback kept) for Eq. 3 retrieval and `forge reuse` near-match, where MinHash is weak on short specs. diff --git a/src/brain.js b/src/brain.js index 1e7999c..6887cf6 100644 --- a/src/brain.js +++ b/src/brain.js @@ -5,11 +5,16 @@ // by construction: the inlined index is capped; overflow stays in fact files, never // silently truncated the way Claude's native 200-line MEMORY.md is (#39811). import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { add as recallAdd, list as recallList } from "./recall.js"; +import { dirname, join } from "node:path"; +import { ledgerFacts, mergeFactSlugs } from "./ledger_read.js"; +import { listStored, add as recallAdd } from "./recall.js"; export const brainStore = (targetRoot = process.cwd()) => join(targetRoot, ".forge", "brain"); +// Brain facts shadow into the REPO ledger (.forge/ledger — the sibling of this store, +// see `forge remember`), so that is where merged teammate facts arrive. +const brainLedger = (store) => join(dirname(store), "ledger"); + /** Store one fact (secret-refused by recall) and rebuild the inlined index. */ export function remember(store, name, body) { const res = recallAdd(store, name, body); @@ -17,7 +22,8 @@ export function remember(store, name, body) { return res; } -export const list = recallList; +/** Merged read (P2 read flip): file facts ∪ live repo-ledger facts, file wins on slug. */ +export const list = (store) => mergeFactSlugs(listStored(store), brainLedger(store)); const gistOf = (text) => text @@ -26,7 +32,9 @@ const gistOf = (text) => .map((l) => l.trim()) .find(Boolean) || ""; -/** Build the capped, cliff-safe index inlined into AGENTS.md. Overflow → a pointer. */ +/** Build the capped, cliff-safe index inlined into AGENTS.md. Overflow → a pointer. + * Merged view (P2 read flip): teammate facts that arrived in the repo ledger via + * `forge ledger merge` join the index; a local file wins on name collision. */ export function buildIndex(store, { capItems = 120 } = {}) { const factsDir = join(store, "facts"); const facts = existsSync(factsDir) @@ -34,17 +42,25 @@ export function buildIndex(store, { capItems = 120 } = {}) { .filter((f) => f.endsWith(".md")) .sort() : []; + const entries = facts.map((file) => ({ + name: file.replace(/\.md$/, ""), + gist: gistOf(readFileSync(join(factsDir, file), "utf8")), + })); + const seen = new Set(entries.map((e) => e.name)); + for (const f of ledgerFacts(brainLedger(store))) { + if (seen.has(f.slug)) continue; + seen.add(f.slug); + entries.push({ name: f.slug, gist: gistOf(f.text) }); + } + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); const rows = []; let overflow = 0; - for (const file of facts) { + for (const e of entries) { if (rows.length >= capItems) { overflow += 1; continue; } - const name = file.replace(/\.md$/, ""); - rows.push( - `- **${name}** — ${gistOf(readFileSync(join(factsDir, file), "utf8")).slice(0, 140)}`, - ); + rows.push(`- **${e.name}** — ${e.gist.slice(0, 140)}`); } const indexed = rows.length; // count real facts before adding the overflow pointer if (overflow) diff --git a/src/cortex.js b/src/cortex.js index 4c7ae63..904d1a6 100644 --- a/src/cortex.js +++ b/src/cortex.js @@ -5,6 +5,7 @@ // without any hook wiring. import { recordLessonEvent, supersedeLessonClaim } from "./ledger_bridge.js"; +import { mergedLessons } from "./ledger_read.js"; import { confidenceOf, confirm, @@ -74,6 +75,13 @@ export function recordMistake(root, { signals, context, nowDay, episodeId, disti const accumulated = fires && p >= 0.4 && recurredWeak; if (!strong && !accumulated) return { action: "logged", p, fires }; + // Deliberately LEGACY-ONLY (not mergedLessons): this lookup decides confirm-vs-create + // for a LOCAL write. A teammate's merged ledger claim has no local file, and letting it + // match would swallow a fresh local mistake into confirm() — which edits a legacy file + // that doesn't exist — and hang the shadow evidence ref (#n) off counters this + // repo never owned. Creating locally is correct AND convergent: the new lesson's shadow + // claim content-addresses to the teammate's claim, so both sides' evidence lands on ONE + // claim at the next `forge ledger merge`. const existing = matchingLessons(load(root), context)[0]; if (existing) { const c = confirm(existing, nowDay); @@ -85,8 +93,8 @@ export function recordMistake(root, { signals, context, nowDay, episodeId, disti }, }; if (!save(root, updated).ok) return { action: "refused", p, fires }; - // Shadow the confirmation into the PCM ledger (P1 bridge — legacy store stays the - // read path; best-effort by design, never blocks the hook). The evidence counter + // Shadow the confirmation into the PCM ledger (best-effort by design, never blocks + // the hook — reads are merged via ledger_read.js since P2). The evidence counter // rides in the ref because episode ids reset per session (ep_m0_…) — without it, // two same-day sessions confirming via the same file would hash identically and // the second real confirmation would be silently deduped away. @@ -144,6 +152,9 @@ export function recordContradiction(root, { context, nowDay, episodeId }) { context, day: nowDay, }); + // Legacy-only for the same reason as recordMistake: contradict() saves the legacy + // file, so only lessons that HAVE one are targets. A teammate's claim still converges + // — the same reversal, recurring locally, mints the local twin whose evidence merges. const targets = matchingLessons(load(root), context, ["active", "quarantined"]); const results = targets.map((l) => { const updated = contradict(l, nowDay); @@ -163,14 +174,17 @@ export function recordContradiction(root, { context, nowDay, episodeId }) { return { action: "contradicted", results }; } -/** The injection block for the current context — what a SessionStart/PreToolUse hook emits. */ +/** The injection block for the current context — what a SessionStart/PreToolUse hook emits. + * Reads the MERGED view (P2 read flip): teammate lessons that arrived via + * `forge ledger merge` inject alongside local ones. */ export function lessonsForContext(root, context, opts = {}) { - return selectForInjection(load(root), context, opts); + return selectForInjection(mergedLessons(root, opts.nowDay ?? 0), context, opts); } -/** Repo-wide top active lessons — what a SessionStart hook injects (no file context yet). */ +/** Repo-wide top active lessons — what a SessionStart hook injects (no file context yet). + * Merged view: a teammate's outcome-confirmed lesson surfaces here too. */ export function startupBlock(root, nowDay = 0, budget = 8) { - const active = load(root).filter((l) => l.status === "active"); + const active = mergedLessons(root, nowDay).filter((l) => l.status === "active"); if (!active.length) return ""; const ranked = active .map((l) => ({ lesson: l, conf: confidenceOf(l, nowDay) })) @@ -194,6 +208,7 @@ export function startupBlock(root, nowDay = 0, budget = 8) { * refused — e.g. the distilled text tripped secret-refusal). */ export function applyDistillation(root, lessonId, distilled) { if (!distilled) return false; + // Legacy-only read: distillation EDITS the legacy file, so only file-backed lessons apply. const lesson = load(root).find((l) => l.id === lessonId); if (!lesson) return false; const updated = { @@ -214,9 +229,9 @@ export function cortexBlock(targetRoot = process.cwd()) { return startupBlock(targetRoot, Math.floor(Date.now() / 86400000)); } -/** Auditable snapshot for `forge cortex status`. */ +/** Auditable snapshot for `forge cortex status` — merged view, like every read surface. */ export function summary(root, nowDay = 0) { - const lessons = load(root); + const lessons = mergedLessons(root, nowDay); const by = (s) => lessons.filter((l) => l.status === s).length; return { total: lessons.length, diff --git a/src/ledger_bridge.js b/src/ledger_bridge.js index 95ab66a..b8144ef 100644 --- a/src/ledger_bridge.js +++ b/src/ledger_bridge.js @@ -1,12 +1,13 @@ // forge ledger bridge — the P1 migration seam between the legacy stores (lessons/*.md, -// recall facts) and the PCM ledger. Legacy files stay the READ path (every existing -// test, hook, and guard keeps working unchanged); the ledger shadows every write as -// the new canonical, and `forge ledger import` back-fills pre-ledger history. P2 flips -// the read path once merge/verify tooling lands. Spec: docs/plans/substrate-v2/01-pcm-protocol.md §7. +// recall facts) and the PCM ledger. The ledger shadows every write as the new +// canonical, and `forge ledger import` back-fills pre-ledger history. P2 (shipped — +// see ledger_read.js) flipped READS to a merged view (legacy ∪ ledger); the legacy +// files remain the canonical LOCAL state until full retirement. +// Spec: docs/plans/substrate-v2/01-pcm-protocol.md §7. // // Every entry point here is BEST-EFFORT by design: the legacy store is still -// canonical in P1, so a bridge failure returns {ok:false} and must never break a -// hook or CLI write that already succeeded. +// canonical for local state, so a bridge failure returns {ok:false} and must never +// break a hook or CLI write that already succeeded. import { mintClaim, outcomeRecord } from "./ledger.js"; import { appendEvidence, @@ -18,7 +19,10 @@ import { tombstone, } from "./ledger_store.js"; import { load as loadLessons } from "./lessons_store.js"; -import { list as listFacts, readFact } from "./recall.js"; +// listStored, NOT list: the bridge reconciles the FILE store against the ledger. The +// merged `list` (P2 read flip) includes ledger-only teammate facts, which have no file +// and would read here as "deleted from the store". +import { listStored as listFacts, readFact } from "./recall.js"; import { epochDay, gitAuthor } from "./util.js"; /** One best-effort policy for the whole bridge (never throws into a caller). */ @@ -175,7 +179,13 @@ export function reconcileFacts(store, ledgerDir, t = epochDay()) { } let removed = 0; for (const c of loadClaims(ledgerDir)) { - if (c.kind === "fact" && !c.tombstone && !current.has(c.id)) { + // Only reconcile claims THIS author minted: a locally-authored claim with no + // backing file means the file was deleted (consolidate rm'd a duplicate). A + // teammate's claim (arrived via `forge ledger merge`) never had a local file — + // with the P2 read flip it IS the readable fact, and tombstoning it here would + // silently delete team knowledge on every consolidate. + const mine = (c.provenance?.author ?? "") === gitAuthor(); + if (c.kind === "fact" && !c.tombstone && mine && !current.has(c.id)) { tombstone(ledgerDir, c.id, { author: gitAuthor(), reason: "removed-from-store", t }); removed++; } diff --git a/src/ledger_read.js b/src/ledger_read.js new file mode 100644 index 0000000..527afae --- /dev/null +++ b/src/ledger_read.js @@ -0,0 +1,152 @@ +// forge ledger read — the P2 read-path flip (docs/plans/substrate-v2/01-pcm-protocol.md §7). +// Since P1 the PCM ledger has been the convergent WRITE store while the legacy stores +// (lessons/*.md, recall/brain facts) served every read. This module turns reads into a +// MERGED VIEW (legacy ∪ ledger) so knowledge that arrives via `forge ledger merge` +// actually reaches injection and retrieval. The legacy file is still the canonical +// LOCAL state — the ledger only ADDS what teammates know — so the merge dedupes by the +// legacy id with the legacy record winning. Retiring the legacy formats entirely is the +// next step (ROADMAP.md). +// +// Everything here is READ-ONLY and best-effort by design: hooks call these on every +// session start / pre-edit, so a missing or corrupt ledger degrades to legacy-only — +// never an error, never a write. +import { DEFAULT_HALF_LIFE_DAYS, val, validOutcome } from "./ledger.js"; +import { loadClaims, repoLedger } from "./ledger_store.js"; +import { load } from "./lessons_store.js"; +import { slug } from "./util.js"; + +/** + * Map a ledger `lesson` claim onto the legacy lesson shape (lessons.js), so every + * existing consumer — matchScore, selectForInjection, confidenceOf, summary — can rank + * a teammate's claim without knowing the ledger exists. Inverse-ish of + * ledger_bridge.lessonClaim: body carries trigger/whatWentWrong/correctedBehavior, and + * the legacy lesson id rides in provenance.task (it is excluded from the content + * address so teammates converge on one claim id). + * + * The legacy lifecycle (candidate/active/quarantined/retired) is local mutable state + * the ledger deliberately does not store, so status is DERIVED from evidence: + * + * | claim state | derived status | rationale | + * |--------------------------------------|----------------|-----------| + * | tombstoned | "retired" | a retraction is the ledger's retirement | + * | 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 | + * | val < 0.45 and ≥ 1 contradiction | "quarantined" | net-negative outcome evidence — mirrors contradict()'s demotion | + * | otherwise | "candidate" | a fresh claim sits at the 0.5 prior, exactly newLesson() | + * + * Count/date fields are rebuilt from the evidence log: evidenceCount = valid confirm + * outcomes, contradictionCount = valid contradict outcomes, lastConfirmedDay = latest + * confirm t (else the mint day), createdDay = the mint day (provenance.t). + * @param {any} claim a materialized lesson claim (ledger_store.loadClaims view) + * @param {number} [nowDay] epoch day used for the val() decay clock + * @returns {object} a legacy-shaped lesson + */ +export function claimToLesson(claim, nowDay = 0) { + const body = claim.body ?? {}; + const evidence = (claim.evidence ?? []).filter(validOutcome); + const confirms = evidence.filter((e) => e.result === "confirm"); + const contradictions = evidence.length - confirms.length; + const createdDay = claim.provenance?.t ?? 0; + const v = val(claim, nowDay); + const status = claim.tombstone + ? "retired" + : v >= 0.6 + ? "active" + : v < 0.45 && contradictions >= 1 + ? "quarantined" + : "candidate"; + return { + id: String(claim.provenance?.task || "") || `lsn_${claim.id.slice(0, 8)}`, + trigger: { + files: body.trigger?.files ?? [], + symbols: body.trigger?.symbols ?? [], + keywords: body.trigger?.keywords ?? [], + action: body.trigger?.action || undefined, + }, + scope: claim.scope?.level ?? "repo", + whatWentWrong: body.whatWentWrong ?? "", + correctedBehavior: body.correctedBehavior ?? "", + evidenceCount: confirms.length, + contradictionCount: contradictions, + quarantineReconfirms: 0, + status, + createdDay, + lastConfirmedDay: confirms.length ? Math.max(...confirms.map((e) => e.t ?? 0)) : createdDay, + halfLifeDays: DEFAULT_HALF_LIFE_DAYS, + // The claim id is the audit pointer back to the ledger (`forge ledger blame `). + provenance: { episodes: [], signals: [], claim: claim.id }, + }; +} + +/** Every lesson claim of the repo ledger, mapped to legacy shape. [] when there is no + * ledger or it is unreadable (best-effort — hooks call this on every event). */ +export function ledgerLessons(root, nowDay = 0) { + try { + return loadClaims(repoLedger(root)) + .filter((c) => c.kind === "lesson") + .map((c) => claimToLesson(c, nowDay)); + } catch { + return []; + } +} + +// Within the ledger, two claims can map to one legacy id (a distillation supersedes its +// template claim; both carry the same provenance.task). Prefer the live one, then the +// most recently confirmed; ties keep the first in claim-id order (loadClaims is sorted), +// so the pick is deterministic across replicas. +const preferred = (a, b) => { + if ((a.status === "retired") !== (b.status === "retired")) return a.status === "retired" ? b : a; + return b.lastConfirmedDay > a.lastConfirmedDay ? b : a; +}; + +/** + * The merged lesson read: legacy `load(root)` ∪ ledger lessons, deduped by the LEGACY + * id with the legacy file winning — the legacy store is still the canonical local + * state (recordMistake/confirm/contradict edit it), so a local lesson's own shadow + * claim (same provenance.task) is invisible here, while a teammate's claim (different + * task, no local file) surfaces. Order is deterministic: legacy lessons in file order, + * then ledger-only lessons sorted by lesson id. + * @param {string} root + * @param {number} [nowDay] + * @returns {object[]} + */ +export function mergedLessons(root, nowDay = 0) { + const legacy = load(root); + const local = new Set(legacy.map((l) => l.id)); + const byId = new Map(); + for (const l of ledgerLessons(root, nowDay)) { + if (local.has(l.id)) continue; // legacy wins — the local file is canonical + const prev = byId.get(l.id); + byId.set(l.id, prev ? preferred(prev, l) : l); + } + return [...legacy, ...[...byId.values()].sort((a, b) => (a.id < b.id ? -1 : 1))]; +} + +/** Live (non-tombstoned) `fact` claims of one ledger as {slug, name, text} — the fact + * counterpart of ledgerLessons. Best-effort: [] when the ledger is missing/corrupt. */ +export function ledgerFacts(dir) { + try { + return loadClaims(dir) + .filter((c) => c.kind === "fact" && !c.tombstone) + .map((c) => ({ + slug: slug(c.body?.name ?? "") || "fact", + name: String(c.body?.name ?? ""), + text: String(c.body?.text ?? ""), + })); + } catch { + return []; + } +} + +/** + * Merged fact slugs for a file store + its ledger: the file store's slugs win on + * collision (a stored file is the canonical local value; shadowFact tombstones stale + * same-name claims, so a surviving collision means the file is newer). Sorted, unique. + * @param {string[]} fileSlugs slugs of the file-backed facts (already the store's truth) + * @param {string} dir the ledger directory that shadows this store + * @returns {string[]} + */ +export function mergeFactSlugs(fileSlugs, dir) { + const seen = new Set(fileSlugs); + for (const f of ledgerFacts(dir)) seen.add(f.slug); + return [...seen].sort(); +} diff --git a/src/recall.js b/src/recall.js index 456f06c..816a11c 100644 --- a/src/recall.js +++ b/src/recall.js @@ -10,6 +10,10 @@ import { join } from "node:path"; // imported it from (lessons_store, guards, tests). See ledger.js for the precision // rationale (credential formats + key-assigned-to-value, never a bare English mention). import { SECRET_RE } from "./ledger.js"; +// The merged read helper (P2 read flip). Import cycle note: ledger_read → lessons_store +// → recall is function-level only (no module-eval use on either side), so ESM resolves +// it safely — same pattern as lessons_store's own SECRET_RE import from here. +import { mergeFactSlugs } from "./ledger_read.js"; export { SECRET_RE }; @@ -47,7 +51,10 @@ export function readFact(store, slug) { return m ? { name: m[1].trim(), text: m[2].trim() } : { name: slug, text: raw.trim() }; } -export function list(store) { +/** File-backed fact slugs ONLY — the store the write path (add/consolidate) manages. + * The ledger bridge reconciles against THIS list: a merged teammate fact has no file, + * and treating it as "deleted from the store" would tombstone it away. */ +export function listStored(store) { const dir = factsDir(store); if (!existsSync(dir)) return []; return readdirSync(dir) @@ -56,6 +63,13 @@ export function list(store) { .sort(); } +/** The read view (P2 read flip): file facts ∪ live facts from the personal ledger this + * store shadows into (`/ledger` — see `forge recall add`). A file wins on slug + * collision (the file is the canonical local value). */ +export function list(store) { + return mergeFactSlugs(listStored(store), join(store, "ledger")); +} + export function reindex(store) { const items = list(store); mkdirSync(store, { recursive: true }); diff --git a/src/route.js b/src/route.js index 047604f..5e2e62e 100644 --- a/src/route.js +++ b/src/route.js @@ -8,10 +8,10 @@ import { adjudicate, asText, buildRunner, llmEnabled } from "./adjudicate.js"; import { matchingLessons } from "./cortex.js"; import { gitChurn, grepFanout } from "./cortex_features.js"; import { recordRoute } from "./cost_report.js"; -import { load as loadLessons } from "./lessons_store.js"; +import { mergedLessons } from "./ledger_read.js"; import { MODELS } from "./model_tiers.js"; import { preflightRepo, referencedEntities } from "./preflight.js"; -import { clamp01, contentHash } from "./util.js"; +import { clamp01, contentHash, epochDay } from "./util.js"; // Weights sum to 1. Each raw signal is normalized by the point where it reads as "complex". @@ -181,7 +181,8 @@ export function routeTask( const { symbols, files } = referencedEntities(task); const fanout = symbols.reduce((m, sym) => Math.max(m, grepFanout(root, sym)), 0); const churn = files.reduce((m, f) => Math.max(m, gitChurn(root, f)), 0); - const pastMistakes = matchingLessons(loadLessons(root), { + // Merged view (P2 read flip): teammate lessons raise past-mistake density here too. + const pastMistakes = matchingLessons(mergedLessons(root, epochDay()), { files, symbols, }).length; diff --git a/src/substrate.js b/src/substrate.js index b988132..2eeb6d5 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -12,7 +12,7 @@ import { assemble as assembleContext } from "./context.js"; import { matchingLessons } from "./cortex.js"; import { recordGate } from "./cost_report.js"; import { leanRepo } from "./lean.js"; -import { load as loadLessons } from "./lessons_store.js"; +import { mergedLessons } from "./ledger_read.js"; import { clarifyBlock, preflightRepo, referencedEntities } from "./preflight.js"; import { reusePeek, reuseQuery } from "./reuse.js"; import { meterRoute, routeTask } from "./route.js"; @@ -264,7 +264,8 @@ export function substrateCheck( const scope = scopedFiles.length ? decompose(root, scopedFiles) : { clusters: [], independentGroups: 0 }; - const lessons = matchingLessons(loadLessons(root), { + // Merged view (P2 read flip): a teammate's merged lesson counts in the advisory too. + const lessons = matchingLessons(mergedLessons(root, epochDay()), { files: scopedFiles, symbols: entities.symbols, }); diff --git a/test/ledger_bridge.test.js b/test/ledger_bridge.test.js index dfe2251..b188ec1 100644 --- a/test/ledger_bridge.test.js +++ b/test/ledger_bridge.test.js @@ -183,6 +183,34 @@ test("reconcileFacts: a fact deleted from the store is tombstoned in the ledger" assert.equal(claims.find((c) => c.body.name === "keep").tombstone, undefined); }); +test("reconcileFacts spares a merged teammate fact (only locally-authored claims reconcile)", () => { + const store = tmp(); + const dir = join(store, "ledger"); + const prev = process.env.FORGE_AUTHOR; + try { + process.env.FORGE_AUTHOR = "Me "; + recallAdd(store, "keep", "this fact stays"); + shadowFact(dir, "keep", "this fact stays", 1); + shadowFact(dir, "gone", "a consolidated-away duplicate", 1); // mine, file deleted + process.env.FORGE_AUTHOR = "Teammate "; + shadowFact(dir, "team wisdom", "arrived via forge ledger merge", 1); // theirs, never had a file + process.env.FORGE_AUTHOR = "Me "; + const r = reconcileFacts(store, dir, 2); + assert.equal(r.ok, true); + assert.equal(r.removed, 1, "only MY fileless claim is removed-from-store"); + const claims = loadClaims(dir); + assert.equal(claims.find((c) => c.body.name === "gone").tombstone.reason, "removed-from-store"); + assert.equal( + claims.find((c) => c.body.name === "team wisdom").tombstone, + undefined, + "a teammate's merged fact must survive my consolidate (it IS the read path now)", + ); + } finally { + if (prev === undefined) delete process.env.FORGE_AUTHOR; + else process.env.FORGE_AUTHOR = prev; + } +}); + test("importLegacy: back-fills pre-ledger history; skips claims already tracked live", () => { const root = tmp(); // A legacy lesson with history that predates the ledger: 3 confirmations, 1 contradiction. diff --git a/test/ledger_read.test.js b/test/ledger_read.test.js new file mode 100644 index 0000000..93ce4ca --- /dev/null +++ b/test/ledger_read.test.js @@ -0,0 +1,277 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { brainStore, buildIndex, remember } from "../src/brain.js"; +import { lessonsForContext, recordMistake, startupBlock, summary } from "../src/cortex.js"; +import { mintClaim, outcomeRecord } from "../src/ledger.js"; +import { recordLessonEvent, shadowFact } from "../src/ledger_bridge.js"; +import { + claimToLesson, + ledgerFacts, + ledgerLessons, + mergedLessons, + mergeFactSlugs, +} from "../src/ledger_read.js"; +import { mergeDirs, putClaim, repoLedger, tombstone } from "../src/ledger_store.js"; +import { newLesson } from "../src/lessons.js"; +import { load, save } from "../src/lessons_store.js"; +import { add as recallAdd, list as recallList, reindex as recallReindex } from "../src/recall.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "forge-readflip-")); + +/** A lesson claim the way ledger_bridge.lessonClaim mints one (body = content only, + * legacy id in provenance.task). */ +const mkClaim = (over = {}) => { + const minted = mintClaim({ + kind: "lesson", + body: { + correctedBehavior: over.correctedBehavior ?? "Check parseCfg callers before editing.", + trigger: { + action: "edit", + files: over.files ?? ["src/cfg.js"], + keywords: [], + symbols: over.symbols ?? ["parseCfg"], + }, + whatWentWrong: over.whatWentWrong ?? "parseCfg was edited without checking callers.", + }, + scope: { level: over.level ?? "symbol" }, + provenance: { + agent: "cortex", + author: over.author ?? "Teammate ", + task: over.task ?? "lsn_parsecfg", + }, + t: over.t ?? 0, + }); + assert.equal(minted.ok, true); + return minted.claim; +}; + +const ev = (result, ref, t, oracle = "test.run") => { + const o = outcomeRecord({ oracle, result, ref, t }); + assert.equal(o.ok, true); + return o.outcome; +}; + +// --- claimToLesson: the mapping table, one test per status branch -------------------- + +test("claimToLesson: fresh claim → candidate at the 0.5 prior, fields from body/provenance", () => { + const claim = mkClaim({ t: 7 }); + const l = claimToLesson(claim, 7); + assert.equal(l.id, "lsn_parsecfg", "legacy id rides in provenance.task"); + assert.equal(l.status, "candidate"); + assert.equal(l.scope, "symbol"); + assert.deepEqual(l.trigger.symbols, ["parseCfg"]); + assert.equal(l.whatWentWrong, "parseCfg was edited without checking callers."); + assert.equal(l.correctedBehavior, "Check parseCfg callers before editing."); + assert.deepEqual( + { + e: l.evidenceCount, + c: l.contradictionCount, + created: l.createdDay, + last: l.lastConfirmedDay, + }, + { e: 0, c: 0, created: 7, last: 7 }, + ); + assert.equal(l.provenance.claim, claim.id, "audit pointer back to the ledger"); +}); + +test("claimToLesson: a fresh confirm crosses val ≥ 0.6 → active, lastConfirmedDay = confirm t", () => { + const claim = mkClaim({ t: 1 }); + claim.evidence = [ev("confirm", "run:1", 5)]; // test.run w=0.8 → val 1.8/2.8 ≈ 0.64 + const l = claimToLesson(claim, 5); + assert.equal(l.status, "active"); + assert.equal(l.evidenceCount, 1); + assert.equal(l.lastConfirmedDay, 5); + assert.equal(l.createdDay, 1); +}); + +test("claimToLesson: net-negative evidence (val < 0.45, ≥1 contradiction) → quarantined", () => { + const claim = mkClaim(); + claim.evidence = [ev("contradict", "revert:abc", 2, "human.revert")]; // val 1/3 ≈ 0.33 + const l = claimToLesson(claim, 2); + assert.equal(l.status, "quarantined"); + assert.equal(l.contradictionCount, 1); +}); + +test("claimToLesson: an old decayed confirm falls back to candidate, NOT quarantined", () => { + const claim = mkClaim({ t: 0 }); + claim.evidence = [ev("confirm", "run:0", 0)]; + const l = claimToLesson(claim, 400); // decay pulls val back to ~0.5 (the prior) + assert.equal(l.status, "candidate", "no contradiction → uncertainty, never quarantine"); +}); + +test("claimToLesson: tombstoned → retired, regardless of evidence", () => { + const claim = mkClaim(); + claim.evidence = [ev("confirm", "run:1", 1)]; + claim.tombstone = { author: "x", reason: "superseded", t: 2 }; + assert.equal(claimToLesson(claim, 1).status, "retired"); +}); + +test("claimToLesson: no provenance.task → deterministic lsn_ fallback; junk evidence ignored", () => { + const claim = mkClaim({ task: "" }); + claim.evidence = [ + { oracle: "made.up", result: "confirm", ref: "x", h: "deadbeef", t: 1 }, // unknown oracle + ev("confirm", "run:1", 1), + ]; + const l = claimToLesson(claim, 1); + assert.equal(l.id, `lsn_${claim.id.slice(0, 8)}`); + assert.equal(l.evidenceCount, 1, "only validOutcome records count"); +}); + +// --- merge / dedupe semantics -------------------------------------------------------- + +test("mergedLessons: dedupes by legacy id with the legacy FILE winning", () => { + const root = tmp(); + const legacy = newLesson( + { + id: "lsn_parsecfg", + trigger: { symbols: ["parseCfg"], files: [], keywords: [] }, + scope: "symbol", + whatWentWrong: "local wording", + correctedBehavior: "local fix wording", + }, + 1, + ); + assert.equal(save(root, legacy).ok, true); + putClaim(repoLedger(root), mkClaim({ task: "lsn_parsecfg" })); // same legacy id, other wording + putClaim(repoLedger(root), mkClaim({ task: "lsn_other", symbols: ["otherFn"] })); + const merged = mergedLessons(root, 1); + assert.deepEqual( + merged.map((l) => l.id), + ["lsn_parsecfg", "lsn_other"], + "legacy first, then ledger-only sorted by id", + ); + assert.equal(merged[0].correctedBehavior, "local fix wording", "the local file is canonical"); + assert.equal(merged[1].provenance.claim.length, 64, "the extra one came from the ledger"); +}); + +test("mergedLessons: within the ledger, the live claim beats its superseded (tombstoned) twin", () => { + const root = tmp(); + const dir = repoLedger(root); + const template = mkClaim({ correctedBehavior: "template wording" }); + const distilled = mkClaim({ correctedBehavior: "distilled wording" }); + putClaim(dir, template); + putClaim(dir, distilled); + tombstone(dir, template.id, { author: "t", reason: `superseded-by:${distilled.id}`, t: 2 }); + const merged = mergedLessons(root, 2); + assert.equal(merged.length, 1, "one legacy id → one lesson"); + assert.equal(merged[0].correctedBehavior, "distilled wording"); +}); + +test("mergedLessons: a corrupt ledger degrades to legacy-only (best-effort, hooks call this)", () => { + const root = tmp(); + save( + root, + newLesson( + { id: "lsn_x", trigger: { symbols: ["x"] }, whatWentWrong: "w", correctedBehavior: "c" }, + 1, + ), + ); + mkdirSync(repoLedger(root), { recursive: true }); + writeFileSync(join(repoLedger(root), "claims"), "not a directory"); // readdir throws ENOTDIR + assert.deepEqual(ledgerLessons(root, 1), []); + const merged = mergedLessons(root, 1); + assert.equal(merged.length, 1); + assert.equal(merged[0].id, "lsn_x"); +}); + +test("write-loop: a local lesson's own shadow claim is invisible in the merged view", () => { + const root = tmp(); + const ctx = { symbols: ["computeTax"], files: ["src/tax.ts"], keywords: [] }; + const strong = [{ signal: "S1" }, { signal: "S6" }]; + recordMistake(root, { signals: strong, context: ctx, nowDay: 1, episodeId: "e1" }); + recordMistake(root, { signals: strong, context: ctx, nowDay: 2, episodeId: "e2" }); // → active + shadow claim + const merged = mergedLessons(root, 2); + assert.equal(merged.length, 1, "shadow claim (provenance.task = legacy id) deduped away"); + assert.deepEqual(merged[0].provenance.episodes, ["e1", "e2"], "it IS the legacy file"); + assert.equal(summary(root, 2).total, 1); +}); + +// --- the read flip end-to-end: a teammate's knowledge reaches injection -------------- + +test("a teammate lesson claim injects after `forge ledger merge` (no local file needed)", () => { + const local = tmp(); + const mate = tmp(); + // On the teammate's machine: their Stop hook created and later confirmed the lesson — + // exactly what recordMistake's shadow writes produce. + const lesson = newLesson( + { + id: "lsn_parsecfg", + trigger: { symbols: ["parseCfg"], files: ["src/cfg.js"], keywords: [] }, + scope: "symbol", + whatWentWrong: "parseCfg was edited without checking callers.", + correctedBehavior: "Check parseCfg callers and tests before editing.", + }, + 3, + ); + assert.equal(recordLessonEvent(mate, lesson, { t: 3 }).ok, true); + assert.equal( + recordLessonEvent(mate, lesson, { result: "confirm", ref: "episode:e1#n1", t: 5 }).ok, + true, + ); + + // Before the merge the local repo knows nothing. + assert.equal(startupBlock(local, 5), ""); + assert.equal( + lessonsForContext(local, { symbols: ["parseCfg"] }, { nowDay: 5 }).selected.length, + 0, + ); + + // `forge ledger merge ` — the CRDT join. + const r = mergeDirs(repoLedger(local), repoLedger(mate)); + assert.equal(r.claims, 1); + + // The teammate's lesson now surfaces on every read path, with NO legacy file created. + const hit = lessonsForContext(local, { symbols: ["parseCfg"] }, { nowDay: 5 }); + assert.equal(hit.selected.length, 1); + assert.equal(hit.selected[0].id, "lsn_parsecfg"); + assert.match(hit.block, /Check parseCfg callers and tests before editing\./); + assert.match(startupBlock(local, 5), /lsn_parsecfg/); + assert.equal(summary(local, 5).active, 1); + assert.equal(load(local).length, 0, "the read flip does NOT materialize legacy files"); +}); + +// --- facts: recall + brain merged reads ---------------------------------------------- + +test("ledgerFacts: live fact claims only (tombstoned/superseded values are skipped)", () => { + const dir = join(tmp(), "ledger"); + shadowFact(dir, "api-base", "https://old.example", 1); + shadowFact(dir, "api-base", "https://new.example", 2); // supersedes → tombstones the old + const facts = ledgerFacts(dir); + assert.equal(facts.length, 1); + assert.deepEqual(facts[0], { slug: "api-base", name: "api-base", text: "https://new.example" }); + assert.deepEqual(ledgerFacts(join(dir, "nope")), [], "missing ledger → []"); +}); + +test("recall.list merges ledger facts; a file wins on slug collision; MEMORY.md keeps its format", () => { + const store = tmp(); + recallAdd(store, "db port", "5433 here, not 5432"); + shadowFact(join(store, "ledger"), "Team Endpoint", "staging lives at stg.example.com", 1); + shadowFact(join(store, "ledger"), "db port", "ledger value must NOT shadow the file", 1); + assert.deepEqual(recallList(store), ["db-port", "team-endpoint"], "merged, deduped, sorted"); + recallReindex(store); + const memory = readFileSync(join(store, "MEMORY.md"), "utf8"); + assert.match(memory, /^# Durable memory index\n\n- db-port\n- team-endpoint\n$/); +}); + +test("mergeFactSlugs is deterministic and file-first", () => { + const dir = join(tmp(), "ledger"); + shadowFact(dir, "beta", "b", 1); + shadowFact(dir, "alpha", "a", 1); + assert.deepEqual(mergeFactSlugs(["zeta", "alpha"], dir), ["alpha", "beta", "zeta"]); +}); + +test("brain.buildIndex inlines repo-ledger facts (merged team memory reaches AGENTS.md)", () => { + const root = tmp(); + const store = brainStore(root); + remember(store, "deploy order", "run migrations before the app roll"); + // A teammate's fact arriving via `forge ledger merge` into the REPO ledger: + shadowFact(repoLedger(root), "flaky suite", "retry integration tests once before failing", 1); + const idx = buildIndex(store); + assert.equal(idx.indexed, 2); + const block = readFileSync(join(store, "AGENTS.brain.md"), "utf8"); + assert.match(block, /- \*\*deploy-order\*\* — run migrations before the app roll/); + assert.match(block, /- \*\*flaky-suite\*\* — retry integration tests once before failing/); +}); From 20a728887fb94d0a858770bc62a5566d764285f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:44:56 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20playwright=20visual=20loop=20?= =?UTF-8?q?=E2=80=94=20forge=20uicheck=20visual=20renders=20computed=20sty?= =?UTF-8?q?les=20through=20the=20design=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static fingerprint (src/uifingerprint.js) parses source CSS/classes and cannot see rendered reality. New src/uivisual.js closes the loop (spec 07-ui-quality-gate.md §5) while keeping package.json dependency-free (ADR-0005 optional tier): - resolvePlaywright(): dynamic import of playwright-core/playwright, or an explicit FORGE_PLAYWRIGHT module path; null on absence, never a throw. - renderedFingerprint(): headless chromium at 1280x800 + 390x844, walks visible elements and lifts computed color/background-color, margin/ padding/gap, font-family, border-radius, box-shadow — margins via the Typed OM so used auto-margins (centering residue) don't pollute the spacing scale, zero-client-rect elements (e.g.