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

## [Unreleased]

### Added

- **Opt-in LLM adjudication for the substrate (`FORGE_LLM=1`)** — one shared, fail-safe `claude -p` proposer (`src/adjudicate.js`) wired thinly into the assumption gate (M2), model routing (M1), impact/blast-radius, and goal-drift (M4). The model only *proposes*; every proposal is verified against the deterministic rubric, the code graph, or a grep before it can move a verdict (routing raise-only; the gate tightens-only; impact edges must resolve and grep-confirm; goal-drift off→on only). Off by default — behaviour is unchanged unless enabled — never blocks, and the ambient Claude Code hook stays deterministic unless `FORGE_LLM_AMBIENT=1`. `forge substrate --json` now carries an `llm.provenance` map (`deterministic` / `llm-verified` / `llm-agreed`) per faculty for auditability.
- **Explicit memory `val` term** — lesson retrieval now decomposes into the white paper's `relevance × freshness × validity × scope`, with `validity()` (a ground-truth Beta posterior over confirmed vs. contradicted outcomes) exported and ranked so outcome-confirmed lessons outrank merely-recent ones.

### Changed

- **Unified the model-call path** — the Cortex distiller now shares the `adjudicate` runner instead of its own `claude` shell-out.

## [0.4.0] - 2026-07-06

### Added
Expand Down
11 changes: 11 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,17 @@ Create `global/crew/<name>.md` with frontmatter. It installs into `~/.claude/age
| the verify checklist | `src/substrate.js` → `verificationChecklist()` |
| 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 into LLM-assisted judgments
By default every judgment is a deterministic rubric. `FORGE_LLM=1` adds a thin **proposer**
layer (`src/adjudicate.js`) — one shared `claude -p` primitive used by M2/M1/impact/M4. The
model never decides: each proposal is verified against the rubric, the code graph, or a grep
before it can move a verdict (routing raise-only; the gate tightens-only; impact edges must be
real + grep-confirmed; goal-drift off→on only). Any failure falls back to the deterministic
path, so the flag is safe to leave off or on. `--json` exposes `llm.provenance` per faculty.
Each faculty pairs a pure `*LLM` proposer with a `reconcile` step — extend by adding both,
never by trusting the model's answer directly.

### Support a new tool
Add an emitter module in `src/emit/<tool>.js` (mirror an existing one like
Expand Down
34 changes: 30 additions & 4 deletions docs/cognitive-substrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,39 @@ JSON default. Full JSON shape and the extension table:

---

## Optional: LLM-assisted judgments (`FORGE_LLM=1`)

The substrate's default judgments are deterministic rubrics — no model call, no dependency.
Set **`FORGE_LLM=1`** to add a **thin, opt-in semantic layer** on top: a cheap `claude -p`
call proposes a completeness reading (M2), a complexity band (M1), the coupled edges the
regex graph misses (impact), and whether an off-goal file actually serves the goal (M4).

The model is **never the judge — only a proposer.** Every proposal is *verified* before it can
move a verdict, in the direction of the paper's *tabayyun* gate (49:6):

- **routing** can only be *raised* (`max` with the rubric), never lowered on the model's word;
- **the assumption gate only tightens** — it can add an ask, never clear a deterministic one;
- **impact edges** are kept only if the file is real *and* a grep confirms the reference;
- **goal-drift** rescues an off-goal file only with a goal-referencing reason (off→on only).

It is **fail-safe**: any error, timeout, or unparseable reply falls back to the deterministic
path (behaviour is byte-identical with the flag off), and it **never blocks**. `--json` output
carries a `llm.provenance` map (`deterministic` / `llm-verified` / `llm-agreed`) per faculty so
every model-touched decision is auditable. Off by default; the ambient Claude Code hook stays
deterministic unless you also set `FORGE_LLM_AMBIENT=1`. Config lives in
[`source/substrate.json`](../../source/substrate.json) → `llm`.

---

## Honest limits

Heuristic, not benchmarked; the graph is regex-approximate (conservative, not a sound call
graph); assumption detection is lexical; auto-run needs a hook surface (ambient on Claude
Code, agent-invoked elsewhere). What's *asserted* is safe to gate on (repo grounding, graph
traversal, scope, routing arithmetic, test commands); everything else is *advisory*. Tests
and human corrections always win. Full list:
graph); assumption detection is lexical by default (opt into a verified LLM refinement with
`FORGE_LLM=1` — see above); auto-run needs a hook surface (ambient on Claude Code,
agent-invoked elsewhere). What's *asserted* is safe to gate on (repo grounding, graph
traversal, scope, routing arithmetic, test commands); everything else is *advisory* — including
every LLM proposal, which is verified against the repo/graph/tests before it counts and is
never trusted blind. Tests and human corrections always win. Full list:
[docs/GUIDE.md → Honest limits](../GUIDE.md#honest-limits).

---
Expand Down
6 changes: 6 additions & 0 deletions source/substrate.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
{ "id": "M6", "name": "inline verification", "command": "forge verify", "status": "partial" }
],
"defaults": { "askThreshold": 0.6, "impactThreshold": 0.1 },
"llm": {
"enabled": false,
"model": "haiku",
"timeoutMs": 20000,
"note": "Opt-in (FORGE_LLM=1). The model PROPOSES; the deterministic rubric + repo grounding + tests VERIFY. Any failure falls back to the deterministic path — never blind, never blocking."
},
"policies": {
"preAction": "Run substrate before ambiguous, expensive, multi-file, or mutating work.",
"escalation": "Start with the cheapest capable tier and escalate only after an external verifier failure.",
Expand Down
82 changes: 82 additions & 0 deletions src/adjudicate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// forge adjudicate — the substrate's ONE shared "LLM proposes, checks verify" primitive.
// The whitepaper's load-bearing rule (Panickssery et al. C12; tabayyun, 49:6) is that a
// model is never its own judge: it may PROPOSE, but an external check arbitrates. This module
// supplies only the proposer half — a cheap, opt-in, fail-safe model call — while each faculty
// keeps its deterministic rubric as the judge and reconciles the two (verify, don't trust).
//
// Design contract (identical to src/cortex_distill.js, generalized so all faculties share it):
// - OPT-IN. Off by default (FORGE_LLM!=1) → callers keep their deterministic result and this
// module is never invoked. Behavior is byte-identical to the pre-LLM substrate.
// - FAIL-SAFE. Any error/timeout/garble/secret → returns null. A null NEVER changes a verdict.
// - ZERO-DEP. Access is a `claude -p` CLI shell-out; the runner is injectable so the pure
// prompt/parse/verify logic is fully testable without the CLI or the network.
import { execFileSync } from "node:child_process";
import { SECRET_RE } from "./recall.js";

/**
* Is the LLM proposer layer active for this call? Explicit opt-in wins; env is the default.
* @param {{llm?:boolean}} [opts]
*/
export function llmEnabled(opts = {}) {
if (typeof opts.llm === "boolean") return opts.llm;
return process.env.FORGE_LLM === "1";
}

/** Build an injectable `claude -p` runner. Pure config in, a (prompt)->string runner out. */
export function buildRunner({ model = "haiku", timeoutMs = 20000 } = {}) {
return (prompt) =>
execFileSync("claude", ["-p", "--model", model], {
input: prompt,
encoding: "utf8",
timeout: timeoutMs,
stdio: ["pipe", "pipe", "ignore"],
});
}

/** Extract the first balanced-ish JSON object from model output, or null. */
export function extractJson(text) {
if (!text) return null;
const match = String(text).match(/\{[\s\S]*\}/);
if (!match) return null;
try {
return JSON.parse(match[0]);
} catch {
return null;
}
}

/**
* Run one adjudication: send `prompt`, parse+validate the reply, refuse secrets both ways.
* The proposer half only — the caller must still verify the returned value against ground truth.
* @template T
* @param {{prompt:string, parse:(obj:any)=>(T|null), run?:(p:string)=>string}} spec
* @returns {T|null} the validated proposal, or null on ANY failure (caller keeps deterministic).
*/
export function adjudicate({ prompt, parse, run = buildRunner() }) {
try {
if (SECRET_RE.test(String(prompt))) return null; // never send a secret to the model
const raw = run(prompt);
if (SECRET_RE.test(String(raw))) return null; // never trust a reply that leaked one back
const obj = extractJson(raw);
if (obj == null) return null;
const parsed = parse(obj);
return parsed ?? null;
} catch {
return null;
}
}

const clamp01 = (x) => Math.max(0, Math.min(1, x));

/** Coerce an unknown model field to a number in [0,1], or null if it isn't one. */
export function asUnit(v) {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? clamp01(n) : null;
}

/** Coerce an unknown model field to a trimmed, length-capped string (empty → ""). */
export function asText(v, cap = 200) {
return String(v ?? "")
.trim()
.slice(0, cap);
}
81 changes: 79 additions & 2 deletions src/anchor.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// 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 { adjudicate, asText, buildRunner, llmEnabled } from "./adjudicate.js";
import { load as loadAtlas, query as queryAtlas } from "./atlas.js";
import { referencedEntities } from "./preflight.js";

Expand Down Expand Up @@ -66,10 +67,47 @@ function goalTargetFiles(root, symbols, files) {
return [...targets];
}

// M4 goal-drift — LLM proposer. Semantically classifies the files the coarse keyword match
// flagged as off-goal. PROPOSER ONLY, and one-directional: the model may only move a file
// off→on (never on→off), and only with a reason that references the goal — so it can quiet a
// false drift flag but can never hide a real one. Preserves the "errs toward on-goal" invariant.
export function buildDriftPrompt(goal, offGoalFiles) {
const list = offGoalFiles
.slice(0, 40)
.map((f) => `- ${f}`)
.join("\n");
return `A developer's stated goal is: """${String(goal).slice(0, 400)}"""
These changed files did NOT obviously match the goal by name:
${list}
For each, decide if the change is plausibly IN SERVICE of that goal. Answer with STRICT JSON and
nothing else, listing only files from above that genuinely serve the goal:
{"onGoal":[{"file":"<path>","reason":"<why it serves the goal>"}]}
Omit files that do not serve the goal. No text outside the JSON object.`;
}

export function parseDriftProposal(obj) {
const onGoal = Array.isArray(obj.onGoal)
? obj.onGoal
.map((e) => ({ file: asText(e?.file, 240), reason: asText(e?.reason, 200) }))
.filter((e) => e.file && e.reason)
: [];
return { onGoal };
}

export function driftLLM(goal, offGoalFiles, { run = buildRunner() } = {}) {
if (!offGoalFiles.length) return { onGoal: [] };
return adjudicate({
prompt: buildDriftPrompt(goal, offGoalFiles),
parse: parseDriftProposal,
run,
});
}

/**
* @param {string} root
* @param {string} goal
* @param {{ changed?: string[] }} [opts] inject changed to skip git (used in tests)
* @param {{ changed?: string[], llm?:boolean, run?:(p:string)=>string, model?:string, timeoutMs?:number }} [opts]
* inject `changed` to skip git; inject `run` to stub the model (used in tests).
*/
export function goalDrift(root, goal, opts = {}) {
const { keywords, symbols, files } = goalKeywords(goal);
Expand All @@ -84,8 +122,47 @@ export function goalDrift(root, goal, opts = {}) {
const named = targets.some((t) => lf === t || lf.endsWith(`/${t}`));
(named || keywords.some((k) => lf.includes(k)) ? onGoal : offGoal).push(f);
}

// Opt-in semantic pass: rescue files the keyword match missed, but only off→on and only when
// the model gives a goal-referencing reason. Verified, not trusted; fail-safe on any error.
let provenance = { path: "deterministic" };
if (llmEnabled({ llm: opts.llm }) && offGoal.length) {
const goalTerms = new Set([...keywords, ...symbols.map((s) => s.toLowerCase())]);
const grounded = (reason) => {
const words = String(reason).toLowerCase();
return goalTerms.size === 0 || [...goalTerms].some((t) => words.includes(t));
};
const proposal = driftLLM(goal, offGoal, {
run: opts.run || buildRunner({ model: opts.model, timeoutMs: opts.timeoutMs }),
});
const rescued = new Set(
(proposal?.onGoal || [])
.filter((e) => offGoal.includes(e.file) && grounded(e.reason))
.map((e) => e.file),
);
if (rescued.size) {
for (const f of [...offGoal]) {
if (rescued.has(f)) {
offGoal.splice(offGoal.indexOf(f), 1);
onGoal.push(f);
}
}
provenance = { path: "llm-verified", rescued: [...rescued] };
} else if (proposal) {
provenance = { path: "llm-agreed" };
}
}

const drift = changedFiles.length > 0 && (offGoal.length > 0 || onGoal.length === 0);
return { goal: String(goal), keywords, changed: changedFiles, onGoal, offGoal, drift };
return {
goal: String(goal),
keywords,
changed: changedFiles,
onGoal,
offGoal,
drift,
provenance,
};
}

export function renderAnchor(r) {
Expand Down
74 changes: 71 additions & 3 deletions src/atlas.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { extname, join, relative } from "node:path";
import { adjudicate, asText, buildRunner, llmEnabled } from "./adjudicate.js";

const IGNORE = new Set([
"node_modules",
Expand Down Expand Up @@ -296,7 +297,50 @@ function targetIds(atlas, target) {

const EDGE_WEIGHT = { calls: 0.95, imports: 0.85, inherits: 0.92, references: 0.7, contains: 0.45 };

export function impact(atlas, target, { threshold = 0.1, maxHops = 6, decay = 0.85 } = {}) {
// Imagination (§8) — LLM proposer for the edges the regex graph structurally misses: dynamic
// dispatch, DI, reflection, string-keyed lookups. PROPOSER ONLY. Every candidate is then
// verified twice — it must resolve to a REAL node in the graph AND (via the caller's `verify`
// predicate, a grep) actually reference the target in source. Unverifiable → dropped, never added.
export function buildImpactPrompt(atlas, target) {
const files = [...new Set((atlas.nodes || []).map((n) => n.file).filter(Boolean))].slice(0, 60);
return `A code symbol/file is about to change. Name the OTHER files in this repo that most
likely break or depend on it through edges a regex misses: dynamic dispatch, dependency
injection, reflection, string-keyed registries, event handlers.
Changing target: ${String(target).slice(0, 120)}
Files in repo:
${files.map((f) => `- ${f}`).join("\n")}
Answer with STRICT JSON and nothing else, listing only files from the list above:
{"files":["<path>"...]}
No text outside the JSON object.`;
}

export function parseImpactProposal(obj) {
const files = Array.isArray(obj.files)
? [...new Set(obj.files.map((f) => asText(f, 240)).filter(Boolean))].slice(0, 20)
: [];
return { files };
}

export function impactLLM(atlas, target, { run = buildRunner() } = {}) {
return adjudicate({ prompt: buildImpactPrompt(atlas, target), parse: parseImpactProposal, run });
}

/**
* @param {object} atlas
* @param {string} target
* @param {object} [opts]
* @param {number} [opts.threshold]
* @param {number} [opts.maxHops]
* @param {number} [opts.decay]
* @param {boolean} [opts.llm]
* @param {(p:string)=>string} [opts.run]
* @param {(file:string, target:string)=>boolean} [opts.verify]
*/
export function impact(
atlas,
target,
{ threshold = 0.1, maxHops = 6, decay = 0.85, llm, run, verify } = {},
) {
const starts = targetIds(atlas, target);
const nodeById = new Map((atlas.nodes || []).map((n) => [n.id, n]));
const incoming = new Map();
Expand Down Expand Up @@ -337,12 +381,36 @@ export function impact(atlas, target, { threshold = 0.1, maxHops = 6, decay = 0.
}
}
const impacted = [...visited.values()].sort((a, b) => b.confidence - a.confidence);
const deterministicFiles = new Set(impacted.map((x) => x.node.file).filter(Boolean));

// Opt-in imagination pass: model proposes missed edges, but only VERIFIED ones are kept.
const llmImpacted = [];
if (llmEnabled({ llm }) && run) {
const knownFiles = new Set((atlas.nodes || []).map((n) => n.file).filter(Boolean));
const proposal = impactLLM(atlas, target, { run });
for (const file of proposal?.files || []) {
if (deterministicFiles.has(file)) continue; // already found deterministically
if (!knownFiles.has(file)) continue; // must be a real file in the graph
if (typeof verify === "function" && !verify(file, target)) continue; // must grep-confirm the ref
if (typeof verify !== "function") continue; // no external check available → never add blind
llmImpacted.push({
id: `llm:${file}`,
node: { id: `llm:${file}`, name: file, kind: "module", file },
confidence: Number((threshold * 0.9).toFixed(4)),
hopDistance: null,
source: "llm-verified",
});
}
}

const all = [...impacted, ...llmImpacted];
return {
target,
found: starts.length > 0,
threshold,
impacted,
impactedFiles: [...new Set(impacted.map((x) => x.node.file).filter(Boolean))].sort(),
impacted: all,
impactedFiles: [...new Set(all.map((x) => x.node.file).filter(Boolean))].sort(),
llmVerified: llmImpacted.map((x) => x.node.file),
totalGraphNodes: (atlas.nodes || []).length,
totalGraphEdges: (atlas.edges || []).length,
};
Expand Down
Loading
Loading