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

## [Unreleased]

### Added

- **Team memory (P2 of the substrate-v2 plan).** The PCM ledger becomes shared:
`forge ledger merge <path>` performs the conflict-free semilattice merge of any other
ledger tree (a teammate's checkout, a worktree, a backup) — identical knowledge minted
independently converges to one claim with every author preserved in its provenance log.
`forge ledger blame <id>` is the accountability view (every mint, every oracle outcome,
every retraction, per-author trust). `forge ledger query "<text>"` ranks live claims by
the paper's Eq. 3. Every claim, evidence record, and tombstone now carries the git
identity (`FORGE_AUTHOR` override; cached; best-effort). **Per-author trust**
`u(author) ∈ [0.5, 1]` is computed from the oracle track record of the claims an author
minted — smoothed to 1.0 for new teammates, floored at 0.5, self-confirmation excluded —
and optionally weights `val()`. `forge doctor` now checks the union-merge driver is
present (a populated ledger without it WILL conflict) and the ledger's normal form.

### Fixed

- **PCM ledger hardened after an 8-angle adversarial review of the P1 merge.** The
Expand Down
75 changes: 73 additions & 2 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const COMMANDS = {
cost: "real per-day spend via ccusage + the cost ceiling",
spec: "spec-as-contract — init (OpenSpec) / lock / check drift",
cortex: "self-correcting project memory — status / why <symbol>",
ledger: "proof-carrying memory ledger — stats / verify / show <id> / import",
ledger: "proof-carrying memory — stats / verify / show / blame / query / merge / import",
preflight: "assumption check — what a task names that the repo doesn't define",
route: "recommend the cheapest capable model for a task (+ gateway config)",
impact: "predict blast radius for a symbol or file from the atlas graph",
Expand Down Expand Up @@ -230,6 +230,77 @@ async function run(argv) {
const { val } = await import("./ledger.js");
return console.log(JSON.stringify({ ...hit, val: val(hit, nowDay) }, null, 2));
}
if (sub === "merge") {
const src = args[2];
const { existsSync } = await import("node:fs");
if (!src || !existsSync(src)) {
console.error(
src
? ` no ledger at ${src}`
: "usage: forge ledger merge <path-to-ledger-dir> (a teammate's checkout, a backup, a worktree)",
);
process.exitCode = 1;
return;
}
const r = ls.mergeDirs(dir, src);
if (json) return console.log(JSON.stringify(r, null, 2));
console.log(` merged: ${r.claims} new claim(s), ${r.records} new record(s) — conflict-free`);
return;
}
if (sub === "blame") {
const b = args[2] && args[2].length >= 2 ? ls.blame(dir, args[2], nowDay) : null;
if (!b) {
console.error(
args[2] ? ` no claim matching ${args[2]}` : "usage: forge ledger blame <id-prefix>",
);
process.exitCode = 1;
return;
}
if (json) return console.log(JSON.stringify(b, null, 2));
console.log(`${BRAND.brand} ledger blame — ${b.kind} ${b.id.slice(0, 12)}\n`);
console.log(` val ${b.val.toFixed(2)} (trust-weighted ${b.valTrustWeighted.toFixed(2)})`);
for (const p of b.minted)
console.log(
` minted day ${p.t} by ${p.author || "(unknown)"}${p.agent ? ` · ${p.agent}` : ""}`,
);
for (const e of b.evidence)
console.log(
` ${e.result === "confirm" ? "confirm " : "contradic"} day ${e.t} ${e.oracle} → ${e.ref}${e.author ? ` by ${e.author}` : ""}`,
);
for (const t of b.tombstones)
console.log(` retract day ${t.t} ${t.reason}${t.author ? ` by ${t.author}` : ""}`);
const trusts = Object.entries(b.trust);
if (trusts.length) {
console.log("\n author trust (earned from oracle outcomes on their claims):");
for (const [a, u] of trusts) console.log(` ${u.toFixed(2)} ${a}`);
}
return;
}
if (sub === "query") {
const q = args.slice(2).join(" ");
if (!q) {
console.error('usage: forge ledger query "<what you are about to do>"');
process.exitCode = 1;
return;
}
const { retrieve, claimText } = await import("./ledger.js");
const claims = ls.loadClaims(dir);
const ranked = retrieve(q, claims, { nowDay, budget: 8 });
if (json)
return console.log(
JSON.stringify(
ranked.map((r) => ({ id: r.claim.id, kind: r.claim.kind, score: r.score })),
null,
2,
),
);
if (!ranked.length) return console.log(" no matching live claims");
for (const r of ranked)
console.log(
` ${r.score.toFixed(3)} ${r.claim.kind.padEnd(9)} ${r.claim.id.slice(0, 8)} ${claimText(r.claim).slice(0, 90)}`,
);
return;
}
if (sub === "import") {
const b = await import("./ledger_bridge.js");
let r;
Expand All @@ -249,7 +320,7 @@ async function run(argv) {
return;
}
console.error(
`ledger: unknown subcommand "${sub}" (stats | verify | show <id> | import) [--personal] [--json]`,
`ledger: unknown subcommand "${sub}" (stats | verify | show <id> | blame <id> | query <text> | merge <path> | import) [--personal] [--json]`,
);
process.exitCode = 1;
return;
Expand Down
29 changes: 29 additions & 0 deletions src/doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { isStale, load as loadAtlas } from "./atlas.js";
import { BRAND } from "./brand.js";
import { summary as cortexSummary } from "./cortex.js";
import { extractHash, hashContent } from "./emit/_shared.js";
import { verify as ledgerVerify, repoLedger } from "./ledger_store.js";
import { PRICING_VERIFIED } from "./model_tiers.js";
import { canonical } from "./sync.js";

Expand Down Expand Up @@ -171,6 +172,33 @@ function checkCortex(out, targetRoot) {
);
}

// PCM ledger: a populated ledger with no union-merge driver WILL conflict the first
// time two teammates append to the same evidence log — the exact failure the ledger's
// design promises away. Also surface normal-form issues (forged/corrupt records).
function checkLedger(out, targetRoot) {
const dir = repoLedger(targetRoot);
if (!existsSync(join(dir, "claims"))) {
out.push(ok("ledger", "empty — claims appear as cortex/recall learn (`forge ledger`)"));
return;
}
const attrs = join(targetRoot, ".gitattributes");
const hasRule = existsSync(attrs) && readFileSync(attrs, "utf8").includes(".forge/ledger/");
out.push(
hasRule
? ok("ledger merge", "union-merge driver present in .gitattributes")
: warn(
"ledger merge",
"no union-merge rule — run `forge init` or teammate merges will conflict",
),
);
const v = ledgerVerify(dir);
out.push(
v.ok
? ok("ledger", `${v.claims} claim(s), ${v.outcomes} outcome(s) — normal form`)
: warn("ledger", `${v.issues.length} issue(s) — run \`forge ledger verify\` to list them`),
);
}

export function doctor({ targetRoot = process.cwd() } = {}) {
const results = [];
checkNode(results);
Expand All @@ -184,5 +212,6 @@ export function doctor({ targetRoot = process.cwd() } = {}) {
checkPricing(results);
checkMcp(results, targetRoot);
checkCortex(results, targetRoot);
checkLedger(results, targetRoot);
return { results, failed: results.filter((r) => r.status === "fail").length };
}
42 changes: 39 additions & 3 deletions src/ledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,20 +175,56 @@ const decayed = (outcome, nowDay, halfLife) =>
* Fresh claim → 0.5. Unreviewed evidence decays, pulling val back toward 0.5
* (uncertainty), never toward 0 — review (new evidence) is what restores weight.
* Records that fail validOutcome (unknown oracle, malformed) are IGNORED, not
* trusted. Pure function of the evidence set ⇒ identical after any merge order.
* trusted. Optional `trust` (author → u, see authorTrust) scales each record by its
* appender's earned reliability. Pure function of (evidence set, trust map) ⇒
* identical after any merge order.
* @param {any} claim
* @param {number} [nowDay]
* @param {{halfLife?: number, trust?: Record<string, number>}} [opts]
*/
export function val(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS } = {}) {
export function val(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS, trust } = {}) {
let confirms = 0;
let all = 0;
for (const e of claim.evidence ?? []) {
if (!validOutcome(e)) continue;
const d = decayed(e, nowDay, halfLife);
const d = decayed(e, nowDay, halfLife) * (trust?.[e.author ?? ""] ?? 1);
all += d;
if (e.result === "confirm") confirms += d;
}
return (1 + confirms) / (2 + all);
}

/**
* Per-author trust u(author) ∈ [0.5, 1] — the historical confirm rate of the claims
* an author MINTED (docs/plans/substrate-v2/02-team-memory.md §3): authors whose
* claims keep being contradicted by oracles contribute less evidence weight going
* forward. Smoothed so a no-history author starts at 1.0 (never punish the new
* teammate) and floored at 0.5 (never silence anyone).
* u(a) = max(0.5, (confirms_a + s) / (confirms_a + contradictions_a + s)), s = 2
* Counts are oracle-weighted, and an author's own evidence on their own claims is
* EXCLUDED — self-confirmation must not raise one's trust (C12 discipline).
* @returns {Record<string, number>} author → u
*/
export function authorTrust(claims) {
const tally = new Map(); // author → {c, m}
for (const claim of claims) {
const author = claim.provenance?.author ?? "";
if (!author) continue;
const t = tally.get(author) ?? { c: 0, m: 0 };
for (const e of claim.evidence ?? []) {
if (!validOutcome(e) || (e.author ?? "") === author) continue;
const w = ORACLES[e.oracle].w;
if (e.result === "confirm") t.c += w;
else t.m += w;
}
tally.set(author, t);
}
/** @type {Record<string, number>} */
const out = {};
for (const [a, { c, m }] of tally) out[a] = Math.max(0.5, (c + 2) / (c + m + 2));
return out;
}

/** Recency — λ^(Δt/T) since the last evidence (or mint, if none). */
export function rec(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS } = {}) {
const last = Math.max(claim.provenance?.t ?? 0, ...(claim.evidence ?? []).map((e) => e.t ?? 0));
Expand Down
16 changes: 11 additions & 5 deletions src/ledger_bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
} from "./ledger_store.js";
import { load as loadLessons } from "./lessons_store.js";
import { list as listFacts, readFact } from "./recall.js";
import { epochDay } from "./util.js";
import { epochDay, gitAuthor } from "./util.js";

/** One best-effort policy for the whole bridge (never throws into a caller). */
const bestEffort = (fn) => {
Expand Down Expand Up @@ -51,7 +51,7 @@ export function lessonClaim(lesson, t = 0) {
whatWentWrong: lesson.whatWentWrong ?? "",
},
scope: { level: lesson.scope ?? "repo" },
provenance: { agent: "cortex", author: "", task: lesson.id ?? "" },
provenance: { agent: "cortex", author: gitAuthor(), task: lesson.id ?? "" },
t,
});
}
Expand All @@ -64,7 +64,7 @@ export function factClaim(name, text, t = 0) {
kind: "fact",
body: { name: String(name).trim(), text: String(text).trim() },
scope: { level: "repo" },
provenance: { agent: "recall", author: "" },
provenance: { agent: "recall", author: gitAuthor() },
t,
});
}
Expand All @@ -90,6 +90,7 @@ export function recordLessonEvent(root, lesson, ev = {}) {
oracle: ev.oracle ?? "cortex.episode",
result: ev.result,
ref: ev.ref,
author: gitAuthor(),
t: ev.t ?? 0,
});
if (!o.ok) return { ok: false, reason: "reason" in o ? o.reason : "invalid outcome" };
Expand Down Expand Up @@ -117,6 +118,7 @@ export function supersedeLessonClaim(root, before, after, t = epochDay()) {
if (oldC.ok && oldC.claim.id !== newC.claim.id) {
for (const o of readEvidence(dir, oldC.claim.id)) appendEvidence(dir, newC.claim.id, o);
tombstone(dir, oldC.claim.id, {
author: gitAuthor(),
reason: `superseded-by:${newC.claim.id}`,
t,
});
Expand Down Expand Up @@ -144,7 +146,11 @@ export function shadowFact(ledgerDir, name, text, t = epochDay()) {
c.id !== minted.claim.id &&
c.body?.name === minted.claim.body.name
)
tombstone(ledgerDir, c.id, { reason: `superseded-by:${minted.claim.id}`, t });
tombstone(ledgerDir, c.id, {
author: gitAuthor(),
reason: `superseded-by:${minted.claim.id}`,
t,
});
}
reindex(ledgerDir, t);
return { ...put, id: minted.claim.id };
Expand All @@ -170,7 +176,7 @@ 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)) {
tombstone(ledgerDir, c.id, { reason: "removed-from-store", t });
tombstone(ledgerDir, c.id, { author: gitAuthor(), reason: "removed-from-store", t });
removed++;
}
}
Expand Down
42 changes: 40 additions & 2 deletions src/ledger_store.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "node:fs";
import { join } from "node:path";
import {
authorTrust,
canonicalize,
claimId,
DORMANT_VAL,
Expand Down Expand Up @@ -179,8 +180,45 @@ export function getClaimByPrefix(dir, prefix) {
return liveClaims(state)[0];
}

/** Semilattice import: merge another ledger state into this directory (P2's `forge
* ledger merge` core). Idempotent; safe to re-run. */
/** `forge ledger merge <path>` — semilattice merge of another on-disk ledger into
* this one. Idempotent and order-independent by the CRDT property, so merging a
* teammate's checkout, a backup, or a branch worktree is always safe. */
export function mergeDirs(dstDir, srcDir) {
return importState(dstDir, loadState(srcDir));
}

/**
* `forge ledger blame <id-prefix>` — the full accountability view of one claim: who
* minted it (every author, via the provenance log), every evidence record in (t, h)
* order, retractions, and the per-author trust the ledger has earned (17:36's audit
* trail: every channel the agent used can be questioned).
*/
export function blame(dir, prefix, nowDay = 0) {
const claim = getClaimByPrefix(dir, prefix);
if (!claim) return null;
const trust = authorTrust(loadClaims(dir));
return {
id: claim.id,
kind: claim.kind,
body: claim.body,
scope: claim.scope,
minted: claim.provenanceAll,
evidence: claim.evidence,
tombstones: readLog(dir, "tombstones", claim.id),
val: val(claim, nowDay),
valTrustWeighted: val(claim, nowDay, { trust }),
trust: Object.fromEntries(
[
...new Set(
[...claim.provenanceAll, ...claim.evidence].map((r) => r.author).filter(Boolean),
),
].map((a) => [a, trust[a] ?? 1]),
),
};
}

/** Semilattice import: merge another ledger state into this directory (the mergeDirs
* core). Idempotent; safe to re-run. */
export function importState(dir, other) {
const merged = mergeStates(loadState(dir), other);
let claims = 0;
Expand Down
21 changes: 21 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,27 @@ export function contentHash(text) {
return createHash("sha256").update(text).digest("hex");
}

let cachedAuthor;
/** The identity stamped on ledger provenance/evidence — FORGE_AUTHOR env override,
* else the git identity, else "" (attribution is best-effort, never a hard fail).
* Cached per process: hooks call this per event and `git config` is a subprocess. */
export function gitAuthor() {
if (process.env.FORGE_AUTHOR !== undefined) return process.env.FORGE_AUTHOR;
if (cachedAuthor !== undefined) return cachedAuthor;
try {
const get = (k) =>
execFileSync("git", ["config", "--get", k], { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
const name = get("user.name");
const email = get("user.email");
cachedAuthor = email ? `${name} <${email}>` : name;
} catch {
cachedAuthor = "";
}
return cachedAuthor;
}

export const IGNORE_DIRS = new Set([
"node_modules",
".git",
Expand Down
Loading
Loading