Skip to content
Open
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 ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,8 @@ forgekit/
doctor.js # health checks
emit/ # one module per tool (claude, codex, cursor, gemini, aider, copilot, windsurf, zed, continue) + mcp
ledger.js # PCM core: content-addressed claims, oracle taxonomy, decayed Beta val, Eq. 3 retrieval, semilattice merge (ADR-0006)
ledger_store.js # git-native on-disk ledger (.forge/ledger/): sharded claims, append-only evidence/tombstone logs, normal-form verify
ledger_store.js # git-native on-disk ledger (.forge/ledger/): sharded claims, append-only evidence/tombstone logs, normal-form verify, local usage log
ledger_retention.js # retention learned from the ledger's own history: archive never-served claims, idle ones past the longest observed comeback, and BIC-detected near-duplicates (`ledger compact`)
ledger_bridge.js # legacy-store bridge, dormant by default (ledger-only); `FORGE_LEDGER_ONLY=0` re-enables cortex/recall/brain shadow-writes + idempotent `ledger import`
ledger_read.js # ledger-only read path by default (`FORGE_LEDGER_ONLY=0` merges legacy∪ledger instead): cortex lesson/fact injection, `recall list`, brain's AGENTS.md index all see teammate knowledge from `ledger merge`
learn_consolidate.js # bin/learn-consolidate.sh: deterministic consolidation of ~/.claude/skills/learned — merge duplicates, drop only ledger-refuted (dormant/retracted/attic) lessons; no model call
Expand Down Expand Up @@ -620,16 +621,16 @@ from the tree it describes.
```mermaid
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%%
flowchart LR
test["test<br/>117 files"]
src["src<br/>109 files"]
test["test<br/>119 files"]
src["src<br/>110 files"]
landing["landing<br/>61 files"]
research["research<br/>37 files"]
global["global<br/>5 files"]
bench["bench<br/>3 files"]
scripts["scripts<br/>2 files"]
docs["docs<br/>1 file"]
examples["examples<br/>1 file"]
test -- 240 --> src
test -- 247 --> src
bench -- 8 --> src
examples -- 4 --> src
test -- 2 --> bench
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **`forge ledger compact [--dry-run]`: ledger retention learned from the ledger's own
history.** It prints every learned number and archives three kinds of claim to the
attic. The attic is reversible: new evidence restores a claim, and `show`/`blame` still
read it.
- **Never-served claims:** tombstoned or dormant claims, which retrieval skips.
- **Idle claims:** idle longer than the longest stretch any claim of this ledger came back
from.
- **Near-duplicates:** a boundary exists only when BIC prefers two groups of
nearest-neighbour similarities to one.
- **A local usage log** (`.forge/ledger/.usage.jsonl`, gitignored) records which claims were
served: the session lesson block, pre-edit lessons, the déjà-vu advisory,
`forge ledger query` and the MCP query. Retention learns from it. Before this, nothing
recorded use.

### Changed

- **The Stop hook's ledger pruning no longer uses a fixed 2 × 45-day window.** Tombstoned and
dormant claims are archived at once. Live claims are archived by the learned idle cut-off,
and only once the usage log spans longer than that cut-off.
- This bounds the per-session summary claims, which were never contradicted and so grew
forever: one is archived once it has been idle past the cut-off. The bound is the ledger's
longest comeback, so a single claim that came back after a long silence raises it for
every claim — deliberately, because an archived claim is no longer served and so cannot
prove itself useful again.
- `forge ledger show` and `forge ledger blame` read the attic, so a fresh retraction stays
inspectable.

## [1.1.1] - 2026-09-22

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ and output live in [`docs/GUIDE.md`](docs/GUIDE.md).
| | `forge recall` | manage cross-session memory (list / add / consolidate) |
| | `forge remember` | add a durable fact to this repo's portable memory (forge brain) |
| | `forge brain` | show / rebuild the portable project memory index |
| | `forge ledger` | evidence-referenced memory — stats / verify / show / blame / query / at / diff / root / ratify / retract / merge / sync / import |
| | `forge ledger` | evidence-referenced memory — stats / verify / show / blame / query / compact / at / diff / root / ratify / retract / merge / sync / import |
| | `forge handoff` | bounded session snapshot — rewrite .forge/state.md, re-injected each session start |
| | `forge decide` | append-only decision log — D-#### ADR-lite entries in .forge/decisions.md |
| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total, never dropped |
Expand Down
4 changes: 3 additions & 1 deletion bench/impact_cases.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
// - test/ledger.test.js imports { mergeStates } (:14) and calls it
// (src/ledger_sync.js:3 also names it in the module header — same file, already labeled.)
//
// claimText (src/ledger.js) — 9 files
// claimText (src/ledger.js) — 10 files
// - src/ledger.js defines it (:610); sketchOf() (:636), termsOf() (:637) and :880 call it
// - src/context.js imports { claimText } (:13) and calls it (:185)
// - src/dash.js imports { claimText } (:16) and calls it (:58, :389, :400)
Expand All @@ -57,6 +57,7 @@
// - src/cortex_mcp.js dynamic-imports { claimText } (:91) and calls it (:96, :106)
// - test/ledger.test.js imports { claimText } (:8) and calls it
// - src/learn_consolidate.js imports { claimText } (:32) and calls it (:110)
// - src/ledger_retention.js imports { claimText } (:29) and calls it (:184)
// (test/dash.test.js:69 mentions the name only inside an assertion message — a string,
// not a reference — so it is NOT labeled as a dependent.)
//
Expand Down Expand Up @@ -116,6 +117,7 @@ export const IMPACT_CASES = [
"src/dash.js",
"src/deja.js",
"src/learn_consolidate.js",
"src/ledger_retention.js",
"src/ledger_store.js",
"src/cli.js",
"src/cortex_mcp.js",
Expand Down
32 changes: 32 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,38 @@ freshly minted copy of the same fact stay two entries until you run it. It moves
evidence and provenance logs with it, unions them into an existing twin rather than
overwriting, and is idempotent.

`forge ledger compact [--dry-run]` archives what this ledger's own history says will not be
used again. It prints every number it learned, and nothing in it is a fixed threshold:

```console
$ forge ledger compact --dry-run
Forge ledger — compact (every cut-off learned from this ledger) [dry run]

claims: 11 · claims with logged use: 10
retention: idle cut-off 4 d = the longest idle stretch any claim came back from (199 comebacks, typical gap 4 d; usage log spans 90 d)
duplicates: boundary 0.28 (two components beat one: BIC -72.6 < 3.5) · 1 group(s)

archive: 3
34a49b8d036e idle 86 d > learned cut-off 4 d
a5e218fd1814 tombstoned (never served)
d3a5a1c9941e near-duplicate of c70ee7d4f505 (similarity 0.55 ≥ learned 0.28)

dry run: nothing written
```

**The three archive rules:**
- **Never served:** a tombstoned or dormant claim goes at once, because retrieval never serves it.
- **Idle too long:** a live claim goes once it has been idle longer than any claim here has ever been idle and then used again. Until the usage log covers that long, no live claim is archived.
- **Near-duplicates:** each claim's similarity to its closest claim of the same kind is modelled as one group or two, and BIC decides which fits. Only two groups produce a duplicate boundary. The claim kept from each group is the one with the highest val.

**Where use comes from:** forge writes `.forge/ledger/.usage.jsonl`, a gitignored local log. It records each claim that the session lesson block, pre-edit lessons, the déjà-vu advisory, `ledger query` or the MCP query served.

**What happens to archived claims:**
- They move to `.forge/ledger/attic/`, and their logs stay where they are.
- `forge ledger show` and `blame` still read them.
- New evidence brings one back.
- The Stop hook applies the first two rules on its own; duplicates are grouped only by this command.

`forge ledger blame <id-prefix>` is the accountability view — every mint, every oracle
outcome, every retraction, and per-author trust:

Expand Down
38 changes: 37 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@ HANDLERS.ledger = async (argv) => {
}
if (sub === "show") {
const id = args[2];
const hit = id && id.length >= 2 ? ls.getClaimByPrefix(dir, id) : null;
const hit = id && id.length >= 2 ? ls.getClaimByPrefix(dir, id, { attic: true }) : null;
if (!hit) {
console.error(
id ? ` no claim matching ${id}` : "usage: forge ledger show <id-prefix (≥2 chars)>",
Expand Down Expand Up @@ -874,6 +874,37 @@ HANDLERS.ledger = async (argv) => {
);
return;
}
// `compact` — archive what this ledger's own history says will not be used again, and
// near-duplicates, printing every learned number (ledger_retention.js). Reversible.
if (sub === "compact") {
const dryRun = argv.includes("--dry-run");
const r = ls.compactLedger(dir, nowDay, { dryRun });
if (json) return console.log(JSON.stringify(r, null, 2));
const rt = r.retention;
const d = r.duplicates;
const lines = [
`Forge ledger — compact (every cut-off learned from this ledger)${dryRun ? " [dry run]" : ""}`,
"",
` claims: ${r.claims} · claims with logged use: ${r.servedClaims}`,
rt.learned
? ` retention: idle cut-off ${rt.cutoff} d = the longest idle stretch any claim came back from (${rt.comebacks} comebacks, typical gap ${rt.typicalGap} d; usage log spans ${rt.usageSpan} d)`
: ` retention: not learned — ${rt.reason}`,
d?.boundary != null
? ` duplicates: boundary ${d.boundary.toFixed(2)} (two components beat one: BIC ${d.bic2?.toFixed(1)} < ${d.bic1?.toFixed(1)}) · ${d.groups.length} group(s)`
: ` duplicates: none — ${d?.compared ? `one component fits the ${d.compared} nearest-neighbour similarities better` : "fewer than two claims of one kind are still live to compare"}`,
"",
` archive: ${r.archive.length}`,
];
for (const a of r.archive.slice(0, 20)) lines.push(` ${a.id.slice(0, 12)} ${a.reason}`);
if (r.archive.length > 20) lines.push(` … ${r.archive.length - 20} more (--json for all)`);
lines.push(
"",
dryRun
? " dry run: nothing written"
: ` archived ${r.archived.length} claim(s) to .forge/ledger/attic/ — new evidence brings one back; show/blame still read it`,
);
return console.log(lines.join("\n"));
}
if (sub === "query") {
const q = args.slice(2).join(" ");
if (!q) {
Expand All @@ -889,6 +920,11 @@ HANDLERS.ledger = async (argv) => {
const claims = ls.loadClaims(dir);
const sim = claimSim(root, q, claims, claimText);
const ranked = retrieve(q, claims, { nowDay, budget: 8, sim });
ls.recordUse(
dir,
ranked.map((r) => r.claim.id),
{ via: "cli.query", t: nowDay },
);
if (json)
return console.log(
JSON.stringify(
Expand Down
2 changes: 1 addition & 1 deletion src/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const COMMANDS = {
cortex: "self-correcting project memory — status / why <symbol>",
deja: "anti-repetition — have you done this task before? ranks prior solved/verified sessions",
ledger:
"evidence-referenced memory — stats / verify / show / blame / query / at / diff / root / ratify / retract / merge / sync / import",
"evidence-referenced memory — stats / verify / show / blame / query / compact / at / diff / root / ratify / retract / merge / sync / import",
reuse: "proof-carrying code cache — query <spec> / mint <spec> --file <path> / stats",
context: "budgeted context assembly + completeness gate — what an edit NEEDS known",
preflight: "assumption check — what a task names that the repo doesn't define",
Expand Down
29 changes: 27 additions & 2 deletions src/cortex.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { recordLessonEvent, supersedeLessonClaim } from "./ledger_bridge.js";
import { ledgerLessons, mergedLessons } from "./ledger_read.js";
import { recordUse, repoLedger } from "./ledger_store.js";
import {
confidenceOf,
confirm,
Expand Down Expand Up @@ -188,15 +189,39 @@ export function lessonsForContext(root, context, opts = {}) {
return selectForInjection(mergedLessons(root, opts.nowDay ?? 0), context, opts);
}

/** Log that these lessons were served (ledger retention learns from it). Only lessons
* backed by a ledger claim have an id to log; best-effort, never throws.
* @param {string} root
* @param {{provenance?: {claim?: string}}[]} lessons
* @param {{via: string, t: number}} opts */
export function recordServedLessons(root, lessons, { via, t }) {
recordUse(
repoLedger(root),
lessons.map((l) => l?.provenance?.claim).filter((id) => typeof id === "string"),
{ via, t },
);
}

/** Repo-wide top active lessons — what a SessionStart hook injects (no file context yet).
* Merged view: a teammate's outcome-confirmed lesson surfaces here too. */
export function startupBlock(root, nowDay = 0, budget = 8) {
* Merged view: a teammate's outcome-confirmed lesson surfaces here too. `record` logs the
* shown lessons as served; only the hook sets it (AGENTS.md emission is not a use).
* @param {string} root
* @param {number} [nowDay]
* @param {number} [budget]
* @param {{record?: boolean}} [opts] */
export function startupBlock(root, nowDay = 0, budget = 8, { record = false } = {}) {
const active = mergedLessons(root, nowDay).filter((l) => l.status === "active");
if (!active.length) return "";
const ranked = active
.map((l) => ({ lesson: l, conf: confidenceOf(l, nowDay) }))
.sort((a, b) => b.conf - a.conf);
const shown = ranked.slice(0, budget);
if (record)
recordServedLessons(
root,
shown.map((x) => x.lesson),
{ via: "session-start", t: nowDay },
);
const rows = shown.map((x) =>
`- **${x.lesson.id}** — ${x.lesson.correctedBehavior}`.slice(0, 200),
);
Expand Down
14 changes: 11 additions & 3 deletions src/cortex_hook_main.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
// stop (Stop) — distill the session into lessons
// stop-gate (Stop, synchronous) — completion gate: block once if code moved but no doc/state did
// session-start (SessionStart) — inject learned lessons as context
import { applyDistillation, lessonsForContext, startupBlock } from "./cortex.js";
import {
applyDistillation,
lessonsForContext,
recordServedLessons,
startupBlock,
} from "./cortex.js";
import {
appendSessionEvent,
classifyEvent,
Expand Down Expand Up @@ -130,7 +135,7 @@ async function main() {
const { stateBlock } = await import("./handoff.js");
const { rehydrationBlock } = await import("./session.js");
const block = [
startupBlock(root, today),
startupBlock(root, today, undefined, { record: true }),
goalBlock(root),
stateBlock(root),
rehydrationBlock(root),
Expand Down Expand Up @@ -254,7 +259,10 @@ async function preEditAdvisory(root, input, today) {
{ files: [file], symbols: [], keywords: [file] },
{ nowDay: today, budget: 3 },
);
if (selected.length) return block; // learned lessons for this file win
if (selected.length) {
recordServedLessons(root, selected, { via: "pre-edit", t: today });
return block; // learned lessons for this file win
}
const { riskFor } = await import("./predictor.js");
const features = await liveEditFeatures(root, file, input, today);
const { band } = riskFor(features, { mode: "heuristic" });
Expand Down
9 changes: 8 additions & 1 deletion src/cortex_mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,21 @@ async function callTool(name, args = {}) {
}
if (name === "forge_ledger_query") {
try {
const { loadClaims, repoLedger, retractionProposals } = await import("./ledger_store.js");
const { loadClaims, recordUse, repoLedger, retractionProposals } = await import(
"./ledger_store.js"
);
const { retrieve, claimText } = await import("./ledger.js");
const { claimSim, simLabel } = await import("./embed.js");
const dir = repoLedger(root);
const q = String(args.query ?? "");
const claims = loadClaims(dir);
const sim = claimSim(root, q, claims, claimText);
const ranked = retrieve(q, claims, { nowDay: today(), budget: 8, sim });
recordUse(
dir,
ranked.map((r) => r.claim.id),
{ via: "mcp.query", t: today() },
);
const pending = retractionProposals(claims);
return JSON.stringify(
{
Expand Down
13 changes: 9 additions & 4 deletions src/deja.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
loadClaims,
pruneLedger,
putClaim,
recordUse,
reindex,
repoLedger,
} from "./ledger_store.js";
Expand Down Expand Up @@ -124,9 +125,10 @@ export function recordSessionSummary(root, sid, events, nowDay = epochDay()) {
});
if (o.ok) appendEvidence(dir, minted.claim.id, o.outcome);
}
// Session-end housekeeping (the murāja'a job): archive what the protocol says is
// forgotten — tombstoned or dormant with nothing new for 2·T — so the ledger the next
// prompt reads stays bounded. Nothing is deleted; new evidence un-archives a claim.
// Session-end housekeeping (the murāja'a job): archive what this ledger's own history
// says will not be served again (ledger_retention.js — never-served claims, and live ones
// idle past the longest comeback), so the ledger the next prompt reads stays bounded.
// Nothing is deleted; new evidence un-archives a claim.
pruneLedger(dir, nowDay);
reindex(dir, nowDay);
return { ok: true, id: minted.claim.id, tested: s.tested };
Expand Down Expand Up @@ -194,7 +196,10 @@ export function dejaAdvisory(root, task, nowDay = epochDay()) {
if (!task || !String(task).trim()) return "";
try {
const hits = dejaFromLedger(root, task, { nowDay, budget: 3 });
return dejaLine(hits[0], nowDay);
const line = dejaLine(hits[0], nowDay);
// Only a surfaced hit counts as use; a hit below the relevance floor was never shown.
if (line) recordUse(repoLedger(root), [hits[0].claim.id], { via: "deja", t: nowDay });
return line;
} catch {
return "";
}
Expand Down
Loading