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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ forge atlas build # once: index symbols so impact/scope wo
# then, for any non-trivial change:
forge substrate "<what you want to do>" # is it clear? which model? what breaks? split?
# …make the edit…
forge anchor "<the goal>" # still on track? flags changed files that drifted off-goal
forge verify # prove it — real tests + hallucinated-symbol check
```

Expand Down Expand Up @@ -181,6 +182,27 @@ Forge scope — task decomposition
[2] src/report.js
```

### `forge anchor "<goal>"` — 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
Expand Down
12 changes: 7 additions & 5 deletions docs/cognitive-substrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -113,6 +113,7 @@ output (see [Use it in a script](#use-it-in-a-script)).
| `forge route "<task>"` | Which model is cheapest-but-capable? | trivial → Haiku · hard → Opus/Fable |
| `forge impact <symbol\|file>` | What will this edit break? | reverse-dependency blast radius |
| `forge scope <file…>` | Can this be split into sessions? | independent vs. coupled files |
| `forge anchor "<goal>"` | 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:
Expand Down Expand Up @@ -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`.
106 changes: 106 additions & 0 deletions src/anchor.js
Original file line number Diff line number Diff line change
@@ -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");
}
17 changes: 17 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fg> <bg> (assertable, no guessing)",
brand: "print the active brand token map",
};
Expand Down Expand Up @@ -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 "<original goal>" [--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);
Expand Down
17 changes: 17 additions & 0 deletions src/substrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: {
Expand All @@ -121,6 +125,7 @@ export function substrateCheck(
advisory: [
"model capability fit",
"scope minimality",
"goal-drift check",
"memory/learning relevance",
"verification completeness",
],
Expand Down Expand Up @@ -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");
Expand All @@ -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):"];
Expand All @@ -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(" · ")}`);
Expand Down
31 changes: 31 additions & 0 deletions test/anchor.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading