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
9 changes: 5 additions & 4 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# PCM ledger evidence logs are append-only sets deduped by content hash on read —
# union merge is safe and makes teammate ledgers conflict-free by construction
# (docs/plans/substrate-v2/02-team-memory.md §1).
.forge/ledger/evidence/*.log merge=union
# PCM ledger logs (evidence/provenance/tombstones) are append-only sets deduped by
# content hash on read — union merge is safe and makes teammate ledgers conflict-free
# by construction (docs/plans/substrate-v2/02-team-memory.md §1). `forge init` emits
# this same rule into consumer repos.
.forge/ledger/*/*.log merge=union
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- **PCM ledger hardened after an 8-angle adversarial review of the P1 merge.** The
conflict-free-merge guarantee is now structural: claim file bytes are a pure function of
(kind, body, scope) — byte-identical on every replica — while provenance and tombstones
move into per-claim append-only logs (hash-deduped, union-merged like evidence), so
concurrent mints and concurrent retractions can never produce a git conflict or a
merge-order-dependent state. Forged evidence is now powerless AND detectable: `val()`
takes oracle weights from the ORACLES table (never the stored record) and skips unknown
oracles, while `forge ledger verify` recomputes every record's content hash and flags
mismatches, ghost oracles, and inflated weights. `forge ledger import` is truly
idempotent (claims already tracked live are never re-synthesized — no double counting).
Cortex shadow-writes: distillation now supersedes (evidence carried over, template claim
tombstoned); evidence refs carry the confirmation counter so same-day sessions with
colliding episode ids stay distinct; regex-detected reverts contradict at the
conservative bridge weight instead of the full-weight human oracle. Fact claims: one
CRLF-tolerant parser (`recall.readFact`), trimmed bodies (shadow path and import path
mint one id), same-name updates supersede the stale claim, and `forge recall
consolidate` reconciles deletions into tombstones. `putClaim` repairs corrupt/truncated
claim files instead of trusting `existsSync`. `forge ledger --personal` reaches the
personal ledger (previously write-only); `forge ledger show` resolves by shard instead
of scanning; `forge init` emits the union-merge `.gitattributes` rule into consumer
repos. `SCOPE_WEIGHT` has one home (ledger core; lessons re-exports).

### Documentation

- **Substrate v2 plan: the whitepaper, completed (`docs/plans/substrate-v2/`).** Nine specs
Expand Down
66 changes: 45 additions & 21 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,24 @@ async function run(argv) {
if (res.ok) {
// Shadow the fact into the PERSONAL ledger beside the global store (repo
// promotion stays an explicit act — docs/plans/substrate-v2/02-team-memory.md §3).
const { join } = await import("node:path");
const { recordFactEvent } = await import("./ledger_bridge.js");
const { epochDay } = await import("./util.js");
recordFactEvent(join(store, "ledger"), name, body, epochDay());
// Best-effort INCLUDING the imports: a broken bridge module must never turn an
// already-persisted fact into a CLI failure.
try {
const { join } = await import("node:path");
const { shadowFact } = await import("./ledger_bridge.js");
shadowFact(join(store, "ledger"), name, body);
} catch {}
}
console.log(res.ok ? ` saved: ${res.slug}` : ` ${res.reason}`);
if (!res.ok) process.exitCode = 1;
} else if (sub === "consolidate") {
const { removed, kept } = r.consolidate(store);
try {
// Deleted duplicates must not survive as live claims in the shadow ledger.
const { join } = await import("node:path");
const { reconcileFacts } = await import("./ledger_bridge.js");
reconcileFacts(store, join(store, "ledger"));
} catch {}
console.log(` consolidated: ${removed} duplicate(s) removed, ${kept} kept`);
} else {
console.error(`recall: unknown subcommand "${sub}" (list | add | consolidate)`);
Expand All @@ -177,8 +186,15 @@ async function run(argv) {
const ls = await import("./ledger_store.js");
const { epochDay } = await import("./util.js");
const root = process.cwd();
const dir = ls.repoLedger(root);
const sub = argv[1] || "stats";
// --personal targets the ledger beside the global recall store (~/.forge/recall/
// ledger) — otherwise facts shadowed by `forge recall add` would be write-only,
// with no command able to inspect or verify them.
const personal = argv.includes("--personal");
const args = argv.filter((a) => a !== "--json" && a !== "--personal");
const dir = personal
? (await import("node:path")).join((await import("./recall.js")).defaultStore(), "ledger")
: ls.repoLedger(root);
const sub = args[1] || "stats";
const json = argv.includes("--json");
const nowDay = epochDay();
if (sub === "stats") {
Expand All @@ -202,32 +218,39 @@ async function run(argv) {
return;
}
if (sub === "show") {
const id = argv[2];
const hit = id && ls.loadClaims(dir).find((c) => c.id.startsWith(id));
const id = args[2];
const hit = id && id.length >= 2 ? ls.getClaimByPrefix(dir, id) : null;
if (!hit) {
console.error(id ? ` no claim matching ${id}` : "usage: forge ledger show <id-prefix>");
console.error(
id ? ` no claim matching ${id}` : "usage: forge ledger show <id-prefix (≥2 chars)>",
);
process.exitCode = 1;
return;
}
const { val } = await import("./ledger.js");
return console.log(JSON.stringify({ ...hit, val: val(hit, nowDay) }, null, 2));
}
if (sub === "import") {
const { importLegacy } = await import("./ledger_bridge.js");
const { brainStore } = await import("./brain.js");
const r = importLegacy(root, {
recallStore: brainStore(root),
recallLedger: dir,
nowDay,
});
const b = await import("./ledger_bridge.js");
let r;
if (personal) {
// Personal import: facts from the global recall store into the personal ledger.
const { defaultStore } = await import("./recall.js");
r = { lessons: 0, outcomes: 0, ...b.importFacts(defaultStore(), dir, nowDay) };
} else {
const { brainStore } = await import("./brain.js");
r = b.importLegacy(root, { recallStore: brainStore(root), recallLedger: dir, nowDay });
}
if (json) return console.log(JSON.stringify(r, null, 2));
console.log(
` imported: ${r.lessons} lesson(s), ${r.facts} fact(s), ${r.outcomes} outcome(s)`,
);
for (const x of r.refused) console.log(` refused: ${x}`);
return;
}
console.error(`ledger: unknown subcommand "${sub}" (stats | verify | show <id> | import)`);
console.error(
`ledger: unknown subcommand "${sub}" (stats | verify | show <id> | import) [--personal] [--json]`,
);
process.exitCode = 1;
return;
}
Expand Down Expand Up @@ -329,10 +352,11 @@ async function run(argv) {
const res = b.remember(b.brainStore(process.cwd()), name, body);
if (res.ok) {
// Brain is repo-scoped and git-committable → shadow into the REPO ledger.
const { recordFactEvent } = await import("./ledger_bridge.js");
const { repoLedger } = await import("./ledger_store.js");
const { epochDay } = await import("./util.js");
recordFactEvent(repoLedger(process.cwd()), name, body, epochDay());
try {
const { shadowFact } = await import("./ledger_bridge.js");
const { repoLedger } = await import("./ledger_store.js");
shadowFact(repoLedger(process.cwd()), name, body);
} catch {}
}
console.log(
res.ok
Expand Down
27 changes: 19 additions & 8 deletions src/cortex.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// contradiction. Kept fs-thin and deterministic (day + ids passed in) so it's testable
// without any hook wiring.

import { recordLessonEvent } from "./ledger_bridge.js";
import { recordLessonEvent, supersedeLessonClaim } from "./ledger_bridge.js";
import {
confidenceOf,
confirm,
Expand Down Expand Up @@ -86,10 +86,13 @@ 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).
// read path; best-effort by design, never blocks the hook). 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.
recordLessonEvent(root, updated, {
result: "confirm",
ref: `episode:${episodeId}`,
ref: `episode:${episodeId}#n${updated.evidenceCount}`,
t: nowDay,
});
return {
Expand Down Expand Up @@ -145,12 +148,14 @@ export function recordContradiction(root, { context, nowDay, episodeId }) {
const results = targets.map((l) => {
const updated = contradict(l, nowDay);
const saved = save(root, updated).ok;
// An explicit human reversal is the strongest oracle we have — shadow it.
// Shadowed at the conservative bridge weight, NOT human.revert (w=1.0): the hook's
// revert detection is regex-based and matches routine `git restore`s, and a
// full-weight contradiction would permanently anchor the claim near dormancy in an
// append-only log. The counter in the ref keeps distinct same-day events distinct.
if (saved)
recordLessonEvent(root, updated, {
result: "contradict",
oracle: "human.revert",
ref: `episode:${episodeId}`,
ref: `episode:${episodeId}#c${updated.contradictionCount}`,
t: nowDay,
});
return { id: updated.id, status: updated.status, saved };
Expand Down Expand Up @@ -191,11 +196,17 @@ export function applyDistillation(root, lessonId, distilled) {
if (!distilled) return false;
const lesson = load(root).find((l) => l.id === lessonId);
if (!lesson) return false;
return save(root, {
const updated = {
...lesson,
whatWentWrong: distilled.whatWentWrong,
correctedBehavior: distilled.correctedBehavior,
}).ok;
};
const ok = save(root, updated).ok;
// A body rewrite changes the content-addressed claim id — supersede in the ledger
// (mint the distilled claim, carry the evidence over, tombstone the template claim)
// or the lesson's history splits across two disjoint claims.
if (ok) supersedeLessonClaim(root, lesson, updated);
return ok;
}

/** The lessons block to inline into AGENTS.md so non-Claude tools see them (empty if none). */
Expand Down
21 changes: 19 additions & 2 deletions src/init.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
// forge init / catalog — the onboarding surface. init gets a repo to a working
// state in one command; catalog is the "Start Here" index of everything active.
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { BRAND } from "./brand.js";
import { GITATTRIBUTES_RULE } from "./ledger_store.js";
import { sync } from "./sync.js";
import { list as tasteList } from "./taste.js";

/** Without the union merge driver, two teammates appending to the same ledger log get
* a git conflict — the exact thing the ledger's design promises can't happen
* (docs/plans/substrate-v2/02-team-memory.md §1). Idempotent append. */
export function ensureLedgerGitattributes(targetRoot = process.cwd()) {
const path = join(targetRoot, ".gitattributes");
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
if (existing.includes(".forge/ledger/")) return { written: false };
appendFileSync(
path,
`${existing && !existing.endsWith("\n") ? "\n" : ""}${GITATTRIBUTES_RULE}\n`,
);
return { written: true };
}

/** Scaffold this repo's cross-tool config (emit every tool) in one step. */
export function init({ targetRoot = process.cwd() } = {}) {
return sync({ targetRoot });
const r = sync({ targetRoot });
ensureLedgerGitattributes(targetRoot);
return r;
}

function skillDescription(dir) {
Expand Down
Loading
Loading