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

## [Unreleased]

### Fixed

- **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
(`recall`/`lessons`) for exactly the high-risk code you most want help on. The word arm now
requires a value-shaped assignment (`password = "…"`, `SECRET_KEY: …`); credential *formats*
(`sk-…`, `ghp_…`, JWTs, …) are still refused.
- **One malformed file no longer takes down memory.** `lessons_store.load`/`readEpisodes` and
`cortex_hook.readSession` now skip a corrupt lesson file / JSONL line instead of throwing
(which previously broke retrieval, routing, and the pre-edit advisory everywhere `load` is used).
- **`recordMistake` reports `refused` (not `created`) when a save is rejected**, so the Stop hook
never tries to distill a phantom lesson; `applyDistillation`/`recordContradiction` surface the
real write result too.
- **Atlas emits `inherits` edges** (`class X extends Y`; Python `class X(Base)`) — the weight was
defined but never produced, so base-class changes were invisible to blast-radius.
- **Atlas is incremental + staleness-aware.** `build()` reuses per-file extraction by content
hash (a sidecar cache) instead of re-parsing the whole repo; `isStale()` lets `verify` rebuild
when the cached graph is out of date (post-edit hallucination detection was running on a stale
atlas). A capped graph now degrades to "uncertain" rather than raising false "unknown symbol".
- **Performance:** `resolveEdges` is O(E) (was O(E·N) — a full node scan per edge); `impact()`
reuses one memoized reverse-adjacency map across the up-to-8 calls per `substrate` run.
- **`substrate` no longer recomputes preflight twice** (or fires a redundant assumption model
call): the gap is computed once and threaded into routing.

### 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. 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` carries an `llm.provenance` map per faculty for auditability.
Expand Down
125 changes: 105 additions & 20 deletions src/atlas.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,17 @@ function nearestSource(nodes, line, fallback) {
return best;
}

function extractFile(path, root) {
function extractFile(path, root, preRead) {
const ext = extname(path);
const rules = RULES[ext];
const rel = relative(root, path);
let text;
try {
text = readFileSync(path, "utf8");
} catch {
return { symbols: [], nodes: [], edges: [], hash: "" };
let text = preRead;
if (text == null) {
try {
text = readFileSync(path, "utf8");
} catch {
return { symbols: [], nodes: [], edges: [], hash: "" };
}
}

const mod = {
Expand Down Expand Up @@ -171,6 +173,32 @@ function extractFile(path, root) {
}
}

// Inheritance edges — `class X extends Y` (JS/TS) and `class X(Base, …)` (Python). Without
// these the `inherits` edge weight was dead and a base-class change never appeared in blast
// radius. The base is a bare name; resolveEdges links it to a real node if one exists.
const classNodes = new Map(nodes.filter((n) => n.kind === "class").map((n) => [n.name, n]));
const INHERIT_RES = [
/\bclass\s+([A-Za-z_$][\w$]*)\s+extends\s+([A-Za-z_$][\w$.]*)/g, // JS/TS
/^\s*class\s+([A-Za-z_]\w*)\s*\(([^)]*)\)/gm, // Python
];
for (const re of INHERIT_RES) {
re.lastIndex = 0;
let cm;
while ((cm = re.exec(text))) {
const child = classNodes.get(cm[1]);
if (!child) continue;
const line = lineOf(text, cm.index);
const bases = cm[2]
.split(",")
.map((b) => b.trim())
.filter((b) => b && !b.includes("=")) // drop Python kwargs like metaclass=ABCMeta
.map((b) => b.split(".").pop()) // module.Base → Base
.filter((b) => b && b !== cm[1] && b.toLowerCase() !== "object");
for (const base of bases)
edges.push({ source: child.id, target: base, kind: "inherits", confidence: 0.9, line });
}
}

IMPORT_RE.lastIndex = 0;
let im;
while ((im = IMPORT_RE.exec(text))) {
Expand Down Expand Up @@ -217,7 +245,9 @@ function extractFile(path, root) {
function resolveEdges(nodes, edges) {
const byName = new Map();
const byQname = new Map();
const idSet = new Set();
for (const n of nodes) {
idSet.add(n.id);
if (n.name) {
const arr = byName.get(n.name) || [];
arr.push(n);
Expand All @@ -226,7 +256,8 @@ function resolveEdges(nodes, edges) {
if (n.qname) byQname.set(n.qname, n);
}
return edges.map((edge) => {
if (nodes.some((n) => n.id === edge.target)) return edge;
// O(1) membership — this was a full nodes.some() scan per edge (O(E·N) on real repos).
if (idSet.has(edge.target)) return edge;
const direct = byQname.get(edge.target);
if (direct) return { ...edge, target: direct.id, resolved: true };
const short = String(edge.target).split(".").pop();
Expand All @@ -237,19 +268,45 @@ function resolveEdges(nodes, edges) {
});
}

const cachePath = (root) => join(root, ".forge", "atlas.cache.json");

function readCache(root) {
try {
return existsSync(cachePath(root)) ? JSON.parse(readFileSync(cachePath(root), "utf8")) : {};
} catch {
return {};
}
}

export function build({ root = process.cwd(), cap = 20000 } = {}) {
const files = [];
walk(root, files, cap);
// Incremental: reuse the prior per-file extraction when the content hash is unchanged, so a
// rebuild only re-parses edited files instead of re-running every regex over the whole repo.
const prev = readCache(root);
const cache = {};
const symbols = [];
const nodes = [];
const rawEdges = [];
const fileHashes = {};
for (const f of files) {
const parsed = extractFile(f, root);
symbols.push(...parsed.symbols);
nodes.push(...parsed.nodes);
rawEdges.push(...parsed.edges);
fileHashes[relative(root, f)] = parsed.hash;
const rel = relative(root, f);
let text;
try {
text = readFileSync(f, "utf8");
} catch {
continue;
}
const h = hash(text);
const reused = prev[rel]?.hash === h ? prev[rel].data : null;
const data =
reused ||
(({ symbols, nodes, edges }) => ({ symbols, nodes, edges }))(extractFile(f, root, text));
cache[rel] = { hash: h, data };
symbols.push(...data.symbols);
nodes.push(...data.nodes);
rawEdges.push(...data.edges);
fileHashes[rel] = h;
}
const edges = resolveEdges(nodes, rawEdges);
const atlas = {
Expand All @@ -263,9 +320,25 @@ export function build({ root = process.cwd(), cap = 20000 } = {}) {
};
mkdirSync(join(root, ".forge"), { recursive: true });
writeFileSync(join(root, ".forge", "atlas.json"), JSON.stringify(atlas));
writeFileSync(cachePath(root), JSON.stringify(cache));
return atlas;
}

/** True if any tracked file's current content hash differs from the atlas (or a file vanished). */
export function isStale(root, atlas) {
if (!atlas?.fileHashes) return true;
for (const [rel, h] of Object.entries(atlas.fileHashes)) {
let text;
try {
text = readFileSync(join(root, rel), "utf8");
} catch {
return true; // a tracked file was deleted
}
if (hash(text) !== h) return true;
}
return false;
}

export function load(root = process.cwd()) {
const p = join(root, ".forge", "atlas.json");
return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : null;
Expand Down Expand Up @@ -297,6 +370,25 @@ function targetIds(atlas, target) {

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

// Reverse-adjacency (node id → incoming edges) + node lookup, built once per atlas and memoized.
// substrateCheck calls impact() up to 8× on the same atlas; without this each call rebuilt both.
const ADJ_CACHE = new WeakMap();
function adjacency(atlas) {
const cached = ADJ_CACHE.get(atlas);
if (cached) return cached;
const nodeById = new Map((atlas.nodes || []).map((n) => [n.id, n]));
const incoming = new Map();
for (const e of atlas.edges || []) {
if (e.unresolved) continue;
const arr = incoming.get(e.target) || [];
arr.push(e);
incoming.set(e.target, arr);
}
const built = { nodeById, incoming };
ADJ_CACHE.set(atlas, built);
return built;
}

// 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`
Expand Down Expand Up @@ -342,14 +434,7 @@ export function impact(
{ 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();
for (const e of atlas.edges || []) {
if (e.unresolved) continue;
const arr = incoming.get(e.target) || [];
arr.push(e);
incoming.set(e.target, arr);
}
const { nodeById, incoming } = adjacency(atlas);
const visited = new Map();
const queue = starts.map((id) => ({ id, confidence: 1, hop: 0, path: [id], edgeKinds: [] }));
while (queue.length) {
Expand Down
18 changes: 10 additions & 8 deletions src/cortex.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function recordMistake(root, { signals, context, nowDay, episodeId, disti
episodes: [...(c.provenance?.episodes ?? []), episodeId],
},
};
save(root, updated);
if (!save(root, updated).ok) return { action: "refused", p, fires };
return {
action: "confirmed",
id: updated.id,
Expand Down Expand Up @@ -117,7 +117,9 @@ export function recordMistake(root, { signals, context, nowDay, episodeId, disti
},
nowDay,
);
save(root, lesson);
// Report "created" only when the write actually landed — a refused save (e.g. secret-bearing
// body) must not make the hook believe a lesson exists and try to distill a phantom.
if (!save(root, lesson).ok) return { action: "refused", p, fires };
return { action: "created", id: lesson.id, status: lesson.status, p, fires };
}

Expand All @@ -135,8 +137,8 @@ export function recordContradiction(root, { context, nowDay, episodeId }) {
const targets = matchingLessons(load(root), context, ["active", "quarantined"]);
const results = targets.map((l) => {
const updated = contradict(l, nowDay);
save(root, updated);
return { id: updated.id, status: updated.status };
const saved = save(root, updated).ok;
return { id: updated.id, status: updated.status, saved };
});
return { action: "contradicted", results };
}
Expand Down Expand Up @@ -168,17 +170,17 @@ export function startupBlock(root, nowDay = 0, budget = 8) {
].join("\n");
}

/** Replace a lesson's body with a model-distilled version (returns false if not found). */
/** Replace a lesson's body with a model-distilled version (false if not found or the save was
* refused — e.g. the distilled text tripped secret-refusal). */
export function applyDistillation(root, lessonId, distilled) {
if (!distilled) return false;
const lesson = load(root).find((l) => l.id === lessonId);
if (!lesson) return false;
save(root, {
return save(root, {
...lesson,
whatWentWrong: distilled.whatWentWrong,
correctedBehavior: distilled.correctedBehavior,
});
return true;
}).ok;
}

/** The lessons block to inline into AGENTS.md so non-Claude tools see them (empty if none). */
Expand Down
12 changes: 8 additions & 4 deletions src/cortex_hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,14 @@ export function appendSessionEvent(root, sid, event) {
export function readSession(root, sid) {
const path = sessionFile(root, sid);
if (!existsSync(path)) return [];
return readFileSync(path, "utf8")
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l));
const out = [];
for (const line of readFileSync(path, "utf8").split("\n")) {
if (!line) continue;
try {
out.push(JSON.parse(line)); // a single corrupt line must not lose the whole session log
} catch {}
}
return out;
}

export function clearSession(root, sid) {
Expand Down
29 changes: 21 additions & 8 deletions src/lessons_store.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,23 @@ export function save(root, lesson) {
return { ok: true };
}

/** Load every persisted lesson (episodes.jsonl is skipped — only .md files are lessons). */
/** Load every persisted lesson (episodes.jsonl is skipped — only .md files are lessons).
* A single malformed/half-written file is skipped, not fatal — one bad file must never take
* down memory retrieval, routing, and the pre-edit advisory (everywhere `load` is called). */
export function load(root) {
const dir = lessonsDir(root);
if (!existsSync(dir)) return [];
return readdirSync(dir)
const out = [];
for (const f of readdirSync(dir)
.filter((f) => f.endsWith(".md"))
.sort()
.map((f) => parse(readFileSync(join(dir, f), "utf8")));
.sort()) {
try {
out.push(parse(readFileSync(join(dir, f), "utf8")));
} catch {
// skip an unreadable / front-matter-less lesson file rather than throwing
}
}
return out;
}

/** Append a correction episode to the audit log (independent evidence, never overwritten). */
Expand All @@ -116,8 +125,12 @@ export function appendEpisode(root, episode) {
export function readEpisodes(root) {
const path = join(lessonsDir(root), "episodes.jsonl");
if (!existsSync(path)) return [];
return readFileSync(path, "utf8")
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l));
const out = [];
for (const line of readFileSync(path, "utf8").split("\n")) {
if (!line) continue;
try {
out.push(JSON.parse(line)); // one corrupt JSONL line must not discard the whole log
} catch {}
}
return out;
}
4 changes: 3 additions & 1 deletion src/preflight.js
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,9 @@ export function preflightRepo(
} = {},
) {
const atlas = loadAtlas(root) || (allowBuild ? buildAtlas({ root }) : null);
const hasSymbol = atlas ? (name) => has(atlas, name) : () => true;
// When the graph is capped (files were dropped) we can't be sure a symbol is truly absent, so
// treat everything as resolvable rather than raising false "not found in the code" clarifications.
const hasSymbol = atlas && !atlas.capped ? (name) => has(atlas, name) : () => true;
const gap = informationGap(text, {
hasSymbol,
fileExists: (f) => existsSync(join(root, f)),
Expand Down
13 changes: 9 additions & 4 deletions src/recall.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ import { homedir } from "node:os";
import { join } from "node:path";

// Anything matching this is refused — store a pointer to where the secret lives instead.
// Conservative (over-refusal is the safe direction for a memory tool). Covers the
// key formats this tool's own users paste most: Anthropic sk-ant-, OpenAI sk-,
// GitHub ghp_/github_pat_, Slack xox*, Google AIza/ya29, AWS AKIA, JWTs, PEM blocks.
// Two arms, both high-precision:
// 1) Known credential FORMATS this tool's users paste most — Anthropic sk-ant-, OpenAI sk-,
// GitHub ghp_/github_pat_, Slack xox*, Google AIza/ya29, AWS AKIA, JWTs, PEM blocks.
// 2) A secret-ish key ASSIGNED to a value — `password = "x"`, `SECRET_KEY: y`, `api_key=z`.
// The second arm deliberately requires the `: `/`=` + value, so a bare English mention
// ("implement password hashing", "rotate the secret", "the api key helper") is NOT refused —
// that over-broad word match previously gutted both the LLM proposer (adjudicate) and the
// memory store (recall/lessons) for a whole class of legitimate auth-related work.
export const SECRET_RE =
/(-----BEGIN |api[_-]?key|secret|passwd|password|\bghp_[A-Za-z0-9]{16,}|\bgithub_pat_[A-Za-z0-9_]{20,}|\bsk-[A-Za-z0-9_-]{16,}|\bxox[baprs]-[A-Za-z0-9-]{10,}|\bAIza[0-9A-Za-z_-]{20,}|\bya29\.[A-Za-z0-9._-]+|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|AKIA[0-9A-Z]{16})/i;
/(-----BEGIN |\bghp_[A-Za-z0-9]{16,}|\bgithub_pat_[A-Za-z0-9_]{20,}|\bsk-[A-Za-z0-9_-]{16,}|\bxox[baprs]-[A-Za-z0-9-]{10,}|\bAIza[0-9A-Za-z_-]{20,}|\bya29\.[A-Za-z0-9._-]+|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|AKIA[0-9A-Z]{16}|\b[\w-]*(?:api[_-]?key|secret|passwd|password|token)[\w-]*["']?\s*[:=]\s*["']?\S)/i;

export function defaultStore() {
return join(process.env.FORGE_HOME || join(homedir(), ".forge"), "recall");
Expand Down
Loading
Loading