diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 6bf27ac..d548507 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -70,7 +70,7 @@ The four layers, brand-named and emitted cross-tool:
drift from. Prose rules in CLAUDE.md get acknowledged and then forgotten after
compaction; a guard does not. Every enforceable invariant belongs here.
- **mcp** — the protocol layer. Forge ships one stdio server (`src/cortex_mcp.js`)
- exposing 20 MCP tools: the substrate checks (`substrate_check` / `predict_impact` /
+ exposing 21 MCP tools: the substrate checks (`substrate_check` / `predict_impact` /
`assumption_gate` / `rank_code` / …), memory reads AND writes (`forge_remember`,
ledger ratify/retract), and ops/health — the full table is in docs/GUIDE.md.
@@ -521,8 +521,8 @@ 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
98 files"]
- src["src
93 files"]
+ test["test
100 files"]
+ src["src
94 files"]
landing["landing
60 files"]
research["research
35 files"]
bench["bench
2 files"]
@@ -530,7 +530,7 @@ flowchart LR
scripts["scripts
2 files"]
docs["docs
1 file"]
examples["examples
1 file"]
- test -- 191 --> src
+ test -- 195 --> src
bench -- 7 --> src
examples -- 4 --> src
test -- 2 --> scripts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 289d0ab..ff18ec0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,45 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+### Added
+
+- **`forge collide` — the parallel-session conflict radar.** The everyday failure of
+ the agent-fleet era: two sessions silently edit the same or import-coupled files and
+ the conflict surfaces at merge time. Every session already mints a ledger summary of
+ the files it touched, and those claims team-merge over plain git — so "who else was
+ just in here?" is a pure read: no server, no presence protocol, no new storage.
+ `risk = 1 − ∏(1 − recᵢ × strengthᵢ)` over recent foreign sessions (7-day recency
+ half-life — a collision is about now; direct hits count full, 1-hop import
+ neighbors half). Advisory and fail-open, with hook-minted absolute paths
+ relativized like the rank join. Exposed to every MCP-capable agent as
+ `collide_check` (21 MCP tools — counts and tables regenerated by
+ `forge docs render`).
+
+### Fixed
+
+- **The rank hazard join now matches production claims.** Hook-minted lessons and
+ session summaries store raw tool-input paths (absolute), while the atlas speaks
+ repo-relative POSIX — so `forge rank`'s history overlay never matched a real claim
+ and hazard silently degenerated to bare centrality (found by adversarial review,
+ reproduced against the live mint pipeline). `history()` now relativizes claim paths
+ against the repo root; a test pins the production path shape.
+- **`beliefDiff` tombstone edge cases.** A claim minted _and_ retracted inside the
+ diff window was reported as "appeared" with a live confidence — a retracted claim
+ presented as a current belief; it now lands in `retired` (`from:null, to:null`).
+ Claims already tombstoned before the window no longer surface as strengthened or
+ weakened through pure decay — dead beliefs don't move.
+- **`forge rank` determinism and hardening.** All orderings now use locale-independent
+ codepoint comparison (`localeCompare` consults ICU tables that differ across
+ machines, contradicting the module's own cross-machine guarantee); `centrality()`
+ counts a duplicated atlas node id once, as PageRank already did; a corrupt
+ `.forge/atlas.json` degrades to the `built:false` hint instead of crashing the CLI
+ and hanging the `rank_code` MCP call; a negative `--top` clamps instead of slicing
+ in from the end of the list.
+- **Temporal CLI guards.** `forge ledger diff` refuses a `` after ``
+ (previously printed silently inverted classes), and `ledger at`/`diff` reject
+ impossible calendar dates (`2026-02-31`) instead of letting `Date.parse` roll them
+ into a day nobody asked about.
+
## [0.29.0] - 2026-08-07
### Added
diff --git a/README.md b/README.md
index f1485fb..53ddf29 100644
--- a/README.md
+++ b/README.md
@@ -188,7 +188,7 @@ git pull && forge ledger merge
On Claude Code the substrate then runs on **every prompt automatically** via a
`UserPromptSubmit` hook — advisory only, silent on clean tasks. Every other tool gets a
-native config rule plus **20 MCP tools** it can call itself — pre-action checks
+native config rule plus **21 MCP tools** it can call itself — pre-action checks
(`substrate_check`, `predict_impact`, `assumption_gate`, `route_task`, `scope_files`),
memory reads and writes, and ops/health — the full list with schemas is in
[`docs/GUIDE.md`](docs/GUIDE.md#mcp-tools).
@@ -257,6 +257,7 @@ that never clobbers your existing settings (skip it with `install.sh --no-settin
| | `forge deja` | anti-repetition — have you done this task before? ranks prior solved/verified sessions |
| | `forge reuse` | proof-carrying code cache — query / mint --file / stats |
| | `forge rank` | load-bearing code — PageRank centrality × past-incident history, circular-dependency clusters, chokepoint files |
+| | `forge collide` | parallel-session conflict radar — who else recently touched the files (or their import neighbors) you are editing |
**→ Every command with a worked example and real output:
diff --git a/ROADMAP.md b/ROADMAP.md
index 256b705..3185b8a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -38,7 +38,7 @@ confidence only from independent oracles, and merges across teammates conflict-f
exposing the complexity tiers as model aliases; point `ANTHROPIC_BASE_URL` at the
proxy and every model call routes through it.
- **MCP server** — the cortex MCP server (`src/cortex_mcp.js`) exposes read-path
- tools for ledger, brain, atlas, recall, cost, substrate, and dashboard (20 MCP tools
+ tools for ledger, brain, atlas, recall, cost, substrate, and dashboard (21 MCP tools
as of 0.8.x, including the write tools added in 0.8.0).
- **Cost dashboard** — `forge dash` serves a local HTML dashboard showing model spend,
event timeline, and ledger health from `.forge/` data.
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index 55da132..8fa2e7b 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -26,14 +26,14 @@ recipes, and how to extend each piece. If you just want to get going, the
Every command is real and wired. Grouped by what it does:
-| Group | Commands |
-| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Core** | `forge init` · `forge sync` · `forge doctor` · `forge tools` · `forge catalog` · `forge docs` · `forge update` · `forge config` |
-| **Substrate** | `forge substrate` · `forge preflight` · `forge impact` · `forge scope` · `forge context` · `forge route` · `forge verify` · `forge precommit` |
-| **Memory** | `forge cortex` · `forge recall` · `forge remember` · `forge brain` · `forge ledger` · `forge handoff` · `forge decide` · `forge know` |
-| **Quality** | `forge scan` · `forge spec` · `forge harden` · `forge radar` |
-| **Config** | `forge brand` · `forge atlas` · `forge stack` · `forge integrations` · `forge cost` |
-| **Labs (experimental)** | `forge taste` · `forge uicheck` · `forge imagine` · `forge lean` · `forge anchor` · `forge diagnose` · `forge dash` · `forge report` · `forge deja` · `forge reuse` · `forge rank` |
+| Group | Commands |
+| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Core** | `forge init` · `forge sync` · `forge doctor` · `forge tools` · `forge catalog` · `forge docs` · `forge update` · `forge config` |
+| **Substrate** | `forge substrate` · `forge preflight` · `forge impact` · `forge scope` · `forge context` · `forge route` · `forge verify` · `forge precommit` |
+| **Memory** | `forge cortex` · `forge recall` · `forge remember` · `forge brain` · `forge ledger` · `forge handoff` · `forge decide` · `forge know` |
+| **Quality** | `forge scan` · `forge spec` · `forge harden` · `forge radar` |
+| **Config** | `forge brand` · `forge atlas` · `forge stack` · `forge integrations` · `forge cost` |
+| **Labs (experimental)** | `forge taste` · `forge uicheck` · `forge imagine` · `forge lean` · `forge anchor` · `forge diagnose` · `forge dash` · `forge report` · `forge deja` · `forge reuse` · `forge rank` · `forge collide` |
Storage in one line: the code graph is `.forge/atlas.json` (plain JSON, not SQLite); the
@@ -283,6 +283,32 @@ Forge impact — blast radius
- src/session.js
```
+### `forge collide` — who else is in these files right now?
+
+The everyday failure of running agents in parallel: two sessions (yours and a
+teammate's — or two of your own) silently edit the same or import-coupled files and
+the conflict surfaces at merge time. The ledger already knows the answer — every
+session mints a summary claim listing the files it touched, and those claims
+team-merge over plain git — so `forge collide` is a pure read: no server, no presence
+protocol, no new storage. Risk composes by the house noisy-OR,
+`1 − ∏(1 − recᵢ × strengthᵢ)`, over recent foreign sessions: recency uses a
+deliberately short 7-day half-life (a collision is about _now_), and strength counts
+direct file hits full and 1-hop import neighbors half. Advisory, fail-open, and
+exposed to every MCP-capable agent as `collide_check` — an agent can ask "is anyone
+else in here?" before its first edit.
+
+```console
+$ forge collide
+ checking 2 file(s) in play
+ collision risk █████░░░ 0.68
+
+ amina day 20671 rec 0.91
+ direct src/ledger.js
+ coupled src/ledger_sync.js
+
+ advisory — coordinate or pull their ledger before editing the shared files
+```
+
### `forge rank` — what here is dangerous to touch?
The standing companion to `forge impact`: impact answers "what breaks if I change X",
@@ -1233,7 +1259,7 @@ one extra turn, exactly when that turn was owed.
> `forge substrate "" --json` (or the MCP tool `substrate_check`). If
> `okToProceed` is false, ask the questions first; read `impact.impactedFiles` before editing.
-…and exposes the substrate as **20 MCP tools** any MCP-capable agent can call directly
+…and exposes the substrate as **21 MCP tools** any MCP-capable agent can call directly
(the stdio server is launched with `forge cortex-mcp`, wired automatically via the
emitted `.mcp.json`):
@@ -1260,6 +1286,7 @@ emitted `.mcp.json`):
| `forge_provider_status` | Provider detection — which API provider is active (auto-detected or configured), env vars set, and health checks. |
| `forge_remember` | Store a durable fact in this repo's portable memory (.forge/brain/). |
| `forge_ledger_ratify` | Promote a ledger claim's confidence — record an independent oracle ratification (the claim held under test). |
+| `collide_check` | Parallel-session conflict radar — which recent teammate/agent sessions touched the files (or their import neighbors) you are about to edit, from the team-merged Forge ledger. |
| `rank_code` | Which code is load-bearing and dangerous to touch — PageRank centrality over the Forge atlas graph joined with past-incident history from the evidence ledger, plus circular-dependency clusters and chokepoint files whose removal disconnects the import graph. |
| `forge_ledger_retract` | Tombstone a ledger claim with a reason — mark it as no longer valid so it stops influencing routing and memory. |
diff --git a/mintlify/cli/substrate.mdx b/mintlify/cli/substrate.mdx
index 9d81825..184afee 100644
--- a/mintlify/cli/substrate.mdx
+++ b/mintlify/cli/substrate.mdx
@@ -43,6 +43,17 @@ Predict the blast radius for a symbol or file from the atlas graph.
forge impact
```
+## `forge collide`
+
+Parallel-session conflict radar: which recent teammate/agent sessions touched the
+files (or their import neighbors) you are editing, read from the team-merged ledger —
+`risk = 1 − ∏(1 − rec × strength)` over recent foreign sessions. Also exposed as the
+`collide_check` MCP tool.
+
+```bash
+forge collide […] [--json]
+```
+
## `forge rank`
Load-bearing code: weighted PageRank centrality over the atlas graph joined with
diff --git a/mintlify/concepts/config-compiler.mdx b/mintlify/concepts/config-compiler.mdx
index 311b9ba..cea0326 100644
--- a/mintlify/concepts/config-compiler.mdx
+++ b/mintlify/concepts/config-compiler.mdx
@@ -53,7 +53,7 @@ Each layer is brand-named and emitted cross-tool.
does not. Every enforceable invariant belongs here.
- Forge ships one stdio server (`src/cortex_mcp.js`) exposing 20 MCP tools: the
+ Forge ships one stdio server (`src/cortex_mcp.js`) exposing 21 MCP tools: the
substrate checks (`substrate_check` / `predict_impact` / `assumption_gate` / …),
memory reads _and_ writes (`forge_remember`, ledger ratify/retract), and ops/health.
diff --git a/mintlify/quickstart.mdx b/mintlify/quickstart.mdx
index 9869d29..3cd3525 100644
--- a/mintlify/quickstart.mdx
+++ b/mintlify/quickstart.mdx
@@ -70,7 +70,7 @@ forge substrate "Change verifyToken in src/auth.js to require length > 20; updat
On Claude Code the substrate runs on **every prompt automatically** via a
`UserPromptSubmit` hook — advisory only, silent on clean tasks. Every other tool gets
- a native config rule plus 20 MCP tools it can call itself.
+ a native config rule plus 21 MCP tools it can call itself.
If `forge substrate` says `ASK FIRST`, ask the returned questions before editing. Read
diff --git a/src/anchor.js b/src/anchor.js
index 8815e68..39b91b9 100644
--- a/src/anchor.js
+++ b/src/anchor.js
@@ -109,7 +109,9 @@ export function onGoalScore(goalTokens, fileTokens) {
// advisory coarse check; widen to a managed-file manifest if that ever matters.
const NOISE = /(^|\/)\.forge\/|(^|\/)\.[^/]+\/|^\.[^/]+$|(^|\/)(AGENTS|CLAUDE)\.md$/i;
-function gitFiles(root) {
+/** The working diff (changed vs HEAD + untracked), forge-noise filtered — exported so
+ * collide.js reads "what am I touching" the exact same way the drift check does. */
+export function gitFiles(root) {
const run = (args) => {
try {
return execFileSync("git", args, {
diff --git a/src/cli.js b/src/cli.js
index d781642..6fb3c29 100755
--- a/src/cli.js
+++ b/src/cli.js
@@ -884,7 +884,11 @@ HANDLERS.ledger = async (argv) => {
const parseDay = (s) => {
if (/^\d{1,6}$/.test(s ?? "")) return Number(s); // bare epoch-day
const t = Date.parse(`${s}T00:00:00Z`);
- return Number.isNaN(t) ? null : Math.floor(t / 86_400_000);
+ if (Number.isNaN(t)) return null;
+ // Round-trip check: Date.parse silently rolls impossible dates over (2026-02-31
+ // → March 3rd), which would answer a temporal query for a day nobody asked about.
+ if (new Date(t).toISOString().slice(0, 10) !== s) return null;
+ return Math.floor(t / 86_400_000);
};
if (sub === "at") {
const day = parseDay(args[2]);
@@ -928,6 +932,13 @@ HANDLERS.ledger = async (argv) => {
process.exitCode = 1;
return;
}
+ if (a > b) {
+ // beliefDiff's contract is dayA ≤ dayB; a reversed window would print silently
+ // inverted appeared/retired classes, so refuse loudly instead.
+ console.error(` (day ${a}) is after (day ${b}) — swap the arguments`);
+ process.exitCode = 1;
+ return;
+ }
const lg = await import("./ledger.js");
const d = lg.beliefDiff(ls.loadState(dir), a, b);
if (json) return console.log(JSON.stringify({ since: a, until: b, ...d }, null, 2));
@@ -1215,6 +1226,30 @@ HANDLERS.atlas = async (argv) => {
}
return;
};
+HANDLERS.collide = async (argv) => {
+ const { collideReport } = await import("./collide.js");
+ const json = argv.includes("--json");
+ const files = argv.slice(1).filter((a) => !a.startsWith("--"));
+ const r = collideReport(process.cwd(), { files });
+ if (json) return console.log(JSON.stringify(r, null, 2));
+ heading(`${BRAND.brand} collide — parallel-session conflict radar\n`);
+ if (!r.mine.length) return console.log(" working tree clean — nothing to collide with");
+ console.log(paint(` checking ${r.mine.length} file(s) in play`, "dim"));
+ if (!r.sessions.length)
+ return console.log(" no recent foreign session touched these files or their import neighbors");
+ console.log(` collision risk ${bar(r.risk, 8)} ${r.risk.toFixed(2)}\n`);
+ for (const s of r.sessions.slice(0, 8)) {
+ console.log(
+ ` ${paint(s.author || "(unknown)", "accent")} day ${s.day} ${paint(`rec ${s.rec.toFixed(2)}`, "dim")}`,
+ );
+ for (const f of s.direct) console.log(` ${paint("direct ", "warn")} ${f}`);
+ for (const f of s.coupled) console.log(` ${paint("coupled", "dim")} ${f}`);
+ }
+ console.log(
+ paint("\n advisory — coordinate or pull their ledger before editing the shared files", "dim"),
+ );
+ return;
+};
HANDLERS.rank = async (argv) => {
const { rankReport } = await import("./rank.js");
const json = argv.includes("--json");
diff --git a/src/collide.js b/src/collide.js
new file mode 100644
index 0000000..7518b2f
--- /dev/null
+++ b/src/collide.js
@@ -0,0 +1,108 @@
+// forge collide — the parallel-session conflict radar. The everyday failure of the
+// agent-fleet era: two sessions (your agent and a teammate's, or two of your own)
+// silently edit the same or import-coupled files and the collision surfaces only at
+// merge time. The ledger already holds the answer — deja mints a session-summary claim
+// (body.files) for every session, and those claims team-merge over git — so "who else
+// was just in here?" is a pure read: no server, no presence protocol, no new storage.
+// (The idea is old workspace-awareness research — Palantír-style conflict early
+// warning — rebuilt on a CRDT ledger instead of a central server.)
+//
+// The formula (DECISIONS are formulas): risk = 1 − ∏(1 − rec_i × s_i) — the house
+// noisy-OR over recent foreign sessions, where rec_i is the ledger's recency decay
+// (short half-life: a session from last month is not a collision) and s_i is the
+// touched-overlap strength: direct hits count full, import-coupled neighbors half.
+// Fail-open: no ledger, no git, no sessions → quiet empty report, never a block.
+import { gitFiles } from "./anchor.js";
+import { rec } from "./ledger.js";
+import { loadClaims, repoLedger } from "./ledger_store.js";
+import { importGraph } from "./scope.js";
+import { clamp01, epochDay, gitAuthor, toPosix } from "./util.js";
+
+/** Recency half-life for collision relevance, in days. Deliberately much shorter than
+ * the ledger's 45-day belief half-life: a collision is about what is happening NOW. */
+export const COLLIDE_HALF_LIFE_DAYS = 7;
+
+/** Strip a root prefix so absolute hook-minted paths match repo-relative graph paths
+ * (same normalization the rank history join needs — hook paths arrive absolute). */
+const relify = (p, prefix) => {
+ const posix = toPosix(String(p));
+ return prefix && posix.startsWith(prefix) ? posix.slice(prefix.length) : posix;
+};
+
+/**
+ * Pure collision scoring over session-summary claims.
+ * @param {any[]} claims live ledger claims (loadClaims output)
+ * @param {string[]} mine repo-relative files this session is touching
+ * @param {{nodes:string[], edges:Map>}} graph undirected import graph
+ * @param {{nowDay?:number, author?:string, root?:string, halfLife?:number}} [opts]
+ * author: sessions minted by this author are skipped (your own past work is not a
+ * collision); pass "" to keep everything.
+ * @returns {{risk:number, sessions:{id:string, author:string, day:number, rec:number,
+ * strength:number, direct:string[], coupled:string[]}[]}}
+ */
+export function collisions(
+ claims,
+ mine,
+ graph,
+ { nowDay = 0, author = "", root = "", halfLife = COLLIDE_HALF_LIFE_DAYS } = {},
+) {
+ const prefix = root ? `${toPosix(String(root)).replace(/\/+$/, "")}/` : "";
+ const mineSet = new Set(mine);
+ // 1-hop import neighborhood of my files — editing a file collides with sessions
+ // that touched what it imports or what imports it.
+ const near = new Set();
+ for (const f of mine) for (const n of graph.edges?.get(f) ?? []) if (!mineSet.has(n)) near.add(n);
+ const sessions = [];
+ for (const claim of claims ?? []) {
+ if (claim.kind !== "summary" || claim.tombstone) continue;
+ const who = claim.provenance?.author ?? "";
+ if (author && who === author) continue;
+ const files = [...new Set((claim.body?.files ?? []).map((f) => relify(f, prefix)))];
+ const direct = files.filter((f) => mineSet.has(f)).sort();
+ const coupled = files.filter((f) => near.has(f)).sort();
+ if (!direct.length && !coupled.length) continue;
+ const r = rec(claim, nowDay, { halfLife });
+ const strength = clamp01((direct.length + 0.5 * coupled.length) / Math.max(1, mine.length));
+ sessions.push({
+ id: claim.id,
+ author: who,
+ day: Math.max(claim.provenance?.t ?? 0, ...(claim.evidence ?? []).map((e) => e.t ?? 0)),
+ rec: Number(r.toFixed(4)),
+ strength: Number(strength.toFixed(4)),
+ direct,
+ coupled,
+ });
+ }
+ sessions.sort((a, b) => b.rec * b.strength - a.rec * a.strength || (a.id < b.id ? -1 : 1));
+ const risk = 1 - sessions.reduce((acc, s) => acc * (1 - s.rec * s.strength), 1);
+ return { risk: Number(risk.toFixed(4)), sessions };
+}
+
+/**
+ * The impure assembler: my working diff (or explicit files), the import graph, the
+ * ledger — composed into one advisory report.
+ * @param {string} root
+ * @param {{files?:string[]}} [opts]
+ */
+export function collideReport(root, { files } = {}) {
+ const mine = (files?.length ? files : gitFiles(root)).map((f) => toPosix(String(f)));
+ if (!mine.length) return { risk: 0, mine: [], sessions: [] };
+ let claims = [];
+ try {
+ claims = loadClaims(repoLedger(root));
+ } catch {
+ claims = []; // no ledger → quiet
+ }
+ let graph = { nodes: [], edges: new Map() };
+ try {
+ graph = importGraph(root);
+ } catch {
+ // unreadable tree → direct overlaps only
+ }
+ const r = collisions(claims, mine, graph, {
+ nowDay: epochDay(),
+ author: gitAuthor(),
+ root,
+ });
+ return { ...r, mine };
+}
diff --git a/src/commands.js b/src/commands.js
index 5ab7369..7f2ee02 100644
--- a/src/commands.js
+++ b/src/commands.js
@@ -94,6 +94,19 @@ export const COMMANDS = {
config: "provider setup — show / switch / add providers, set default model",
route: "recommend the cheapest capable model for a task (+ gateway config)",
impact: "predict blast radius for a symbol or file from the atlas graph",
+ collide: {
+ summary:
+ "parallel-session conflict radar — who else recently touched the files (or their import neighbors) you are editing",
+ usage: "forge collide […] [--json]",
+ flags: [
+ {
+ flag: "[…]",
+ desc: "check these files instead of the working diff",
+ },
+ { flag: "--json", desc: "machine-readable report" },
+ ],
+ examples: ["forge collide", "forge collide src/auth.js --json"],
+ },
rank: {
summary:
"load-bearing code — PageRank centrality × past-incident history, circular-dependency clusters, chokepoint files",
@@ -192,6 +205,7 @@ export const GROUPS = {
"deja",
"reuse",
"rank",
+ "collide",
],
};
diff --git a/src/cortex_mcp.js b/src/cortex_mcp.js
index 2166d4e..c01c573 100644
--- a/src/cortex_mcp.js
+++ b/src/cortex_mcp.js
@@ -155,6 +155,11 @@ async function callTool(name, args = {}) {
remember(store, String(args.name ?? ""), String(args.body ?? ""));
return `Remembered "${args.name}" in ${store}.`;
}
+ if (name === "collide_check") {
+ const { collideReport } = await import("./collide.js");
+ const files = Array.isArray(args.files) ? args.files.map(String) : [];
+ return JSON.stringify(collideReport(root, { files }), null, 2);
+ }
if (name === "rank_code") {
const { rankReport } = await import("./rank.js");
return JSON.stringify(rankReport(root, { top: Number(args.top ?? 15) || 15 }), null, 2);
diff --git a/src/ledger.js b/src/ledger.js
index aa5a0df..a76e73b 100644
--- a/src/ledger.js
+++ b/src/ledger.js
@@ -676,23 +676,29 @@ export function beliefDiff(
for (const c of after) {
const prev = before.get(c.id);
const text = claimText(c).slice(0, 120);
- if (!prev) {
- appeared.push({
+ // The tombstone test must run before the appeared test: a claim minted AND
+ // retracted inside the window came and went — reporting it as "appeared" with a
+ // live val would present a retracted claim as believed-at-dayB (review-verified).
+ if (c.tombstone && !prev?.tombstone) {
+ retired.push({
id: c.id,
kind: c.kind,
text,
- from: null,
- to: round4(val(c, dayB, { halfLife })),
+ from: prev ? round4(val(prev, dayA, { halfLife })) : null,
+ to: null,
});
continue;
}
- if (!prev.tombstone && c.tombstone) {
- retired.push({
+ // Already dead at dayA: a retired belief does not "strengthen" or "weaken" by
+ // pure decay — it is out of the belief set on both days, so no row at all.
+ if (prev?.tombstone && c.tombstone) continue;
+ if (!prev) {
+ appeared.push({
id: c.id,
kind: c.kind,
text,
- from: round4(val(prev, dayA, { halfLife })),
- to: null,
+ from: null,
+ to: round4(val(c, dayB, { halfLife })),
});
continue;
}
diff --git a/src/mcp_tools.js b/src/mcp_tools.js
index 4f10491..7792537 100644
--- a/src/mcp_tools.js
+++ b/src/mcp_tools.js
@@ -192,6 +192,21 @@ export const TOOLS = [
required: ["id"],
},
},
+ {
+ name: "collide_check",
+ description:
+ "Parallel-session conflict radar — which recent teammate/agent sessions touched the files (or their import neighbors) you are about to edit, from the team-merged Forge ledger. Advisory: coordinate before editing high-risk files.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ files: {
+ type: "array",
+ items: { type: "string" },
+ description: "files in play (default: the working diff)",
+ },
+ },
+ },
+ },
{
name: "rank_code",
description:
diff --git a/src/rank.js b/src/rank.js
index a7a584c..c0b780c 100644
--- a/src/rank.js
+++ b/src/rank.js
@@ -20,6 +20,11 @@ import { globToRe } from "./lessons.js";
import { directedImportGraph } from "./scope.js";
import { epochDay, toPosix } from "./util.js";
+/** Locale-independent codepoint comparison — localeCompare consults ICU collation
+ * tables that differ across machines/locales, which would break the "same ranking on
+ * two machines" guarantee this module makes. */
+const byCode = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
+
/** Node kinds that are containers/artifacts, not symbols — excluded from the symbol view. */
const NON_SYMBOL_KINDS = new Set(["module", "doc", "config", "unknown"]);
@@ -42,7 +47,7 @@ export function pagerank(atlas, { damping = 0.85, maxIter = 60, tol = 1e-9 } = {
const out = ids.map(() => []);
const outWeight = new Float64Array(n);
const sortedEdges = [...(atlas.edges ?? [])].sort(
- (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target),
+ (a, b) => byCode(a.source, b.source) || byCode(a.target, b.target),
);
for (const e of sortedEdges) {
if (e.unresolved) continue;
@@ -90,15 +95,19 @@ export function centrality(atlas, opts) {
const scores = pagerank(atlas, opts);
const byFile = new Map();
const symbols = [];
- for (const node of [...(atlas.nodes ?? [])].sort((a, b) => a.id.localeCompare(b.id))) {
+ const seen = new Set(); // pagerank dedups ids — the per-file sum must too, or a
+ // duplicate node id would count its score twice
+ for (const node of [...(atlas.nodes ?? [])].sort((a, b) => byCode(a.id, b.id))) {
+ if (seen.has(node.id)) continue;
+ seen.add(node.id);
const s = scores.get(node.id) ?? 0;
if (node.file) byFile.set(node.file, (byFile.get(node.file) ?? 0) + s);
if (!NON_SYMBOL_KINDS.has(node.kind) && node.name && node.file)
symbols.push({ id: node.id, name: node.name, file: node.file, score: s });
}
const files = [...byFile.entries()].map(([file, score]) => ({ file, score }));
- files.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file));
- symbols.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
+ files.sort((a, b) => b.score - a.score || byCode(a.file, b.file));
+ symbols.sort((a, b) => b.score - a.score || byCode(a.id, b.id));
return { files, symbols };
}
@@ -167,7 +176,7 @@ export function cycles(graph) {
}
}
}
- comps.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]));
+ comps.sort((a, b) => b.length - a.length || byCode(a[0], b[0]));
return comps;
}
@@ -220,7 +229,7 @@ export function chokepoints(graph) {
if (rootChildren > 1) splits.set(root, rootChildren - 1);
}
const out = [...splits.entries()].map(([file, s]) => ({ file, splits: s }));
- out.sort((a, b) => b.splits - a.splits || a.file.localeCompare(b.file));
+ out.sort((a, b) => b.splits - a.splits || byCode(a.file, b.file));
return out;
}
@@ -230,21 +239,33 @@ export function chokepoints(graph) {
* summary claims (deja session records) that list it. val() is the ledger's
* time-decayed Beta posterior, so stale incidents fade on the same clock everything
* else in the substrate uses. Pure; fail-open — no claims → all zeros.
+ *
+ * Path normalization is load-bearing, not cosmetic: hook-minted claims store the raw
+ * tool-input paths (ABSOLUTE — cortex_hook stores file_path verbatim), while atlas
+ * files are repo-relative POSIX. Without stripping the root prefix here, no
+ * production claim ever matches and the whole join is silently inert (review-verified
+ * against the live mint pipeline).
* @param {any[]} claims live ledger claims (loadClaims output)
- * @param {string[]} files repo-relative file paths
+ * @param {string[]} files repo-relative POSIX file paths
* @param {number} nowDay epoch day for val()
+ * @param {string} [root] repo root — absolute claim paths under it are relativized
* @returns {Map} file → history
*/
-export function history(claims, files, nowDay) {
+export function history(claims, files, nowDay, root = "") {
+ const prefix = root ? `${toPosix(String(root)).replace(/\/+$/, "")}/` : "";
+ const norm = (p) => {
+ const posix = toPosix(String(p));
+ return prefix && posix.startsWith(prefix) ? posix.slice(prefix.length) : posix;
+ };
const out = new Map(files.map((f) => [f, { weight: 0, hits: 0 }]));
for (const claim of claims ?? []) {
let touched = [];
if (claim.kind === "lesson") {
- const globs = claim.body?.trigger?.files ?? [];
+ const globs = (claim.body?.trigger?.files ?? []).map(norm);
if (globs.length)
touched = files.filter((f) => globs.some((g) => globToRe(String(g)).test(f)));
} else if (claim.kind === "summary") {
- const set = new Set((claim.body?.files ?? []).map((f) => toPosix(String(f))));
+ const set = new Set((claim.body?.files ?? []).map(norm));
touched = files.filter((f) => set.has(f));
}
if (!touched.length) continue;
@@ -269,7 +290,13 @@ const round6 = (x) => Number(x.toFixed(6));
* cycles?:string[][], chokepoints?:{file:string,splits:number}[]}}
*/
export function rankReport(root, { top = 15 } = {}) {
- const atlas = load(root);
+ top = Math.max(1, Math.floor(Number(top)) || 15); // a negative slice() would return the whole graph
+ let atlas = null;
+ try {
+ atlas = load(root); // corrupt atlas.json must degrade like a missing one, not crash
+ } catch {
+ atlas = null;
+ }
if (!atlas) return { built: false };
// One walk serves both graph readings: cycles need the directed edges, articulation
// points the undirected view derived from them.
@@ -292,6 +319,7 @@ export function rankReport(root, { top = 15 } = {}) {
claims,
files.map((f) => f.file),
epochDay(),
+ root,
);
const maxScore = files[0]?.score || 1;
const ranked = files.map(({ file, score }) => {
@@ -304,7 +332,7 @@ export function rankReport(root, { top = 15 } = {}) {
hazard: round6((score / maxScore) * (1 + h.weight)),
};
});
- ranked.sort((a, b) => b.hazard - a.hazard || a.file.localeCompare(b.file));
+ ranked.sort((a, b) => b.hazard - a.hazard || byCode(a.file, b.file));
return {
built: true,
nodes: (atlas.nodes ?? []).length,
diff --git a/test/cli_ledger.test.js b/test/cli_ledger.test.js
new file mode 100644
index 0000000..a7e9e38
--- /dev/null
+++ b/test/cli_ledger.test.js
@@ -0,0 +1,37 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+// CLI-boundary contracts for the temporal ledger subcommands — exit codes and
+// stderr routing are the interface here, so these spawn the real dispatcher.
+const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url));
+const run = (args, cwd) =>
+ spawnSync("node", [CLI, ...args], {
+ cwd,
+ encoding: "utf8",
+ env: { ...process.env, FORGE_NO_HINT: "1" },
+ });
+
+test("ledger diff with after refuses loudly instead of inverting classes", () => {
+ const root = mkdtempSync(join(tmpdir(), "forge-cliledger-"));
+ const r = run(["ledger", "diff", "2026-08-01", "2026-05-02"], root);
+ assert.equal(r.status, 1, "a reversed window must FAIL, not print inverted results");
+ assert.match(r.stderr, /is after/, "the reason names the swapped arguments");
+});
+
+test("ledger at rejects an impossible calendar date instead of letting Date.parse roll it over", () => {
+ const root = mkdtempSync(join(tmpdir(), "forge-cliledger-"));
+ const r = run(["ledger", "at", "2026-02-31"], root);
+ assert.equal(r.status, 1, "2026-02-31 is not a day that ever existed");
+ assert.match(r.stderr, /usage/, "rejected at parse time with usage");
+});
+
+test("ledger at accepts a real date and a bare epoch-day equally", () => {
+ const root = mkdtempSync(join(tmpdir(), "forge-cliledger-"));
+ assert.equal(run(["ledger", "at", "2026-08-01"], root).status, 0);
+ assert.equal(run(["ledger", "at", "20666"], root).status, 0);
+});
diff --git a/test/collide.test.js b/test/collide.test.js
new file mode 100644
index 0000000..b9492de
--- /dev/null
+++ b/test/collide.test.js
@@ -0,0 +1,113 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import { collideReport, collisions } from "../src/collide.js";
+import { mintClaim } from "../src/ledger.js";
+import { putClaim } from "../src/ledger_store.js";
+import { importGraph } from "../src/scope.js";
+
+const dir = () => mkdtempSync(join(tmpdir(), "forge-collide-"));
+
+const summary = (files, author, t) =>
+ mintClaim({
+ kind: "summary",
+ body: { files, text: `session by ${author}` },
+ scope: { level: "repo" },
+ provenance: { agent: "deja", author },
+ t,
+ }).claim;
+
+const graphOf = (edges) => ({
+ nodes: [...new Set(Object.keys(edges))],
+ edges: new Map(Object.entries(edges).map(([k, v]) => [k, new Set(v)])),
+});
+
+test("collisions flags direct overlap full-strength and import-coupled overlap at half", () => {
+ const g = graphOf({ "a.js": ["b.js"], "b.js": ["a.js"], "c.js": [] });
+ const direct = collisions([summary(["a.js"], "amina", 10)], ["a.js"], g, {
+ nowDay: 10,
+ });
+ const coupled = collisions([summary(["b.js"], "amina", 10)], ["a.js"], g, {
+ nowDay: 10,
+ });
+ assert.equal(direct.sessions[0].direct.length, 1);
+ assert.equal(coupled.sessions[0].coupled.length, 1);
+ assert.ok(
+ direct.risk > coupled.risk,
+ "touching my exact file is riskier than touching its import neighbor",
+ );
+ const unrelated = collisions([summary(["c.js"], "amina", 10)], ["a.js"], g, {
+ nowDay: 10,
+ });
+ assert.equal(unrelated.sessions.length, 0, "an uncoupled file is no collision at all");
+});
+
+test("collisions decays with recency and skips my own sessions and tombstoned summaries", () => {
+ const g = graphOf({ "a.js": [] });
+ const fresh = collisions([summary(["a.js"], "amina", 10)], ["a.js"], g, {
+ nowDay: 10,
+ });
+ const stale = collisions([summary(["a.js"], "amina", 10)], ["a.js"], g, {
+ nowDay: 40,
+ });
+ assert.ok(fresh.risk > stale.risk, "a month-old session is barely a collision");
+ const mine = collisions([summary(["a.js"], "me", 10)], ["a.js"], g, {
+ nowDay: 10,
+ author: "me",
+ });
+ assert.equal(mine.sessions.length, 0, "my own past session is not a collision");
+ const dead = { ...summary(["a.js"], "amina", 10), tombstone: { t: 11 } };
+ assert.equal(
+ collisions([dead], ["a.js"], g, { nowDay: 10 }).sessions.length,
+ 0,
+ "retracted summaries are ignored",
+ );
+});
+
+test("collisions relativizes absolute hook-minted paths against root (production shape)", () => {
+ const g = graphOf({ "src/a.js": [] });
+ const abs = summary(["/home/u/repo/src/a.js"], "amina", 10);
+ const r = collisions([abs], ["src/a.js"], g, {
+ nowDay: 10,
+ root: "/home/u/repo",
+ });
+ assert.equal(r.sessions.length, 1, "the absolute claim path matches after relativization");
+ assert.deepEqual(r.sessions[0].direct, ["src/a.js"]);
+});
+
+test("risk composes by noisy-OR — two half-risks beat either alone but stay under 1", () => {
+ const g = graphOf({ "a.js": [], "x.js": [] });
+ // mine = 2 files, each session touches 1 → strength 0.5 per session
+ const mine = ["a.js", "x.js"];
+ const two = collisions([summary(["a.js"], "amina", 10), summary(["x.js"], "sami", 10)], mine, g, {
+ nowDay: 10,
+ });
+ const one = collisions([summary(["a.js"], "amina", 10)], mine, g, {
+ nowDay: 10,
+ });
+ assert.equal(one.risk, 0.5, "one half-strength session → risk 0.5");
+ assert.equal(two.risk, 0.75, "noisy-OR: 1 − (1−0.5)² — compounds, never counts");
+ assert.ok(two.risk < 1, "risk is a probability, not a count");
+});
+
+test("collideReport is fail-open end-to-end and finds a real ledger session on disk", () => {
+ const root = dir();
+ writeFileSync(join(root, "a.js"), 'import "./b.js";\nexport const a = 1;\n');
+ writeFileSync(join(root, "b.js"), "export const b = 1;\n");
+ assert.deepEqual(
+ collideReport(root, { files: [] }).sessions ?? [],
+ [],
+ "no git, no ledger → quiet empty report, never a throw",
+ );
+ const today = 20670;
+ putClaim(join(root, ".forge", "ledger"), summary(["b.js"], "amina", today));
+ const r = collideReport(root, { files: ["a.js"] });
+ assert.equal(r.mine.length, 1);
+ assert.ok(
+ r.sessions.some((s) => s.coupled.includes("b.js")),
+ "the teammate's session on the imported file is surfaced as coupled",
+ );
+ assert.ok(importGraph(root).edges.get("a.js").has("b.js"), "via the real import graph");
+});
diff --git a/test/cortex_mcp.test.js b/test/cortex_mcp.test.js
index 4a3d880..af52c29 100644
--- a/test/cortex_mcp.test.js
+++ b/test/cortex_mcp.test.js
@@ -42,6 +42,7 @@ test("handle: tools/list exposes the cortex + preflight tools", async () => {
"forge_ledger_ratify",
"forge_ledger_retract",
"rank_code",
+ "collide_check",
]) {
assert.ok(names.includes(t), `exposes ${t}`);
}
diff --git a/test/ledger.test.js b/test/ledger.test.js
index 93db56e..cbf3d5c 100644
--- a/test/ledger.test.js
+++ b/test/ledger.test.js
@@ -685,3 +685,37 @@ test("stateRoot distinguishes states that liveClaims-level summaries could confl
assert.notEqual(stateRoot(plain).root, stateRoot(tombed).root);
assert.notEqual(stateRoot(state([])).root, stateRoot(plain).root, "empty ≠ one-claim");
});
+
+test("beliefDiff routes a claim minted AND tombstoned inside the window to retired, not appeared", () => {
+ const c = mintClaim({
+ kind: "fact",
+ body: { name: "flash", text: "came and went" },
+ t: 15,
+ }).claim;
+ const s = state(
+ [c],
+ {},
+ { [c.id]: [tomb("wrong", 20, "alice")] },
+ { [c.id]: [prov("alice", 15)] },
+ );
+ const d = beliefDiff(s, 10, 30);
+ assert.deepEqual(d.appeared, [], "a retracted claim is never presented as a live belief");
+ assert.equal(d.retired.length, 1, "the retraction inside the window is reported");
+ assert.equal(d.retired[0].from, null, "it did not exist at dayA");
+ assert.equal(d.retired[0].to, null, "and is not believed at dayB");
+});
+
+test("beliefDiff ignores claims already tombstoned before the window — dead beliefs do not move", () => {
+ const c = mintClaim({ kind: "fact", body: { name: "old", text: "long dead" }, t: 1 }).claim;
+ const s = state(
+ [c],
+ { [c.id]: [ev("confirm", 2)] },
+ { [c.id]: [tomb("obsolete", 5, "alice")] },
+ { [c.id]: [prov("alice", 1)] },
+ );
+ const d = beliefDiff(s, 10, 90);
+ assert.deepEqual(d.appeared, []);
+ assert.deepEqual(d.retired, [], "the retirement predates the window");
+ assert.deepEqual(d.strengthened, []);
+ assert.deepEqual(d.weakened, [], "pure decay on a dead claim is not a belief change");
+});
diff --git a/test/rank.test.js b/test/rank.test.js
index 6be93e5..b2ed3c2 100644
--- a/test/rank.test.js
+++ b/test/rank.test.js
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
-import { mkdtempSync, writeFileSync } from "node:fs";
+import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
@@ -178,3 +178,59 @@ test("rankReport ranks a historied file above an equally-central clean one (the
"hazard ordering surfaces the historied file first",
);
});
+
+test("history relativizes absolute hook-minted claim paths against root (the production shape)", () => {
+ // Production claims carry ABSOLUTE paths: cortex_hook stores tool_input.file_path
+ // verbatim into lesson trigger.files and deja summary body.files. The join must
+ // strip the repo root or it never matches the repo-relative atlas paths.
+ const root = "/home/u/repo";
+ const lesson = mintClaim({
+ kind: "lesson",
+ body: {
+ correctedBehavior: "guard the flow",
+ trigger: { action: "edit", files: ["/home/u/repo/src/app.js"], keywords: [], symbols: [] },
+ whatWentWrong: "app.js regressed",
+ },
+ scope: { level: "repo" },
+ provenance: { author: "amina" },
+ t: 10,
+ }).claim;
+ const summary = mintClaim({
+ kind: "summary",
+ body: { files: ["/home/u/repo/src/app.js"], text: "session touched app" },
+ scope: { level: "repo" },
+ provenance: { author: "amina" },
+ t: 10,
+ }).claim;
+ const h = history([lesson, summary], ["src/app.js"], 10, root);
+ assert.equal(h.get("src/app.js").hits, 2, "both claim kinds match after relativization");
+ assert.ok(h.get("src/app.js").weight > 0, "the hazard join is alive for production claims");
+ const without = history([lesson, summary], ["src/app.js"], 10);
+ assert.equal(without.get("src/app.js").hits, 0, "without root the absolute paths cannot match");
+});
+
+test("centrality counts a duplicated atlas node id once, like pagerank does", () => {
+ const atlas = hubAtlas();
+ atlas.nodes.push({ ...atlas.nodes[3] }); // exact duplicate of the hub node
+ const dup = centrality(atlas);
+ const clean = centrality(hubAtlas());
+ assert.equal(
+ dup.files.find((f) => f.file === "util.js").score,
+ clean.files.find((f) => f.file === "util.js").score,
+ "a duplicate node id must not double the file's score",
+ );
+});
+
+test("rankReport degrades on a corrupt atlas.json and clamps a negative --top", () => {
+ const root = mkdtempSync(join(tmpdir(), "forge-rank-"));
+ mkdirSync(join(root, ".forge"), { recursive: true });
+ writeFileSync(join(root, ".forge", "atlas.json"), "{not json");
+ assert.deepEqual(rankReport(root), { built: false }, "corrupt atlas degrades like a missing one");
+ const root2 = mkdtempSync(join(tmpdir(), "forge-rank-"));
+ writeFileSync(join(root2, "a.js"), "export const a = 1;\n");
+ writeFileSync(join(root2, "b.js"), 'import "./a.js";\n');
+ build({ root: root2 });
+ const r = rankReport(root2, { top: -5 });
+ assert.ok(r.built);
+ assert.equal(r.topFiles.length, 1, "negative top clamps to 1 instead of slicing the whole graph");
+});