From bed9042c172265a7208c988fc3688a19e6d8ff2e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:37:52 +0000 Subject: [PATCH] =?UTF-8?q?feat(substrate):=20implement=20M4=20goal-anchor?= =?UTF-8?q?ing=20=E2=80=94=20the=20one=20missing=20paper=20capability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs mapped "M4 goal-anchoring -> substrate" but no goal-drift logic existed. Of the paper's 11 capabilities, this was the only one that was documented but never built; the other 10 already ship a real mechanism. - src/anchor.js: goalDrift(root, goal) — a deterministic goal-drift check. Re-reads the stated objective against files actually changed (git diff HEAD + untracked, minus forge's own generated config) and splits them on-goal vs off-goal. Reuses referencedEntities + the atlas (a symbol-named goal anchors to the file its symbol lives in). Coarse/advisory by design, ceiling named. - forge anchor "" [--json] CLI command. - Folded into forge substrate (result.goalAnchor): quiet on a clean tree, speaks mid-session when work wandered off-goal — render + ambient advisory. - test/anchor.test.js: 4 tests (off-goal flagged, on-goal quiet, no-diff quiet, pure-drift flagged). Full suite 151/151; typecheck + biome check clean. - Docs made honest: the 11-point mapping now points M4 at `anchor`; README command list, GUIDE (worked example + workflow), and CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Eh3SNx15EeVbTmU4nH36XC --- CHANGELOG.md | 4 ++ README.md | 1 + docs/GUIDE.md | 22 ++++++ docs/cognitive-substrate/README.md | 12 ++-- src/anchor.js | 106 +++++++++++++++++++++++++++++ src/cli.js | 17 +++++ src/substrate.js | 17 +++++ test/anchor.test.js | 31 +++++++++ 8 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 src/anchor.js create mode 100644 test/anchor.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 959ed9f..6c89d96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **M4 goal-anchoring (`forge anchor`)** — the one paper capability that was mapped in the docs but never implemented. A deterministic goal-drift check: it re-reads the stated objective against the files actually changed (`git diff HEAD` + untracked, minus forge's own generated config) and flags work that wandered off-goal. Reuses `referencedEntities` + the atlas; folded into `forge substrate` (quiet on a clean tree, speaks mid-session on drift). All 11 paper capabilities now ship a real mechanism. + ### Changed - **Substrate now auto-runs in Claude Code.** The `UserPromptSubmit` hook injects the full substrate advisory (assumption gate + model routing + blast radius + memory + verify) when it matters, silent otherwise. Load-only — never builds/writes `.forge/` from a hook, fail-safe, never blocks. diff --git a/README.md b/README.md index 86cd7b7..755f281 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,7 @@ forge preflight assumption check — what a task names that the repo doesn't d forge route cheapest capable model for a task (+ gateway config) forge impact predict blast radius for a symbol or file forge scope decompose files into independent clusters +forge anchor goal-drift check — are your git changes still on the stated goal? forge verify independent verification — tests + hallucinated-symbol check forge uicheck deterministic WCAG contrast check diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 7ac9584..41629f4 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -47,6 +47,7 @@ forge atlas build # once: index symbols so impact/scope wo # then, for any non-trivial change: forge substrate "" # is it clear? which model? what breaks? split? # …make the edit… +forge anchor "" # still on track? flags changed files that drifted off-goal forge verify # prove it — real tests + hallucinated-symbol check ``` @@ -181,6 +182,27 @@ Forge scope — task decomposition [2] src/report.js ``` +### `forge anchor ""` — are your changes still on the stated goal? + +Goal-anchoring (the paper's M4): it re-reads your original objective against the files +you've *actually* changed (`git diff HEAD` + untracked, minus forge's own generated +config), and flags work that wandered off-goal. Quiet on a clean tree — it only speaks +once there's a diff to compare, so it's a mid-session "am I still on track?" check. + +```console +$ forge anchor "harden verifyToken in src/auth.js" +Forge anchor — goal-drift check + + changed: 2 file(s) · on-goal 1 · off-goal 1 + + off-goal (unrelated to the stated goal — intended, or drift?): + - src/report.js +``` + +`src/auth.js` maps to the goal (named file + where `verifyToken` lives); `src/report.js` +doesn't — so it's surfaced as drift to confirm or undo. Coarse and advisory by design +(path/keyword match, not semantic). `forge substrate` folds this in automatically. + ### `forge verify` — did it actually work? The independent check: runs the real test suite and flags edited symbols that aren't in diff --git a/docs/cognitive-substrate/README.md b/docs/cognitive-substrate/README.md index 3a1e8c3..cfe7a38 100644 --- a/docs/cognitive-substrate/README.md +++ b/docs/cognitive-substrate/README.md @@ -103,7 +103,7 @@ output (see [Use it in a script](#use-it-in-a-script)). --- -## The five checks (each is also its own command) +## The checks (each is also its own command) `forge substrate` bundles these. Run any one on its own when that's all you need. @@ -113,6 +113,7 @@ output (see [Use it in a script](#use-it-in-a-script)). | `forge route ""` | Which model is cheapest-but-capable? | trivial → Haiku · hard → Opus/Fable | | `forge impact ` | What will this edit break? | reverse-dependency blast radius | | `forge scope ` | Can this be split into sessions? | independent vs. coupled files | +| `forge anchor ""` | Are my changes still on the stated goal? | flags changed files that drifted off-goal | | `forge verify` | Did it actually work? | runs the real tests/build, not the model's word | Real output for the two most-used: @@ -236,7 +237,8 @@ over-engineered, and whether a past lesson is relevant. Tests and human correcti - **[Ecosystem map](./ecosystem_map.md)** — each capability vs. the real 2026 tool stack - **[Prototype source](../../research/python-prototypes/)** — the auditable Python originals -**How the paper maps to what ships:** memory → `recall`/`cortex` · learning → `cortex` · -imagination → `impact` · self-correction → `verify` · impact-awareness → `atlas`/`impact` · -M1 routing → `route` · M2 assumption gate → `preflight` · M3 decomposition → `scope` · -M4 goal-anchoring, M5 minimality, M6 verification → `substrate`. +**How the paper maps to what ships (all 11):** memory → `recall`/`cortex` · learning → +`cortex` · imagination → `impact` · self-correction → `verify`/`doom-loop` · +impact-awareness → `atlas`/`impact` · M1 routing → `route` · M2 assumption gate → +`preflight` · M3 decomposition → `scope` · M4 goal-anchoring → `anchor` · M5 minimality → +`lean`/`substrate` · M6 verification → `verify`/`substrate`. diff --git a/src/anchor.js b/src/anchor.js new file mode 100644 index 0000000..bee3089 --- /dev/null +++ b/src/anchor.js @@ -0,0 +1,106 @@ +// forge anchor — M4 goal-anchoring (the paper's continuous goal-drift check, made +// deterministic). Given the original objective, compare the files you've ACTUALLY +// changed (git) against the area the goal named. Flags work that has wandered off it. +// Advisory — a stated goal is re-read against real diffs, not trusted to stay in view. +import { execFileSync } from "node:child_process"; +import { load as loadAtlas, query as queryAtlas } from "./atlas.js"; +import { referencedEntities } from "./preflight.js"; + +const STOP = new Set( + "the a an to of in on for and or add fix make use update change new it its into with from this that be is are so all any".split( + " ", + ), +); + +function goalKeywords(goal) { + const { symbols, files } = referencedEntities(goal); + // Strip path/file tokens (src/auth.js) so their generic parts (src, js) don't become + // keywords that match every file — the files themselves are matched via goalTargetFiles. + const prose = String(goal) + .toLowerCase() + .replace(/\S*[/.]\S*/g, " "); + const words = prose.match(/[a-z][a-z0-9_-]{2,}/g) || []; + const keywords = new Set(words.filter((w) => !STOP.has(w))); + for (const s of symbols) keywords.add(s.toLowerCase()); + return { keywords: [...keywords], symbols, files }; +} + +// Machine-generated / tool-config noise that isn't the developer's own work: forge's +// cache and every config file `forge init` emits (dot-paths + AGENTS/CLAUDE). +// ponytail: this also hides drift in genuine dot-dir edits (.github/*) — fine for an +// advisory coarse check; widen to a managed-file manifest if that ever matters. +const NOISE = /(^|\/)\.forge\/|(^|\/)\.[^/]+\/|^\.[^/]+$|(^|\/)(AGENTS|CLAUDE)\.md$/i; + +function gitFiles(root) { + const run = (args) => { + try { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + return ""; + } + }; + const out = + run(["diff", "--name-only", "HEAD"]) + run(["ls-files", "--others", "--exclude-standard"]); + return [ + ...new Set( + out + .split("\n") + .map((s) => s.trim()) + .filter(Boolean), + ), + ].filter((f) => !NOISE.test(f)); +} + +// Files the goal's named symbols actually live in, so a symbol-named goal anchors to its +// file (e.g. "change verifyToken" anchors to src/auth.js, which the name alone wouldn't match). +function goalTargetFiles(root, symbols, files) { + const targets = new Set(files.map((f) => f.replace(/^\.?\//, ""))); + const atlas = loadAtlas(root); + if (atlas) + for (const s of symbols) + for (const hit of queryAtlas(atlas, s)) if (hit.name === s) targets.add(hit.file); + return [...targets]; +} + +/** + * @param {string} root + * @param {string} goal + * @param {{ changed?: string[] }} [opts] inject changed to skip git (used in tests) + */ +export function goalDrift(root, goal, opts = {}) { + const { keywords, symbols, files } = goalKeywords(goal); + const changedFiles = opts.changed || gitFiles(root); + const targets = goalTargetFiles(root, symbols, files).map((t) => t.toLowerCase()); + const onGoal = []; + const offGoal = []; + for (const f of changedFiles) { + const lf = f.toLowerCase(); + // ponytail: path/keyword match, not semantic — coarse on purpose (are you in the + // right AREA?), and errs toward "on-goal" so it never cries drift on a match. + const named = targets.some((t) => lf === t || lf.endsWith(`/${t}`)); + (named || keywords.some((k) => lf.includes(k)) ? onGoal : offGoal).push(f); + } + const drift = changedFiles.length > 0 && (offGoal.length > 0 || onGoal.length === 0); + return { goal: String(goal), keywords, changed: changedFiles, onGoal, offGoal, drift }; +} + +export function renderAnchor(r) { + const lines = ["Forge anchor — goal-drift check", ""]; + if (!r.changed.length) + return `${lines.join("\n")}\n no changes yet vs HEAD — nothing to check against the goal.`; + lines.push( + ` changed: ${r.changed.length} file(s) · on-goal ${r.onGoal.length} · off-goal ${r.offGoal.length}`, + ); + if (r.offGoal.length) { + lines.push("", " off-goal (unrelated to the stated goal — intended, or drift?):"); + for (const f of r.offGoal.slice(0, 12)) lines.push(` - ${f}`); + } + if (r.drift && !r.offGoal.length) + lines.push("", " ! no changed file matches the goal — are you working on the right thing?"); + if (!r.drift) lines.push("", " ✓ on goal — every change maps to what you set out to do."); + return lines.join("\n"); +} diff --git a/src/cli.js b/src/cli.js index 4c9c515..93bd200 100755 --- a/src/cli.js +++ b/src/cli.js @@ -24,6 +24,7 @@ const COMMANDS = { impact: "predict blast radius for a symbol or file from the atlas graph", 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?", uicheck: "deterministic UI check — WCAG contrast (assertable, no guessing)", brand: "print the active brand token map", }; @@ -463,6 +464,22 @@ async function run(argv) { console.log("\n advisory · auto-routing: `forge route gateway`"); return; } + if (cmd === "anchor") { + const { goalDrift, renderAnchor } = await import("./anchor.js"); + const json = argv.includes("--json"); + const goal = argv + .slice(1) + .filter((a) => a !== "--json") + .join(" "); + if (!goal) { + console.error('usage: forge anchor "" [--json]'); + process.exitCode = 1; + return; + } + const r = goalDrift(process.cwd(), goal); + console.log(json ? JSON.stringify(r, null, 2) : renderAnchor(r)); + return; // advisory — never fails the process + } if (cmd === "scope") { const { decompose } = await import("./scope.js"); const files = argv.slice(1); diff --git a/src/substrate.js b/src/substrate.js index cafc5b3..0f46159 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -5,6 +5,7 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { goalDrift } from "./anchor.js"; import { build as buildAtlas, impact as impactGraph, load as loadAtlas } from "./atlas.js"; import { matchingLessons } from "./cortex.js"; import { load as loadLessons } from "./lessons_store.js"; @@ -108,6 +109,9 @@ export function substrateCheck( })), }, minimality: { warnings: minimalityWarnings(text, route, preflight) }, + // M4 goal-anchoring: re-read the stated goal against files already changed this session. + // Quiet pre-action (clean tree → no drift); speaks mid-session when work wandered off-goal. + goalAnchor: goalDrift(root, text), verification: { checklist: verificationChecklist(root) }, substrate: loadSubstrateSpec(), guarantees: { @@ -121,6 +125,7 @@ export function substrateCheck( advisory: [ "model capability fit", "scope minimality", + "goal-drift check", "memory/learning relevance", "verification completeness", ], @@ -152,6 +157,13 @@ export function renderSubstrate(result) { lines.push("", " minimality warnings:"); for (const w of result.minimality.warnings) lines.push(` - ${w}`); } + if (result.goalAnchor?.drift) { + lines.push( + "", + ` goal drift: ${result.goalAnchor.offGoal.length} changed file(s) off the stated goal:`, + ); + for (const f of result.goalAnchor.offGoal.slice(0, 8)) lines.push(` - ${f}`); + } lines.push("", " verify:"); for (const c of result.verification.checklist) lines.push(` - ${c}`); return lines.join("\n"); @@ -166,6 +178,7 @@ export function substrateContext(result) { result.assumption.shouldAsk || result.impact.impactedFiles.length > 0 || result.minimality.warnings.length > 0 || + result.goalAnchor?.drift || ["opus", "fable"].includes(result.route.key); if (!worthSaying) return ""; const lines = ["Forge substrate — pre-action advisory (advisory, never blocks):"]; @@ -185,6 +198,10 @@ export function substrateContext(result) { ); } for (const w of result.minimality.warnings) lines.push(`- Minimality: ${w}`); + if (result.goalAnchor?.drift) + lines.push( + `- Goal drift: ${result.goalAnchor.offGoal.length} changed file(s) off the stated goal (${result.goalAnchor.offGoal.slice(0, 5).join(", ")}). Intended, or wandering?`, + ); if (result.memory.matchingLessons) lines.push(`- ${result.memory.matchingLessons} past lesson(s) match this area (advisory).`); lines.push(`- Verify with: ${result.verification.checklist.join(" · ")}`); diff --git a/test/anchor.test.js b/test/anchor.test.js new file mode 100644 index 0000000..e011b90 --- /dev/null +++ b/test/anchor.test.js @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { goalDrift, renderAnchor } from "../src/anchor.js"; + +// changed[] is injected so these are pure (no git needed). +test("goalDrift flags a changed file unrelated to the goal", () => { + const r = goalDrift("/nope", "add rate limiting to the login route", { + changed: ["src/login.js", "src/reports.js"], + }); + assert.ok(r.onGoal.includes("src/login.js")); + assert.ok(r.offGoal.includes("src/reports.js")); + assert.equal(r.drift, true); +}); + +test("goalDrift is quiet when every change maps to the goal", () => { + const r = goalDrift("/nope", "fix login validation", { changed: ["src/login.js"] }); + assert.equal(r.drift, false); + assert.deepEqual(r.offGoal, []); +}); + +test("goalDrift is quiet with no changes yet", () => { + const r = goalDrift("/nope", "anything at all", { changed: [] }); + assert.equal(r.drift, false); + assert.match(renderAnchor(r), /nothing to check/); +}); + +test("goalDrift flags pure drift — changes exist but none match the goal", () => { + const r = goalDrift("/nope", "update the billing invoice totals", { changed: ["src/auth.js"] }); + assert.equal(r.onGoal.length, 0); + assert.equal(r.drift, true); +});