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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **The PCM ledger is now the DEFAULT memory store — the legacy-store migration is
finished (M2).** `FORGE_LEDGER_ONLY` defaults on: `.forge/lessons/*.md` and recall/brain
fact files are no longer written, and every read (cortex injection/summary, routing,
`recall`/`brain`, the pre-edit advisory, doctor) materializes from the ledger. The ledger
has been the convergent dual-write store since P1, so existing memory is already there;
`forge ledger import` back-fills any pre-ledger history. `FORGE_LEDGER_ONLY=0` is a
one-release escape hatch that restores the legacy file store. The legacy-store test
suites are pinned to the escape hatch; a new end-to-end test exercises the full
create→confirm→promote learning loop under the default.

### Fixed

- **Closed the ledger-only read/write gaps the default flip exposed (M2).** `brain.remember`
(and the `forge_remember` MCP tool) now shadow the fact into the ledger, so a fact is no
longer lost when no file is written; `cortex_features.featuresForEdit` and the distill
loop's lesson lookup now read the merged ledger view instead of the legacy file store.

## [0.26.2] - 2026-07-20

### Fixed
Expand Down
15 changes: 8 additions & 7 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -746,12 +746,13 @@ the recall store shadows into, so recall facts become portable across machines.

**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.
The ledger is now the **default and only store**: the legacy files (`.forge/lessons/*.md`,
recall/brain fact files) are no longer written, and every read — cortex injection, routing,
`recall`/`brain` — comes from the ledger alone (materialized from its content-addressed,
conflict-free claims). Upgrading from an older version? Run `forge ledger import` once
(idempotent) so any pre-ledger local history lands in the ledger. Set **`FORGE_LEDGER_ONLY=0`**
(the one-release escape hatch) to temporarily restore the legacy file store while external
tooling migrates.

### `forge reuse` — proof-carrying code cache

Expand Down Expand Up @@ -1302,7 +1303,7 @@ code reads but this table misses fails CI on the forge repo):
| `FORGE_LLM_HTTP` | `1` forces direct HTTP (Anthropic Messages or OpenAI-compatible, per the resolved provider) 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_LEDGER_ONLY` | Ledger-only is the DEFAULT (the ledger is the sole store). `0` is the escape hatch — restores the legacy `lessons/*.md` + recall/brain file store while external tooling migrates |
| `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
15 changes: 13 additions & 2 deletions src/brain.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// silently truncated the way Claude's native 200-line MEMORY.md is (#39811).
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { shadowFact } from "./ledger_bridge.js";
import { ledgerFacts, mergeFactSlugs } from "./ledger_read.js";
import { listStored, add as recallAdd } from "./recall.js";

Expand All @@ -15,10 +16,20 @@ export const brainStore = (targetRoot = process.cwd()) => join(targetRoot, ".for
// 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. */
/** Store one fact (secret-refused by recall) and rebuild the inlined index. The fact is
* ALSO shadowed into the repo ledger, so it persists as a claim — required under
* FORGE_LEDGER_ONLY (no file is written, so the ledger is the only home) and harmless
* otherwise (shadowFact is idempotent/content-addressed). Best-effort: a bridge failure
* must never break a remember that already stored the fact. Shadow BEFORE buildIndex so
* the index picks the ledger fact up under ledger-only. */
export function remember(store, name, body) {
const res = recallAdd(store, name, body);
if (res.ok) buildIndex(store);
if (res.ok) {
try {
shadowFact(brainLedger(store), name, body);
} catch {}
buildIndex(store);
}
return res;
}

Expand Down
6 changes: 4 additions & 2 deletions src/cortex_features.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
// zero-dep grep gives a rough fan-out today; adopting a graph MCP (agent-lsp/Serena) drops
// a precise call graph straight in without touching the predictor.
import { execFileSync } from "node:child_process";
import { mergedLessons } from "./ledger_read.js";
import { confidenceOf, matchScore } from "./lessons.js";
import { load } from "./lessons_store.js";

/**
* Pure: edit + gathered signals → the predictor feature vector (all features in [0,1]).
Expand Down Expand Up @@ -68,7 +68,9 @@ export function grepFanout(root, symbol) {

/** Build the feature vector for a real edit from actual repo state (best-effort, degrades). */
export function featuresForEdit(root, edit, { nowDay = 0 } = {}) {
const activeLessons = load(root).filter((l) => l.status === "active");
// Ledger-aware read: the merged view (legacy ∪ ledger) so this works under
// FORGE_LEDGER_ONLY (no legacy files) and also sees merged teammate lessons.
const activeLessons = mergedLessons(root, nowDay).filter((l) => l.status === "active");
const callerCount = grepFanout(root, edit.symbol);
return computeFeatures(edit, {
activeLessons,
Expand Down
4 changes: 2 additions & 2 deletions src/cortex_hook_main.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
processSession,
readSession,
} from "./cortex_hook.js";
import { load } from "./lessons_store.js";
import { mergedLessons } from "./ledger_read.js";
import { enforceDecision, substrateCheck, substrateContext } from "./substrate.js";
import { epochDay } from "./util.js";

Expand All @@ -31,7 +31,7 @@ async function enrichCreated(root, results) {
if (!created.length) return;
const { distill } = await import("./cortex_distill.js");
for (const r of created) {
const lesson = load(root).find((l) => l.id === r.id);
const lesson = mergedLessons(root, epochDay()).find((l) => l.id === r.id);
if (!lesson) continue;
const better = distill({
context: lesson.trigger,
Expand Down
12 changes: 6 additions & 6 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ export const toPosix = (p) => String(p).replaceAll("\\", "/");
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.
// Legacy-store retirement (ROADMAP): the PCM ledger is the convergent WRITE store
// (dual-write via ledger_bridge since P1) and serves the read view (ledger_read). The
// ledger is now the DEFAULT and only store — legacy files (lessons/*.md, recall/brain
// fact files) are no longer written or read. Set FORGE_LEDGER_ONLY=0 (the one-release
// escape hatch) to restore the legacy file store while external tooling migrates.
export const ledgerOnly = () =>
process.env.FORGE_LEDGER_ONLY === "1" || process.env.FORGE_LEDGER_ONLY === "true";
process.env.FORGE_LEDGER_ONLY !== "0" && process.env.FORGE_LEDGER_ONLY !== "false";

export function hasBin(bin) {
try {
Expand Down
4 changes: 4 additions & 0 deletions test/cli_cortex.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { recordMistake } from "../src/cortex.js";

// Default is now ledger-only; these cases exercise the legacy FILE store (the
// FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly.
process.env.FORGE_LEDGER_ONLY = "0";

const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url));
const runCli = (args, cwd) => spawnSync("node", [CLI, ...args], { cwd, encoding: "utf8" });

Expand Down
4 changes: 4 additions & 0 deletions test/cortex.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { test } from "node:test";
import { lessonsForContext, recordContradiction, recordMistake, summary } from "../src/cortex.js";
import { load } from "../src/lessons_store.js";

// Default is now ledger-only; these cases exercise the legacy FILE store (the
// FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly.
process.env.FORGE_LEDGER_ONLY = "0";

const fixture = () => mkdtempSync(join(tmpdir(), "forge-cortex-"));
const ctx = {
symbols: ["validateToken"],
Expand Down
4 changes: 4 additions & 0 deletions test/cortex_distill.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { processSession } from "../src/cortex_hook.js";
import { load } from "../src/lessons_store.js";
import { fakeAnthropic } from "./_fixtures.js";

// Default is now ledger-only; these cases exercise the legacy FILE store (the
// FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly.
process.env.FORGE_LEDGER_ONLY = "0";

test("buildPrompt names the location and asks for strict JSON", () => {
const p = buildPrompt({
context: { symbols: ["computeTax"] },
Expand Down
4 changes: 4 additions & 0 deletions test/cortex_emit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { test } from "node:test";
import { processSession } from "../src/cortex_hook.js";
import { canonical } from "../src/sync.js";

// Default is now ledger-only; these cases exercise the legacy FILE store (the
// FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly.
process.env.FORGE_LEDGER_ONLY = "0";

const fixture = () => mkdtempSync(join(tmpdir(), "forge-emit-"));
const brokenSession = () => [
{ type: "bash", command: "npm test", exitCode: 1 },
Expand Down
4 changes: 4 additions & 0 deletions test/cortex_hook.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
} from "../src/cortex_hook.js";
import { load } from "../src/lessons_store.js";

// Default is now ledger-only; these cases exercise the legacy FILE store (the
// FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly.
process.env.FORGE_LEDGER_ONLY = "0";

const fixture = () => mkdtempSync(join(tmpdir(), "forge-hook-"));

test("classifyEvent normalizes edits, bash, and prompts", () => {
Expand Down
4 changes: 4 additions & 0 deletions test/cortex_hook_main.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { load } from "../src/lessons_store.js";

// Default is now ledger-only; these cases exercise the legacy FILE store (the
// FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly.
process.env.FORGE_LEDGER_ONLY = "0";

const ENTRY = fileURLToPath(new URL("../src/cortex_hook_main.js", import.meta.url));
const feed = (mode, payload) =>
spawnSync("node", [ENTRY, mode], {
Expand Down
4 changes: 4 additions & 0 deletions test/cortex_mcp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { fileURLToPath } from "node:url";
import { processSession } from "../src/cortex_hook.js";
import { handle } from "../src/cortex_mcp.js";

// Default is now ledger-only; these cases exercise the legacy FILE store (the
// FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly.
process.env.FORGE_LEDGER_ONLY = "0";

test("handle: initialize advertises the forge-cortex server", async () => {
const r = await handle({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
assert.equal(r.result.serverInfo.name, "forge-cortex");
Expand Down
4 changes: 4 additions & 0 deletions test/cortex_preedit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { processSession } from "../src/cortex_hook.js";

// Default is now ledger-only; these cases exercise the legacy FILE store (the
// FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly.
process.env.FORGE_LEDGER_ONLY = "0";

const ENTRY = fileURLToPath(new URL("../src/cortex_hook_main.js", import.meta.url));
const preEdit = (root, file) =>
spawnSync("node", [ENTRY, "pre-edit"], {
Expand Down
4 changes: 4 additions & 0 deletions test/doctor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import { processSession } from "../src/cortex_hook.js";
import { doctor, repairFailure } from "../src/doctor.js";
import { mergeSettings } from "../src/init.js";

// Default is now ledger-only; these cases exercise the legacy FILE store (the
// FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly.
process.env.FORGE_LEDGER_ONLY = "0";

const fixture = () => mkdtempSync(join(tmpdir(), "forge-doctor-"));

test("doctor reports subsystem health in the standard vocabulary (P1-06)", () => {
Expand Down
4 changes: 4 additions & 0 deletions test/knowledge_router.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
} from "../src/knowledge_router.js";
import { loadClaims, repoLedger } from "../src/ledger_store.js";

// Default is now ledger-only; these cases exercise the legacy FILE store (the
// FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly.
process.env.FORGE_LEDGER_ONLY = "0";

const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url));

test("routeFact: every home recognized from unseen phrasings (per-family routing)", () => {
Expand Down
4 changes: 4 additions & 0 deletions test/ledger_bridge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import { newLesson } from "../src/lessons.js";
import { save } from "../src/lessons_store.js";
import { readFact, add as recallAdd } from "../src/recall.js";

// Default is now ledger-only; these cases exercise the legacy FILE store (the
// FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly.
process.env.FORGE_LEDGER_ONLY = "0";

const tmp = () => mkdtempSync(join(tmpdir(), "forge-bridge-"));
const ctx = { symbols: ["computeTax"], files: ["src/tax.ts"], keywords: [] };
const strongSignals = [{ signal: "S1" }, { signal: "S6" }];
Expand Down
63 changes: 56 additions & 7 deletions test/ledger_only.test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// 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.
// Legacy-store retirement (FORGE_LEDGER_ONLY): ledger-only is now the DEFAULT — the
// legacy files (lessons/*.md, recall/brain fact files) are not written and reads come
// from the ledger alone. FORGE_LEDGER_ONLY=0 is the escape hatch that restores the file
// store (covered by the legacy-pinned suites). These tests exercise the default path
// end-to-end, from the store layer up through the cortex learning loop.
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 { recordMistake, summary } from "../src/cortex.js";
import { reconcileFacts, recordLessonEvent, shadowFact } from "../src/ledger_bridge.js";
import { ledgerFacts, ledgerLessons, mergedLessons } from "../src/ledger_read.js";
import { newLesson } from "../src/lessons.js";
Expand Down Expand Up @@ -71,13 +73,27 @@ test("ledger-only: a minted lesson is served from the ledger via ledgerLessons +
});
});

test("default (off): save() DOES write the legacy .md file", () => {
withEnv({ FORGE_LEDGER_ONLY: undefined }, () => {
test("escape hatch (FORGE_LEDGER_ONLY=0): save() DOES write the legacy .md file", () => {
withEnv({ FORGE_LEDGER_ONLY: "0" }, () => {
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");
assert.equal(
existsSync(lessonsDir(root)),
true,
"legacy lessons dir written with the escape hatch",
);
});
});

test("default is ledger-only: save() writes no file even with FORGE_LEDGER_ONLY unset", () => {
withEnv({ FORGE_LEDGER_ONLY: undefined }, () => {
const root = tmpRoot();
const s = save(root, makeLesson("lsn_d"));
assert.equal(s.ok, true);
assert.equal(s.ledgerOnly, true, "ledger-only is the default now");
assert.equal(existsSync(lessonsDir(root)), false, "no legacy lessons dir by default");
});
});

Expand Down Expand Up @@ -112,3 +128,36 @@ test("ledger-only: reconcileFacts is a no-op and never tombstones ledger facts (
assert.equal(ledgerFacts(ledgerDir).length, 2, "both facts survive — no memory wiped");
});
});

// End-to-end under the DEFAULT (ledger-only): the cortex learning loop must create,
// promote, and surface a lesson entirely through the ledger — no legacy files.
test("default ledger-only: recordMistake create→confirm promotes via the ledger; summary counts it", () => {
withEnv({ FORGE_LEDGER_ONLY: undefined, FORGE_AUTHOR: "Tester <[email protected]>" }, () => {
const root = mkdtempSync(join(tmpdir(), "forge-lonly-e2e-"));
const ctx = { symbols: ["validateToken"], files: ["src/auth/token.ts"], keywords: [] };
const strong = [{ signal: "S1" }, { signal: "S6" }];
const first = recordMistake(root, {
signals: strong,
context: ctx,
nowDay: 1,
episodeId: "ep1",
});
assert.equal(first.action, "created");
assert.equal(first.status, "candidate");
const again = recordMistake(root, {
signals: strong,
context: ctx,
nowDay: 2,
episodeId: "ep2",
});
assert.equal(again.action, "confirmed", "recurrence confirms via the ledger, not a duplicate");
assert.equal(again.status, "active", "a recurrence promotes the lesson");
// Read surfaces work off the ledger under the default.
const merged = mergedLessons(root, 2);
assert.equal(merged.length, 1, "exactly one lesson (no fork)");
assert.equal(merged[0].status, "active");
const s = summary(root, 2);
assert.equal(s.total, 1);
assert.equal(s.active, 1, "cortex summary counts the ledger lesson");
});
});
4 changes: 4 additions & 0 deletions test/ledger_read.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ 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";

// Default is now ledger-only; these cases exercise the legacy FILE store (the
// FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly.
process.env.FORGE_LEDGER_ONLY = "0";

const tmp = () => mkdtempSync(join(tmpdir(), "forge-readflip-"));

/** A lesson claim the way ledger_bridge.lessonClaim mints one (body = content only,
Expand Down
Loading
Loading