Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
10 changes: 10 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
21 changes: 15 additions & 6 deletions src/cortex.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"}`;

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/ledger_read.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
5 changes: 5 additions & 0 deletions src/lessons_store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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);
Expand Down
28 changes: 19 additions & 9 deletions src/recall.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}`)) {
Expand All @@ -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 };
}
Expand All @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
92 changes: 92 additions & 0 deletions test/ledger_only.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading