diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a51b8..05b18af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, @@ -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 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index c483f53..b5bd614 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -424,6 +424,8 @@ Create `global/crew/.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** diff --git a/global/settings.template.json b/global/settings.template.json index 2f923fa..bebed60 100644 --- a/global/settings.template.json +++ b/global/settings.template.json @@ -79,6 +79,10 @@ "UserPromptSubmit": [ { "hooks": [ + { + "type": "command", + "command": "bash ~/.forge/guards/cortex.sh prompt" + }, { "type": "command", "command": "bash ~/.forge/guards/cortex.sh preflight" @@ -93,6 +97,10 @@ { "type": "command", "command": "bash ~/.forge/guards/recall-load.sh" + }, + { + "type": "command", + "command": "bash ~/.forge/guards/cortex.sh session-start" } ] } @@ -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": [ @@ -123,6 +140,10 @@ { "type": "command", "command": "bash ~/.forge/guards/format-on-edit.sh" + }, + { + "type": "command", + "command": "bash ~/.forge/guards/cortex.sh capture" } ] }, @@ -132,6 +153,10 @@ { "type": "command", "command": "bash ~/.forge/guards/secret-redact.sh" + }, + { + "type": "command", + "command": "bash ~/.forge/guards/cortex.sh capture" } ] } @@ -146,6 +171,10 @@ { "type": "command", "command": "bash ~/.forge/guards/session-learner.sh" + }, + { + "type": "command", + "command": "bash ~/.forge/guards/cortex.sh stop" } ] } diff --git a/src/cortex_hook_main.js b/src/cortex_hook_main.js index f6f55b9..ad3dd2e 100644 --- a/src/cortex_hook_main.js +++ b/src/cortex_hook_main.js @@ -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). @@ -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); } } diff --git a/src/substrate.js b/src/substrate.js index 0f257f7..dcf386b 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -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"}`); diff --git a/src/verify.js b/src/verify.js index e736745..1b69d6b 100644 --- a/src/verify.js +++ b/src/verify.js @@ -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, diff --git a/test/substrate.test.js b/test/substrate.test.js index 90b9b97..ffd0a6e 100644 --- a/test/substrate.test.js +++ b/test/substrate.test.js @@ -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/); +});