Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **M5 anti-over-engineering is now measured, not guessed (`forge lean`).** The paper's
`φ(y) − φ*(x)` check replaces the old three-keyword stub: `src/lean.js` reads the working diff
and flags the footprint beyond what the task asked for — new abstractions the task never named,
a large diff for a short ask, files touched beyond the stated scope. Folded into
`forge substrate` (a `minimality.footprint` field) and available standalone as `forge lean "<task>"`.
- **Doom-loop breaker (self-correction).** Complements the shell guard (which catches the *same
action* repeated) by catching the subtler loop the paper names — *different edits that keep
producing the same test failure*. `cortex_hook` now captures a normalized signature of failing
test output; `detectDoomLoop` fires when one signature recurs past a threshold, and the
pre-edit hook surfaces a "stop and find the root cause" advisory with the diagnosis.
- **Consequence simulation — failing-tests class (Eq 4).** `forge substrate` now predicts the
tests likely to break *before* an edit (`impact.predictedTests`): the impacted files that are
tests, plus each impacted source file's sibling test — surfaced so you run the narrowest
affected tests first, not after the fact.

### Changed

- **`forge sync` now adopts an existing project `CLAUDE.md` instead of skipping it.** Previously a
Expand Down
19 changes: 19 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const COMMANDS = {
substrate: "one pre-action gate: assumptions, route, impact, scope, memory, verify",
scope: "decompose files into independent clusters (+ coupled files you didn't name)",
anchor: "goal-drift check — are your actual (git) changes still on the stated goal?",
lean: "scope-minimality (M5) — measure the diff's footprint vs what the task asked for",
uicheck: "deterministic UI check — WCAG contrast <fg> <bg> (assertable, no guessing)",
brand: "print the active brand token map",
};
Expand Down Expand Up @@ -480,6 +481,24 @@ async function run(argv) {
console.log(json ? JSON.stringify(r, null, 2) : renderAnchor(r));
return; // advisory — never fails the process
}
if (cmd === "lean") {
const { leanRepo, renderLean } = await import("./lean.js");
const json = argv.includes("--json");
const task = argv
.slice(1)
.filter((a) => a !== "--json")
.join(" ");
if (!task) {
console.error(
'usage: forge lean "<task>" [--json] (measures the working diff vs the task)',
);
process.exitCode = 1;
return;
}
const r = leanRepo(process.cwd(), task);
console.log(json ? JSON.stringify(r, null, 2) : renderLean(r));
return; // advisory — never fails the process
}
if (cmd === "scope") {
const { decompose } = await import("./scope.js");
const files = argv.slice(1);
Expand Down
65 changes: 64 additions & 1 deletion src/cortex_hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ const slug = (s) =>
.replace(/(^-|-$)/g, "")
.slice(0, 40);

// A cheap, dependency-free signature of a failing test's output. Line numbers, hex addresses,
// timings, and temp paths are normalized out so "the same failure" hashes the same across runs
// even as surrounding noise shifts — this is what lets us catch a same-error doom loop.
function outputSignature(text) {
const norm = String(text)
.toLowerCase()
.replace(/0x[0-9a-f]+/g, "0xADDR")
.replace(/\b\d+(\.\d+)?(ms|s)\b/g, "T")
.replace(/:\d+:\d+/g, ":L:C")
.replace(/\b\d+\b/g, "N")
.replace(/\/tmp\/\S+/g, "/tmp/X")
.replace(/\s+/g, " ")
.trim()
.slice(0, 800);
let h = 5381;
for (let i = 0; i < norm.length; i++) h = ((h << 5) + h + norm.charCodeAt(i)) >>> 0;
return norm ? h.toString(36) : "";
}

/** Normalize a raw hook payload into a compact event, or null if it carries no signal. */
export function classifyEvent(hook) {
const tool = hook.tool_name;
Expand All @@ -55,10 +74,15 @@ export function classifyEvent(hook) {
return { type: "edit", file: inp.file_path ?? "" };
}
if (tool === "Bash") {
const exitCode = hook.exitCode;
const failed = typeof exitCode === "number" && exitCode !== 0;
const out = hook.tool_response?.stdout ?? hook.tool_response ?? hook.output ?? "";
return {
type: "bash",
command: inp.command ?? "",
exitCode: hook.exitCode,
exitCode,
// Only carry a signature for FAILED runs — that's all the doom-loop check needs.
...(failed && out ? { outputSig: outputSignature(out) } : {}),
};
}
if (hook.hook_event_name === "UserPromptSubmit" || typeof hook.prompt === "string") {
Expand Down Expand Up @@ -132,6 +156,45 @@ export function detectEpisodes(events, { nowDay = 0 } = {}) {
return episodes;
}

/**
* Doom-loop breaker (self-correction #5). The shell guard catches the SAME action repeated;
* this catches the subtler loop the paper names — different edits that keep producing the SAME
* test failure. When one failure signature recurs ≥ `threshold` times, the agent is stuck: halt
* and escalate with a diagnosis rather than burning more attempts. Pure + advisory.
* @returns {{loop:boolean, signature?:string, count?:number, files?:string[]}}
*/
export function detectDoomLoop(events, { threshold = 3 } = {}) {
const counts = new Map(); // outputSig -> occurrences
const filesAround = new Map(); // outputSig -> Set(files edited between failures)
let editsSinceFail = [];
for (const e of events) {
if (e.type === "edit" && e.file) {
editsSinceFail.push(e.file);
} else if (e.type === "bash" && e.outputSig) {
counts.set(e.outputSig, (counts.get(e.outputSig) ?? 0) + 1);
const set = filesAround.get(e.outputSig) ?? new Set();
for (const f of editsSinceFail) set.add(f);
filesAround.set(e.outputSig, set);
editsSinceFail = [];
}
}
let worst = null;
for (const [sig, count] of counts) {
if (count >= threshold && (!worst || count > worst.count)) {
worst = { signature: sig, count, files: [...(filesAround.get(sig) ?? [])] };
}
}
return worst ? { loop: true, ...worst } : { loop: false };
}

/** The advisory string for a detected doom loop (empty when there's no loop). */
export function doomLoopAdvisory(events, opts = {}) {
const r = detectDoomLoop(events, opts);
if (!r.loop) return "";
const where = r.files.length ? ` around ${r.files.slice(0, 5).join(", ")}` : "";
return `Forge Cortex — doom loop: the SAME test failure has recurred ${r.count}× this session${where}. Different edits aren't fixing it. Stop, find the root cause (re-read the failing assertion and the code it exercises), or ask a human — don't keep patching.`;
}

/** Drive the orchestrator from a session's events (called by the Stop hook). */
export function processSession(root, events, nowDay = 0) {
return detectEpisodes(events, { nowDay }).map((ep) =>
Expand Down
6 changes: 5 additions & 1 deletion src/cortex_hook_main.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
appendSessionEvent,
classifyEvent,
clearSession,
doomLoopAdvisory,
processSession,
readSession,
} from "./cortex_hook.js";
Expand Down Expand Up @@ -69,7 +70,10 @@ async function main() {
const block = startupBlock(root, today);
if (block) emit("SessionStart", block);
} else if (mode === "pre-edit") {
const advice = await preEditAdvisory(root, hook.tool_input?.file_path, today);
// A doom loop (the same failure recurring) is the loudest thing to say — it means "stop",
// so it takes precedence over lesson/risk advice.
const loop = doomLoopAdvisory(readSession(root, sid));
const advice = loop || (await preEditAdvisory(root, hook.tool_input?.file_path, today));
if (advice) emit("PreToolUse", advice);
} else if (mode === "preflight") {
// Ambient cognitive substrate: assumption gate + (when an atlas is already cached)
Expand Down
147 changes: 147 additions & 0 deletions src/lean.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// forge lean — M5 anti-over-engineering, made measurable. The paper flags φ(y) − φ*(x) > 0: the
// solution's footprint beyond the task's minimal sufficient footprint. The shipped substrate only
// had three keyword regexes; this measures the ACTUAL footprint from the working diff — files
// touched, lines added, and NEW abstractions introduced — against what the task NAMED, and flags
// the excess. Deterministic, git/diff-based, zero-dep. Advisory (never blocks); tests always win.
import { execFileSync } from "node:child_process";
import { referencedEntities } from "./preflight.js";

// A new top-level definition introduced on an added (+) diff line — the over-abstraction signal.
const NEW_DEF_RES = [
/^\+\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/,
/^\+\s*(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
/^\+\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/,
/^\+\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*=/,
// const/let/var bound to a function value (a real new abstraction, not a scalar)
/^\+\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/,
/^\+\s*def\s+([A-Za-z_]\w*)/,
/^\+\s*class\s+([A-Za-z_]\w*)/,
];

/** Pure: a unified diff → the actual footprint {files, linesAdded, newSymbols}. */
export function parseDiffFootprint(diff) {
const files = new Set();
const newSymbols = [];
let linesAdded = 0;
for (const line of String(diff).split("\n")) {
if (line.startsWith("+++ ")) {
const m = line.match(/^\+\+\+ (?:b\/)?(.+)$/);
if (m && m[1] !== "/dev/null") files.add(m[1].trim());
continue;
}
if (line.startsWith("+") && !line.startsWith("+++")) {
linesAdded += 1;
for (const re of NEW_DEF_RES) {
const m = line.match(re);
if (m) {
newSymbols.push(m[1]);
break;
}
}
}
}
return { files: [...files], linesAdded, newSymbols };
}

/**
* Pure: compare the actual footprint against what the task asked for and flag the excess.
* @param {string} task
* @param {{files:string[], linesAdded:number, newSymbols:string[]}} actual
*/
export function assessFootprint(task, actual, { maxLinesForShortTask = 120 } = {}) {
const { symbols: named, files: namedFiles } = referencedEntities(task);
const namedSymbols = new Set(named.map((s) => s.toLowerCase()));
const words = String(task).trim().split(/\s+/).filter(Boolean).length;
const warnings = [];

// Abstractions the task never named — the core φ(y) − φ*(x) signal.
const unrequested = [...new Set(actual.newSymbols)].filter(
(s) => !namedSymbols.has(s.toLowerCase()),
);
if (unrequested.length >= 3) {
warnings.push(
`${unrequested.length} new abstractions the task didn't ask for (${unrequested.slice(0, 5).join(", ")}) — is each one necessary, or is this over-built?`,
);
}

// A short ask that produced a large diff.
if (words <= 12 && actual.linesAdded > maxLinesForShortTask) {
warnings.push(
`${actual.linesAdded} lines added for a ${words}-word task — confirm the scope matches the request.`,
);
}

// Touched far more files than the task named.
if (namedFiles.length) {
const extra = actual.files.filter(
(f) => !namedFiles.some((nf) => f.endsWith(nf) || nf.endsWith(f)),
);
if (extra.length > Math.max(2, namedFiles.length * 2)) {
warnings.push(
`Touched ${actual.files.length} files but the task named ${namedFiles.length} — ${extra.length} are beyond the stated scope.`,
);
}
}

return {
warnings,
footprint: {
files: actual.files.length,
linesAdded: actual.linesAdded,
newAbstractions: [...new Set(actual.newSymbols)],
unrequestedAbstractions: unrequested,
},
};
}

function gitDiff(root, base) {
try {
const staged = execFileSync("git", ["diff", "--unified=0", base], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
return (
staged ||
execFileSync("git", ["diff", "--unified=0", "--cached"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
})
);
} catch {
return "";
}
}

/**
* Repo wrapper: measure the working-tree footprint against a task. `diff` injectable for tests.
* @param {string} root
* @param {string} task
* @param {object} [opts]
* @param {string} [opts.base]
* @param {string} [opts.diff]
*/
export function leanRepo(root, task, { base = "HEAD", diff } = {}) {
const d = diff ?? gitDiff(root, base);
return {
...assessFootprint(String(task || ""), parseDiffFootprint(d)),
hasDiff: Boolean(d.trim()),
};
}

export function renderLean(r) {
const lines = ["Forge lean — scope minimality (M5)", ""];
if (!r.hasDiff) return `${lines.join("\n")} no diff vs HEAD yet — nothing to measure.`;
const f = r.footprint;
lines.push(
` footprint: ${f.files} file(s), +${f.linesAdded} line(s), ${f.newAbstractions.length} new abstraction(s)`,
);
if (r.warnings.length) {
lines.push("", " possible over-engineering:");
for (const w of r.warnings) lines.push(` - ${w}`);
} else {
lines.push("", " ✓ footprint looks proportionate to the task.");
}
return lines.join("\n");
}
Loading
Loading