diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3c482..f7708fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **Legacy-store retirement (`FORGE_LEDGER_ONLY`)** — the PCM ledger can now be the + *only* store. Since P1 it has been the convergent write store (dual-write) with a + merged read (`ledger_read`); with `FORGE_LEDGER_ONLY=1` the legacy files + (`.forge/lessons/*.md`, recall/brain fact files) stop being written and every read + materializes from the ledger — cortex confirm/create/distill dedup against + `ledgerLessons`, `mergedLessons` returns the ledger view, and `recall.readFact` falls + back to the ledger (also fixing merged teammate facts that had no local file). Run + `forge ledger import` first to backfill. Default off keeps the legacy files canonical. ## [0.13.0] - 2026-07-15 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 9014b6c..c6bfbf2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,12 +63,6 @@ confidence only from independent oracles, and merges across teammates conflict-f - **OpenAI + Gemini provider detection** — extend `autoDetectProvider()` beyond Anthropic/OpenRouter/LiteLLM (`OPENAI_API_KEY`, `GEMINI_API_KEY`) with the same zero-config contract. -- **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. - **Playwright loop** — still open: interaction checks and feeding verdicts back as oracle evidence on design claims (fingerprinting itself shipped as `forge uicheck visual`). diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 3621801..a2a3147 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -498,6 +498,15 @@ moves confidence; `verify` recomputes every content hash (CI-friendly, exit 1 on tampering); `import` back-fills legacy lessons/facts idempotently. Add `--personal` to target the per-user ledger beside the global recall store, `--json` for scripts. +**Retiring the legacy stores.** Since P1 the ledger has been the convergent WRITE store +(every lesson/fact dual-writes into it) and reads are the merged view (legacy ∪ ledger). +Set **`FORGE_LEDGER_ONLY=1`** to finish the job: the legacy files (`.forge/lessons/*.md`, +recall/brain fact files) stop being written and every read — cortex injection, routing, +`recall`/`brain` — comes from the ledger alone. Run `forge ledger import` first (idempotent) +so nothing local is stranded; the ledger is content-addressed and merges conflict-free, so +it is a complete standalone store. Default off keeps the legacy files as the canonical +local copy. + ### `forge reuse` — proof-carrying code cache Verified code becomes an `artifact` claim keyed by a normalized task fingerprint; a @@ -995,6 +1004,7 @@ code reads but this table misses fails CI on the forge repo): | `FORGE_LLM_HTTP` | `1` forces direct HTTP (Anthropic Messages API) instead of the `claude` CLI; automatic when the CLI is absent | | `FORGE_ENFORCE` | `1` turns the substrate advisory into a hard block on the strongest signals | | `FORGE_AUTOSYNC` | `0` disables the Stop-hook AGENTS.md auto-repair | +| `FORGE_LEDGER_ONLY` | `1` retires the legacy stores — stop writing `lessons/*.md` + recall/brain fact files; the ledger is the only store (reads materialize from it) | | `FORGE_EMBED` / `FORGE_EMBED_MODEL` / `FORGE_EMBED_TIMEOUT_MS` | optional embeddings tier (ADR-0005) | | `FORGE_HOME` | override `~/.forge` (recall store location) | | `FORGE_ROOT` | repo root override for the MCP server | diff --git a/src/cli.js b/src/cli.js index 4593bfa..53c85df 100755 --- a/src/cli.js +++ b/src/cli.js @@ -257,6 +257,9 @@ async function run(argv) { const { shadowFact } = await import("./ledger_bridge.js"); shadowFact(join(store, "ledger"), name, body); } catch {} + // Re-index after the shadow so a ledger-only store's MEMORY.md includes the fact + // (its only copy now lives in the ledger, written just above). + r.reindex(store); } console.log(res.ok ? ` saved: ${res.slug}` : ` ${res.reason}`); if (!res.ok) process.exitCode = 1; @@ -739,6 +742,9 @@ async function run(argv) { const { repoLedger } = await import("./ledger_store.js"); shadowFact(repoLedger(process.cwd()), name, body); } catch {} + // Rebuild the inlined index after the shadow so a ledger-only brain's + // AGENTS.brain.md includes the fact (its only copy now lives in the ledger). + b.buildIndex(b.brainStore(process.cwd())); } console.log( res.ok diff --git a/src/cortex.js b/src/cortex.js index 904d1a6..e2d2401 100644 --- a/src/cortex.js +++ b/src/cortex.js @@ -5,7 +5,7 @@ // without any hook wiring. import { recordLessonEvent, supersedeLessonClaim } from "./ledger_bridge.js"; -import { mergedLessons } from "./ledger_read.js"; +import { ledgerLessons, mergedLessons } from "./ledger_read.js"; import { confidenceOf, confirm, @@ -16,7 +16,14 @@ import { selectForInjection, } from "./lessons.js"; import { appendEpisode, load, readEpisodes, save } from "./lessons_store.js"; -import { slug } from "./util.js"; +import { ledgerOnly, slug } from "./util.js"; + +// The lesson set the confirm-vs-create dedup compares against. Normally the legacy +// files (canonical local state); under FORGE_LEDGER_ONLY there are none, so the ledger's +// own materialized lessons (same legacy ids via provenance.task) are the dedup source — +// a recurring local mistake then appends confirm evidence to its existing claim instead +// of minting a duplicate. +const localLessons = (root, nowDay) => (ledgerOnly() ? ledgerLessons(root, nowDay) : load(root)); const lessonIdFor = (ctx) => `lsn_${slug(ctx.symbols?.[0] || ctx.files?.[0] || "ctx") || "ctx"}`; @@ -82,7 +89,7 @@ export function recordMistake(root, { signals, context, nowDay, episodeId, disti // 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]; + const existing = matchingLessons(localLessons(root, nowDay), context)[0]; if (existing) { const c = confirm(existing, nowDay); const updated = { @@ -155,7 +162,7 @@ export function recordContradiction(root, { context, nowDay, episodeId }) { // 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 targets = matchingLessons(localLessons(root, nowDay), context, ["active", "quarantined"]); const results = targets.map((l) => { const updated = contradict(l, nowDay); const saved = save(root, updated).ok; @@ -208,8 +215,10 @@ 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); + // Normally EDITS the legacy file, so read the file-backed lessons; under + // FORGE_LEDGER_ONLY there are none, so resolve the target from the ledger and let the + // supersede below rewrite it there (save() is a ledger-only no-op that still succeeds). + const lesson = localLessons(root, 0).find((l) => l.id === lessonId); if (!lesson) return false; const updated = { ...lesson, diff --git a/src/ledger_read.js b/src/ledger_read.js index 527afae..e560e18 100644 --- a/src/ledger_read.js +++ b/src/ledger_read.js @@ -13,7 +13,7 @@ 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"; +import { ledgerOnly, slug } from "./util.js"; /** * Map a ledger `lesson` claim onto the legacy lesson shape (lessons.js), so every @@ -110,7 +110,8 @@ const preferred = (a, b) => { * @returns {object[]} */ export function mergedLessons(root, nowDay = 0) { - const legacy = load(root); + // Legacy-store retirement: with no legacy files, the merged view is the ledger alone. + const legacy = ledgerOnly() ? [] : load(root); const local = new Set(legacy.map((l) => l.id)); const byId = new Map(); for (const l of ledgerLessons(root, nowDay)) { diff --git a/src/lessons_store.js b/src/lessons_store.js index cb9b727..43e10d2 100644 --- a/src/lessons_store.js +++ b/src/lessons_store.js @@ -12,6 +12,7 @@ import { } from "node:fs"; import { join } from "node:path"; import { hasSecret } from "./recall.js"; +import { ledgerOnly } from "./util.js"; export const lessonsDir = (root = process.cwd()) => join(root, ".forge", "lessons"); @@ -90,6 +91,10 @@ export function save(root, lesson) { reason: "refused: lesson looks like it contains a secret/credential", }; } + // Legacy-store retirement: the ledger already holds this lesson (recordLessonEvent + // shadows every save), so under FORGE_LEDGER_ONLY skip the .md file — the ledger is + // the store. The secret check above still runs so nothing unsafe is minted either way. + if (ledgerOnly()) return { ok: true, ledgerOnly: true }; const dir = lessonsDir(root); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, `${lesson.id}.md`), text); diff --git a/src/recall.js b/src/recall.js index d21a2ee..654518c 100644 --- a/src/recall.js +++ b/src/recall.js @@ -7,7 +7,7 @@ import { join } from "node:path"; // 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"; +import { ledgerFacts, mergeFactSlugs } from "./ledger_read.js"; // Secret-refusal now lives in secrets.js (format grammars + entropy gate) so no // store — and no shell guard — can disagree; re-exported here because recall is where // callers historically imported it from (lessons_store, guards, tests). See secrets.js @@ -22,7 +22,7 @@ export function defaultStore() { const factsDir = (store) => join(store, "facts"); -import { slug as slugify } from "./util.js"; +import { ledgerOnly, slug as slugify } from "./util.js"; export function add(store, name, body) { if (hasSecret(`${name}\n${body}`)) { @@ -31,10 +31,15 @@ export function add(store, name, body) { reason: "refused: looks like a secret/credential — store a pointer, not the value", }; } - const dir = factsDir(store); - mkdirSync(dir, { recursive: true }); const slug = slugify(name) || "fact"; - writeFileSync(join(dir, `${slug}.md`), `# ${name}\n\n${body.trim()}\n`); + // Legacy-store retirement: under FORGE_LEDGER_ONLY skip the fact file — the caller + // shadows the fact into the ledger (`forge recall add`/`remember`), and readFact/list + // resolve it from there. reindex still rebuilds MEMORY.md (a projection, not a store). + if (!ledgerOnly()) { + const dir = factsDir(store); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${slug}.md`), `# ${name}\n\n${body.trim()}\n`); + } reindex(store); return { ok: true, slug }; } @@ -44,10 +49,15 @@ export function add(store, name, body) { * Everything that reads fact files (bridge import, consolidation) must use this. */ export function readFact(store, slug) { const path = join(factsDir(store), `${slug}.md`); - if (!existsSync(path)) return null; - const raw = readFileSync(path, "utf8").replace(/\r\n/g, "\n"); - const m = raw.match(/^# (.*)\n\n([\s\S]*)$/); - return m ? { name: m[1].trim(), text: m[2].trim() } : { name: slug, text: raw.trim() }; + if (existsSync(path)) { + const raw = readFileSync(path, "utf8").replace(/\r\n/g, "\n"); + const m = raw.match(/^# (.*)\n\n([\s\S]*)$/); + return m ? { name: m[1].trim(), text: m[2].trim() } : { name: slug, text: raw.trim() }; + } + // No file — resolve from the ledger this store shadows into. Covers a merged teammate + // fact and, under FORGE_LEDGER_ONLY, the fact's only home. null when neither has it. + const f = ledgerFacts(join(store, "ledger")).find((x) => x.slug === slug); + return f ? { name: f.name, text: f.text } : null; } /** File-backed fact slugs ONLY — the store the write path (add/consolidate) manages. diff --git a/src/util.js b/src/util.js index c7f9988..a06aaae 100644 --- a/src/util.js +++ b/src/util.js @@ -16,6 +16,14 @@ export const clamp01 = (x) => Math.max(0, Math.min(1, x)); export const MS_PER_DAY = 86400000; export const epochDay = () => Math.floor(Date.now() / MS_PER_DAY); +// Legacy-store retirement (ROADMAP): the PCM ledger is already the convergent WRITE +// store (dual-write via ledger_bridge) and serves a merged read (ledger_read). With +// FORGE_LEDGER_ONLY=1 the legacy files (lessons/*.md, recall/brain fact files) stop +// being written and reads come from the ledger alone — the ledger becomes the only +// store. Default off: the legacy files remain the canonical local copy. +export const ledgerOnly = () => + process.env.FORGE_LEDGER_ONLY === "1" || process.env.FORGE_LEDGER_ONLY === "true"; + export function hasBin(bin) { try { execFileSync(bin, ["--version"], { stdio: "ignore" }); diff --git a/test/ledger_only.test.js b/test/ledger_only.test.js new file mode 100644 index 0000000..a597f3d --- /dev/null +++ b/test/ledger_only.test.js @@ -0,0 +1,92 @@ +// Legacy-store retirement (FORGE_LEDGER_ONLY): with the switch on, the legacy files +// (lessons/*.md, recall/brain fact files) are not written and reads come from the +// ledger alone. Default off keeps the legacy files canonical (covered by every other +// suite). These tests exercise the on path end-to-end at the store layer. +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { recordLessonEvent, shadowFact } from "../src/ledger_bridge.js"; +import { ledgerLessons, mergedLessons } from "../src/ledger_read.js"; +import { newLesson } from "../src/lessons.js"; +import { lessonsDir, save } from "../src/lessons_store.js"; +import { add, list, readFact } from "../src/recall.js"; + +const tmpRoot = () => mkdtempSync(join(tmpdir(), "forge-lonly-")); + +function withEnv(overrides, fn) { + const saved = {}; + for (const k of Object.keys(overrides)) { + saved[k] = process.env[k]; + if (overrides[k] === undefined) delete process.env[k]; + else process.env[k] = overrides[k]; + } + try { + return fn(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +const makeLesson = (id) => + newLesson( + { + id, + trigger: { symbols: ["verifyToken"], files: [], keywords: [], action: "edit" }, + scope: "symbol", + whatWentWrong: "forgot to check expiry", + correctedBehavior: "assert exp before trusting the token", + provenance: { episodes: [], signals: [] }, + }, + 0, + ); + +test("ledger-only: save() writes no .md file and reports ledgerOnly", () => { + withEnv({ FORGE_LEDGER_ONLY: "1" }, () => { + const root = tmpRoot(); + const s = save(root, makeLesson("lsn_a")); + assert.equal(s.ok, true); + assert.equal(s.ledgerOnly, true); + assert.equal(existsSync(lessonsDir(root)), false, "no legacy lessons dir should be created"); + }); +}); + +test("ledger-only: a minted lesson is served from the ledger via ledgerLessons + mergedLessons", () => { + withEnv({ FORGE_LEDGER_ONLY: "1" }, () => { + const root = tmpRoot(); + const lesson = makeLesson("lsn_b"); + save(root, lesson); // no-op file write + recordLessonEvent(root, lesson, { t: 0 }); // the ledger is the store + assert.ok(ledgerLessons(root, 0).some((l) => l.id === "lsn_b")); + assert.ok(mergedLessons(root, 0).some((l) => l.id === "lsn_b")); + }); +}); + +test("default (off): save() DOES write the legacy .md file", () => { + withEnv({ FORGE_LEDGER_ONLY: undefined }, () => { + const root = tmpRoot(); + const s = save(root, makeLesson("lsn_c")); + assert.equal(s.ok, true); + assert.equal(s.ledgerOnly, undefined); + assert.equal(existsSync(lessonsDir(root)), true, "legacy lessons dir is written by default"); + }); +}); + +test("ledger-only: recall.add writes no fact file; readFact + list resolve from the ledger", () => { + withEnv({ FORGE_LEDGER_ONLY: "1" }, () => { + const store = tmpRoot(); + const res = add(store, "API base", "https://api.example.com"); + assert.equal(res.ok, true); + assert.equal(existsSync(join(store, "facts")), false, "no legacy fact files under ledger-only"); + // The caller shadows the fact into the ledger (what `forge recall add` does). + shadowFact(join(store, "ledger"), "API base", "https://api.example.com", 0); + const f = readFact(store, res.slug); + assert.ok(f, "fact resolves from the ledger"); + assert.equal(f.text, "https://api.example.com"); + assert.ok(list(store).includes(res.slug), "merged list includes the ledger fact"); + }); +});