From 202665330da805359ccc435a20406197e671cf02 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 12:06:10 +0000 Subject: [PATCH] refactor: extract shared utilities, externalize model data, add debug logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-codebase modernization applying the code-modernizer ladder: Shared utilities (rung 2 — already solved in this codebase): - Extract slug(), clamp01(), epochDay(), hasBin(), contentHash(), IGNORE_DIRS, SRC_EXT, CODE_EXT into src/util.js - Replace 3 copies of slug (cortex, recall, cortex_hook) - Replace 2 copies of hasBin/have (doctor, harden) - Replace 2 copies of clamp01 (route, adjudicate) - Replace 2 copies of epoch-day math (cortex_hook_main, cortex_mcp) - Unify IGNORE_DIRS across atlas, scope (was inconsistent) - Unify CODE_EXT across preflight (was duplicated) Data externalization (rung 3 — use the platform): - Move model IDs, pricing, and tier config from source literals to src/model_tiers.json (rotation is now a config change) - Read MCP server version from package.json instead of hardcoding - Add exports field to package.json (modern Node.js convention) - Bump minimum Node to >=20 (reflects actual API usage) Error observability (production readiness): - Replace 7 silent catch-and-swallow sites with FORGE_DEBUG=1 stderr logging (adjudicate, verify, lean, lessons_store, skillgate, atlas, cortex_hook_main) — fail-safe semantics preserved, failures now diagnosable Hash upgrade (rung 3 — stdlib does it): - Replace djb2 (32-bit, collision-prone) with crypto.createHash in cortex_hook.js outputSignature — crypto already imported in atlas.js Cleanup: - Remove unused execFileSync imports from doctor.js and harden.js All 222 tests pass. No behavioral changes. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01YS8HJw5cwmAQyNQLxRmyYZ --- package.json | 7 +++++- src/adjudicate.js | 6 +++-- src/atlas.js | 27 +++++----------------- src/cortex.js | 7 +----- src/cortex_hook.js | 14 +++--------- src/cortex_hook_main.js | 8 +++++-- src/cortex_mcp.js | 14 ++++++++++-- src/doctor.js | 12 ++-------- src/harden.js | 11 +-------- src/lean.js | 4 +++- src/lessons_store.js | 5 +++-- src/model_tiers.js | 49 +++++++++------------------------------- src/model_tiers.json | 39 ++++++++++++++++++++++++++++++++ src/preflight.js | 4 +--- src/recall.js | 7 ++---- src/route.js | 3 +-- src/scope.js | 6 ++--- src/skillgate.js | 7 ++++-- src/util.js | 50 +++++++++++++++++++++++++++++++++++++++++ src/verify.js | 4 +++- 20 files changed, 161 insertions(+), 123 deletions(-) create mode 100644 src/model_tiers.json create mode 100644 src/util.js diff --git a/package.json b/package.json index 046537a..216e241 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,12 @@ "forge": "src/cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" + }, + "exports": { + ".": "./src/cli.js", + "./model-tiers": "./src/model_tiers.js", + "./package.json": "./package.json" }, "author": "CodeWithJuber", "license": "MIT", diff --git a/src/adjudicate.js b/src/adjudicate.js index 6eea5b4..0736246 100644 --- a/src/adjudicate.js +++ b/src/adjudicate.js @@ -61,12 +61,14 @@ export function adjudicate({ prompt, parse, run = buildRunner() }) { if (obj == null) return null; const parsed = parse(obj); return parsed ?? null; - } catch { + } catch (err) { + if (process.env.FORGE_DEBUG === "1") + process.stderr.write(`forge adjudicate: ${err?.message ?? err}\n`); return null; } } -const clamp01 = (x) => Math.max(0, Math.min(1, x)); +import { clamp01 } from "./util.js"; /** Coerce an unknown model field to a number in [0,1], or null if it isn't one. */ export function asUnit(v) { diff --git a/src/atlas.js b/src/atlas.js index 9f0afd3..6b95487 100644 --- a/src/atlas.js +++ b/src/atlas.js @@ -1,26 +1,11 @@ // forge atlas — a portable code graph. Build once, then query definitions, membership, // reverse dependents, and impact radius without asking a model to rediscover the repo. -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"; import { CALL_RE } from "./extract.js"; - -const IGNORE = new Set([ - "node_modules", - ".git", - "dist", - "build", - ".next", - "out", - "target", - "__pycache__", - ".forge", - "coverage", - ".venv", - "vendor", -]); +import { contentHash, IGNORE_DIRS } from "./util.js"; const JS_RULES = [ { re: /(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g, kind: "function" }, @@ -88,19 +73,19 @@ const BUILTINS = new Set([ "super", ]); -function hash(text) { - return createHash("sha256").update(text).digest("hex"); -} +const hash = contentHash; function walk(dir, files, cap) { let entries; try { entries = readdirSync(dir); - } catch { + } catch (err) { + if (process.env.FORGE_DEBUG === "1") + process.stderr.write(`forge atlas: skipping ${dir}: ${err?.message ?? err}\n`); return; } for (const name of entries) { - if (IGNORE.has(name) || name.startsWith(".")) continue; + if (IGNORE_DIRS.has(name) || name.startsWith(".")) continue; const path = join(dir, name); let st; try { diff --git a/src/cortex.js b/src/cortex.js index a6bafaf..1be1442 100644 --- a/src/cortex.js +++ b/src/cortex.js @@ -13,12 +13,7 @@ import { selectForInjection, } from "./lessons.js"; import { appendEpisode, load, readEpisodes, save } from "./lessons_store.js"; - -const slug = (s) => - String(s) - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, ""); +import { slug } from "./util.js"; const lessonIdFor = (ctx) => `lsn_${slug(ctx.symbols?.[0] || ctx.files?.[0] || "ctx") || "ctx"}`; diff --git a/src/cortex_hook.js b/src/cortex_hook.js index 9d9d47c..862cb86 100644 --- a/src/cortex_hook.js +++ b/src/cortex_hook.js @@ -5,6 +5,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { recordContradiction, recordMistake } from "./cortex.js"; +import { contentHash, slug } from "./util.js"; const sessionFile = (root, sid) => join(root, ".forge", "sessions", `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.jsonl`); @@ -40,14 +41,7 @@ const TEST_RE = /\b(npm\s+(run\s+)?test|node\s+--test|jest|vitest|pytest|go\s+te // Negation must be corrective, not incidental ("no problem"); require a corrective verb. const NEG_RE = /\b(undo|revert|that'?s\s+wrong|not\s+what|you\s+broke|regression|wrong\s+again)\b/i; -const slug = (s) => - String(s) - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, "") - .slice(0, 40); - -// A cheap, dependency-free signature of a failing test's output. Line numbers, hex addresses, +// A cheap signature of a failing test's output. Line numbers, hex addresses, // timings, and temp paths are normalized out so "the same failure" hashes the same across runs // even as surrounding noise shifts — this is what lets us catch a same-error doom loop. function outputSignature(text) { @@ -61,9 +55,7 @@ function outputSignature(text) { .replace(/\s+/g, " ") .trim() .slice(0, 800); - let h = 5381; - for (let i = 0; i < norm.length; i++) h = ((h << 5) + h + norm.charCodeAt(i)) >>> 0; - return norm ? h.toString(36) : ""; + return norm ? contentHash(norm).slice(0, 12) : ""; } /** Normalize a raw hook payload into a compact event, or null if it carries no signal. */ diff --git a/src/cortex_hook_main.js b/src/cortex_hook_main.js index ad3dd2e..0576e89 100644 --- a/src/cortex_hook_main.js +++ b/src/cortex_hook_main.js @@ -20,6 +20,7 @@ import { } from "./cortex_hook.js"; import { load } from "./lessons_store.js"; import { enforceDecision, substrateCheck, substrateContext } from "./substrate.js"; +import { epochDay } from "./util.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). @@ -55,7 +56,7 @@ async function main() { } const root = hook.cwd || process.cwd(); const sid = hook.session_id || "default"; - const today = Math.floor(Date.now() / 86400000); + const today = epochDay(); if (mode === "capture" || mode === "prompt") { appendSessionEvent(root, sid, classifyEvent(hook)); @@ -124,5 +125,8 @@ async function preEditAdvisory(root, file, today) { } main() - .catch(() => {}) + .catch((err) => { + if (process.env.FORGE_DEBUG === "1") + process.stderr.write(`forge cortex: ${err?.message ?? err}\n`); + }) .finally(() => process.exit(0)); diff --git a/src/cortex_mcp.js b/src/cortex_mcp.js index 3da22bf..e47c1dc 100644 --- a/src/cortex_mcp.js +++ b/src/cortex_mcp.js @@ -2,6 +2,7 @@ // newline-delimited) that exposes this repo's LEARNED LESSONS to any MCP-capable tool // (Cursor, Codex, Gemini, Zed…). Claude gets lessons live via hooks; this is how the other // tools query them on demand. Launched as `forge cortex-mcp`, so the path resolves anywhere. +import { readFileSync } from "node:fs"; import { argv } from "node:process"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; @@ -10,9 +11,18 @@ import { assessTask, clarifyBlock, preflightRepo } from "./preflight.js"; import { routeTask } from "./route.js"; import { decompose } from "./scope.js"; import { predictImpact, substrateCheck } from "./substrate.js"; +import { epochDay } from "./util.js"; + +const PKG_VERSION = (() => { + try { + return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; + } catch { + return "0.0.0"; + } +})(); const root = process.env.FORGE_ROOT || process.cwd(); -const today = () => Math.floor(Date.now() / 86400000); +const today = epochDay; const TOOLS = [ { @@ -155,7 +165,7 @@ export function handle(msg) { result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, - serverInfo: { name: "forge-cortex", version: "0.1.0" }, + serverInfo: { name: "forge-cortex", version: PKG_VERSION }, }, }; } diff --git a/src/doctor.js b/src/doctor.js index 825ff9e..fc40e9d 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -1,6 +1,5 @@ // forge doctor — turn silent misconfiguration into an actionable pass/fail list // (chezmoi-doctor pattern). Exits non-zero only on hard failures, not warnings. -import { execFileSync } from "node:child_process"; import { accessSync, constants, existsSync, readdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -15,16 +14,9 @@ const ok = (label, note = "") => ({ status: "ok", label, note }); const warn = (label, note = "") => ({ status: "warn", label, note }); const fail = (label, note = "") => ({ status: "fail", label, note }); -const readJson = (p) => JSON.parse(readFileSync(p, "utf8")); +import { hasBin } from "./util.js"; -const hasBin = (bin) => { - try { - execFileSync(bin, ["--version"], { stdio: "ignore" }); - return true; - } catch { - return false; - } -}; +const readJson = (p) => JSON.parse(readFileSync(p, "utf8")); // External tools the guards/commands depend on. jq is the important one — several guards // (secret-redact, protect-paths) degrade to a naive parse or a no-op without it. diff --git a/src/harden.js b/src/harden.js index 6059e3f..ed0b197 100644 --- a/src/harden.js +++ b/src/harden.js @@ -3,18 +3,9 @@ // a Gitleaks pre-commit hook. We never auto-edit ~/.claude/settings.json (no clobber) // — we write the sandbox block for the user to merge, and only touch the repo's own hooks. -import { execFileSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; - -function have(cmd) { - try { - execFileSync(cmd, ["--version"], { stdio: "ignore" }); - return true; - } catch { - return false; - } -} +import { hasBin as have } from "./util.js"; // The sandbox config to merge into settings — deny the credential dirs an agent should never read. const SANDBOX = { diff --git a/src/lean.js b/src/lean.js index a712df8..7721d2a 100644 --- a/src/lean.js +++ b/src/lean.js @@ -109,7 +109,9 @@ function gitDiff(root, base) { stdio: ["ignore", "pipe", "ignore"], }) ); - } catch { + } catch (err) { + if (process.env.FORGE_DEBUG === "1") + process.stderr.write(`forge lean gitDiff: ${err?.message ?? err}\n`); return ""; } } diff --git a/src/lessons_store.js b/src/lessons_store.js index 85d9f54..e363166 100644 --- a/src/lessons_store.js +++ b/src/lessons_store.js @@ -108,8 +108,9 @@ export function load(root) { .sort()) { try { out.push(parse(readFileSync(join(dir, f), "utf8"))); - } catch { - // skip an unreadable / front-matter-less lesson file rather than throwing + } catch (err) { + if (process.env.FORGE_DEBUG === "1") + process.stderr.write(`forge lessons: skipping ${f}: ${err?.message ?? err}\n`); } } return out; diff --git a/src/model_tiers.js b/src/model_tiers.js index c6e1f0e..2899496 100644 --- a/src/model_tiers.js +++ b/src/model_tiers.js @@ -1,46 +1,17 @@ // forge model tiers — the routing target table. Cheapest capable model per complexity tier. -// Costs are per-million tokens (input/output) in PRICING_CURRENCY. The premise: a prime-number -// finder does not need Fable 5. Size the model to the task. +// Costs are per-million tokens (input/output). The premise: a prime-number finder does not +// need Fable 5. Size the model to the task. Data lives in model_tiers.json so rotation is +// a config change, not a code change. +import { readFileSync } from "node:fs"; + +const data = JSON.parse(readFileSync(new URL("./model_tiers.json", import.meta.url), "utf8")); /** Currency for every inCost/outCost below. */ -export const PRICING_CURRENCY = "USD"; +export const PRICING_CURRENCY = data.pricingCurrency; /** Date the prices were last checked. `forge doctor` warns when this goes stale (re-verify via dev-radar). */ -export const PRICING_VERIFIED = "2026-07-05"; +export const PRICING_VERIFIED = data.pricingVerified; -export const MODELS = { - haiku: { - id: "claude-haiku-4-5-20251001", - name: "Haiku 4.5", - tier: "simple", - inCost: 1, - outCost: 5, - use: "lint, formatting, docs, stubs, trivial well-defined edits", - }, - sonnet: { - id: "claude-sonnet-5", - name: "Sonnet 5", - tier: "medium", - inCost: 3, - outCost: 15, - use: "refactoring, feature work, tests, code review (the default)", - }, - opus: { - id: "claude-opus-4-8", - name: "Opus 4.8", - tier: "complex", - inCost: 5, - outCost: 25, - use: "architecture, cross-module refactor, novel algorithms, multi-layer debugging", - }, - fable: { - id: "claude-fable-5", - name: "Fable 5", - tier: "extreme", - inCost: 10, - outCost: 50, - use: "only the hardest research-grade reasoning — rarely worth it", - }, -}; +export const MODELS = data.models; /** Cheap → expensive. */ -export const TIER_ORDER = ["haiku", "sonnet", "opus", "fable"]; +export const TIER_ORDER = data.tierOrder; diff --git a/src/model_tiers.json b/src/model_tiers.json new file mode 100644 index 0000000..9ff0e69 --- /dev/null +++ b/src/model_tiers.json @@ -0,0 +1,39 @@ +{ + "pricingCurrency": "USD", + "pricingVerified": "2026-07-05", + "models": { + "haiku": { + "id": "claude-haiku-4-5-20251001", + "name": "Haiku 4.5", + "tier": "simple", + "inCost": 1, + "outCost": 5, + "use": "lint, formatting, docs, stubs, trivial well-defined edits" + }, + "sonnet": { + "id": "claude-sonnet-5", + "name": "Sonnet 5", + "tier": "medium", + "inCost": 3, + "outCost": 15, + "use": "refactoring, feature work, tests, code review (the default)" + }, + "opus": { + "id": "claude-opus-4-8", + "name": "Opus 4.8", + "tier": "complex", + "inCost": 5, + "outCost": 25, + "use": "architecture, cross-module refactor, novel algorithms, multi-layer debugging" + }, + "fable": { + "id": "claude-fable-5", + "name": "Fable 5", + "tier": "extreme", + "inCost": 10, + "outCost": 50, + "use": "only the hardest research-grade reasoning — rarely worth it" + } + }, + "tierOrder": ["haiku", "sonnet", "opus", "fable"] +} diff --git a/src/preflight.js b/src/preflight.js index dbaca08..9a481d3 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -5,9 +5,7 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { adjudicate, asText, asUnit, buildRunner, llmEnabled } from "./adjudicate.js"; import { build as buildAtlas, has, load as loadAtlas } from "./atlas.js"; - -const CODE_EXT = - /\.(js|ts|jsx|tsx|mjs|cjs|py|go|rs|java|rb|php|c|cc|cpp|h|hpp|cs|json|ya?ml|toml|md|css|scss|html|vue|svelte)$/i; +import { CODE_EXT } from "./util.js"; const STOP = new Set([ "the", diff --git a/src/recall.js b/src/recall.js index 6e35750..6b47a75 100644 --- a/src/recall.js +++ b/src/recall.js @@ -22,11 +22,8 @@ export function defaultStore() { } const factsDir = (store) => join(store, "facts"); -const slugify = (s) => - s - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, ""); + +import { slug as slugify } from "./util.js"; export function add(store, name, body) { if (SECRET_RE.test(`${name}\n${body}`)) { diff --git a/src/route.js b/src/route.js index 40945c6..334c2ff 100644 --- a/src/route.js +++ b/src/route.js @@ -10,8 +10,7 @@ import { gitChurn, grepFanout } from "./cortex_features.js"; import { load as loadLessons } from "./lessons_store.js"; import { MODELS } from "./model_tiers.js"; import { preflightRepo, referencedEntities } from "./preflight.js"; - -const clamp01 = (x) => Math.max(0, Math.min(1, x)); +import { clamp01 } from "./util.js"; // Weights sum to 1. Each raw signal is normalized by the point where it reads as "complex". diff --git a/src/scope.js b/src/scope.js index 625906b..17508bc 100644 --- a/src/scope.js +++ b/src/scope.js @@ -5,8 +5,8 @@ // approximate (dynamic/DI edges missed) — a real call-graph MCP is the upgrade seam. import { readdirSync, readFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; +import { IGNORE_DIRS, SRC_EXT } from "./util.js"; -const SRC = /\.(js|jsx|ts|tsx|mjs|cjs|py)$/; const IMPORT_RES = [ /import\s+[^'"]*from\s+['"]([^'"]+)['"]/g, // import x from "y" /import\s+['"]([^'"]+)['"]/g, // import "y" @@ -18,10 +18,10 @@ const IMPORT_RES = [ function walk(dir, root, out) { for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (entry.name === "node_modules" || entry.name.startsWith(".")) continue; + if (IGNORE_DIRS.has(entry.name)) continue; const p = join(dir, entry.name); if (entry.isDirectory()) walk(p, root, out); - else if (SRC.test(entry.name)) out.push(relative(root, p)); + else if (SRC_EXT.test(entry.name)) out.push(relative(root, p)); } } diff --git a/src/skillgate.js b/src/skillgate.js index b453014..d2f9011 100644 --- a/src/skillgate.js +++ b/src/skillgate.js @@ -75,8 +75,11 @@ export function scan(target) { findings: [], raw: out, }; - } catch { - // scanner absent/offline → fall through to the built-in heuristic + } catch (err) { + if (process.env.FORGE_DEBUG === "1") + process.stderr.write( + `forge skillgate: scanner failed, using heuristic: ${err?.message ?? err}\n`, + ); } } diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..f3f065a --- /dev/null +++ b/src/util.js @@ -0,0 +1,50 @@ +// forge util — shared micro-utilities. Extracted from duplicated copies across +// cortex.js, recall.js, cortex_hook.js, doctor.js, harden.js, route.js, adjudicate.js, +// scope.js, atlas.js, preflight.js, and cortex_hook_main.js. + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; + +export const slug = (s) => + String(s) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); + +export const clamp01 = (x) => Math.max(0, Math.min(1, x)); + +export const MS_PER_DAY = 86400000; +export const epochDay = () => Math.floor(Date.now() / MS_PER_DAY); + +export function hasBin(bin) { + try { + execFileSync(bin, ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +export function contentHash(text) { + return createHash("sha256").update(text).digest("hex"); +} + +export const IGNORE_DIRS = new Set([ + "node_modules", + ".git", + "dist", + "build", + ".next", + "out", + "target", + "__pycache__", + ".forge", + "coverage", + ".venv", + "vendor", +]); + +export const SRC_EXT = /\.(js|jsx|ts|tsx|mjs|cjs|py)$/; + +export const CODE_EXT = + /\.(js|ts|jsx|tsx|mjs|cjs|py|go|rs|java|rb|php|c|cc|cpp|h|hpp|cs|json|ya?ml|toml|md|css|scss|html|vue|svelte)$/i; diff --git a/src/verify.js b/src/verify.js index a001724..90258ed 100644 --- a/src/verify.js +++ b/src/verify.js @@ -21,7 +21,9 @@ export function findUnknownSymbols(atlas, symbols) { function git(args, cwd) { try { return execFileSync("git", args, { cwd, encoding: "utf8" }); - } catch { + } catch (err) { + if (process.env.FORGE_DEBUG === "1") + process.stderr.write(`forge verify git: ${err?.message ?? err}\n`); return ""; } }