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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **Opt-in enforcing gate (`FORGE_ENFORCE=1`).** The substrate's assumption gate can now be a real
*halt* (the paper's Eq 5 / M2 "block on insufficient input"), not just advice. On the Claude Code
ambient path it blocks a prompt with **no concrete anchor at all** ("fix it", "make it better") —
or an action into a very large predicted blast radius — and returns the clarifying questions.
Deliberately low-false-positive: a specified task is never blocked, and it's **off by default**
(`enforceDecision()` in `src/substrate.js`).
- **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,
Expand All @@ -33,6 +39,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- **The Cortex capture/learn loop now works in the dotfile install too.** `global/settings.template.json`
wired only `cortex.sh preflight` (1 of 6 modes), so dotfile users got the substrate advisory but
**never captured events or distilled lessons** — the learning loop was dead for them while plugin
users had it. The template now wires all six modes (`session-start`, `prompt`, `preflight`,
`pre-edit`, `capture`, `stop`), matching `hooks/hooks.json`.
- **`forge verify` can't hang.** `runTests` now bounds the test run with a timeout
(`FORGE_VERIFY_TIMEOUT_MS`, default 10 min); a timeout is reported honestly as "did not complete",
never as a pass.
- **Secret-refusal no longer guts auth-related work.** `SECRET_RE` matched the bare words
`secret`/`password`/`api key`, so any task or lesson merely mentioning them was silently
refused — disabling the LLM proposer (`adjudicate`) and blocking memory persistence
Expand Down
2 changes: 2 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,8 @@ Create `global/crew/<name>.md` with frontmatter. It installs into `~/.claude/age
| when the ambient hook speaks | `src/substrate.js` → `substrateContext()` |
| the cross-tool rule wording | `source/rules.json` → `substrate` section (then `forge init`) |
| opt-in LLM adjudication | `FORGE_LLM=1` (+ `FORGE_LLM_AMBIENT=1` for the hook); config in `source/substrate.json` → `llm` |
| opt-in enforcing gate (halt, don't just advise) | `FORGE_ENFORCE=1` — blocks a no-anchor prompt or a very-large-blast action; `src/substrate.js` → `enforceDecision()`. Off by default. |
| verify test timeout | `FORGE_VERIFY_TIMEOUT_MS` (default 600000) |

### Opt into LLM-assisted judgments
By default every judgment is a deterministic rubric. `FORGE_LLM=1` adds a thin **proposer**
Expand Down
29 changes: 29 additions & 0 deletions global/settings.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh prompt"
},
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh preflight"
Expand All @@ -93,6 +97,10 @@
{
"type": "command",
"command": "bash ~/.forge/guards/recall-load.sh"
},
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh session-start"
}
]
}
Expand All @@ -114,6 +122,15 @@
"command": "bash ~/.forge/guards/doom-loop.sh"
}
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh pre-edit"
}
]
}
],
"PostToolUse": [
Expand All @@ -123,6 +140,10 @@
{
"type": "command",
"command": "bash ~/.forge/guards/format-on-edit.sh"
},
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh capture"
}
]
},
Expand All @@ -132,6 +153,10 @@
{
"type": "command",
"command": "bash ~/.forge/guards/secret-redact.sh"
},
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh capture"
}
]
}
Expand All @@ -146,6 +171,10 @@
{
"type": "command",
"command": "bash ~/.forge/guards/session-learner.sh"
},
{
"type": "command",
"command": "bash ~/.forge/guards/cortex.sh stop"
}
]
}
Expand Down
12 changes: 10 additions & 2 deletions src/cortex_hook_main.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
readSession,
} from "./cortex_hook.js";
import { load } from "./lessons_store.js";
import { substrateCheck, substrateContext } from "./substrate.js";
import { enforceDecision, substrateCheck, substrateContext } from "./substrate.js";

// Opt-in: distill newly-created lessons into real prose via a cheap model call. Off by
// default (deterministic template is used); fail-safe (any error → keep the template).
Expand Down Expand Up @@ -80,7 +80,15 @@ async function main() {
// model routing, blast-radius, memory, and minimality — surfaced before the agent acts.
// allowBuild:false keeps it cheap and never writes .forge/ from a hook; advisory only.
if (typeof hook.prompt === "string" && hook.prompt.trim()) {
const advisory = substrateContext(substrateCheck(root, hook.prompt, { allowBuild: false }));
const result = substrateCheck(root, hook.prompt, { allowBuild: false });
// Opt-in enforcing mode (FORGE_ENFORCE=1) turns the gate from advisory into a real halt on
// a vacuous prompt. Default: advisory, never blocks.
const gate = enforceDecision(result);
if (gate.block) {
process.stdout.write(JSON.stringify({ decision: "block", reason: gate.reason }));
return;
}
const advisory = substrateContext(result);
if (advisory) emit("UserPromptSubmit", advisory);
}
}
Expand Down
32 changes: 32 additions & 0 deletions src/substrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,38 @@ export function substrateCheck(
return result;
}

/**
* Opt-in mandatory gate (the paper's Eq 5 / M2 "halt on insufficient input"). Turns the advisory
* assumption gate into an actual BLOCK — but only on the strongest, lowest-false-positive signals,
* so it halts a vacuous prompt ("fix it", "make it better") or an edit into a very large blast
* radius, and never a specified task. Off unless `FORGE_ENFORCE=1` (or `enforce:true`); default
* behaviour is unchanged. `reason` is written to be shown to the agent.
* @param {object} result - substrateCheck() result
* @param {object} [opts]
* @param {boolean} [opts.enforce]
* @param {number} [opts.blastThreshold]
*/
export function enforceDecision(result, { enforce, blastThreshold = 25 } = {}) {
const on = typeof enforce === "boolean" ? enforce : process.env.FORGE_ENFORCE === "1";
if (!on || !result) return { block: false };
const tail = "\n(Set FORGE_ENFORCE=0 to make Forge advisory again.)";
if (result.assumption?.hardUnderspecified) {
const qs = (result.assumption.questions || []).map((q) => ` • ${q}`).join("\n");
return {
block: true,
reason: `Forge gate (enforcing): this task has no concrete anchor to act on — clarify before I start:\n${qs}${tail}`,
};
}
const blast = result.impact?.impactedFiles?.length ?? 0;
if (blast >= blastThreshold) {
return {
block: true,
reason: `Forge gate (enforcing): this touches a large blast radius (${blast} files predicted). Review the impacted files (or narrow the change) before editing.${tail}`,
};
}
return { block: false };
}

export function renderSubstrate(result) {
const lines = ["Forge substrate — pre-action check", ""];
lines.push(` proceed: ${result.okToProceed ? "yes" : "ASK FIRST"}`);
Expand Down
14 changes: 11 additions & 3 deletions src/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,19 +97,27 @@ function git(args, cwd) {
}
}

// Run the project's own tests (JS or Python). The gate trusts these, not a benchmark.
// Run the project's own tests (JS or Python). The gate trusts these, not a benchmark. Bounded by
// a timeout (FORGE_VERIFY_TIMEOUT_MS, default 10 min) so a hanging test can't hang the gate — a
// timeout is reported as an honest "did not complete", never as a pass.
function runTests(cwd) {
const timeout = Number(process.env.FORGE_VERIFY_TIMEOUT_MS) || 600000;
const run = (cmd, args) =>
execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: "pipe", timeout });
try {
if (existsSync(join(cwd, "package.json"))) {
execFileSync("npm", ["test"], { cwd, encoding: "utf8", stdio: "pipe" });
run("npm", ["test"]);
return { ran: true, passed: true, runner: "npm test" };
}
if (existsSync(join(cwd, "pyproject.toml")) || existsSync(join(cwd, "pytest.ini"))) {
execFileSync("pytest", ["-q"], { cwd, encoding: "utf8", stdio: "pipe" });
run("pytest", ["-q"]);
return { ran: true, passed: true, runner: "pytest" };
}
return { ran: false };
} catch (e) {
if (e.code === "ETIMEDOUT" || e.signal === "SIGTERM") {
return { ran: true, passed: false, timedOut: true, output: `test run exceeded ${timeout}ms` };
}
return {
ran: true,
passed: false,
Expand Down
36 changes: 36 additions & 0 deletions test/substrate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,39 @@ test("substrateCheck surfaces impact.predictedTests", () => {
const r = substrateCheck(root, "Update `computeTax` in `math.js`");
assert.ok(Array.isArray(r.impact.predictedTests), "predictedTests present");
});

test("enforceDecision: off by default → never blocks", async () => {
const { enforceDecision } = await import("../src/substrate.js");
const r = {
assumption: { hardUnderspecified: true, questions: ["what?"] },
impact: { impactedFiles: [] },
};
assert.equal(enforceDecision(r, { enforce: false }).block, false);
});

test("enforceDecision (enforcing): blocks a vacuous prompt, not a specified one", async () => {
const { enforceDecision } = await import("../src/substrate.js");
const vacuous = {
assumption: { hardUnderspecified: true, questions: ["What should this produce?"] },
impact: { impactedFiles: [] },
};
const g = enforceDecision(vacuous, { enforce: true });
assert.equal(g.block, true);
assert.match(g.reason, /no concrete anchor/);
const specified = {
assumption: { hardUnderspecified: false, questions: [] },
impact: { impactedFiles: ["a.js"] },
};
assert.equal(enforceDecision(specified, { enforce: true }).block, false);
});

test("enforceDecision (enforcing): blocks an edit into a large blast radius", async () => {
const { enforceDecision } = await import("../src/substrate.js");
const big = {
assumption: { hardUnderspecified: false, questions: [] },
impact: { impactedFiles: Array.from({ length: 30 }, (_, i) => `f${i}.js`) },
};
const g = enforceDecision(big, { enforce: true, blastThreshold: 25 });
assert.equal(g.block, true);
assert.match(g.reason, /large blast radius/);
});
Loading