@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
package/src/route.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// forge route — complexity-based model routing. Generic routers score prompt difficulty;
|
|
2
|
+
// this scores CODE-TASK complexity from signals Forge already computes (files in scope, impact
|
|
3
|
+
// fan-out, churn/fragility, past-mistake density here, ambiguity, task size) → cheapest capable
|
|
4
|
+
// tier. Advisory by default; a LiteLLM config emit exposes the tiers as gateway aliases you request.
|
|
5
|
+
import { writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { adjudicate, asText, buildRunner, llmEnabled } from "./adjudicate.js";
|
|
8
|
+
import { matchingLessons } from "./cortex.js";
|
|
9
|
+
import { gitChurn, grepFanout } from "./cortex_features.js";
|
|
10
|
+
import { recordRoute } from "./cost_report.js";
|
|
11
|
+
import { mergedLessons } from "./ledger_read.js";
|
|
12
|
+
import { MODELS } from "./model_tiers.js";
|
|
13
|
+
import { preflightRepo, referencedEntities } from "./preflight.js";
|
|
14
|
+
import { activeProvider } from "./providers.js";
|
|
15
|
+
import { clamp01, contentHash, epochDay } from "./util.js";
|
|
16
|
+
|
|
17
|
+
// Weights sum to 1. Each raw signal is normalized by the point where it reads as "complex".
|
|
18
|
+
|
|
19
|
+
const ALGO_TERMS =
|
|
20
|
+
/\b(recursion|recursive|recursive-?descent|dynamic programming|dijkstra|a\*|concurren|thread-?safe|mutex|race condition|deadlock|distributed|consensus|parser|compiler|cryptograph|np-hard|state machine|invariant|numerical stability|back-?pressure|token[- ]bucket|rate limiter|idempoten|migration|producer|consumer|blocking queue|condition[- ]variable)\b/i;
|
|
21
|
+
const ARCH_TERMS =
|
|
22
|
+
/\b(architect|\bdesign\b|trade-?off|refactor a|migrate|scal(e|able|ing)|schema (migration|design)|api design|multi-?module|cross-?module|end-?to-?end|consistency (guarantee|trade)|locking strategy|module boundaries)\b/i;
|
|
23
|
+
const MODERATE_TERMS =
|
|
24
|
+
/\b(class\b|cache|lru|queue|stack|heap|linked list|binary tree|tree|traversal|graph|decorator|regex|debounce|throttle|merge|sort(ed|ing)?|parse|o\(\s*\d|o\(n|o\(1|thread|async|lock|validate|in-?order|adjacency)\b/i;
|
|
25
|
+
const TRIVIAL_TERMS =
|
|
26
|
+
/\b(hello world|rename|typo|indent|add a comment|reverse a string|reverse the string|is[_ ]?even|is[_ ]?odd|factorial|fibonacci|is[_ ]?prime|prime\b|sum of|sum_list|capitalize|count vowels|celsius|fahrenheit|lower ?case|upper ?case)\b/i;
|
|
27
|
+
const MULTISTEP =
|
|
28
|
+
/\b(and then|after that|first.*then|step \d|multiple|several|each of|for every)\b/i;
|
|
29
|
+
|
|
30
|
+
export function rubricSignals(task = "") {
|
|
31
|
+
const text = String(task);
|
|
32
|
+
return {
|
|
33
|
+
lengthTokens: Math.max(1, Math.floor(text.length / 4)),
|
|
34
|
+
hasAlgorithmicTerms: ALGO_TERMS.test(text),
|
|
35
|
+
hasArchitecturalTerms: ARCH_TERMS.test(text),
|
|
36
|
+
hasModerateTerms: MODERATE_TERMS.test(text),
|
|
37
|
+
hasTrivialMarkers: TRIVIAL_TERMS.test(text),
|
|
38
|
+
hasMultistep: MULTISTEP.test(text),
|
|
39
|
+
hasCodeContext: /```/.test(text),
|
|
40
|
+
nConstraints: (text.match(/(^\s*[-*\d.]|\b(must|should|ensure|require|constraint)\b)/gim) || [])
|
|
41
|
+
.length,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function rubricComplexity(task = "") {
|
|
46
|
+
const sig = rubricSignals(task);
|
|
47
|
+
const reasons = [{ weight: 1.5, reason: "base cost of any task" }];
|
|
48
|
+
let score = 1.5;
|
|
49
|
+
if (sig.hasAlgorithmicTerms) {
|
|
50
|
+
score += 4;
|
|
51
|
+
reasons.push({ weight: 4, reason: "algorithmic/systems difficulty" });
|
|
52
|
+
}
|
|
53
|
+
if (sig.hasArchitecturalTerms) {
|
|
54
|
+
score += 4;
|
|
55
|
+
reasons.push({ weight: 4, reason: "architectural/design scope" });
|
|
56
|
+
}
|
|
57
|
+
if (sig.hasModerateTerms) {
|
|
58
|
+
score += 2;
|
|
59
|
+
reasons.push({ weight: 2, reason: "data-structure/class/library-level work" });
|
|
60
|
+
}
|
|
61
|
+
if (sig.hasMultistep) {
|
|
62
|
+
score += 1;
|
|
63
|
+
reasons.push({ weight: 1, reason: "multi-step request" });
|
|
64
|
+
}
|
|
65
|
+
if (sig.hasCodeContext) {
|
|
66
|
+
score += 1;
|
|
67
|
+
reasons.push({ weight: 1, reason: "carries code context" });
|
|
68
|
+
}
|
|
69
|
+
if (sig.lengthTokens > 120) {
|
|
70
|
+
score += 1.5;
|
|
71
|
+
reasons.push({ weight: 1.5, reason: `long spec (~${sig.lengthTokens} tok)` });
|
|
72
|
+
} else if (sig.lengthTokens > 55) {
|
|
73
|
+
score += 0.7;
|
|
74
|
+
reasons.push({ weight: 0.7, reason: `medium spec (~${sig.lengthTokens} tok)` });
|
|
75
|
+
}
|
|
76
|
+
if (sig.nConstraints >= 5) {
|
|
77
|
+
score += 1;
|
|
78
|
+
reasons.push({ weight: 1, reason: `${sig.nConstraints} explicit constraints` });
|
|
79
|
+
}
|
|
80
|
+
if (sig.hasTrivialMarkers && !sig.hasAlgorithmicTerms && !sig.hasArchitecturalTerms) {
|
|
81
|
+
score -= 3;
|
|
82
|
+
reasons.push({ weight: -3, reason: "trivial-task marker" });
|
|
83
|
+
}
|
|
84
|
+
const rawScore = Math.max(0, score);
|
|
85
|
+
const band = rawScore < 3 ? "cheap" : rawScore <= 6 ? "mid" : "premium";
|
|
86
|
+
return { rawScore, band, signals: sig, reasons };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const WEIGHTS = {
|
|
90
|
+
files: 0.22,
|
|
91
|
+
fanout: 0.22,
|
|
92
|
+
churn: 0.12,
|
|
93
|
+
mistakes: 0.18,
|
|
94
|
+
ambiguity: 0.12,
|
|
95
|
+
size: 0.14,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pure: raw task signals → complexity in [0,1].
|
|
100
|
+
* @param {{files?:number, fanout?:number, churn?:number, pastMistakes?:number, ambiguity?:number, sizeWords?:number}} s
|
|
101
|
+
*/
|
|
102
|
+
export function complexity(s = {}) {
|
|
103
|
+
const norm = {
|
|
104
|
+
files: clamp01((s.files ?? 0) / 5), // >5 files touched = complex
|
|
105
|
+
fanout: clamp01((s.fanout ?? 0) / 15), // >15 call sites = complex
|
|
106
|
+
churn: clamp01((s.churn ?? 0) / 12), // fragile, frequently-changed area
|
|
107
|
+
mistakes: clamp01((s.pastMistakes ?? 0) / 3), // repeated pain here
|
|
108
|
+
ambiguity: clamp01(s.ambiguity ?? 0), // already 0..1 (from preflight)
|
|
109
|
+
size: clamp01((s.sizeWords ?? 0) / 60), // long ask = more moving parts
|
|
110
|
+
};
|
|
111
|
+
let score = 0;
|
|
112
|
+
for (const k of Object.keys(WEIGHTS)) score += WEIGHTS[k] * norm[k];
|
|
113
|
+
return { score: clamp01(score), norm };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Pure: score → recommended model + the reasons that drove it. */
|
|
117
|
+
export function recommend(score, norm = {}) {
|
|
118
|
+
const key = score < 0.25 ? "haiku" : score < 0.55 ? "sonnet" : score < 0.8 ? "opus" : "fable";
|
|
119
|
+
const reasons = Object.entries(norm)
|
|
120
|
+
.filter(([, v]) => v >= 0.5)
|
|
121
|
+
.map(([k]) => k)
|
|
122
|
+
.sort();
|
|
123
|
+
return { key, model: MODELS[key], tier: MODELS[key].tier, reasons };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// M1 routing — LLM proposer. Estimates task complexity c(x) as a coarse band. PROPOSER ONLY:
|
|
127
|
+
// the reconcile in routeTask() lets a RAISE through freely but bounds any LOWER (within one band
|
|
128
|
+
// and never below a strong-signal floor), so the model can escalate on hidden complexity yet can
|
|
129
|
+
// never under-provision a genuinely hard task; escalation still gates on a verified failure.
|
|
130
|
+
const BAND_FLOOR = { cheap: 0.15, mid: 0.4, premium: 0.65 };
|
|
131
|
+
|
|
132
|
+
export function buildComplexityPrompt(task) {
|
|
133
|
+
return `Judge the intrinsic complexity of this coding task for model selection (not how to do it).
|
|
134
|
+
Task: """${String(task).slice(0, 1200)}"""
|
|
135
|
+
Answer with STRICT JSON and nothing else:
|
|
136
|
+
{"band":"cheap|mid|premium","reason":"<short why>"}
|
|
137
|
+
cheap = trivial/boilerplate; mid = a data structure, class, or library-level change; premium =
|
|
138
|
+
algorithmic/systems/architectural/multi-module work. No text outside the JSON object.`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function parseComplexityProposal(obj) {
|
|
142
|
+
const band = String(obj.band ?? "")
|
|
143
|
+
.trim()
|
|
144
|
+
.toLowerCase();
|
|
145
|
+
if (!(band in BAND_FLOOR)) return null;
|
|
146
|
+
return { band, score: BAND_FLOOR[band], reason: asText(obj.reason) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Ask the model for a complexity band (proposer). Returns null when off/unavailable. */
|
|
150
|
+
export function complexityLLM(task, { run = buildRunner() } = {}) {
|
|
151
|
+
return adjudicate({ prompt: buildComplexityPrompt(task), parse: parseComplexityProposal, run });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Repo wrapper: gather the real signals for a task and route it. `run` is injectable for tests.
|
|
156
|
+
* @param {string} root
|
|
157
|
+
* @param {string} task
|
|
158
|
+
* @param {object} [opts]
|
|
159
|
+
* @param {boolean} [opts.llm]
|
|
160
|
+
* @param {string} [opts.model]
|
|
161
|
+
* @param {number} [opts.timeoutMs]
|
|
162
|
+
* @param {(p:string)=>string} [opts.run]
|
|
163
|
+
* @param {boolean} [opts.bidirectional]
|
|
164
|
+
* @param {number} [opts.routingBand]
|
|
165
|
+
* @param {number} [opts.signalFloor]
|
|
166
|
+
* @param {number} [opts.ambiguity] precomputed information-gap (skips a duplicate preflight pass)
|
|
167
|
+
*/
|
|
168
|
+
export function routeTask(
|
|
169
|
+
root,
|
|
170
|
+
task,
|
|
171
|
+
{
|
|
172
|
+
llm,
|
|
173
|
+
model,
|
|
174
|
+
timeoutMs,
|
|
175
|
+
run,
|
|
176
|
+
bidirectional = true,
|
|
177
|
+
routingBand = 0.2,
|
|
178
|
+
signalFloor = 0.4,
|
|
179
|
+
ambiguity,
|
|
180
|
+
} = {},
|
|
181
|
+
) {
|
|
182
|
+
const { symbols, files } = referencedEntities(task);
|
|
183
|
+
const fanout = symbols.reduce((m, sym) => Math.max(m, grepFanout(root, sym)), 0);
|
|
184
|
+
const churn = files.reduce((m, f) => Math.max(m, gitChurn(root, f)), 0);
|
|
185
|
+
// Merged view (P2 read flip): teammate lessons raise past-mistake density here too.
|
|
186
|
+
const pastMistakes = matchingLessons(mergedLessons(root, epochDay()), {
|
|
187
|
+
files,
|
|
188
|
+
symbols,
|
|
189
|
+
}).length;
|
|
190
|
+
// The routing signal only needs the DETERMINISTIC gap. Accept a precomputed one (substrate
|
|
191
|
+
// already has it) and, when computing our own, force llm:false — the gap never depends on the
|
|
192
|
+
// model, so an LLM assumption call here would be pure wasted latency.
|
|
193
|
+
const ambiguityScore =
|
|
194
|
+
typeof ambiguity === "number"
|
|
195
|
+
? ambiguity
|
|
196
|
+
: preflightRepo(root, task, { allowBuild: false, llm: false }).gap;
|
|
197
|
+
const sizeWords = task.trim().split(/\s+/).filter(Boolean).length;
|
|
198
|
+
const signals = {
|
|
199
|
+
files: files.length,
|
|
200
|
+
fanout,
|
|
201
|
+
churn,
|
|
202
|
+
pastMistakes,
|
|
203
|
+
ambiguity: ambiguityScore,
|
|
204
|
+
sizeWords,
|
|
205
|
+
};
|
|
206
|
+
const { score: repoScore, norm } = complexity(signals);
|
|
207
|
+
const rubric = rubricComplexity(task);
|
|
208
|
+
const rubricScore = Math.min(1, rubric.rawScore / 10);
|
|
209
|
+
const detScore = Math.max(repoScore, rubricScore);
|
|
210
|
+
// M1 proposer (opt-in): the model PROPOSES a complexity band. A RAISE is free (spotting hidden
|
|
211
|
+
// complexity costs at most a bigger model). A LOWER is bounded — never more than one `band`
|
|
212
|
+
// below the rubric, and never below `signalFloor` when the rubric detected algorithmic or
|
|
213
|
+
// architectural terms, so a "distributed rate-limiter" can't be talked down to the cheap tier.
|
|
214
|
+
// With `bidirectional:false` it stays raise-only. Fail-safe: a null proposal is ignored.
|
|
215
|
+
const proposal = llmEnabled({ llm })
|
|
216
|
+
? complexityLLM(task, { run: run || buildRunner({ model, timeoutMs }) })
|
|
217
|
+
: null;
|
|
218
|
+
const strongSignal = rubric.signals.hasAlgorithmicTerms || rubric.signals.hasArchitecturalTerms;
|
|
219
|
+
let score = detScore;
|
|
220
|
+
let path = proposal ? "llm-agreed" : "deterministic";
|
|
221
|
+
if (proposal) {
|
|
222
|
+
if (proposal.score > detScore) {
|
|
223
|
+
score = proposal.score; // free raise
|
|
224
|
+
path = "llm-raised";
|
|
225
|
+
} else if (bidirectional && proposal.score < detScore) {
|
|
226
|
+
const floor = Math.max(detScore - routingBand, strongSignal ? signalFloor : 0);
|
|
227
|
+
const lowered = Math.max(floor, proposal.score);
|
|
228
|
+
if (lowered < detScore) {
|
|
229
|
+
score = lowered; // bounded lower
|
|
230
|
+
path = "llm-lowered";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const recommended = recommend(score, norm);
|
|
235
|
+
return {
|
|
236
|
+
score,
|
|
237
|
+
repoScore,
|
|
238
|
+
signals,
|
|
239
|
+
rubric,
|
|
240
|
+
llm: proposal
|
|
241
|
+
? { band: proposal.band, reason: proposal.reason, direction: path.replace("llm-", "") }
|
|
242
|
+
: null,
|
|
243
|
+
provenance: { path },
|
|
244
|
+
...recommended,
|
|
245
|
+
reasons: [
|
|
246
|
+
...new Set([
|
|
247
|
+
...(recommended.reasons || []),
|
|
248
|
+
...rubric.reasons.filter((r) => r.weight > 0).map((r) => r.reason),
|
|
249
|
+
...(path === "llm-raised" || path === "llm-lowered"
|
|
250
|
+
? [`model judged ${proposal.band} (${path.replace("llm-", "")}): ${proposal.reason}`]
|
|
251
|
+
: []),
|
|
252
|
+
]),
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Best-effort route-stage metering (05-cost-model.md) for EXPLICIT callers only — the
|
|
259
|
+
* `forge route` CLI and the explicit substrate gate call this AFTER a routing decision.
|
|
260
|
+
* Deliberately NOT called from routeTask itself: ambient hooks route on every prompt
|
|
261
|
+
* and must stay write-free, same rule as recordGate in substrate.js. One metrics line:
|
|
262
|
+
* the chosen tier + a short task hash as the ref (never the task text — metrics are
|
|
263
|
+
* telemetry, not a prompt log). No token counts here — this is an advisory routing
|
|
264
|
+
* decision, not a priced generation, and the cost report excludes unpriced events
|
|
265
|
+
* rather than estimating them.
|
|
266
|
+
* @param {string} root
|
|
267
|
+
* @param {string} task
|
|
268
|
+
* @param {{tier?: string}} rec the routeTask result (only .tier is read)
|
|
269
|
+
*/
|
|
270
|
+
export function meterRoute(root, task, rec) {
|
|
271
|
+
try {
|
|
272
|
+
recordRoute(root, { tier: rec?.tier, ref: contentHash(String(task)).slice(0, 12) });
|
|
273
|
+
} catch {}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Emit a LiteLLM config exposing the complexity tiers as aliases (request the one `forge route` picks).
|
|
277
|
+
* Provider-aware: uses the active provider's model IDs for the passthrough entries
|
|
278
|
+
* and the correct LiteLLM model prefix (anthropic/ for direct, openrouter/ for OR).
|
|
279
|
+
* Returns `{ ok: false, reason }` for hosted gateways the user cannot configure. */
|
|
280
|
+
export function emitGatewayConfig(root = process.cwd()) {
|
|
281
|
+
const prov = activeProvider(root);
|
|
282
|
+
if (prov._autoDetected && prov._source === "LITELLM_BASE_URL") {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
reason:
|
|
286
|
+
`Hosted LiteLLM gateway detected at ${prov.baseUrl}. ` +
|
|
287
|
+
`No local config needed — requests go directly to the hosted gateway. ` +
|
|
288
|
+
`Use standard model names (the gateway handles routing).`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const prefix = prov.type === "openrouter" ? "openrouter/" : "anthropic/";
|
|
292
|
+
const path = join(root, "litellm.config.yaml");
|
|
293
|
+
const body = `# Forge Preflight — LiteLLM routing config (complexity tier -> model).
|
|
294
|
+
# HOW ROUTING WORKS: LiteLLM routes by the REQUESTED model name; it cannot infer task
|
|
295
|
+
# complexity on its own. So 'forge route' tells you the tier, and your tool REQUESTS the
|
|
296
|
+
# matching alias (forge-simple/medium/complex). A normal claude-* request passes through
|
|
297
|
+
# unchanged — pointing ANTHROPIC_BASE_URL here never breaks existing traffic.
|
|
298
|
+
# pip install "litellm[proxy]==<pin an exact verified version>" # supply-chain: pin exact, no floating tag
|
|
299
|
+
# litellm --config litellm.config.yaml # then export ANTHROPIC_BASE_URL=http://localhost:4000
|
|
300
|
+
# Provider: ${prov.label || prov.name} (${prov.type})
|
|
301
|
+
# Models verified 2026-07-05; re-verify via dev-radar.
|
|
302
|
+
model_list:
|
|
303
|
+
# Tier aliases — request one of these (per 'forge route') to pick a model by complexity.
|
|
304
|
+
- model_name: forge-simple # ${MODELS.haiku.name} — ${MODELS.haiku.use}
|
|
305
|
+
litellm_params: { model: ${prefix}${MODELS.haiku.id} }
|
|
306
|
+
- model_name: forge-medium # ${MODELS.sonnet.name} — default
|
|
307
|
+
litellm_params: { model: ${prefix}${MODELS.sonnet.id} }
|
|
308
|
+
- model_name: forge-complex # ${MODELS.opus.name}
|
|
309
|
+
litellm_params: { model: ${prefix}${MODELS.opus.id} }
|
|
310
|
+
# Passthrough — a normal claude-* request still works when pointed at the gateway.
|
|
311
|
+
- model_name: ${MODELS.haiku.id}
|
|
312
|
+
litellm_params: { model: ${prefix}${MODELS.haiku.id} }
|
|
313
|
+
- model_name: ${MODELS.sonnet.id}
|
|
314
|
+
litellm_params: { model: ${prefix}${MODELS.sonnet.id} }
|
|
315
|
+
- model_name: ${MODELS.opus.id}
|
|
316
|
+
litellm_params: { model: ${prefix}${MODELS.opus.id} }
|
|
317
|
+
${prov.envKey ? `litellm_settings:\n drop_params: true\n set_verbose: false` : ""}
|
|
318
|
+
router_settings:
|
|
319
|
+
routing_strategy: simple-shuffle
|
|
320
|
+
`;
|
|
321
|
+
writeFileSync(path, body);
|
|
322
|
+
return path;
|
|
323
|
+
}
|
package/src/scope.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// forge scope — deterministic task decomposition. Build a cheap import graph (no LLM), find
|
|
2
|
+
// connected components, and tell the developer which touched files are INDEPENDENT (→ run in
|
|
3
|
+
// separate sessions, so the context window isn't polluted) vs. COUPLED — and which coupled
|
|
4
|
+
// files they didn't mention (the "forgot the related module" guard). Regex imports are
|
|
5
|
+
// approximate (dynamic/DI edges missed) — a real call-graph MCP is the upgrade seam.
|
|
6
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
8
|
+
import { IGNORE_DIRS, SRC_EXT } from "./util.js";
|
|
9
|
+
|
|
10
|
+
const IMPORT_RES = [
|
|
11
|
+
/import\s+[^'"]*from\s+['"]([^'"]+)['"]/g, // import x from "y"
|
|
12
|
+
/import\s+['"]([^'"]+)['"]/g, // import "y"
|
|
13
|
+
/require\(\s*['"]([^'"]+)['"]\s*\)/g, // require("y")
|
|
14
|
+
/export\s+[^'"]*from\s+['"]([^'"]+)['"]/g, // export … from "y"
|
|
15
|
+
/import\(\s*['"]([^'"]+)['"]\s*\)/g, // dynamic import("y")
|
|
16
|
+
/^\s*from\s+(\.[.\w/]*)\s+import/gm, // python: from .y import
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function walk(dir, root, out) {
|
|
20
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
21
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
22
|
+
const p = join(dir, entry.name);
|
|
23
|
+
if (entry.isDirectory()) walk(p, root, out);
|
|
24
|
+
else if (SRC_EXT.test(entry.name)) out.push(relative(root, p));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveSpec(fromRel, spec, root, fileSet) {
|
|
29
|
+
if (!spec.startsWith(".")) return null; // external / stdlib — not a local edge
|
|
30
|
+
const raw = resolve(root, dirname(fromRel), spec);
|
|
31
|
+
const cands = [
|
|
32
|
+
raw,
|
|
33
|
+
...[".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".py"].map((ext) => raw + ext),
|
|
34
|
+
...["index.js", "index.ts"].map((idx) => join(raw, idx)),
|
|
35
|
+
];
|
|
36
|
+
for (const c of cands) {
|
|
37
|
+
const rel = relative(root, c);
|
|
38
|
+
if (fileSet.has(rel)) return rel;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build an UNDIRECTED file→file import graph (coupling is symmetric for decomposition). */
|
|
44
|
+
export function importGraph(root) {
|
|
45
|
+
const files = [];
|
|
46
|
+
walk(root, root, files);
|
|
47
|
+
const fileSet = new Set(files);
|
|
48
|
+
const edges = new Map(files.map((f) => [f, new Set()]));
|
|
49
|
+
for (const f of files) {
|
|
50
|
+
let text = "";
|
|
51
|
+
try {
|
|
52
|
+
text = readFileSync(join(root, f), "utf8");
|
|
53
|
+
} catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const re of IMPORT_RES) {
|
|
57
|
+
for (const m of text.matchAll(re)) {
|
|
58
|
+
const target = resolveSpec(f, m[1], root, fileSet);
|
|
59
|
+
if (target && target !== f) {
|
|
60
|
+
edges.get(f).add(target);
|
|
61
|
+
edges.get(target)?.add(f);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { nodes: files, edges };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Connected components (iterative DFS). Each = a set of mutually-coupled files. */
|
|
70
|
+
export function components(graph) {
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const comps = [];
|
|
73
|
+
for (const start of graph.nodes) {
|
|
74
|
+
if (seen.has(start)) continue;
|
|
75
|
+
const stack = [start];
|
|
76
|
+
const comp = [];
|
|
77
|
+
seen.add(start);
|
|
78
|
+
while (stack.length) {
|
|
79
|
+
const cur = stack.pop();
|
|
80
|
+
comp.push(cur);
|
|
81
|
+
for (const nb of graph.edges.get(cur) ?? []) {
|
|
82
|
+
if (!seen.has(nb)) {
|
|
83
|
+
seen.add(nb);
|
|
84
|
+
stack.push(nb);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
comps.push(comp);
|
|
89
|
+
}
|
|
90
|
+
return comps;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Decompose a set of touched files into independent clusters + the coupled files not mentioned.
|
|
95
|
+
* @returns {{clusters:{touched:string[], coupled:string[]}[], independentGroups:number}}
|
|
96
|
+
*/
|
|
97
|
+
export function decompose(root, touched) {
|
|
98
|
+
// Normalize to repo-relative (the graph's key form) so `./src/a.js` or an absolute path
|
|
99
|
+
// still matches — otherwise a coupled file is missed and reported as an independent solo.
|
|
100
|
+
const norm = touched.map((t) => relative(root, resolve(root, t)));
|
|
101
|
+
const normSet = new Set(norm);
|
|
102
|
+
const comps = components(importGraph(root));
|
|
103
|
+
const compOf = new Map();
|
|
104
|
+
comps.forEach((comp, i) => {
|
|
105
|
+
for (const f of comp) compOf.set(f, i);
|
|
106
|
+
});
|
|
107
|
+
const buckets = new Map();
|
|
108
|
+
let singleton = 0;
|
|
109
|
+
for (const t of norm) {
|
|
110
|
+
const id = compOf.has(t) ? compOf.get(t) : `solo:${singleton++}`;
|
|
111
|
+
if (!buckets.has(id)) buckets.set(id, { touched: [], coupled: new Set() });
|
|
112
|
+
buckets.get(id).touched.push(t);
|
|
113
|
+
if (typeof id === "number") {
|
|
114
|
+
for (const f of comps[id]) if (!normSet.has(f)) buckets.get(id).coupled.add(f);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const clusters = [...buckets.values()].map((b) => ({
|
|
118
|
+
touched: b.touched,
|
|
119
|
+
coupled: [...b.coupled],
|
|
120
|
+
}));
|
|
121
|
+
return { clusters, independentGroups: clusters.length };
|
|
122
|
+
}
|
package/src/skillgate.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// forge skill-gate — vet a skill / MCP config BEFORE it's installed. ToxicSkills
|
|
2
|
+
// found ~36% of skills flawed / 13.4% critical, ~60% of the risk in the instruction
|
|
3
|
+
// (markdown) layer — so string prose matters as much as code. Prefer a real scanner
|
|
4
|
+
// (Snyk agent-scan) when present; otherwise a built-in heuristic catches the loud cases.
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
// Instruction-layer + tool-layer red flags. `critical` blocks install.
|
|
10
|
+
const RULES = [
|
|
11
|
+
{
|
|
12
|
+
re: /\bcurl\b[^\n|]*\|\s*(sh|bash|zsh)\b/i,
|
|
13
|
+
sev: "critical",
|
|
14
|
+
msg: "pipes curl to a shell (remote code execution)",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
re: /\bwget\b[^\n|]*\|\s*(sh|bash|zsh)\b/i,
|
|
18
|
+
sev: "critical",
|
|
19
|
+
msg: "pipes wget to a shell (remote code execution)",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
re: /base64\s+(--decode|-d|-D)\b[^\n]*\|\s*(sh|bash)/i,
|
|
23
|
+
sev: "critical",
|
|
24
|
+
msg: "decodes and executes an obfuscated payload",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
re: /ignore\s+(all\s+)?(your\s+)?previous\s+instructions|disregard\s+(your|the|all)\s+(system|previous|prior)/i,
|
|
28
|
+
sev: "critical",
|
|
29
|
+
msg: "prompt-injection phrasing in the instruction layer",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
re: /(id_rsa|id_ed25519|\.aws\/credentials|\.claude\.json|\.env)\b[^\n]{0,80}\b(curl|wget|nc|https?:\/\/|base64|>\s*\/dev\/tcp)/i,
|
|
33
|
+
sev: "critical",
|
|
34
|
+
msg: "reads a credential file near a network/exfil call",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
re: /\brm\s+-rf\s+[~/]/i,
|
|
38
|
+
sev: "high",
|
|
39
|
+
msg: "destructive recursive delete of an absolute/home path",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
re: /\beval\s*\(\s*(await\s+)?fetch|child_process|\bexec(Sync)?\s*\(/i,
|
|
43
|
+
sev: "high",
|
|
44
|
+
msg: "dynamic code execution / process spawn",
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Pure: scan raw text for red flags. Returns findings [{sev, msg}]. */
|
|
49
|
+
export function heuristicScan(text) {
|
|
50
|
+
const s = String(text);
|
|
51
|
+
return RULES.filter((r) => r.re.test(s)).map(({ sev, msg }) => ({
|
|
52
|
+
sev,
|
|
53
|
+
msg,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Scan a path (SKILL.md/.mcp.json) or raw text. Real scanner if available, else heuristic. */
|
|
58
|
+
export function scan(target) {
|
|
59
|
+
const isPath = typeof target === "string" && existsSync(target);
|
|
60
|
+
const content = isPath ? readFileSync(target, "utf8") : String(target);
|
|
61
|
+
|
|
62
|
+
if (isPath && process.env.FORGE_SKILLGATE_NOEXTERNAL !== "1") {
|
|
63
|
+
try {
|
|
64
|
+
// Pinned (verified 2026-07-05) — never @latest for code we execute; re-verify via dev-radar.
|
|
65
|
+
const out = execFileSync("uvx", ["snyk-agent-scan==0.5.12", target], {
|
|
66
|
+
encoding: "utf8",
|
|
67
|
+
stdio: "pipe",
|
|
68
|
+
timeout: 90000,
|
|
69
|
+
});
|
|
70
|
+
const critical = /\bcritical\b|tool poisoning|prompt injection|malicious/i.test(out);
|
|
71
|
+
return {
|
|
72
|
+
ok: !critical,
|
|
73
|
+
critical,
|
|
74
|
+
scanner: "snyk-agent-scan",
|
|
75
|
+
findings: [],
|
|
76
|
+
raw: out,
|
|
77
|
+
};
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (process.env.FORGE_DEBUG === "1")
|
|
80
|
+
process.stderr.write(
|
|
81
|
+
`forge skillgate: scanner failed, using heuristic: ${err?.message ?? err}\n`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const findings = heuristicScan(content);
|
|
87
|
+
const critical = findings.some((f) => f.sev === "critical");
|
|
88
|
+
return { ok: !critical, critical, scanner: "heuristic", findings };
|
|
89
|
+
}
|
package/src/speclock.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// forge spec-lock — cheap spec-as-contract drift detection. It does NOT rebuild a
|
|
2
|
+
// spec framework (OpenSpec / Spec Kit do that); it snapshots which code symbols each
|
|
3
|
+
// spec CLAIMS (via the atlas index) and later flags a spec that still claims a symbol
|
|
4
|
+
// the code no longer defines — i.e. the code moved on and the spec didn't.
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join, relative } from "node:path";
|
|
7
|
+
import { build as buildAtlas, has } from "./atlas.js";
|
|
8
|
+
import { hashContent } from "./emit/_shared.js";
|
|
9
|
+
|
|
10
|
+
const SPEC_DIRS = ["specs", "openspec/changes", "openspec/specs", ".kiro/steering"];
|
|
11
|
+
|
|
12
|
+
function walk(dir, files) {
|
|
13
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
14
|
+
const p = join(dir, entry.name);
|
|
15
|
+
if (entry.isDirectory()) walk(p, files);
|
|
16
|
+
else if (entry.name.endsWith(".md")) files.push(p);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function specFiles(root) {
|
|
21
|
+
const files = [];
|
|
22
|
+
for (const d of SPEC_DIRS) {
|
|
23
|
+
const dir = join(root, d);
|
|
24
|
+
if (existsSync(dir)) walk(dir, files);
|
|
25
|
+
}
|
|
26
|
+
return files;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Pure: code-identifier tokens a spec references — `backtick` tokens, 3+ chars. */
|
|
30
|
+
export function referencedSymbols(text) {
|
|
31
|
+
const out = new Set();
|
|
32
|
+
for (const m of String(text).matchAll(/`([A-Za-z_$][\w$]{2,})`/g)) out.add(m[1]);
|
|
33
|
+
return [...out];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Record, per spec, the symbols it references that currently EXIST in the code. */
|
|
37
|
+
export function snapshot(root = process.cwd()) {
|
|
38
|
+
const atlas = buildAtlas({ root });
|
|
39
|
+
const specs = {};
|
|
40
|
+
for (const f of specFiles(root)) {
|
|
41
|
+
const text = readFileSync(f, "utf8");
|
|
42
|
+
const claimed = referencedSymbols(text).filter((s) => has(atlas, s));
|
|
43
|
+
specs[relative(root, f)] = { hash: hashContent(text), claimed };
|
|
44
|
+
}
|
|
45
|
+
mkdirSync(join(root, ".forge"), { recursive: true });
|
|
46
|
+
writeFileSync(join(root, ".forge", "spec-lock.json"), JSON.stringify(specs, null, 2));
|
|
47
|
+
return { specs, count: Object.keys(specs).length };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Flag specs that still claim a symbol the code no longer defines (drift). */
|
|
51
|
+
export function check(root = process.cwd()) {
|
|
52
|
+
const lockPath = join(root, ".forge", "spec-lock.json");
|
|
53
|
+
if (!existsSync(lockPath))
|
|
54
|
+
return { ok: true, drift: [], note: "no lock — run `forge spec lock`" };
|
|
55
|
+
const lock = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
56
|
+
const atlas = buildAtlas({ root });
|
|
57
|
+
const drift = [];
|
|
58
|
+
for (const [spec, entry] of Object.entries(lock)) {
|
|
59
|
+
for (const sym of entry.claimed || []) {
|
|
60
|
+
if (!has(atlas, sym)) drift.push({ spec, symbol: sym });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { ok: drift.length === 0, drift };
|
|
64
|
+
}
|