@codewithjuber/forgekit 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.mcp.json +8 -0
- package/ARCHITECTURE.md +314 -0
- package/CHANGELOG.md +467 -0
- package/LICENSE +21 -0
- package/ONBOARDING.md +180 -0
- package/README.md +286 -0
- package/bin/claude-init.sh +90 -0
- package/bin/claude-taste.sh +33 -0
- package/bin/learn-consolidate.sh +51 -0
- package/brand.json +15 -0
- package/global/CLAUDE.md +31 -0
- package/global/crew/frontend-verifier.md +36 -0
- package/global/crew/independent-reviewer.md +29 -0
- package/global/crew/scout.md +24 -0
- package/global/crew/verifier.md +28 -0
- package/global/guards/_guardlib.sh +36 -0
- package/global/guards/cortex.sh +14 -0
- package/global/guards/cost-budget.sh +41 -0
- package/global/guards/doom-loop.sh +25 -0
- package/global/guards/format-on-edit.sh +32 -0
- package/global/guards/lean-guard.sh +20 -0
- package/global/guards/protect-paths.sh +45 -0
- package/global/guards/recall-load.sh +22 -0
- package/global/guards/secret-redact.sh +18 -0
- package/global/guards/session-learner.sh +72 -0
- package/global/recall/MEMORY.md +7 -0
- package/global/rules/self-correction.md +17 -0
- package/global/rules/stack-notes.md +24 -0
- package/global/rules/tech-currency.md +19 -0
- package/global/settings.template.json +183 -0
- package/global/statusline.sh +51 -0
- package/global/taste/brutalist.json +9 -0
- package/global/taste/brutalist.md +19 -0
- package/global/taste/corporate.json +9 -0
- package/global/taste/corporate.md +19 -0
- package/global/taste/editorial.json +9 -0
- package/global/taste/editorial.md +19 -0
- package/global/taste/minimalist.json +9 -0
- package/global/taste/minimalist.md +20 -0
- package/global/taste/playful.json +9 -0
- package/global/taste/playful.md +19 -0
- package/global/tools/atlas/SKILL.md +27 -0
- package/global/tools/code-modernization/SKILL.md +275 -0
- package/global/tools/code-modernization/references/cost-impact-preflight.md +54 -0
- package/global/tools/code-modernization/references/design-patterns-cheatsheet.md +24 -0
- package/global/tools/code-modernization/references/research-protocol.md +42 -0
- package/global/tools/code-modernization/scripts/preflight_scan.py +190 -0
- package/global/tools/cognitive-substrate/SKILL.md +56 -0
- package/global/tools/cognitive-substrate/references/capability-map.md +17 -0
- package/global/tools/cost-guard/SKILL.md +50 -0
- package/global/tools/design-md/SKILL.md +54 -0
- package/global/tools/dev-radar/SKILL.md +56 -0
- package/global/tools/explore-plan-code/SKILL.md +24 -0
- package/global/tools/lean/SKILL.md +41 -0
- package/global/tools/recall/SKILL.md +31 -0
- package/global/tools/reuse-first/SKILL.md +64 -0
- package/global/tools/self-improve/SKILL.md +44 -0
- package/global/tools/taste/SKILL.md +26 -0
- package/global/tools/tech-selector/SKILL.md +35 -0
- package/global/tools/ui-workflow/SKILL.md +44 -0
- package/hooks/hooks.json +107 -0
- package/install.sh +88 -0
- package/package.json +93 -0
- package/public/index.html +45 -0
- package/scripts/build-pages.mjs +180 -0
- package/scripts/bump.mjs +322 -0
- package/skills/cognitive-substrate/SKILL.md +56 -0
- package/skills/cognitive-substrate/references/capability-map.md +17 -0
- package/source/mcp.json +10 -0
- package/source/rules.json +106 -0
- package/source/substrate.json +41 -0
- package/src/adjudicate.js +84 -0
- package/src/anchor.js +210 -0
- package/src/atlas.js +487 -0
- package/src/brain.js +84 -0
- package/src/brand.js +25 -0
- package/src/cli.js +1509 -0
- package/src/context.js +273 -0
- package/src/cortex.js +251 -0
- package/src/cortex_distill.js +55 -0
- package/src/cortex_features.js +81 -0
- package/src/cortex_hook.js +197 -0
- package/src/cortex_hook_main.js +139 -0
- package/src/cortex_mcp.js +352 -0
- package/src/cost_report.js +271 -0
- package/src/dash.html +396 -0
- package/src/dash.js +220 -0
- package/src/diagnose.js +0 -0
- package/src/doctor.js +315 -0
- package/src/embed.js +244 -0
- package/src/emit/_shared.js +39 -0
- package/src/emit/aider.js +22 -0
- package/src/emit/claude.js +44 -0
- package/src/emit/codex.js +17 -0
- package/src/emit/continue.js +28 -0
- package/src/emit/copilot.js +12 -0
- package/src/emit/cursor.js +23 -0
- package/src/emit/gemini.js +40 -0
- package/src/emit/mcp.js +94 -0
- package/src/emit/windsurf.js +22 -0
- package/src/emit/zed.js +34 -0
- package/src/eval.js +47 -0
- package/src/extract.js +82 -0
- package/src/harden.js +44 -0
- package/src/imagine.js +301 -0
- package/src/init.js +178 -0
- package/src/lean.js +149 -0
- package/src/ledger.js +475 -0
- package/src/ledger_bridge.js +279 -0
- package/src/ledger_read.js +152 -0
- package/src/ledger_store.js +360 -0
- package/src/lessons.js +185 -0
- package/src/lessons_store.js +137 -0
- package/src/metrics.js +54 -0
- package/src/model_tiers.js +17 -0
- package/src/model_tiers.json +39 -0
- package/src/predictor.js +143 -0
- package/src/preflight.js +410 -0
- package/src/providers.js +320 -0
- package/src/recall.js +103 -0
- package/src/reuse.js +0 -0
- package/src/route.js +323 -0
- package/src/scope.js +122 -0
- package/src/skillgate.js +89 -0
- package/src/speclock.js +64 -0
- package/src/substrate.js +492 -0
- package/src/sync.js +132 -0
- package/src/taste.js +55 -0
- package/src/uicheck.js +96 -0
- package/src/uifingerprint.js +861 -0
- package/src/uivisual.js +334 -0
- package/src/util.js +71 -0
- package/src/verify.js +117 -0
- package/templates/project-layer/.claude/settings.json +22 -0
- package/templates/project-layer/.claude/skills/hostlelo-deploy/SKILL.md +38 -0
- package/templates/project-layer/AGENTS.md +28 -0
- package/templates/project-layer/CLAUDE.md +40 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// forge cortex hooks — the AMBIENT layer. Turns raw Claude Code hook events (edits, bash
|
|
2
|
+
// runs, user prompts) into the correction episodes the orchestrator consumes, with zero
|
|
3
|
+
// commands from the developer. Signal detection is PURE (over a normalized event log) so
|
|
4
|
+
// it's unit-testable; the shell hooks just append events and call processSession on Stop.
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { recordContradiction, recordMistake } from "./cortex.js";
|
|
8
|
+
import { contentHash, slug } from "./util.js";
|
|
9
|
+
|
|
10
|
+
const sessionFile = (root, sid) =>
|
|
11
|
+
join(root, ".forge", "sessions", `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.jsonl`);
|
|
12
|
+
|
|
13
|
+
/** Append one normalized event to a session's log (called by capture hooks). */
|
|
14
|
+
export function appendSessionEvent(root, sid, event) {
|
|
15
|
+
if (!event) return;
|
|
16
|
+
const path = sessionFile(root, sid);
|
|
17
|
+
mkdirSync(join(root, ".forge", "sessions"), { recursive: true });
|
|
18
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readSession(root, sid) {
|
|
22
|
+
const path = sessionFile(root, sid);
|
|
23
|
+
if (!existsSync(path)) return [];
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
26
|
+
if (!line) continue;
|
|
27
|
+
try {
|
|
28
|
+
out.push(JSON.parse(line)); // a single corrupt line must not lose the whole session log
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clearSession(root, sid) {
|
|
35
|
+
const path = sessionFile(root, sid);
|
|
36
|
+
if (existsSync(path)) rmSync(path);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const REVERT_RE = /\bgit\s+(revert|reset\s+--hard|checkout\s+--|restore)\b/;
|
|
40
|
+
const TEST_RE = /\b(npm\s+(run\s+)?test|node\s+--test|jest|vitest|pytest|go\s+test|cargo\s+test)\b/;
|
|
41
|
+
// Negation must be corrective, not incidental ("no problem"); require a corrective verb.
|
|
42
|
+
const NEG_RE = /\b(undo|revert|that'?s\s+wrong|not\s+what|you\s+broke|regression|wrong\s+again)\b/i;
|
|
43
|
+
|
|
44
|
+
// A cheap signature of a failing test's output. Line numbers, hex addresses,
|
|
45
|
+
// timings, and temp paths are normalized out so "the same failure" hashes the same across runs
|
|
46
|
+
// even as surrounding noise shifts — this is what lets us catch a same-error doom loop.
|
|
47
|
+
function outputSignature(text) {
|
|
48
|
+
const norm = String(text)
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/0x[0-9a-f]+/g, "0xADDR")
|
|
51
|
+
.replace(/\b\d+(\.\d+)?(ms|s)\b/g, "T")
|
|
52
|
+
.replace(/:\d+:\d+/g, ":L:C")
|
|
53
|
+
.replace(/\b\d+\b/g, "N")
|
|
54
|
+
.replace(/\/tmp\/\S+/g, "/tmp/X")
|
|
55
|
+
.replace(/\s+/g, " ")
|
|
56
|
+
.trim()
|
|
57
|
+
.slice(0, 800);
|
|
58
|
+
return norm ? contentHash(norm).slice(0, 12) : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Normalize a raw hook payload into a compact event, or null if it carries no signal. */
|
|
62
|
+
export function classifyEvent(hook) {
|
|
63
|
+
const tool = hook.tool_name;
|
|
64
|
+
const inp = hook.tool_input ?? {};
|
|
65
|
+
if (tool === "Edit" || tool === "Write" || tool === "MultiEdit") {
|
|
66
|
+
return { type: "edit", file: inp.file_path ?? "" };
|
|
67
|
+
}
|
|
68
|
+
if (tool === "Bash") {
|
|
69
|
+
const exitCode = hook.exitCode;
|
|
70
|
+
const failed = typeof exitCode === "number" && exitCode !== 0;
|
|
71
|
+
const out = hook.tool_response?.stdout ?? hook.tool_response ?? hook.output ?? "";
|
|
72
|
+
return {
|
|
73
|
+
type: "bash",
|
|
74
|
+
command: inp.command ?? "",
|
|
75
|
+
exitCode,
|
|
76
|
+
// Only carry a signature for FAILED runs — that's all the doom-loop check needs.
|
|
77
|
+
...(failed && out ? { outputSig: outputSignature(out) } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (hook.hook_event_name === "UserPromptSubmit" || typeof hook.prompt === "string") {
|
|
81
|
+
return { type: "prompt", text: hook.prompt ?? "" };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Scan a session's normalized events and emit correction episodes. Signals are grouped
|
|
88
|
+
* PER FILE so the cross-family gate works (a lone thrash never fires; test-fail-then-pass
|
|
89
|
+
* on a file that was also edited does). Reverts become contradiction episodes.
|
|
90
|
+
* @returns {{kind:string, context:object, signals?:{signal:string}[], episodeId:string, nowDay:number}[]}
|
|
91
|
+
*/
|
|
92
|
+
export function detectEpisodes(events, { nowDay = 0 } = {}) {
|
|
93
|
+
const sig = new Map(); // file -> Set(signal)
|
|
94
|
+
const editCount = new Map();
|
|
95
|
+
const recentEdits = [];
|
|
96
|
+
const contradictions = [];
|
|
97
|
+
let sawTestFail = false;
|
|
98
|
+
let failFiles = new Set();
|
|
99
|
+
const add = (file, s) => {
|
|
100
|
+
if (!file) return;
|
|
101
|
+
if (!sig.has(file)) sig.set(file, new Set());
|
|
102
|
+
sig.get(file).add(s);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
for (const e of events) {
|
|
106
|
+
if (e.type === "edit" && e.file) {
|
|
107
|
+
const n = (editCount.get(e.file) ?? 0) + 1;
|
|
108
|
+
editCount.set(e.file, n);
|
|
109
|
+
if (n >= 2) add(e.file, "S2"); // edited again this session = self-correction
|
|
110
|
+
if (n >= 3) add(e.file, "S3"); // thrash
|
|
111
|
+
recentEdits.push(e.file);
|
|
112
|
+
if (sawTestFail) failFiles.add(e.file);
|
|
113
|
+
} else if (e.type === "bash") {
|
|
114
|
+
if (TEST_RE.test(e.command)) {
|
|
115
|
+
if (typeof e.exitCode === "number" && e.exitCode !== 0) sawTestFail = true;
|
|
116
|
+
else if (e.exitCode === 0 && sawTestFail) {
|
|
117
|
+
for (const f of failFiles) add(f, "S1"); // fail → edit → pass, same files
|
|
118
|
+
sawTestFail = false;
|
|
119
|
+
failFiles = new Set();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (REVERT_RE.test(e.command)) contradictions.push({ files: recentEdits.slice(-3) });
|
|
123
|
+
} else if (e.type === "prompt" && NEG_RE.test(e.text)) {
|
|
124
|
+
const f = recentEdits.at(-1);
|
|
125
|
+
if (f) add(f, "S5"); // weak; never fires alone (gate), needs a co-occurring signal
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let seq = 0;
|
|
130
|
+
const episodes = [];
|
|
131
|
+
for (const [file, signals] of sig) {
|
|
132
|
+
episodes.push({
|
|
133
|
+
kind: "mistake",
|
|
134
|
+
context: { files: [file], symbols: [] },
|
|
135
|
+
signals: [...signals].map((s) => ({ signal: s })),
|
|
136
|
+
episodeId: `ep_m${seq++}_${slug(file)}`,
|
|
137
|
+
nowDay,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
for (const c of contradictions) {
|
|
141
|
+
episodes.push({
|
|
142
|
+
kind: "contradiction",
|
|
143
|
+
context: { files: c.files, symbols: [] },
|
|
144
|
+
episodeId: `ep_c${seq++}`,
|
|
145
|
+
nowDay,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return episodes;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Doom-loop breaker (self-correction #5). The shell guard catches the SAME action repeated;
|
|
153
|
+
* this catches the subtler loop the paper names — different edits that keep producing the SAME
|
|
154
|
+
* test failure. When one failure signature recurs ≥ `threshold` times, the agent is stuck: halt
|
|
155
|
+
* and escalate with a diagnosis rather than burning more attempts. Pure + advisory.
|
|
156
|
+
* @returns {{loop:boolean, signature?:string, count?:number, files?:string[]}}
|
|
157
|
+
*/
|
|
158
|
+
export function detectDoomLoop(events, { threshold = 3 } = {}) {
|
|
159
|
+
const counts = new Map(); // outputSig -> occurrences
|
|
160
|
+
const filesAround = new Map(); // outputSig -> Set(files edited between failures)
|
|
161
|
+
let editsSinceFail = [];
|
|
162
|
+
for (const e of events) {
|
|
163
|
+
if (e.type === "edit" && e.file) {
|
|
164
|
+
editsSinceFail.push(e.file);
|
|
165
|
+
} else if (e.type === "bash" && e.outputSig) {
|
|
166
|
+
counts.set(e.outputSig, (counts.get(e.outputSig) ?? 0) + 1);
|
|
167
|
+
const set = filesAround.get(e.outputSig) ?? new Set();
|
|
168
|
+
for (const f of editsSinceFail) set.add(f);
|
|
169
|
+
filesAround.set(e.outputSig, set);
|
|
170
|
+
editsSinceFail = [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
let worst = null;
|
|
174
|
+
for (const [sig, count] of counts) {
|
|
175
|
+
if (count >= threshold && (!worst || count > worst.count)) {
|
|
176
|
+
worst = { signature: sig, count, files: [...(filesAround.get(sig) ?? [])] };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return worst ? { loop: true, ...worst } : { loop: false };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The advisory string for a detected doom loop (empty when there's no loop). */
|
|
183
|
+
export function doomLoopAdvisory(events, opts = {}) {
|
|
184
|
+
const r = detectDoomLoop(events, opts);
|
|
185
|
+
if (!r.loop) return "";
|
|
186
|
+
const where = r.files.length ? ` around ${r.files.slice(0, 5).join(", ")}` : "";
|
|
187
|
+
return `Forge Cortex — doom loop: the SAME test failure has recurred ${r.count}× this session${where}. Different edits aren't fixing it. Stop, find the root cause (re-read the failing assertion and the code it exercises), or ask a human — don't keep patching.`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Drive the orchestrator from a session's events (called by the Stop hook). */
|
|
191
|
+
export function processSession(root, events, nowDay = 0) {
|
|
192
|
+
return detectEpisodes(events, { nowDay }).map((ep) =>
|
|
193
|
+
ep.kind === "contradiction"
|
|
194
|
+
? recordContradiction(root, ep)
|
|
195
|
+
: recordMistake(root, { ...ep, signals: ep.signals ?? [] }),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// forge cortex hook entrypoint — invoked by the shell hooks with the hook JSON on stdin.
|
|
3
|
+
// FAIL-SAFE BY CONSTRUCTION: any error is swallowed and the process exits 0, so Cortex can
|
|
4
|
+
// never block or break a tool call or a session. It is advisory memory, nothing more.
|
|
5
|
+
//
|
|
6
|
+
// modes: capture (PostToolUse Edit|Write|Bash) — log a signal event
|
|
7
|
+
// prompt (UserPromptSubmit) — log a user-utterance event
|
|
8
|
+
// preflight (UserPromptSubmit) — inject the substrate pre-action advisory
|
|
9
|
+
// pre-edit (PreToolUse Edit|Write) — advise on lessons/risk before an edit
|
|
10
|
+
// stop (Stop) — distill the session into lessons
|
|
11
|
+
// session-start (SessionStart) — inject learned lessons as context
|
|
12
|
+
import { applyDistillation, lessonsForContext, startupBlock } from "./cortex.js";
|
|
13
|
+
import {
|
|
14
|
+
appendSessionEvent,
|
|
15
|
+
classifyEvent,
|
|
16
|
+
clearSession,
|
|
17
|
+
doomLoopAdvisory,
|
|
18
|
+
processSession,
|
|
19
|
+
readSession,
|
|
20
|
+
} from "./cortex_hook.js";
|
|
21
|
+
import { load } from "./lessons_store.js";
|
|
22
|
+
import { enforceDecision, substrateCheck, substrateContext } from "./substrate.js";
|
|
23
|
+
import { epochDay } from "./util.js";
|
|
24
|
+
|
|
25
|
+
// Opt-in: distill newly-created lessons into real prose via a cheap model call. Off by
|
|
26
|
+
// default (deterministic template is used); fail-safe (any error → keep the template).
|
|
27
|
+
async function enrichCreated(root, results) {
|
|
28
|
+
if (process.env.ENABLE_CORTEX_DISTILL !== "1") return;
|
|
29
|
+
const created = results.filter((r) => r?.action === "created" && r.id);
|
|
30
|
+
if (!created.length) return;
|
|
31
|
+
const { distill } = await import("./cortex_distill.js");
|
|
32
|
+
for (const r of created) {
|
|
33
|
+
const lesson = load(root).find((l) => l.id === r.id);
|
|
34
|
+
if (!lesson) continue;
|
|
35
|
+
const better = distill({
|
|
36
|
+
context: lesson.trigger,
|
|
37
|
+
signals: lesson.provenance?.signals ?? [],
|
|
38
|
+
});
|
|
39
|
+
if (better) applyDistillation(root, r.id, better);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readStdin() {
|
|
44
|
+
const chunks = [];
|
|
45
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
46
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function main() {
|
|
50
|
+
const mode = process.argv[2];
|
|
51
|
+
let hook = {};
|
|
52
|
+
try {
|
|
53
|
+
hook = JSON.parse((await readStdin()) || "{}");
|
|
54
|
+
} catch {
|
|
55
|
+
return; // no/garbled payload → nothing to do
|
|
56
|
+
}
|
|
57
|
+
const root = hook.cwd || process.cwd();
|
|
58
|
+
const sid = hook.session_id || "default";
|
|
59
|
+
const today = epochDay();
|
|
60
|
+
|
|
61
|
+
if (mode === "capture" || mode === "prompt") {
|
|
62
|
+
appendSessionEvent(root, sid, classifyEvent(hook));
|
|
63
|
+
} else if (mode === "stop") {
|
|
64
|
+
const events = readSession(root, sid);
|
|
65
|
+
if (events.length) {
|
|
66
|
+
const results = processSession(root, events, today);
|
|
67
|
+
clearSession(root, sid);
|
|
68
|
+
await enrichCreated(root, results);
|
|
69
|
+
}
|
|
70
|
+
} else if (mode === "session-start") {
|
|
71
|
+
const block = startupBlock(root, today);
|
|
72
|
+
if (block) emit("SessionStart", block);
|
|
73
|
+
} else if (mode === "pre-edit") {
|
|
74
|
+
// A doom loop (the same failure recurring) is the loudest thing to say — it means "stop",
|
|
75
|
+
// so it takes precedence over lesson/risk advice.
|
|
76
|
+
const loop = doomLoopAdvisory(readSession(root, sid));
|
|
77
|
+
const advice = loop || (await preEditAdvisory(root, hook.tool_input?.file_path, today));
|
|
78
|
+
if (advice) emit("PreToolUse", advice);
|
|
79
|
+
} else if (mode === "preflight") {
|
|
80
|
+
// Ambient cognitive substrate: assumption gate + (when an atlas is already cached)
|
|
81
|
+
// model routing, blast-radius, memory, and minimality — surfaced before the agent acts.
|
|
82
|
+
// allowBuild:false keeps it cheap and never writes .forge/ from a hook; advisory only.
|
|
83
|
+
if (typeof hook.prompt === "string" && hook.prompt.trim()) {
|
|
84
|
+
const result = substrateCheck(root, hook.prompt, { allowBuild: false });
|
|
85
|
+
// Best-effort metrics recording — fills the cost dashboard pipeline without
|
|
86
|
+
// blocking the hook. A failing write is silently swallowed.
|
|
87
|
+
try {
|
|
88
|
+
const { record } = await import("./metrics.js");
|
|
89
|
+
record(root, { stage: "gate", outcome: result.gate?.halted ? "halt" : "pass" });
|
|
90
|
+
if (result.route?.key) {
|
|
91
|
+
record(root, { stage: "route", tier: result.route.tier });
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
const gate = enforceDecision(result);
|
|
95
|
+
if (gate.block) {
|
|
96
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: gate.reason }));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const advisory = substrateContext(result);
|
|
100
|
+
if (advisory) emit("UserPromptSubmit", advisory);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function emit(hookEventName, additionalContext) {
|
|
106
|
+
process.stdout.write(
|
|
107
|
+
JSON.stringify({
|
|
108
|
+
hookSpecificOutput: { hookEventName, additionalContext },
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Advisory before an edit: surface matching lessons (cheap), and — only if none matched —
|
|
114
|
+
// a one-line high-risk note from the predictor. Advisory only, never blocks. Low-nag by
|
|
115
|
+
// design: nothing is emitted unless there's a real lesson or genuinely high risk.
|
|
116
|
+
async function preEditAdvisory(root, file, today) {
|
|
117
|
+
if (!file) return "";
|
|
118
|
+
const { block, selected } = lessonsForContext(
|
|
119
|
+
root,
|
|
120
|
+
{ files: [file], symbols: [], keywords: [file] },
|
|
121
|
+
{ nowDay: today, budget: 3 },
|
|
122
|
+
);
|
|
123
|
+
if (selected.length) return block; // learned lessons for this file win
|
|
124
|
+
const { featuresForEdit } = await import("./cortex_features.js");
|
|
125
|
+
const { riskFor } = await import("./predictor.js");
|
|
126
|
+
const { band } = riskFor(featuresForEdit(root, { file }, { nowDay: today }), {
|
|
127
|
+
mode: "heuristic",
|
|
128
|
+
});
|
|
129
|
+
return band === "high"
|
|
130
|
+
? `Forge Cortex — ${file} looks high-risk (churn / prior mistakes here). Re-read and check impact before editing.`
|
|
131
|
+
: "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
main()
|
|
135
|
+
.catch((err) => {
|
|
136
|
+
if (process.env.FORGE_DEBUG === "1")
|
|
137
|
+
process.stderr.write(`forge cortex: ${err?.message ?? err}\n`);
|
|
138
|
+
})
|
|
139
|
+
.finally(() => process.exit(0));
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// forge cortex MCP — a minimal, zero-dependency MCP server (JSON-RPC 2.0 over stdio,
|
|
2
|
+
// newline-delimited) that exposes this repo's LEARNED LESSONS to any MCP-capable tool
|
|
3
|
+
// (Cursor, Codex, Gemini, Zed…). Claude gets lessons live via hooks; this is how the other
|
|
4
|
+
// tools query them on demand. Launched as `forge cortex-mcp`, so the path resolves anywhere.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { argv } from "node:process";
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { lessonsForContext, summary } from "./cortex.js";
|
|
10
|
+
import { report as costReport } from "./cost_report.js";
|
|
11
|
+
import { dashData } from "./dash.js";
|
|
12
|
+
import { diagnose } from "./diagnose.js";
|
|
13
|
+
import { doctor } from "./doctor.js";
|
|
14
|
+
import { assessTask, clarifyBlock, preflightRepo } from "./preflight.js";
|
|
15
|
+
import { routeTask } from "./route.js";
|
|
16
|
+
import { decompose } from "./scope.js";
|
|
17
|
+
import { predictImpact, substrateCheck } from "./substrate.js";
|
|
18
|
+
import { epochDay } from "./util.js";
|
|
19
|
+
|
|
20
|
+
const PKG_VERSION = (() => {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
23
|
+
} catch {
|
|
24
|
+
return "0.0.0";
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
27
|
+
|
|
28
|
+
const root = process.env.FORGE_ROOT || process.cwd();
|
|
29
|
+
const today = epochDay;
|
|
30
|
+
|
|
31
|
+
const TOOLS = [
|
|
32
|
+
{
|
|
33
|
+
name: "cortex_lessons",
|
|
34
|
+
description:
|
|
35
|
+
"Lessons Forge Cortex learned from past mistakes on THIS repo, for the given files/symbols. Background context — verify before acting, don't blindly obey.",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
files: {
|
|
40
|
+
type: "array",
|
|
41
|
+
items: { type: "string" },
|
|
42
|
+
description: "file paths in play",
|
|
43
|
+
},
|
|
44
|
+
symbols: {
|
|
45
|
+
type: "array",
|
|
46
|
+
items: { type: "string" },
|
|
47
|
+
description: "symbol names in play",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "cortex_status",
|
|
54
|
+
description: "Summary of learned lessons on this repo (counts by state, top by confidence).",
|
|
55
|
+
inputSchema: { type: "object", properties: {} },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "preflight_check",
|
|
59
|
+
description:
|
|
60
|
+
"BEFORE starting a task, check what it names that the repo doesn't define — the things you'd otherwise ASSUME. Returns a clarify list or an all-clear. Ask instead of assuming.",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: { task: { type: "string", description: "the task/prompt" } },
|
|
64
|
+
required: ["task"],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "route_task",
|
|
69
|
+
description:
|
|
70
|
+
"Recommend the cheapest CAPABLE model for a task by code-task complexity (files, fan-out, churn, past mistakes, ambiguity). Advisory — don't burn a top model on a trivial task.",
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: { task: { type: "string" } },
|
|
74
|
+
required: ["task"],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
name: "assumption_gate",
|
|
80
|
+
description:
|
|
81
|
+
"Score specification completeness before work starts. Returns shouldAsk, risk, missing dimensions, and concrete questions.",
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: { task: { type: "string", description: "the task/prompt" } },
|
|
85
|
+
required: ["task"],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "predict_impact",
|
|
90
|
+
description:
|
|
91
|
+
"Predict blast radius for a symbol or file using Forge atlas reverse-dependency traversal.",
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: "object",
|
|
94
|
+
properties: {
|
|
95
|
+
target: { type: "string", description: "symbol name, qualified name, or file" },
|
|
96
|
+
threshold: { type: "number", description: "confidence threshold, default 0.1" },
|
|
97
|
+
},
|
|
98
|
+
required: ["target"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "substrate_check",
|
|
103
|
+
description:
|
|
104
|
+
"Full Forge cognitive-substrate pre-action check: assumption gate, route, impact, scope, memory, minimality, and verification checklist.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: { task: { type: "string", description: "the task/prompt" } },
|
|
108
|
+
required: ["task"],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "scope_files",
|
|
113
|
+
description:
|
|
114
|
+
"Decompose files into INDEPENDENT clusters (run as separate sessions) vs coupled, and surface coupled files you didn't name (the 'forgot the related module' guard).",
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: { files: { type: "array", items: { type: "string" } } },
|
|
118
|
+
required: ["files"],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "forge_cost",
|
|
123
|
+
description:
|
|
124
|
+
"Cost report — measured stage factors (gate, cache, route, context) from .forge/metrics.jsonl with multiplicative composition.",
|
|
125
|
+
inputSchema: { type: "object", properties: {} },
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "forge_dash_data",
|
|
129
|
+
description:
|
|
130
|
+
"Dashboard JSON payload — ledger stats, metrics, atlas info. Same data forge dash serves at /api/data.",
|
|
131
|
+
inputSchema: { type: "object", properties: {} },
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "forge_brain",
|
|
135
|
+
description: "Project memory index — list all remembered facts stored in .forge/brain/.",
|
|
136
|
+
inputSchema: { type: "object", properties: {} },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "forge_ledger_query",
|
|
140
|
+
description:
|
|
141
|
+
"Query the proof-carrying memory ledger with a natural language query. Returns ranked matching claims.",
|
|
142
|
+
inputSchema: {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
query: { type: "string", description: "what you are about to do or looking for" },
|
|
146
|
+
},
|
|
147
|
+
required: ["query"],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "forge_diagnose",
|
|
152
|
+
description:
|
|
153
|
+
"Doom-loop check — record a failure and check if the same signature has recurred (3x = escalation). Prevents thrashing.",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: "object",
|
|
156
|
+
properties: {
|
|
157
|
+
errorText: { type: "string", description: "the error message" },
|
|
158
|
+
file: { type: "string", description: "file where the error occurred" },
|
|
159
|
+
symbol: { type: "string", description: "symbol involved" },
|
|
160
|
+
},
|
|
161
|
+
required: ["errorText"],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "forge_doctor",
|
|
166
|
+
description:
|
|
167
|
+
"Health check — verify installed tools, guards, MCP auth, config drift, and system state.",
|
|
168
|
+
inputSchema: { type: "object", properties: {} },
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: "forge_provider_status",
|
|
172
|
+
description:
|
|
173
|
+
"Provider detection — which API provider is active (auto-detected or configured), env vars set, and health checks.",
|
|
174
|
+
inputSchema: { type: "object", properties: {} },
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
async function callTool(name, args = {}) {
|
|
179
|
+
if (name === "cortex_lessons") {
|
|
180
|
+
const files = args.files ?? [];
|
|
181
|
+
const symbols = args.symbols ?? [];
|
|
182
|
+
const { block } = lessonsForContext(
|
|
183
|
+
root,
|
|
184
|
+
{ files, symbols, keywords: [...files, ...symbols] },
|
|
185
|
+
{ nowDay: today() },
|
|
186
|
+
);
|
|
187
|
+
return block || "No lessons recorded for these files/symbols yet.";
|
|
188
|
+
}
|
|
189
|
+
if (name === "cortex_status") return JSON.stringify(summary(root, today()), null, 2);
|
|
190
|
+
if (name === "preflight_check") {
|
|
191
|
+
const r = preflightRepo(root, String(args.task ?? ""));
|
|
192
|
+
return clarifyBlock(r) || "All clear — everything this task names is grounded in the codebase.";
|
|
193
|
+
}
|
|
194
|
+
if (name === "route_task") {
|
|
195
|
+
const rec = routeTask(root, String(args.task ?? ""));
|
|
196
|
+
return `Recommended: ${rec.model.name} (${rec.tier}). complexity ${rec.score.toFixed(2)}${rec.reasons.length ? ` — ${rec.reasons.join(", ")}` : ""}.`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (name === "assumption_gate")
|
|
200
|
+
return JSON.stringify(assessTask(String(args.task ?? "")), null, 2);
|
|
201
|
+
if (name === "predict_impact")
|
|
202
|
+
return JSON.stringify(
|
|
203
|
+
predictImpact(root, String(args.target ?? ""), { threshold: Number(args.threshold ?? 0.1) }),
|
|
204
|
+
null,
|
|
205
|
+
2,
|
|
206
|
+
);
|
|
207
|
+
if (name === "substrate_check")
|
|
208
|
+
return JSON.stringify(substrateCheck(root, String(args.task ?? "")), null, 2);
|
|
209
|
+
if (name === "scope_files") {
|
|
210
|
+
const d = decompose(root, args.files ?? []);
|
|
211
|
+
return JSON.stringify(d, null, 2);
|
|
212
|
+
}
|
|
213
|
+
if (name === "forge_cost") return JSON.stringify(costReport(root), null, 2);
|
|
214
|
+
if (name === "forge_dash_data") return JSON.stringify(dashData(root), null, 2);
|
|
215
|
+
if (name === "forge_brain") {
|
|
216
|
+
try {
|
|
217
|
+
const { brainStore, list, buildIndex } = await import("./brain.js");
|
|
218
|
+
const store = brainStore(root);
|
|
219
|
+
const idx = buildIndex(store);
|
|
220
|
+
const items = list(store);
|
|
221
|
+
return JSON.stringify({ items, indexed: idx.indexed, overflow: idx.overflow }, null, 2);
|
|
222
|
+
} catch {
|
|
223
|
+
return "No brain store found — run `forge remember` to start.";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (name === "forge_ledger_query") {
|
|
227
|
+
try {
|
|
228
|
+
const { loadClaims, repoLedger } = await import("./ledger_store.js");
|
|
229
|
+
const { retrieve, claimText } = await import("./ledger.js");
|
|
230
|
+
const { claimSim, simLabel } = await import("./embed.js");
|
|
231
|
+
const dir = repoLedger(root);
|
|
232
|
+
const q = String(args.query ?? "");
|
|
233
|
+
const claims = loadClaims(dir);
|
|
234
|
+
const sim = claimSim(root, q, claims, claimText);
|
|
235
|
+
const ranked = retrieve(q, claims, { nowDay: today(), budget: 8, sim });
|
|
236
|
+
return JSON.stringify(
|
|
237
|
+
{
|
|
238
|
+
sim: simLabel(sim),
|
|
239
|
+
results: ranked.map((r) => ({
|
|
240
|
+
id: r.claim.id,
|
|
241
|
+
kind: r.claim.kind,
|
|
242
|
+
score: r.score,
|
|
243
|
+
text: claimText(r.claim).slice(0, 200),
|
|
244
|
+
})),
|
|
245
|
+
},
|
|
246
|
+
null,
|
|
247
|
+
2,
|
|
248
|
+
);
|
|
249
|
+
} catch {
|
|
250
|
+
return "No ledger claims found.";
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (name === "forge_diagnose") {
|
|
254
|
+
const r = diagnose(root, {
|
|
255
|
+
errorText: String(args.errorText ?? ""),
|
|
256
|
+
file: args.file,
|
|
257
|
+
symbol: args.symbol,
|
|
258
|
+
});
|
|
259
|
+
return JSON.stringify(r, null, 2);
|
|
260
|
+
}
|
|
261
|
+
if (name === "forge_doctor") {
|
|
262
|
+
const { results, failed } = doctor({ targetRoot: root });
|
|
263
|
+
return JSON.stringify({ results, failed }, null, 2);
|
|
264
|
+
}
|
|
265
|
+
if (name === "forge_provider_status") {
|
|
266
|
+
const { activeProvider, providerStatus, listDetectedProviders } = await import(
|
|
267
|
+
"./providers.js"
|
|
268
|
+
);
|
|
269
|
+
const prov = activeProvider(root);
|
|
270
|
+
const status = providerStatus(root);
|
|
271
|
+
const detected = listDetectedProviders();
|
|
272
|
+
return JSON.stringify(
|
|
273
|
+
{
|
|
274
|
+
active: {
|
|
275
|
+
name: prov.name,
|
|
276
|
+
type: prov.type,
|
|
277
|
+
label: prov.label || prov.name,
|
|
278
|
+
baseUrl: prov.baseUrl,
|
|
279
|
+
autoDetected: Boolean(prov._autoDetected),
|
|
280
|
+
source: prov._source || null,
|
|
281
|
+
},
|
|
282
|
+
checks: status.checks,
|
|
283
|
+
availableProviders: detected,
|
|
284
|
+
envScan: status.envScan,
|
|
285
|
+
},
|
|
286
|
+
null,
|
|
287
|
+
2,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Handle one JSON-RPC message; returns a response object, or null for notifications. */
|
|
294
|
+
export async function handle(msg) {
|
|
295
|
+
const { id, method, params } = msg;
|
|
296
|
+
if (id === undefined || String(method).startsWith("notifications/")) return null;
|
|
297
|
+
if (method === "initialize") {
|
|
298
|
+
return {
|
|
299
|
+
jsonrpc: "2.0",
|
|
300
|
+
id,
|
|
301
|
+
result: {
|
|
302
|
+
protocolVersion: "2024-11-05",
|
|
303
|
+
capabilities: { tools: {} },
|
|
304
|
+
serverInfo: { name: "forge-cortex", version: PKG_VERSION },
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (method === "tools/list") return { jsonrpc: "2.0", id, result: { tools: TOOLS } };
|
|
309
|
+
if (method === "tools/call") {
|
|
310
|
+
const text = await callTool(params?.name, params?.arguments);
|
|
311
|
+
if (text === null) {
|
|
312
|
+
return {
|
|
313
|
+
jsonrpc: "2.0",
|
|
314
|
+
id,
|
|
315
|
+
error: { code: -32602, message: `unknown tool: ${params?.name}` },
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
jsonrpc: "2.0",
|
|
320
|
+
id,
|
|
321
|
+
result: { content: [{ type: "text", text }] },
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
jsonrpc: "2.0",
|
|
326
|
+
id,
|
|
327
|
+
error: { code: -32601, message: `unknown method: ${method}` },
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function serve(input = process.stdin, output = process.stdout) {
|
|
332
|
+
const rl = createInterface({ input });
|
|
333
|
+
rl.on("line", (line) => {
|
|
334
|
+
const trimmed = line.trim();
|
|
335
|
+
if (!trimmed) return;
|
|
336
|
+
let msg;
|
|
337
|
+
try {
|
|
338
|
+
msg = JSON.parse(trimmed);
|
|
339
|
+
} catch {
|
|
340
|
+
return; // ignore malformed frames
|
|
341
|
+
}
|
|
342
|
+
handle(msg)
|
|
343
|
+
.then((res) => {
|
|
344
|
+
if (res) output.write(`${JSON.stringify(res)}\n`);
|
|
345
|
+
})
|
|
346
|
+
.catch(() => {});
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Only start the server when run directly (`forge cortex-mcp` / `node src/cortex_mcp.js`),
|
|
351
|
+
// not when imported by tests.
|
|
352
|
+
if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) serve();
|