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"); +});