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
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions src/adjudicate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
27 changes: 6 additions & 21 deletions src/atlas.js
Original file line number Diff line number Diff line change
@@ -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" },
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 1 addition & 6 deletions src/cortex.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`;

Expand Down
14 changes: 3 additions & 11 deletions src/cortex_hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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) {
Expand All @@ -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. */
Expand Down
8 changes: 6 additions & 2 deletions src/cortex_hook_main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
14 changes: 12 additions & 2 deletions src/cortex_mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = [
{
Expand Down Expand Up @@ -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 },
},
};
}
Expand Down
12 changes: 2 additions & 10 deletions src/doctor.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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.
Expand Down
11 changes: 1 addition & 10 deletions src/harden.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion src/lean.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/lessons_store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
49 changes: 10 additions & 39 deletions src/model_tiers.js
Original file line number Diff line number Diff line change
@@ -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;
39 changes: 39 additions & 0 deletions src/model_tiers.json
Original file line number Diff line number Diff line change
@@ -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"]
}
4 changes: 1 addition & 3 deletions src/preflight.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 2 additions & 5 deletions src/recall.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)) {
Expand Down
3 changes: 1 addition & 2 deletions src/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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".

Expand Down
Loading
Loading