@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/cli.js
ADDED
|
@@ -0,0 +1,1509 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// forge — zero-dependency dispatcher. Works identically whether installed via the
|
|
3
|
+
// npm bin, the hardened install.sh symlink, or the Claude Code plugin.
|
|
4
|
+
import { BRAND } from "./brand.js";
|
|
5
|
+
|
|
6
|
+
const COMMANDS = {
|
|
7
|
+
init: "scaffold this repo's config — emits every tool from one shared source",
|
|
8
|
+
sync: "recompile the canonical source into each tool's native config files",
|
|
9
|
+
doctor: "health-check installed tools, guards, MCP auth, and config drift",
|
|
10
|
+
taste: "enable one UI-taste tool for this repo (no arg = list)",
|
|
11
|
+
atlas: "build / query the code-graph (where-is-Y, has-symbol)",
|
|
12
|
+
recall: "manage cross-session memory (list / add / consolidate)",
|
|
13
|
+
catalog: "Start Here — list every tool, crew, and guard with a one-line why",
|
|
14
|
+
scan: "vet a skill/MCP for injection/RCE/exfil before install (skill-gate)",
|
|
15
|
+
verify: "independent verification gate — tests + hallucinated-symbol + provenance",
|
|
16
|
+
harden: "wire security controls — gitleaks pre-commit + sandbox settings",
|
|
17
|
+
remember: "add a durable fact to this repo's portable memory (forge brain)",
|
|
18
|
+
brain: "show / rebuild the portable project memory index",
|
|
19
|
+
cost: "real per-day spend via ccusage + measured stage factors (--stages)",
|
|
20
|
+
spec: "spec-as-contract — init (OpenSpec) / lock / check drift",
|
|
21
|
+
cortex: "self-correcting project memory — status / why <symbol>",
|
|
22
|
+
ledger:
|
|
23
|
+
"proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / import",
|
|
24
|
+
reuse: "proof-carrying code cache — query <spec> / mint <spec> --file <path> / stats",
|
|
25
|
+
context: "budgeted context assembly + completeness gate — what an edit NEEDS known",
|
|
26
|
+
preflight: "assumption check — what a task names that the repo doesn't define",
|
|
27
|
+
config: "provider setup — show / switch / add providers, set default model",
|
|
28
|
+
route: "recommend the cheapest capable model for a task (+ gateway config)",
|
|
29
|
+
impact: "predict blast radius for a symbol or file from the atlas graph",
|
|
30
|
+
substrate: "one pre-action gate: assumptions, route, impact, scope, memory, verify",
|
|
31
|
+
scope: "decompose files into independent clusters (+ coupled files you didn't name)",
|
|
32
|
+
anchor: "goal-drift check — are your actual (git) changes still on the stated goal?",
|
|
33
|
+
diagnose:
|
|
34
|
+
"doom-loop check — record a failure; 3× the same signature mints a diagnosis + escalation",
|
|
35
|
+
imagine: "consequence simulation — predicted breaks + the minimal dry-run test suite for a task",
|
|
36
|
+
lean: "scope-minimality (M5) — measure the diff's footprint vs what the task asked for",
|
|
37
|
+
uicheck:
|
|
38
|
+
"deterministic UI checks — contrast <fg> <bg> · fingerprint <file...> · design <file...> · visual <file-or-url>",
|
|
39
|
+
dash: "local dashboard over the ledger, metrics, and blast radius",
|
|
40
|
+
brand: "print the active brand token map",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const printVersion = () => console.log(`${BRAND.brand} (${BRAND.pkg}) v${BRAND.version}`);
|
|
44
|
+
|
|
45
|
+
function printHelp() {
|
|
46
|
+
printVersion();
|
|
47
|
+
console.log(`\n${BRAND.tagline}\n`);
|
|
48
|
+
console.log(`Usage: ${BRAND.cli} <command> [options]\n`);
|
|
49
|
+
console.log("Commands:");
|
|
50
|
+
for (const [name, desc] of Object.entries(COMMANDS)) {
|
|
51
|
+
console.log(` ${name.padEnd(8)} ${desc}`);
|
|
52
|
+
}
|
|
53
|
+
console.log(`\nRun \`${BRAND.cli} <command> --help\` for details.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function run(argv) {
|
|
57
|
+
const [cmd] = argv;
|
|
58
|
+
if (!cmd || cmd === "-h" || cmd === "--help") return printHelp();
|
|
59
|
+
if (cmd === "-v" || cmd === "--version") return printVersion();
|
|
60
|
+
if (cmd === "cortex-mcp") {
|
|
61
|
+
const { serve } = await import("./cortex_mcp.js"); // stdio MCP server for other tools
|
|
62
|
+
serve();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (cmd === "brand") {
|
|
66
|
+
const { brand, cli, pkg, version, layers } = BRAND;
|
|
67
|
+
return console.log(JSON.stringify({ brand, cli, pkg, version, layers }, null, 2));
|
|
68
|
+
}
|
|
69
|
+
if (cmd === "init") {
|
|
70
|
+
const { init } = await import("./init.js");
|
|
71
|
+
const noSettings = argv.includes("--no-settings");
|
|
72
|
+
const { report, bytes, settings, detected } = init({ targetRoot: process.cwd(), noSettings });
|
|
73
|
+
const wrote = report.filter((r) => r.action === "written").map((r) => r.target);
|
|
74
|
+
console.log(`${BRAND.brand} init — this repo now speaks every AI tool from one source.\n`);
|
|
75
|
+
console.log(` emitted: ${wrote.length ? wrote.join(", ") : "(all up to date)"}`);
|
|
76
|
+
console.log(
|
|
77
|
+
` source: AGENTS.md (${bytes} B) — edit rules in source/, re-run \`${BRAND.cli} sync\``,
|
|
78
|
+
);
|
|
79
|
+
if (settings?.action === "merged" && "added" in settings) {
|
|
80
|
+
console.log(` settings: merged ${settings.added.join(", ")} into ${settings.path}`);
|
|
81
|
+
} else if (settings?.action === "unchanged" && "path" in settings) {
|
|
82
|
+
console.log(` settings: already up to date (${settings.path})`);
|
|
83
|
+
} else if (settings?.action === "skipped") {
|
|
84
|
+
console.log(" settings: skipped (--no-settings)");
|
|
85
|
+
}
|
|
86
|
+
if (detected) {
|
|
87
|
+
console.log(` provider: auto-detected ${detected.name} from ${detected.source}`);
|
|
88
|
+
} else {
|
|
89
|
+
console.log(
|
|
90
|
+
` provider: none detected — set ANTHROPIC_API_KEY, OPENROUTER_API_KEY, or LITELLM_BASE_URL`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
console.log(` active: tools · crew · guards → \`${BRAND.cli} catalog\``);
|
|
94
|
+
console.log(` verify: \`${BRAND.cli} doctor\``);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (cmd === "catalog") {
|
|
98
|
+
const { catalog } = await import("./init.js");
|
|
99
|
+
const c = catalog();
|
|
100
|
+
console.log(`${BRAND.brand} catalog — Start Here\n`);
|
|
101
|
+
console.log(" TOOLS (model-invoked skills)");
|
|
102
|
+
for (const t of c.tools) console.log(` ${t.name.padEnd(18)} ${t.why.slice(0, 66)}`);
|
|
103
|
+
console.log(`\n CREW (isolated sub-agents) ${c.crew.join(" · ")}`);
|
|
104
|
+
console.log(` GUARDS (enforced hooks) ${c.guards.join(" · ")}`);
|
|
105
|
+
if (c.taste?.length)
|
|
106
|
+
console.log(
|
|
107
|
+
` TASTE (design directions) ${c.taste.join(" · ")} → \`${BRAND.cli} taste <style>\``,
|
|
108
|
+
);
|
|
109
|
+
if (c.cortex) console.log(`\n CORTEX (self-correcting memory) ${c.cortex}`);
|
|
110
|
+
if (c.preflight) console.log(` PREFLIGHT (before you spend tokens) ${c.preflight}`);
|
|
111
|
+
console.log(`\n Full detail: ARCHITECTURE.md · per-tool config: \`${BRAND.cli} sync\``);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (cmd === "taste") {
|
|
115
|
+
const t = await import("./taste.js");
|
|
116
|
+
const style = argv[1];
|
|
117
|
+
if (!style) {
|
|
118
|
+
console.log(
|
|
119
|
+
`${BRAND.brand} taste — pick ONE visual direction per repo (every tool then follows it):\n`,
|
|
120
|
+
);
|
|
121
|
+
for (const s of t.list()) console.log(` ${s}`);
|
|
122
|
+
console.log(`\n apply: \`${BRAND.cli} taste <style>\` (writes a managed DESIGN.md)`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const res = t.apply(style, process.cwd());
|
|
126
|
+
if (res.ok) {
|
|
127
|
+
console.log(
|
|
128
|
+
` DESIGN.md ${res.action} → taste: ${res.style}. Every AI tool now builds in this direction.`,
|
|
129
|
+
);
|
|
130
|
+
} else {
|
|
131
|
+
console.error(` ${res.reason}`);
|
|
132
|
+
process.exitCode = 1;
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (cmd === "sync") {
|
|
137
|
+
const { sync } = await import("./sync.js");
|
|
138
|
+
const { report, warnings, bytes } = sync({ targetRoot: process.cwd() });
|
|
139
|
+
console.log(`${BRAND.brand} sync — one source → every tool\n`);
|
|
140
|
+
for (const r of report) {
|
|
141
|
+
console.log(
|
|
142
|
+
` ${r.action.padEnd(16)} ${String(r.target).padEnd(22)} ${r.tool}${r.note ? " · " + r.note : ""}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
for (const w of warnings) console.warn(` ! ${w}`);
|
|
146
|
+
const written = report.filter((r) => r.action === "written").length;
|
|
147
|
+
console.log(`\n${written} file(s) written · canonical ${bytes} B`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (cmd === "doctor") {
|
|
151
|
+
const { doctor } = await import("./doctor.js");
|
|
152
|
+
const { results, failed } = doctor({ targetRoot: process.cwd() });
|
|
153
|
+
if (argv.includes("--json")) {
|
|
154
|
+
console.log(JSON.stringify({ results, failed }, null, 2));
|
|
155
|
+
if (failed) process.exitCode = 1;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const icon = { ok: "✓", warn: "!", fail: "✗" };
|
|
159
|
+
console.log(`${BRAND.brand} doctor\n`);
|
|
160
|
+
for (const r of results) console.log(` ${icon[r.status]} ${r.label.padEnd(16)} ${r.note}`);
|
|
161
|
+
console.log(`\n${failed === 0 ? "all clear" : failed + " problem(s)"}`);
|
|
162
|
+
if (failed) process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (cmd === "recall") {
|
|
166
|
+
const r = await import("./recall.js");
|
|
167
|
+
const store = r.defaultStore();
|
|
168
|
+
const sub = argv[1] || "list";
|
|
169
|
+
if (sub === "list") {
|
|
170
|
+
const items = r.list(store);
|
|
171
|
+
console.log(items.length ? items.map((s) => ` - ${s}`).join("\n") : " (no memories yet)");
|
|
172
|
+
} else if (sub === "add") {
|
|
173
|
+
const name = argv[2];
|
|
174
|
+
const body = argv.slice(3).join(" ");
|
|
175
|
+
if (!name || !body) {
|
|
176
|
+
console.error('usage: forge recall add "<name>" "<fact>"');
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const res = r.add(store, name, body);
|
|
181
|
+
if (res.ok) {
|
|
182
|
+
// Shadow the fact into the PERSONAL ledger beside the global store (repo
|
|
183
|
+
// promotion stays an explicit act — docs/plans/substrate-v2/02-team-memory.md §3).
|
|
184
|
+
// Best-effort INCLUDING the imports: a broken bridge module must never turn an
|
|
185
|
+
// already-persisted fact into a CLI failure.
|
|
186
|
+
try {
|
|
187
|
+
const { join } = await import("node:path");
|
|
188
|
+
const { shadowFact } = await import("./ledger_bridge.js");
|
|
189
|
+
shadowFact(join(store, "ledger"), name, body);
|
|
190
|
+
} catch {}
|
|
191
|
+
}
|
|
192
|
+
console.log(res.ok ? ` saved: ${res.slug}` : ` ${res.reason}`);
|
|
193
|
+
if (!res.ok) process.exitCode = 1;
|
|
194
|
+
} else if (sub === "consolidate") {
|
|
195
|
+
const { removed, kept } = r.consolidate(store);
|
|
196
|
+
try {
|
|
197
|
+
// Deleted duplicates must not survive as live claims in the shadow ledger.
|
|
198
|
+
const { join } = await import("node:path");
|
|
199
|
+
const { reconcileFacts } = await import("./ledger_bridge.js");
|
|
200
|
+
reconcileFacts(store, join(store, "ledger"));
|
|
201
|
+
} catch {}
|
|
202
|
+
console.log(` consolidated: ${removed} duplicate(s) removed, ${kept} kept`);
|
|
203
|
+
} else {
|
|
204
|
+
console.error(`recall: unknown subcommand "${sub}" (list | add | consolidate)`);
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (cmd === "ledger") {
|
|
210
|
+
const ls = await import("./ledger_store.js");
|
|
211
|
+
const { epochDay, gitAuthor } = await import("./util.js");
|
|
212
|
+
const root = process.cwd();
|
|
213
|
+
// --personal targets the ledger beside the global recall store (~/.forge/recall/
|
|
214
|
+
// ledger) — otherwise facts shadowed by `forge recall add` would be write-only,
|
|
215
|
+
// with no command able to inspect or verify them.
|
|
216
|
+
const personal = argv.includes("--personal");
|
|
217
|
+
const args = argv.filter((a) => a !== "--json" && a !== "--personal");
|
|
218
|
+
const dir = personal
|
|
219
|
+
? (await import("node:path")).join((await import("./recall.js")).defaultStore(), "ledger")
|
|
220
|
+
: ls.repoLedger(root);
|
|
221
|
+
const sub = args[1] || "stats";
|
|
222
|
+
const json = argv.includes("--json");
|
|
223
|
+
const nowDay = epochDay();
|
|
224
|
+
if (sub === "stats") {
|
|
225
|
+
const s = ls.stats(dir, nowDay);
|
|
226
|
+
if (json) return console.log(JSON.stringify(s, null, 2));
|
|
227
|
+
console.log(`${BRAND.brand} ledger — proof-carrying memory\n`);
|
|
228
|
+
console.log(` claims: ${s.total} (tombstoned ${s.tombstoned})`);
|
|
229
|
+
for (const [kind, n] of Object.entries(s.byKind)) console.log(` ${kind}: ${n}`);
|
|
230
|
+
console.log(
|
|
231
|
+
` val: trusted ${s.val.trusted} · uncertain ${s.val.uncertain} · dormant ${s.val.dormant}`,
|
|
232
|
+
);
|
|
233
|
+
console.log("\n stored in .forge/ledger/ (git-committable, conflict-free merge)");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (sub === "verify") {
|
|
237
|
+
const r = ls.verify(dir);
|
|
238
|
+
if (json) return console.log(JSON.stringify(r, null, 2));
|
|
239
|
+
console.log(` ${r.ok ? "OK" : "ISSUES"} — ${r.claims} claim(s), ${r.outcomes} outcome(s)`);
|
|
240
|
+
for (const i of r.issues) console.log(` - ${i}`);
|
|
241
|
+
if (!r.ok) process.exitCode = 1;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (sub === "show") {
|
|
245
|
+
const id = args[2];
|
|
246
|
+
const hit = id && id.length >= 2 ? ls.getClaimByPrefix(dir, id) : null;
|
|
247
|
+
if (!hit) {
|
|
248
|
+
console.error(
|
|
249
|
+
id ? ` no claim matching ${id}` : "usage: forge ledger show <id-prefix (≥2 chars)>",
|
|
250
|
+
);
|
|
251
|
+
process.exitCode = 1;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const { val } = await import("./ledger.js");
|
|
255
|
+
return console.log(JSON.stringify({ ...hit, val: val(hit, nowDay) }, null, 2));
|
|
256
|
+
}
|
|
257
|
+
if (sub === "merge") {
|
|
258
|
+
const src = args[2];
|
|
259
|
+
const { existsSync } = await import("node:fs");
|
|
260
|
+
if (!src || !existsSync(src)) {
|
|
261
|
+
console.error(
|
|
262
|
+
src
|
|
263
|
+
? ` no ledger at ${src}`
|
|
264
|
+
: "usage: forge ledger merge <path-to-ledger-dir> (a teammate's checkout, a backup, a worktree)",
|
|
265
|
+
);
|
|
266
|
+
process.exitCode = 1;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const r = ls.mergeDirs(dir, src);
|
|
270
|
+
if (json) return console.log(JSON.stringify(r, null, 2));
|
|
271
|
+
console.log(` merged: ${r.claims} new claim(s), ${r.records} new record(s) — conflict-free`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (sub === "blame") {
|
|
275
|
+
const b = args[2] && args[2].length >= 2 ? ls.blame(dir, args[2], nowDay) : null;
|
|
276
|
+
if (!b) {
|
|
277
|
+
console.error(
|
|
278
|
+
args[2] ? ` no claim matching ${args[2]}` : "usage: forge ledger blame <id-prefix>",
|
|
279
|
+
);
|
|
280
|
+
process.exitCode = 1;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (json) return console.log(JSON.stringify(b, null, 2));
|
|
284
|
+
console.log(`${BRAND.brand} ledger blame — ${b.kind} ${b.id.slice(0, 12)}\n`);
|
|
285
|
+
console.log(` val ${b.val.toFixed(2)} (trust-weighted ${b.valTrustWeighted.toFixed(2)})`);
|
|
286
|
+
for (const p of b.minted)
|
|
287
|
+
console.log(
|
|
288
|
+
` minted day ${p.t} by ${p.author || "(unknown)"}${p.agent ? ` · ${p.agent}` : ""}`,
|
|
289
|
+
);
|
|
290
|
+
for (const e of b.evidence)
|
|
291
|
+
console.log(
|
|
292
|
+
` ${e.result === "confirm" ? "confirm " : "contradic"} day ${e.t} ${e.oracle} → ${e.ref}${e.author ? ` by ${e.author}` : ""}`,
|
|
293
|
+
);
|
|
294
|
+
for (const t of b.tombstones)
|
|
295
|
+
console.log(` retract day ${t.t} ${t.reason}${t.author ? ` by ${t.author}` : ""}`);
|
|
296
|
+
const trusts = Object.entries(b.trust);
|
|
297
|
+
if (trusts.length) {
|
|
298
|
+
console.log("\n author trust (earned from oracle outcomes on their claims):");
|
|
299
|
+
for (const [a, u] of trusts) console.log(` ${u.toFixed(2)} ${a}`);
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// The two writes (08-dashboard-ux.md §2) — CLI twins of the dashboard's POSTs, so
|
|
304
|
+
// the dashboard stays a convenience, never a requirement. Both append-only.
|
|
305
|
+
if (sub === "ratify") {
|
|
306
|
+
const id = args[2];
|
|
307
|
+
if (!id || id.length < 2) {
|
|
308
|
+
console.error("usage: forge ledger ratify <id-prefix (≥2 chars)>");
|
|
309
|
+
process.exitCode = 1;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// Human-only promotion: the author is YOUR git identity, minted as a decision claim.
|
|
313
|
+
const r = ls.ratify(dir, id, { author: gitAuthor(), t: nowDay });
|
|
314
|
+
if (!r.ok) {
|
|
315
|
+
console.error(` ${r.reason}`);
|
|
316
|
+
process.exitCode = 1;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (json) return console.log(JSON.stringify(r, null, 2));
|
|
320
|
+
console.log(
|
|
321
|
+
` ratified ${r.ratifies.slice(0, 12)} → decision ${r.decisionId.slice(0, 12)}${r.existed ? " (already ratified — same decision)" : ""}`,
|
|
322
|
+
);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (sub === "retract") {
|
|
326
|
+
const id = args[2];
|
|
327
|
+
const ri = args.indexOf("--reason");
|
|
328
|
+
const reason = ri >= 0 ? (args[ri + 1] ?? "") : "";
|
|
329
|
+
if (!id || id.length < 2 || id === "--reason" || !reason) {
|
|
330
|
+
console.error('usage: forge ledger retract <id-prefix> --reason "<why>"');
|
|
331
|
+
process.exitCode = 1;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const hit = ls.getClaimByPrefix(dir, id);
|
|
335
|
+
if (!hit) {
|
|
336
|
+
console.error(` no claim matching ${id}`);
|
|
337
|
+
process.exitCode = 1;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const r = ls.tombstone(dir, hit.id, { author: gitAuthor(), reason, t: nowDay });
|
|
341
|
+
if (!r.ok) {
|
|
342
|
+
console.error(` ${r.reason}`);
|
|
343
|
+
process.exitCode = 1;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (json) return console.log(JSON.stringify({ ...r, id: hit.id }, null, 2));
|
|
347
|
+
console.log(
|
|
348
|
+
` retracted ${hit.id.slice(0, 12)} — ${reason}${r.deduped ? " (already retracted with this record)" : ""}`,
|
|
349
|
+
);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (sub === "query") {
|
|
353
|
+
const q = args.slice(2).join(" ");
|
|
354
|
+
if (!q) {
|
|
355
|
+
console.error('usage: forge ledger query "<what you are about to do>"');
|
|
356
|
+
process.exitCode = 1;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const { retrieve, claimText } = await import("./ledger.js");
|
|
360
|
+
// The embeddings tier (ADR-0005) is assembled HERE, not in ledger.js — the pure
|
|
361
|
+
// core stays provider-free. No FORGE_EMBED (or a failing provider) → sim is
|
|
362
|
+
// null and retrieval is the stock MinHash path.
|
|
363
|
+
const { claimSim, simLabel } = await import("./embed.js");
|
|
364
|
+
const claims = ls.loadClaims(dir);
|
|
365
|
+
const sim = claimSim(root, q, claims, claimText);
|
|
366
|
+
const ranked = retrieve(q, claims, { nowDay, budget: 8, sim });
|
|
367
|
+
if (json)
|
|
368
|
+
return console.log(
|
|
369
|
+
JSON.stringify(
|
|
370
|
+
{
|
|
371
|
+
sim: simLabel(sim),
|
|
372
|
+
results: ranked.map((r) => ({ id: r.claim.id, kind: r.claim.kind, score: r.score })),
|
|
373
|
+
},
|
|
374
|
+
null,
|
|
375
|
+
2,
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
console.log(` sim: ${simLabel(sim)}`);
|
|
379
|
+
if (!ranked.length) return console.log(" no matching live claims");
|
|
380
|
+
for (const r of ranked)
|
|
381
|
+
console.log(
|
|
382
|
+
` ${r.score.toFixed(3)} ${r.claim.kind.padEnd(9)} ${r.claim.id.slice(0, 8)} ${claimText(r.claim).slice(0, 90)}`,
|
|
383
|
+
);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (sub === "import") {
|
|
387
|
+
const b = await import("./ledger_bridge.js");
|
|
388
|
+
let r;
|
|
389
|
+
if (personal) {
|
|
390
|
+
// Personal import: facts from the global recall store into the personal ledger.
|
|
391
|
+
const { defaultStore } = await import("./recall.js");
|
|
392
|
+
r = { lessons: 0, outcomes: 0, ...b.importFacts(defaultStore(), dir, nowDay) };
|
|
393
|
+
} else {
|
|
394
|
+
const { brainStore } = await import("./brain.js");
|
|
395
|
+
r = b.importLegacy(root, { recallStore: brainStore(root), recallLedger: dir, nowDay });
|
|
396
|
+
}
|
|
397
|
+
if (json) return console.log(JSON.stringify(r, null, 2));
|
|
398
|
+
console.log(
|
|
399
|
+
` imported: ${r.lessons} lesson(s), ${r.facts} fact(s), ${r.outcomes} outcome(s)`,
|
|
400
|
+
);
|
|
401
|
+
for (const x of r.refused) console.log(` refused: ${x}`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
console.error(
|
|
405
|
+
`ledger: unknown subcommand "${sub}" (stats | verify | show <id> | blame <id> | query <text> | ratify <id> | retract <id> --reason "<why>" | merge <path> | import) [--personal] [--json]`,
|
|
406
|
+
);
|
|
407
|
+
process.exitCode = 1;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (cmd === "reuse") {
|
|
411
|
+
const ru = await import("./reuse.js");
|
|
412
|
+
const { load: loadAtlas } = await import("./atlas.js");
|
|
413
|
+
const { epochDay } = await import("./util.js");
|
|
414
|
+
const root = process.cwd();
|
|
415
|
+
const nowDay = epochDay();
|
|
416
|
+
const json = argv.includes("--json");
|
|
417
|
+
const flagVal = (name) => {
|
|
418
|
+
const i = argv.indexOf(name);
|
|
419
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
420
|
+
};
|
|
421
|
+
const args = argv.filter(
|
|
422
|
+
(a, i) => !a.startsWith("--") && argv[i - 1] !== "--file" && argv[i - 1] !== "--ref",
|
|
423
|
+
);
|
|
424
|
+
const sub = args[1] || "stats";
|
|
425
|
+
if (sub === "query") {
|
|
426
|
+
const spec = args.slice(2).join(" ");
|
|
427
|
+
if (!spec) {
|
|
428
|
+
console.error('usage: forge reuse query "<what you are about to build>" [--json]');
|
|
429
|
+
process.exitCode = 1;
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const r = ru.reuseQuery(root, spec, { atlas: loadAtlas(root), nowDay });
|
|
433
|
+
if (json)
|
|
434
|
+
return console.log(
|
|
435
|
+
JSON.stringify(
|
|
436
|
+
{
|
|
437
|
+
tier: r.tier,
|
|
438
|
+
artifact: r.artifact?.id,
|
|
439
|
+
jaccard: r.jaccard,
|
|
440
|
+
similarity: r.similarity,
|
|
441
|
+
sim: r.sim,
|
|
442
|
+
reasons: r.reasons,
|
|
443
|
+
},
|
|
444
|
+
null,
|
|
445
|
+
2,
|
|
446
|
+
),
|
|
447
|
+
);
|
|
448
|
+
console.log(` sim: ${r.sim}`);
|
|
449
|
+
if (r.tier === "miss") {
|
|
450
|
+
console.log(" miss — nothing verified matches; generate, then `forge reuse mint` it");
|
|
451
|
+
} else {
|
|
452
|
+
const a = r.artifact;
|
|
453
|
+
console.log(
|
|
454
|
+
` ${r.tier.toUpperCase()} hit (similarity ${(r.similarity ?? r.jaccard ?? 1).toFixed(2)}) — ${a.body.form}${a.body.code?.path ? ` at ${a.body.code.path}` : ""}`,
|
|
455
|
+
);
|
|
456
|
+
console.log(
|
|
457
|
+
` claim ${a.id.slice(0, 12)} — \`forge ledger blame ${a.id.slice(0, 8)}\` for its proof`,
|
|
458
|
+
);
|
|
459
|
+
if (r.tier === "adapt")
|
|
460
|
+
console.log(
|
|
461
|
+
" adapt tier: inject as a verified starting point, generate only the delta",
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
for (const why of r.reasons) console.log(` note: ${why}`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (sub === "mint") {
|
|
468
|
+
const spec = args.slice(2).join(" ");
|
|
469
|
+
const file = flagVal("--file");
|
|
470
|
+
const ref = flagVal("--ref");
|
|
471
|
+
if (!spec || !file) {
|
|
472
|
+
console.error(
|
|
473
|
+
'usage: forge reuse mint "<task the code solves>" --file <path> [--ref <test-run/commit>] [--json]',
|
|
474
|
+
);
|
|
475
|
+
process.exitCode = 1;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const { repoLedger } = await import("./ledger_store.js");
|
|
479
|
+
const desc = ru.describeFile(root, file);
|
|
480
|
+
const r = ru.mintArtifact(
|
|
481
|
+
repoLedger(root),
|
|
482
|
+
{ spec, form: "module", ...desc },
|
|
483
|
+
ref
|
|
484
|
+
? { evidence: { oracle: "test.run", result: "confirm", ref }, t: nowDay }
|
|
485
|
+
: { t: nowDay },
|
|
486
|
+
);
|
|
487
|
+
if (json) return console.log(JSON.stringify(r, null, 2));
|
|
488
|
+
if (!r.ok) {
|
|
489
|
+
console.error(` ${r.reason}`);
|
|
490
|
+
process.exitCode = 1;
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
console.log(
|
|
494
|
+
` minted: ${r.id.slice(0, 12)} (${desc.iface.length} export(s), ${desc.deps.length} dep(s))`,
|
|
495
|
+
);
|
|
496
|
+
console.log(
|
|
497
|
+
r.serves
|
|
498
|
+
? " serving: yes — verification evidence attached"
|
|
499
|
+
: " serving: NOT YET — no evidence; attach a verified test/commit ref (--ref) or it stays at the 0.5 prior",
|
|
500
|
+
);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (sub === "stats") {
|
|
504
|
+
const { summarize } = await import("./metrics.js");
|
|
505
|
+
const s = summarize(root).cache ?? { events: 0, byOutcome: {}, savedEstimate: 0 };
|
|
506
|
+
if (json) return console.log(JSON.stringify(s, null, 2));
|
|
507
|
+
console.log(`${BRAND.brand} reuse — proof-carrying code cache\n`);
|
|
508
|
+
console.log(` lookups: ${s.events}`);
|
|
509
|
+
for (const [o, n] of Object.entries(s.byOutcome)) console.log(` ${o}: ${n}`);
|
|
510
|
+
console.log(` est. tokens saved: ${s.savedEstimate}`);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
console.error(
|
|
514
|
+
`reuse: unknown subcommand "${sub}" (query <spec> | mint <spec> --file <path> | stats)`,
|
|
515
|
+
);
|
|
516
|
+
process.exitCode = 1;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (cmd === "context") {
|
|
520
|
+
const { assemble, renderContext } = await import("./context.js");
|
|
521
|
+
const { load: loadAtlas } = await import("./atlas.js");
|
|
522
|
+
const { epochDay } = await import("./util.js");
|
|
523
|
+
const json = argv.includes("--json");
|
|
524
|
+
const bi = argv.indexOf("--budget");
|
|
525
|
+
const budget = bi >= 0 ? Number(argv[bi + 1]) || undefined : undefined;
|
|
526
|
+
const task = argv
|
|
527
|
+
.filter((a, i) => i > 0 && !a.startsWith("--") && argv[i - 1] !== "--budget")
|
|
528
|
+
.join(" ");
|
|
529
|
+
if (!task) {
|
|
530
|
+
console.error('usage: forge context "<task>" [--budget <tokens>] [--json]');
|
|
531
|
+
process.exitCode = 1;
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const r = assemble(process.cwd(), task, {
|
|
535
|
+
atlas: loadAtlas(process.cwd()),
|
|
536
|
+
nowDay: epochDay(),
|
|
537
|
+
...(budget ? { budget } : {}),
|
|
538
|
+
});
|
|
539
|
+
if (json) {
|
|
540
|
+
const { block, ...rest } = r;
|
|
541
|
+
return console.log(JSON.stringify(rest, null, 2));
|
|
542
|
+
}
|
|
543
|
+
console.log(renderContext(r));
|
|
544
|
+
if (!r.ok) process.exitCode = 1;
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (cmd === "atlas") {
|
|
548
|
+
const a = await import("./atlas.js");
|
|
549
|
+
const sub = argv[1] || "build";
|
|
550
|
+
const need = () => {
|
|
551
|
+
if (a.load()) return a.load();
|
|
552
|
+
console.error(" no index — run `forge atlas build` first");
|
|
553
|
+
process.exitCode = 1;
|
|
554
|
+
return null;
|
|
555
|
+
};
|
|
556
|
+
if (sub === "build") {
|
|
557
|
+
const at = a.build({ root: process.cwd() });
|
|
558
|
+
console.log(
|
|
559
|
+
` indexed ${at.symbols.length} symbols in ${at.files} files → .forge/atlas.json${at.capped ? " (capped)" : ""}`,
|
|
560
|
+
);
|
|
561
|
+
} else if (sub === "query") {
|
|
562
|
+
const at = need();
|
|
563
|
+
if (!at) return;
|
|
564
|
+
const hits = a.query(at, argv.slice(2).join(" "));
|
|
565
|
+
console.log(
|
|
566
|
+
hits.length
|
|
567
|
+
? hits
|
|
568
|
+
.slice(0, 30)
|
|
569
|
+
.map((s) => ` ${s.file}:${s.line} ${s.kind} ${s.name}`)
|
|
570
|
+
.join("\n")
|
|
571
|
+
: " no match",
|
|
572
|
+
);
|
|
573
|
+
} else if (sub === "has") {
|
|
574
|
+
const at = need();
|
|
575
|
+
if (!at) return;
|
|
576
|
+
const name = argv[2];
|
|
577
|
+
const yes = a.has(at, name);
|
|
578
|
+
console.log(` ${yes ? "✓ defined" : "✗ not found (possible hallucinated symbol)"}: ${name}`);
|
|
579
|
+
if (!yes) process.exitCode = 1;
|
|
580
|
+
} else {
|
|
581
|
+
console.error(`atlas: unknown subcommand "${sub}" (build | query | has)`);
|
|
582
|
+
process.exitCode = 1;
|
|
583
|
+
}
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (cmd === "scan") {
|
|
587
|
+
const { scan } = await import("./skillgate.js");
|
|
588
|
+
const target = argv[1];
|
|
589
|
+
if (!target) {
|
|
590
|
+
console.error("usage: forge scan <SKILL.md | .mcp.json | path>");
|
|
591
|
+
process.exitCode = 1;
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const r = scan(target);
|
|
595
|
+
console.log(`${BRAND.brand} scan — skill-gate (${r.scanner})\n`);
|
|
596
|
+
if (r.findings?.length) {
|
|
597
|
+
for (const f of r.findings) console.log(` [${f.sev}] ${f.msg}`);
|
|
598
|
+
} else if (r.raw) {
|
|
599
|
+
console.log(" " + r.raw.trim().split("\n").slice(-6).join("\n "));
|
|
600
|
+
} else {
|
|
601
|
+
console.log(" no obvious red flags");
|
|
602
|
+
}
|
|
603
|
+
console.log(
|
|
604
|
+
`\n ${r.critical ? "BLOCKED — critical finding, do not install" : "ok to install"}`,
|
|
605
|
+
);
|
|
606
|
+
if (r.critical) process.exitCode = 1;
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (cmd === "verify") {
|
|
610
|
+
const { verify } = await import("./verify.js");
|
|
611
|
+
const json = argv.includes("--json");
|
|
612
|
+
const r = verify({ targetRoot: process.cwd() });
|
|
613
|
+
if (json) {
|
|
614
|
+
console.log(JSON.stringify(r, null, 2));
|
|
615
|
+
if (!r.ok) process.exitCode = 1;
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
console.log(`${BRAND.brand} verify\n`);
|
|
619
|
+
console.log(` changed files: ${r.changedFiles.length}`);
|
|
620
|
+
console.log(
|
|
621
|
+
` tests: ${r.tests.ran ? (r.tests.passed ? "✓ pass" : "✗ FAIL") : "— none detected"}`,
|
|
622
|
+
);
|
|
623
|
+
console.log(` symbols checked: ${r.provenance.symbolsChecked}`);
|
|
624
|
+
if (r.unknown.length)
|
|
625
|
+
console.log(
|
|
626
|
+
` ! not in codebase (possible hallucination): ${r.unknown.slice(0, 12).join(", ")}`,
|
|
627
|
+
);
|
|
628
|
+
console.log(` provenance: .forge/provenance.json`);
|
|
629
|
+
console.log(`\n ${r.ok ? "PASS" : "BLOCKED — tests failing"}`);
|
|
630
|
+
if (!r.ok) process.exitCode = 1;
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (cmd === "remember") {
|
|
634
|
+
const b = await import("./brain.js");
|
|
635
|
+
const name = argv[1];
|
|
636
|
+
const body = argv.slice(2).join(" ");
|
|
637
|
+
if (!name || !body) {
|
|
638
|
+
console.error('usage: forge remember "<name>" "<fact>"');
|
|
639
|
+
process.exitCode = 1;
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const res = b.remember(b.brainStore(process.cwd()), name, body);
|
|
643
|
+
if (res.ok) {
|
|
644
|
+
// Brain is repo-scoped and git-committable → shadow into the REPO ledger.
|
|
645
|
+
try {
|
|
646
|
+
const { shadowFact } = await import("./ledger_bridge.js");
|
|
647
|
+
const { repoLedger } = await import("./ledger_store.js");
|
|
648
|
+
shadowFact(repoLedger(process.cwd()), name, body);
|
|
649
|
+
} catch {}
|
|
650
|
+
}
|
|
651
|
+
console.log(
|
|
652
|
+
res.ok
|
|
653
|
+
? ` remembered: ${res.slug} — run \`forge sync\` to inline it into every tool`
|
|
654
|
+
: ` ${res.reason}`,
|
|
655
|
+
);
|
|
656
|
+
if (!res.ok) process.exitCode = 1;
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (cmd === "brain") {
|
|
660
|
+
const b = await import("./brain.js");
|
|
661
|
+
const store = b.brainStore(process.cwd());
|
|
662
|
+
const idx = b.buildIndex(store);
|
|
663
|
+
const items = b.list(store);
|
|
664
|
+
console.log(`${BRAND.brand} brain — portable project memory\n`);
|
|
665
|
+
console.log(
|
|
666
|
+
items.length
|
|
667
|
+
? items.map((s) => ` - ${s}`).join("\n")
|
|
668
|
+
: ' (no facts yet — forge remember "<name>" "<fact>")',
|
|
669
|
+
);
|
|
670
|
+
console.log(
|
|
671
|
+
`\n ${idx.indexed} inlined into AGENTS.md${idx.overflow ? `, ${idx.overflow} in overflow` : ""} · stored in .forge/brain/`,
|
|
672
|
+
);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (cmd === "cost") {
|
|
676
|
+
// `--stages` is the P8 measured report (per-stage factors from .forge/metrics.jsonl);
|
|
677
|
+
// the default path stays the ccusage per-day spend view, untouched.
|
|
678
|
+
if (argv.includes("--stages")) {
|
|
679
|
+
const { renderCostReport, report } = await import("./cost_report.js");
|
|
680
|
+
const r = report(process.cwd());
|
|
681
|
+
console.log(argv.includes("--json") ? JSON.stringify(r, null, 2) : renderCostReport(r));
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const { execFileSync } = await import("node:child_process");
|
|
685
|
+
const run = (bin, args) => execFileSync(bin, args, { encoding: "utf8", stdio: "pipe" });
|
|
686
|
+
console.log(`${BRAND.brand} cost — real per-day spend (ccusage)\n`);
|
|
687
|
+
try {
|
|
688
|
+
let out;
|
|
689
|
+
try {
|
|
690
|
+
out = run("ccusage", ["daily"]);
|
|
691
|
+
} catch {
|
|
692
|
+
// Pinned (verified 2026-07-05) — never @latest for code we execute; re-verify via dev-radar.
|
|
693
|
+
out = run("npx", ["-y", "ccusage@20.0.14", "daily"]);
|
|
694
|
+
}
|
|
695
|
+
console.log(out.trim());
|
|
696
|
+
} catch {
|
|
697
|
+
const { estimateSpendFromLogs } = await import("./cost_report.js");
|
|
698
|
+
const est = estimateSpendFromLogs();
|
|
699
|
+
if (est && est.totalCost > 0) {
|
|
700
|
+
console.log(
|
|
701
|
+
` $${est.totalCost.toFixed(2)} estimated from Claude session logs (${est.sessions} session(s))`,
|
|
702
|
+
);
|
|
703
|
+
if (est.byModel.length) {
|
|
704
|
+
for (const m of est.byModel)
|
|
705
|
+
console.log(
|
|
706
|
+
` ${m.model.padEnd(30)} $${m.cost.toFixed(4)} (${m.inTokens} in / ${m.outTokens} out)`,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
console.log("\n install ccusage for precise tracking: npm i -g ccusage");
|
|
710
|
+
} else {
|
|
711
|
+
console.log(
|
|
712
|
+
" ccusage not found. Install for real spend (reads local JSONL, nothing leaves your machine):\n npm i -g ccusage # then: forge cost",
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
console.log(
|
|
717
|
+
`\n ceiling: FORGE_COST_CEILING (default $10) — the cost-budget guard warns when a day exceeds it.`,
|
|
718
|
+
);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (cmd === "spec") {
|
|
722
|
+
const s = await import("./speclock.js");
|
|
723
|
+
const sub = argv[1] || "check";
|
|
724
|
+
if (sub === "init") {
|
|
725
|
+
const { execFileSync } = await import("node:child_process");
|
|
726
|
+
try {
|
|
727
|
+
// Pinned (verified 2026-07-05) — never @latest for code we execute; re-verify via dev-radar.
|
|
728
|
+
execFileSync("npx", ["-y", "@fission-ai/openspec@1.5.0", "init"], {
|
|
729
|
+
stdio: "inherit",
|
|
730
|
+
});
|
|
731
|
+
} catch {
|
|
732
|
+
console.log(
|
|
733
|
+
" OpenSpec not run. Scaffold spec-driven dev:\n npx -y @fission-ai/openspec init # lightweight (default)\n # or GitHub Spec Kit for heavier/governed projects",
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (sub === "lock") {
|
|
739
|
+
const { count } = s.snapshot(process.cwd());
|
|
740
|
+
console.log(` spec-lock: snapshotted ${count} spec(s) → .forge/spec-lock.json`);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const r = s.check(process.cwd());
|
|
744
|
+
console.log(`${BRAND.brand} spec check\n`);
|
|
745
|
+
if (r.note) console.log(` ${r.note}`);
|
|
746
|
+
else if (r.drift.length) {
|
|
747
|
+
for (const d of r.drift) {
|
|
748
|
+
console.log(` ✗ ${d.spec} claims \`${d.symbol}\` — no longer defined in the code`);
|
|
749
|
+
}
|
|
750
|
+
} else console.log(" ✓ no drift — every claimed symbol still exists");
|
|
751
|
+
console.log(`\n ${r.ok ? "PASS" : "DRIFT — update the spec or restore the symbol"}`);
|
|
752
|
+
if (!r.ok) process.exitCode = 1;
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (cmd === "harden") {
|
|
756
|
+
const { harden } = await import("./harden.js");
|
|
757
|
+
const r = harden({ targetRoot: process.cwd() });
|
|
758
|
+
console.log(`${BRAND.brand} harden\n`);
|
|
759
|
+
console.log(` gitleaks pre-commit: ${r.gitleaks}`);
|
|
760
|
+
console.log(
|
|
761
|
+
` sandbox settings: ${r.sandbox} — merge into ~/.claude/settings.json to enable (84% fewer prompts)`,
|
|
762
|
+
);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if (cmd === "cortex") {
|
|
766
|
+
const c = await import("./cortex.js");
|
|
767
|
+
const root = process.cwd();
|
|
768
|
+
const nowDay = Math.floor(Date.now() / 86400000);
|
|
769
|
+
const sub = argv[1] || "status";
|
|
770
|
+
if (sub === "why") {
|
|
771
|
+
const key = argv[2];
|
|
772
|
+
if (!key) {
|
|
773
|
+
console.error("usage: forge cortex why <symbol|file>");
|
|
774
|
+
process.exitCode = 1;
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const { block, selected } = c.lessonsForContext(
|
|
778
|
+
root,
|
|
779
|
+
{ symbols: [key], files: [key], keywords: [key] },
|
|
780
|
+
{ nowDay },
|
|
781
|
+
);
|
|
782
|
+
console.log(selected.length ? block : ` no lessons for ${key} yet`);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const s = c.summary(root, nowDay);
|
|
786
|
+
console.log(`${BRAND.brand} cortex — self-correcting project memory\n`);
|
|
787
|
+
console.log(
|
|
788
|
+
` lessons: ${s.total} (active ${s.active} · candidate ${s.candidate} · quarantined ${s.quarantined} · retired ${s.retired})`,
|
|
789
|
+
);
|
|
790
|
+
if (s.topActive.length) {
|
|
791
|
+
console.log("\n top active (by confidence):");
|
|
792
|
+
for (const t of s.topActive) console.log(` ${t.confidence.toFixed(2)} ${t.id}`);
|
|
793
|
+
} else {
|
|
794
|
+
console.log("\n (no active lessons yet — Cortex learns from corrections as you work)");
|
|
795
|
+
}
|
|
796
|
+
console.log("\n stored in .forge/lessons/ (git-committable, auditable)");
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (cmd === "preflight") {
|
|
800
|
+
const { preflightRepo, clarifyBlock } = await import("./preflight.js");
|
|
801
|
+
const json = argv.includes("--json");
|
|
802
|
+
const task = argv
|
|
803
|
+
.slice(1)
|
|
804
|
+
.filter((a) => a !== "--json")
|
|
805
|
+
.join(" ");
|
|
806
|
+
if (!task) {
|
|
807
|
+
console.error('usage: forge preflight "<task description>" [--json]');
|
|
808
|
+
process.exitCode = 1;
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const r = preflightRepo(process.cwd(), task);
|
|
812
|
+
if (json) {
|
|
813
|
+
console.log(JSON.stringify(r, null, 2));
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
console.log(`${BRAND.brand} preflight — assumption check\n`);
|
|
817
|
+
console.log(
|
|
818
|
+
` info-gap: ${r.gap.toFixed(2)} · completeness ${r.assumption.completeness.toFixed(2)} (referenced ${r.entities.symbols.length} symbol(s), ${r.entities.files.length} file(s))`,
|
|
819
|
+
);
|
|
820
|
+
const block = clarifyBlock(r);
|
|
821
|
+
console.log(
|
|
822
|
+
block ? `\n${block}` : "\n ✓ everything this task names is grounded in the codebase.",
|
|
823
|
+
);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (cmd === "impact") {
|
|
827
|
+
const { predictImpact } = await import("./substrate.js");
|
|
828
|
+
const json = argv.includes("--json");
|
|
829
|
+
const target = argv
|
|
830
|
+
.slice(1)
|
|
831
|
+
.filter((a) => a !== "--json")
|
|
832
|
+
.join(" ");
|
|
833
|
+
if (!target) {
|
|
834
|
+
console.error("usage: forge impact <symbol|file> [--json]");
|
|
835
|
+
process.exitCode = 1;
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const r = predictImpact(process.cwd(), target);
|
|
839
|
+
if (json) {
|
|
840
|
+
console.log(JSON.stringify(r, null, 2));
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
console.log(`${BRAND.brand} impact — blast radius\n`);
|
|
844
|
+
console.log(` target: ${target} ${r.found ? "✓ found" : "not found"}`);
|
|
845
|
+
console.log(` impacted files: ${r.impactedFiles.length}`);
|
|
846
|
+
for (const file of r.impactedFiles.slice(0, 20)) console.log(` - ${file}`);
|
|
847
|
+
if (r.impactedFiles.length > 20) console.log(` … ${r.impactedFiles.length - 20} more`);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (cmd === "substrate") {
|
|
851
|
+
const { renderSubstrate, substrateCheck } = await import("./substrate.js");
|
|
852
|
+
const json = argv.includes("--json");
|
|
853
|
+
const task = argv
|
|
854
|
+
.slice(1)
|
|
855
|
+
.filter((a) => a !== "--json")
|
|
856
|
+
.join(" ");
|
|
857
|
+
if (!task) {
|
|
858
|
+
console.error('usage: forge substrate "<task>" [--json]');
|
|
859
|
+
process.exitCode = 1;
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const r = substrateCheck(process.cwd(), task);
|
|
863
|
+
console.log(json ? JSON.stringify(r, null, 2) : renderSubstrate(r));
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (cmd === "config") {
|
|
867
|
+
const sub = argv[1] || "show";
|
|
868
|
+
const { loadProviders, activeProvider, setProvider, addProvider, listProviders, applyRoute } =
|
|
869
|
+
await import("./providers.js");
|
|
870
|
+
const json = argv.includes("--json");
|
|
871
|
+
if (sub === "show") {
|
|
872
|
+
const prov = activeProvider(process.cwd());
|
|
873
|
+
const config = loadProviders(process.cwd());
|
|
874
|
+
if (json)
|
|
875
|
+
return console.log(JSON.stringify({ active: config.active, provider: prov }, null, 2));
|
|
876
|
+
console.log(`${BRAND.brand} config\n`);
|
|
877
|
+
console.log(` provider: ${prov.name} (${prov.label || prov.name})`);
|
|
878
|
+
if (prov._autoDetected) console.log(` detected: auto (from ${prov._source})`);
|
|
879
|
+
console.log(` base URL: ${prov.baseUrl}`);
|
|
880
|
+
console.log(
|
|
881
|
+
` env key: ${prov.envKey || "(none)"}${prov.envKey ? (process.env[prov.envKey] ? " ✓ set" : " ✗ not set") : ""}`,
|
|
882
|
+
);
|
|
883
|
+
console.log(` models:`);
|
|
884
|
+
for (const [tier, id] of Object.entries(prov.models || {}))
|
|
885
|
+
console.log(` ${tier.padEnd(8)} ${id}`);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (sub === "providers") {
|
|
889
|
+
const list = listProviders(process.cwd());
|
|
890
|
+
if (json) return console.log(JSON.stringify(list, null, 2));
|
|
891
|
+
console.log(`${BRAND.brand} config providers\n`);
|
|
892
|
+
for (const p of list)
|
|
893
|
+
console.log(
|
|
894
|
+
` ${p.active ? "▸" : " "} ${p.name.padEnd(14)} ${p.label.padEnd(20)} ${p.envKey ? (p.hasKey ? "✓ key set" : "✗ key missing") : ""}`,
|
|
895
|
+
);
|
|
896
|
+
console.log(`\n switch: \`${BRAND.cli} config provider <name>\``);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (sub === "provider") {
|
|
900
|
+
const name = argv[2];
|
|
901
|
+
if (!name) {
|
|
902
|
+
console.error(
|
|
903
|
+
`usage: ${BRAND.cli} config provider <name> | ${BRAND.cli} config provider add <name> --base-url <url> [--key-env <VAR>]`,
|
|
904
|
+
);
|
|
905
|
+
process.exitCode = 1;
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (name === "add") {
|
|
909
|
+
const addName = argv[3];
|
|
910
|
+
const flagVal = (f) => {
|
|
911
|
+
const i = argv.indexOf(f);
|
|
912
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
913
|
+
};
|
|
914
|
+
const baseUrl = flagVal("--base-url");
|
|
915
|
+
const envKey = flagVal("--key-env");
|
|
916
|
+
const label = flagVal("--label");
|
|
917
|
+
const r = addProvider(process.cwd(), addName, { baseUrl, envKey, label });
|
|
918
|
+
if (!r.ok) {
|
|
919
|
+
console.error(` ${r.reason}`);
|
|
920
|
+
process.exitCode = 1;
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
console.log(` added provider "${addName}" → ${r.provider.baseUrl}`);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const r = setProvider(process.cwd(), name);
|
|
927
|
+
if (!r.ok) {
|
|
928
|
+
console.error(` ${r.reason}`);
|
|
929
|
+
process.exitCode = 1;
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
console.log(` switched to provider "${name}" (${r.provider.label || name})`);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (sub === "model") {
|
|
936
|
+
const tier = argv[2];
|
|
937
|
+
if (!tier) {
|
|
938
|
+
console.error(`usage: ${BRAND.cli} config model <haiku|sonnet|opus|fable>`);
|
|
939
|
+
process.exitCode = 1;
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const r = applyRoute(tier);
|
|
943
|
+
if (!r.ok) {
|
|
944
|
+
console.error(` ${r.reason}`);
|
|
945
|
+
process.exitCode = 1;
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
console.log(` model set to ${r.model} (${r.modelId})${r.prev ? ` — was: ${r.prev}` : ""}`);
|
|
949
|
+
console.log(` written to ${r.path}`);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if (sub === "gateway") {
|
|
953
|
+
const { emitGatewayConfig } = await import("./route.js");
|
|
954
|
+
const result = emitGatewayConfig(process.cwd());
|
|
955
|
+
if (typeof result === "object" && !result.ok) {
|
|
956
|
+
console.log(` ${result.reason}`);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
console.log(` wrote ${result}`);
|
|
960
|
+
console.log(`\n setup LiteLLM gateway:`);
|
|
961
|
+
console.log(` 1. pip install "litellm[proxy]" # pin an exact verified version`);
|
|
962
|
+
console.log(` 2. litellm --config litellm.config.yaml`);
|
|
963
|
+
console.log(` 3. export ANTHROPIC_BASE_URL=http://localhost:4000`);
|
|
964
|
+
console.log(`\n then switch to the gateway provider:`);
|
|
965
|
+
console.log(` ${BRAND.cli} config provider litellm`);
|
|
966
|
+
console.log(`\n routing flows through: forge route → tier alias → LiteLLM → model`);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (sub === "setup") {
|
|
970
|
+
const { providerStatus, listDetectedProviders } = await import("./providers.js");
|
|
971
|
+
const prov = activeProvider(process.cwd());
|
|
972
|
+
const status = providerStatus(process.cwd());
|
|
973
|
+
const detected = listDetectedProviders();
|
|
974
|
+
console.log(`${BRAND.brand} config setup\n`);
|
|
975
|
+
console.log(` active provider: ${prov.name} (${prov.label || prov.name})`);
|
|
976
|
+
if (prov._autoDetected) console.log(` source: auto-detected from ${prov._source}`);
|
|
977
|
+
console.log();
|
|
978
|
+
for (const c of status.checks) {
|
|
979
|
+
console.log(` ${c.ok ? "✓" : "✗"} ${c.detail}`);
|
|
980
|
+
}
|
|
981
|
+
console.log(`\n environment:`);
|
|
982
|
+
for (const e of status.envScan) {
|
|
983
|
+
console.log(` ${e.set ? "✓" : "·"} ${e.key}${e.set ? " (set)" : ""}`);
|
|
984
|
+
}
|
|
985
|
+
if (detected.length) {
|
|
986
|
+
console.log(`\n available providers (auto-detected):`);
|
|
987
|
+
for (const d of detected) {
|
|
988
|
+
console.log(` ${d.name.padEnd(18)} ${d.label.padEnd(24)} via ${d.source}`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
console.log(`\n Anthropic Console API key:`);
|
|
992
|
+
console.log(` 1. Go to console.anthropic.com/settings/keys`);
|
|
993
|
+
console.log(` 2. Create a key, then: export ANTHROPIC_API_KEY=sk-ant-...`);
|
|
994
|
+
console.log(`\n OpenRouter API key:`);
|
|
995
|
+
console.log(` 1. Go to openrouter.ai/keys`);
|
|
996
|
+
console.log(` 2. Create a key, then: export OPENROUTER_API_KEY=sk-or-...`);
|
|
997
|
+
console.log(`\n LiteLLM hosted gateway (no admin access needed):`);
|
|
998
|
+
console.log(` export LITELLM_BASE_URL=https://your-gateway.example.com`);
|
|
999
|
+
console.log(` export LITELLM_API_KEY=sk-... # or uses ANTHROPIC_API_KEY`);
|
|
1000
|
+
console.log(`\n LiteLLM self-hosted gateway:`);
|
|
1001
|
+
console.log(` ${BRAND.cli} config gateway # emit litellm.config.yaml`);
|
|
1002
|
+
console.log(` ${BRAND.cli} config provider litellm`);
|
|
1003
|
+
console.log(`\n quick start:`);
|
|
1004
|
+
console.log(` ${BRAND.cli} config provider anthropic # direct Anthropic API`);
|
|
1005
|
+
console.log(` ${BRAND.cli} config provider openrouter # OpenRouter multi-model`);
|
|
1006
|
+
console.log(` ${BRAND.cli} config provider litellm # LiteLLM self-hosted`);
|
|
1007
|
+
console.log(` ${BRAND.cli} config model sonnet # set default model tier`);
|
|
1008
|
+
console.log(` ${BRAND.cli} route "<task>" --apply # route + apply model`);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
console.error(
|
|
1012
|
+
`config: unknown subcommand "${sub}" (show | providers | provider <name> | model <tier> | gateway | setup)`,
|
|
1013
|
+
);
|
|
1014
|
+
process.exitCode = 1;
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (cmd === "route") {
|
|
1018
|
+
const r = await import("./route.js");
|
|
1019
|
+
if (argv[1] === "gateway") {
|
|
1020
|
+
const result = r.emitGatewayConfig(process.cwd());
|
|
1021
|
+
if (typeof result === "object" && !result.ok) {
|
|
1022
|
+
console.log(` ${result.reason}`);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
console.log(
|
|
1026
|
+
` wrote ${result} — LiteLLM tiers: forge-simple / forge-medium / forge-complex.`,
|
|
1027
|
+
);
|
|
1028
|
+
console.log(" next: pin+install litellm, run it, point ANTHROPIC_BASE_URL at it, then");
|
|
1029
|
+
console.log(
|
|
1030
|
+
" REQUEST the tier `forge route` recommends (a plain claude-* request passes through).",
|
|
1031
|
+
);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const json = argv.includes("--json");
|
|
1035
|
+
const apply = argv.includes("--apply");
|
|
1036
|
+
const providerIdx = argv.indexOf("--provider");
|
|
1037
|
+
const providerName = providerIdx >= 0 ? argv[providerIdx + 1] : undefined;
|
|
1038
|
+
const FLAGS = new Set(["--json", "--apply"]);
|
|
1039
|
+
const task = argv
|
|
1040
|
+
.slice(1)
|
|
1041
|
+
.filter((a, i) => !FLAGS.has(a) && a !== "--provider" && argv[i] !== "--provider")
|
|
1042
|
+
.join(" ");
|
|
1043
|
+
if (!task) {
|
|
1044
|
+
console.error(
|
|
1045
|
+
'usage: forge route "<task>" [--apply] [--provider <name>] [--json] | forge route gateway',
|
|
1046
|
+
);
|
|
1047
|
+
process.exitCode = 1;
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (providerName) {
|
|
1051
|
+
const { setProvider } = await import("./providers.js");
|
|
1052
|
+
const sr = setProvider(process.cwd(), providerName);
|
|
1053
|
+
if (!sr.ok) {
|
|
1054
|
+
console.error(` ${sr.reason}`);
|
|
1055
|
+
process.exitCode = 1;
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const rec = r.routeTask(process.cwd(), task);
|
|
1060
|
+
r.meterRoute(process.cwd(), task, rec);
|
|
1061
|
+
if (json) {
|
|
1062
|
+
console.log(JSON.stringify(rec, null, 2));
|
|
1063
|
+
} else {
|
|
1064
|
+
console.log(`${BRAND.brand} route — cheapest capable model\n`);
|
|
1065
|
+
console.log(
|
|
1066
|
+
` → ${rec.model.name} (${rec.tier}, $${rec.model.inCost}/$${rec.model.outCost} per M tok)`,
|
|
1067
|
+
);
|
|
1068
|
+
console.log(` ${rec.model.use}`);
|
|
1069
|
+
console.log(
|
|
1070
|
+
` complexity ${rec.score.toFixed(2)}${rec.reasons.length ? ` · driven by: ${rec.reasons.join(", ")}` : ""}`,
|
|
1071
|
+
);
|
|
1072
|
+
console.log(
|
|
1073
|
+
` signals: ${rec.signals.files} file(s), fan-out ${rec.signals.fanout}, churn ${rec.signals.churn}, past-mistakes ${rec.signals.pastMistakes}, ambiguity ${rec.signals.ambiguity.toFixed(2)}`,
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
if (apply) {
|
|
1077
|
+
const { applyRoute } = await import("./providers.js");
|
|
1078
|
+
const ar = applyRoute(rec.key);
|
|
1079
|
+
if (ar.ok) {
|
|
1080
|
+
if (!json)
|
|
1081
|
+
console.log(`\n applied: model set to ${ar.model} (${ar.modelId}) in ${ar.path}`);
|
|
1082
|
+
} else {
|
|
1083
|
+
if (!json) console.error(`\n apply failed: ${ar.reason}`);
|
|
1084
|
+
process.exitCode = 1;
|
|
1085
|
+
}
|
|
1086
|
+
} else if (!json) {
|
|
1087
|
+
console.log(
|
|
1088
|
+
`\n advisory · apply: \`${BRAND.cli} route "<task>" --apply\` · gateway: \`${BRAND.cli} route gateway\``,
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (cmd === "anchor") {
|
|
1094
|
+
const { goalDrift, renderAnchor } = await import("./anchor.js");
|
|
1095
|
+
const json = argv.includes("--json");
|
|
1096
|
+
const goal = argv
|
|
1097
|
+
.slice(1)
|
|
1098
|
+
.filter((a) => a !== "--json")
|
|
1099
|
+
.join(" ");
|
|
1100
|
+
if (!goal) {
|
|
1101
|
+
console.error('usage: forge anchor "<original goal>" [--json]');
|
|
1102
|
+
process.exitCode = 1;
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
const r = goalDrift(process.cwd(), goal);
|
|
1106
|
+
console.log(json ? JSON.stringify(r, null, 2) : renderAnchor(r));
|
|
1107
|
+
return; // advisory — never fails the process
|
|
1108
|
+
}
|
|
1109
|
+
if (cmd === "diagnose") {
|
|
1110
|
+
const { diagnose, THRASH_K } = await import("./diagnose.js");
|
|
1111
|
+
const json = argv.includes("--json");
|
|
1112
|
+
const flagVal = (name) => {
|
|
1113
|
+
const i = argv.indexOf(name);
|
|
1114
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
1115
|
+
};
|
|
1116
|
+
const args = argv.filter(
|
|
1117
|
+
(a, i) => !a.startsWith("--") && argv[i - 1] !== "--file" && argv[i - 1] !== "--symbol",
|
|
1118
|
+
);
|
|
1119
|
+
const errorText = args.slice(1).join(" ");
|
|
1120
|
+
if (!errorText) {
|
|
1121
|
+
console.error('usage: forge diagnose "<error text>" [--file f] [--symbol s] [--json]');
|
|
1122
|
+
process.exitCode = 1;
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const r = diagnose(process.cwd(), {
|
|
1126
|
+
errorText,
|
|
1127
|
+
file: flagVal("--file"),
|
|
1128
|
+
symbol: flagVal("--symbol"),
|
|
1129
|
+
});
|
|
1130
|
+
if (json) return console.log(JSON.stringify(r, null, 2));
|
|
1131
|
+
console.log(`${BRAND.brand} diagnose — doom-loop check\n`);
|
|
1132
|
+
console.log(
|
|
1133
|
+
` signature: ${r.signature.slice(0, 12)} · seen ${r.count}× in the recent failure window`,
|
|
1134
|
+
);
|
|
1135
|
+
if (r.thrash) {
|
|
1136
|
+
if (r.claimId)
|
|
1137
|
+
console.log(
|
|
1138
|
+
` diagnosis claim: ${r.claimId.slice(0, 12)} (\`forge ledger show ${r.claimId.slice(0, 8)}\`)`,
|
|
1139
|
+
);
|
|
1140
|
+
console.log(`\n ${r.escalate ?? r.reason}`);
|
|
1141
|
+
} else {
|
|
1142
|
+
console.log(` below the thrash threshold (${THRASH_K}) — recorded; keep going.`);
|
|
1143
|
+
}
|
|
1144
|
+
return; // advisory — halting the retry loop is the AGENT's move, not an exit code
|
|
1145
|
+
}
|
|
1146
|
+
if (cmd === "imagine") {
|
|
1147
|
+
const { dryRun, imagineTask, renderImagine } = await import("./imagine.js");
|
|
1148
|
+
const json = argv.includes("--json");
|
|
1149
|
+
const doRun = argv.includes("--run");
|
|
1150
|
+
const allowDirty = argv.includes("--allow-dirty");
|
|
1151
|
+
const FLAGS = new Set(["--json", "--run", "--allow-dirty"]);
|
|
1152
|
+
const task = argv
|
|
1153
|
+
.slice(1)
|
|
1154
|
+
.filter((a) => !FLAGS.has(a))
|
|
1155
|
+
.join(" ");
|
|
1156
|
+
if (!task) {
|
|
1157
|
+
console.error('usage: forge imagine "<task>" [--run] [--allow-dirty] [--json]');
|
|
1158
|
+
process.exitCode = 1;
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
const root = process.cwd();
|
|
1162
|
+
const r = imagineTask(root, task);
|
|
1163
|
+
if (!doRun) {
|
|
1164
|
+
console.log(json ? JSON.stringify(r, null, 2) : renderImagine(r));
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
// --run: the static prediction first (always), then the measured half. The sandbox
|
|
1168
|
+
// is a git worktree of HEAD — uncommitted changes are INVISIBLE to it — so a dirty
|
|
1169
|
+
// tree is refused by default rather than silently dry-running the wrong code.
|
|
1170
|
+
if (!json) console.log(renderImagine(r, { footer: false }));
|
|
1171
|
+
if (!allowDirty) {
|
|
1172
|
+
let dirty = "";
|
|
1173
|
+
try {
|
|
1174
|
+
const { execFileSync } = await import("node:child_process");
|
|
1175
|
+
dirty = execFileSync("git", ["status", "--porcelain"], {
|
|
1176
|
+
cwd: root,
|
|
1177
|
+
encoding: "utf8",
|
|
1178
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1179
|
+
}).trim();
|
|
1180
|
+
} catch {} // not a repo / no git → dryRun reports its own precondition failure
|
|
1181
|
+
if (dirty) {
|
|
1182
|
+
console.error(
|
|
1183
|
+
"\n imagine --run refused: the working tree is dirty and the sandbox runs HEAD,\n" +
|
|
1184
|
+
" so your uncommitted changes would NOT be in the dry-run. Commit or stash them,\n" +
|
|
1185
|
+
" or pass --allow-dirty to knowingly measure the last commit instead.",
|
|
1186
|
+
);
|
|
1187
|
+
process.exitCode = 1;
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
const d = dryRun(root, { tests: r.tests });
|
|
1192
|
+
// Metrics are best-effort telemetry (05-cost-model.md) — never let recording
|
|
1193
|
+
// failure break the verdict. Only a run that happened is worth counting.
|
|
1194
|
+
try {
|
|
1195
|
+
if (d.durationMs !== undefined) {
|
|
1196
|
+
const { record } = await import("./metrics.js");
|
|
1197
|
+
record(root, {
|
|
1198
|
+
stage: "imagine",
|
|
1199
|
+
outcome: d.ok && d.failed === 0 ? "clean" : "breaks",
|
|
1200
|
+
ref: task.slice(0, 120),
|
|
1201
|
+
durationMs: d.durationMs,
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
} catch {}
|
|
1205
|
+
if (json) {
|
|
1206
|
+
console.log(JSON.stringify({ ...r, dryRun: d }, null, 2));
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (!d.ok) {
|
|
1210
|
+
console.log(`\n dry-run: did not produce a verdict — ${d.reason}`);
|
|
1211
|
+
if (d.output) console.log(`\n${d.output.replace(/^/gm, " ")}`);
|
|
1212
|
+
process.exitCode = 1;
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
console.log(`\n dry-run (sandboxed worktree of HEAD · ${d.runner}):`);
|
|
1216
|
+
console.log(
|
|
1217
|
+
` pass ${d.passed} · fail ${d.failed} · ${d.durationMs}ms · worktree ${d.worktree}`,
|
|
1218
|
+
);
|
|
1219
|
+
if (d.perFile)
|
|
1220
|
+
for (const [t, s] of Object.entries(d.perFile))
|
|
1221
|
+
console.log(` ${s === "pass" ? "ok " : "FAIL"} ${t}`);
|
|
1222
|
+
if (d.failed > 0) {
|
|
1223
|
+
console.log("\n measured consequence: the selected suite BREAKS at HEAD — output tail:");
|
|
1224
|
+
console.log(`\n${(d.output || "").replace(/^/gm, " ")}`);
|
|
1225
|
+
} else {
|
|
1226
|
+
console.log("\n measured consequence: the selected suite is green at HEAD.");
|
|
1227
|
+
}
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (cmd === "lean") {
|
|
1231
|
+
const { leanRepo, renderLean } = await import("./lean.js");
|
|
1232
|
+
const json = argv.includes("--json");
|
|
1233
|
+
const task = argv
|
|
1234
|
+
.slice(1)
|
|
1235
|
+
.filter((a) => a !== "--json")
|
|
1236
|
+
.join(" ");
|
|
1237
|
+
if (!task) {
|
|
1238
|
+
console.error(
|
|
1239
|
+
'usage: forge lean "<task>" [--json] (measures the working diff vs the task)',
|
|
1240
|
+
);
|
|
1241
|
+
process.exitCode = 1;
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
const r = leanRepo(process.cwd(), task);
|
|
1245
|
+
console.log(json ? JSON.stringify(r, null, 2) : renderLean(r));
|
|
1246
|
+
return; // advisory — never fails the process
|
|
1247
|
+
}
|
|
1248
|
+
if (cmd === "scope") {
|
|
1249
|
+
const { decompose } = await import("./scope.js");
|
|
1250
|
+
const json = argv.includes("--json");
|
|
1251
|
+
const files = argv.slice(1).filter((a) => a !== "--json");
|
|
1252
|
+
if (!files.length) {
|
|
1253
|
+
console.error("usage: forge scope <file> [file...] [--json]");
|
|
1254
|
+
process.exitCode = 1;
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const d = decompose(process.cwd(), files);
|
|
1258
|
+
if (json) {
|
|
1259
|
+
console.log(JSON.stringify(d, null, 2));
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
console.log(`${BRAND.brand} scope — task decomposition\n`);
|
|
1263
|
+
if (d.independentGroups > 1) {
|
|
1264
|
+
console.log(
|
|
1265
|
+
` ${d.independentGroups} independent groups → consider a separate session per group:\n`,
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
d.clusters.forEach((c, i) => {
|
|
1269
|
+
console.log(` [${i + 1}] ${c.touched.join(", ")}`);
|
|
1270
|
+
if (c.coupled.length) {
|
|
1271
|
+
const shown = c.coupled.slice(0, 8).join(", ");
|
|
1272
|
+
console.log(
|
|
1273
|
+
` ! also coupled (you didn't name): ${shown}${c.coupled.length > 8 ? " …" : ""}`,
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
1277
|
+
if (d.independentGroups === 1) console.log("\n all coupled — keep as one change.");
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
if (cmd === "uicheck") {
|
|
1281
|
+
const sub = argv[1];
|
|
1282
|
+
if (sub === "visual") {
|
|
1283
|
+
// The Playwright visual loop (spec §5): render in a real browser, fingerprint
|
|
1284
|
+
// the COMPUTED styles, run the same design gate. Playwright is an optional
|
|
1285
|
+
// tier (ADR-0005) — its absence is a note and exit 0, never a failure.
|
|
1286
|
+
const { visualGate } = await import("./uivisual.js");
|
|
1287
|
+
const args = argv.slice(2);
|
|
1288
|
+
const tasteIdx = args.indexOf("--taste");
|
|
1289
|
+
const tasteArg = tasteIdx >= 0 ? (args.splice(tasteIdx, 2)[1] ?? null) : null;
|
|
1290
|
+
const json = args.includes("--json");
|
|
1291
|
+
const remote = args.includes("--remote");
|
|
1292
|
+
const targets = args.filter((a) => !a.startsWith("--"));
|
|
1293
|
+
if (targets.length !== 1 || (tasteIdx >= 0 && !tasteArg)) {
|
|
1294
|
+
console.error(
|
|
1295
|
+
`usage: ${BRAND.cli} uicheck visual <file-or-url> [--taste <name>] [--json] [--remote]`,
|
|
1296
|
+
);
|
|
1297
|
+
process.exitCode = 1;
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
const r = await visualGate(targets[0], { taste: tasteArg, remote, root: process.cwd() });
|
|
1301
|
+
if (!r.ok) {
|
|
1302
|
+
const reason = "reason" in r ? r.reason : "visual gate failed";
|
|
1303
|
+
if ("skipped" in r && r.skipped) {
|
|
1304
|
+
// Graceful absence (ADR-0005): a missing optional tier is not a failure.
|
|
1305
|
+
if (json) console.log(JSON.stringify({ skipped: true, reason }, null, 2));
|
|
1306
|
+
else {
|
|
1307
|
+
console.log(`${BRAND.brand} uicheck visual — skipped (no browser runtime)\n`);
|
|
1308
|
+
console.log(` ${reason}`);
|
|
1309
|
+
console.log(
|
|
1310
|
+
" enable it: npm i -D playwright-core (or point FORGE_PLAYWRIGHT at an existing install, e.g. FORGE_PLAYWRIGHT=/path/to/node_modules/playwright-core)",
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
return; // exit 0 — the static gate still stands
|
|
1314
|
+
}
|
|
1315
|
+
console.error(reason);
|
|
1316
|
+
process.exitCode = 1;
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
if (json) {
|
|
1320
|
+
const { ok: _ok, fail: _fail, ...body } = r;
|
|
1321
|
+
console.log(JSON.stringify(body, null, 2));
|
|
1322
|
+
} else {
|
|
1323
|
+
console.log(`${BRAND.brand} uicheck visual — rendered fingerprint + design gate\n`);
|
|
1324
|
+
console.log(` rendered: ${r.url} (${r.elements} visible element style(s))`);
|
|
1325
|
+
console.log(` screenshots: ${r.screenshots.join(", ")}`);
|
|
1326
|
+
if (r.taste) console.log(` taste: ${r.taste} (thresholds from its profile)`);
|
|
1327
|
+
console.log(
|
|
1328
|
+
` slop distance: ${r.slop} (need ≥ ${r.tauSlop} — farther from generic is better)`,
|
|
1329
|
+
);
|
|
1330
|
+
console.log(
|
|
1331
|
+
r.hasProjectFingerprint
|
|
1332
|
+
? ` conformance: ${r.conform} (need ≤ ${r.tauConform} — closer to the project system is better)`
|
|
1333
|
+
: ` conformance: (no project fingerprint claim — slop-only; mint one: \`${BRAND.cli} uicheck fingerprint <ui files> --mint\`)`,
|
|
1334
|
+
);
|
|
1335
|
+
for (const v of r.violations) console.log(`\n ✗ ${v.detail}\n fix: ${v.hint}`);
|
|
1336
|
+
console.log("");
|
|
1337
|
+
for (const c of r.checks)
|
|
1338
|
+
console.log(
|
|
1339
|
+
` ${c.pass ? "✓" : "✗"} ${c.id}: ${c.detail}${c.pass || !c.hint ? "" : `\n fix: ${c.hint}`}`,
|
|
1340
|
+
);
|
|
1341
|
+
console.log(`\n ${r.fail ? "✗ FAIL" : "✓ PASS"}`);
|
|
1342
|
+
}
|
|
1343
|
+
if (r.fail) process.exitCode = 1;
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
if (sub === "fingerprint" || sub === "design") {
|
|
1347
|
+
const ui = await import("./uifingerprint.js");
|
|
1348
|
+
// `--taste <name>` (design only) takes a VALUE — splice it out before the
|
|
1349
|
+
// file filter so the profile name is never mistaken for a file.
|
|
1350
|
+
const args = argv.slice(2);
|
|
1351
|
+
const tasteIdx = args.indexOf("--taste");
|
|
1352
|
+
const tasteArg = tasteIdx >= 0 ? (args.splice(tasteIdx, 2)[1] ?? null) : null;
|
|
1353
|
+
const json = args.includes("--json");
|
|
1354
|
+
const files = args.filter((a) => !a.startsWith("--"));
|
|
1355
|
+
if (!files.length || (tasteIdx >= 0 && !tasteArg)) {
|
|
1356
|
+
console.error(
|
|
1357
|
+
`usage: ${BRAND.cli} uicheck ${sub} <file...> [--json]${sub === "fingerprint" ? " [--mint]" : " [--taste <name>]"}`,
|
|
1358
|
+
);
|
|
1359
|
+
process.exitCode = 1;
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
const fp = ui.fingerprintFiles(process.cwd(), files);
|
|
1363
|
+
if (sub === "fingerprint") {
|
|
1364
|
+
let minted = null;
|
|
1365
|
+
if (argv.includes("--mint")) {
|
|
1366
|
+
const { epochDay } = await import("./util.js");
|
|
1367
|
+
minted = ui.mintProjectFingerprint(process.cwd(), files, { t: epochDay() });
|
|
1368
|
+
}
|
|
1369
|
+
if (json) {
|
|
1370
|
+
console.log(JSON.stringify(minted ? { fingerprint: fp, minted } : fp, null, 2));
|
|
1371
|
+
} else {
|
|
1372
|
+
console.log(`${BRAND.brand} uicheck fingerprint — the design feature vector\n`);
|
|
1373
|
+
console.log(
|
|
1374
|
+
` palette: ${fp.paletteSize} color(s), hue bins [${fp.hueBuckets.join(" ")}]`,
|
|
1375
|
+
);
|
|
1376
|
+
console.log(
|
|
1377
|
+
` spacing: ${fp.spacing.join(", ") || "(none)"} px — base ${fp.spacingBase ?? "(none)"}, ${Math.round(fp.spacingOnScale * 100)}% on-scale`,
|
|
1378
|
+
);
|
|
1379
|
+
console.log(` type: ${fp.fontFamilies.join(", ") || "(none)"}`);
|
|
1380
|
+
console.log(
|
|
1381
|
+
` shape: radii ${fp.radii.join(", ") || "(none)"} (${fp.radiusLevels} level(s)) · ${fp.shadowLevels} shadow level(s)`,
|
|
1382
|
+
);
|
|
1383
|
+
if (minted) {
|
|
1384
|
+
if (minted.ok)
|
|
1385
|
+
console.log(
|
|
1386
|
+
`\n minted fingerprint claim ${minted.id.slice(0, 12)}${minted.existed ? " (already in ledger)" : ""} — the gate's "home"`,
|
|
1387
|
+
);
|
|
1388
|
+
else console.error(`\n mint failed: ${"reason" in minted ? minted.reason : ""}`);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
if (minted && !minted.ok) process.exitCode = 1;
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
// design — the two-sided gate: fail when too close to generic OR (when the
|
|
1395
|
+
// project has minted its fingerprint) too far from the project's own system.
|
|
1396
|
+
// A taste profile (explicit --taste, else the style pinned by a
|
|
1397
|
+
// `forge taste`-managed DESIGN.md) overrides thresholds + adds its checks.
|
|
1398
|
+
const tasteName = tasteArg ?? ui.activeTasteStyle(process.cwd());
|
|
1399
|
+
const profile = tasteName ? ui.loadTasteProfile(tasteName) : null;
|
|
1400
|
+
if (tasteArg && !profile) {
|
|
1401
|
+
// Explicit --taste must exist; an auto-picked style without a JSON sibling
|
|
1402
|
+
// silently falls back to defaults (custom prose styles stay legal).
|
|
1403
|
+
console.error(
|
|
1404
|
+
`unknown taste profile "${tasteArg}" — run \`${BRAND.cli} taste\` to list styles`,
|
|
1405
|
+
);
|
|
1406
|
+
process.exitCode = 1;
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const projectFp = ui.loadProjectFingerprint(process.cwd());
|
|
1410
|
+
const tauSlop = profile?.gate?.tau_slop ?? ui.UI_GATE_DEFAULTS.tauSlop;
|
|
1411
|
+
const tauConform = profile?.gate?.tau_conform ?? ui.UI_GATE_DEFAULTS.tauConform;
|
|
1412
|
+
const gate = ui.uiGate(fp, { projectFp, tauSlop, tauConform });
|
|
1413
|
+
const checks = [...ui.scaleChecks(fp), ...(profile ? ui.profileChecks(fp, profile) : [])];
|
|
1414
|
+
const fail = !gate.pass || checks.some((c) => !c.pass);
|
|
1415
|
+
if (json) {
|
|
1416
|
+
console.log(
|
|
1417
|
+
JSON.stringify(
|
|
1418
|
+
{
|
|
1419
|
+
...gate,
|
|
1420
|
+
checks,
|
|
1421
|
+
hasProjectFingerprint: !!projectFp,
|
|
1422
|
+
taste: profile ? tasteName : null,
|
|
1423
|
+
tauSlop,
|
|
1424
|
+
tauConform,
|
|
1425
|
+
},
|
|
1426
|
+
null,
|
|
1427
|
+
2,
|
|
1428
|
+
),
|
|
1429
|
+
);
|
|
1430
|
+
} else {
|
|
1431
|
+
console.log(`${BRAND.brand} uicheck design — slop distance + project conformance\n`);
|
|
1432
|
+
if (profile) console.log(` taste: ${tasteName} (thresholds from its profile)`);
|
|
1433
|
+
console.log(
|
|
1434
|
+
` slop distance: ${gate.slop} (need ≥ ${tauSlop} — farther from generic is better)`,
|
|
1435
|
+
);
|
|
1436
|
+
console.log(
|
|
1437
|
+
projectFp
|
|
1438
|
+
? ` conformance: ${gate.conform} (need ≤ ${tauConform} — closer to the project system is better)`
|
|
1439
|
+
: ` conformance: (no project fingerprint claim — slop-only; mint one: \`${BRAND.cli} uicheck fingerprint <ui files> --mint\`)`,
|
|
1440
|
+
);
|
|
1441
|
+
for (const v of gate.violations) console.log(`\n ✗ ${v.detail}\n fix: ${v.hint}`);
|
|
1442
|
+
console.log("");
|
|
1443
|
+
for (const c of checks)
|
|
1444
|
+
console.log(
|
|
1445
|
+
` ${c.pass ? "✓" : "✗"} ${c.id}: ${c.detail}${c.pass || !c.hint ? "" : `\n fix: ${c.hint}`}`,
|
|
1446
|
+
);
|
|
1447
|
+
console.log(`\n ${fail ? "✗ FAIL" : "✓ PASS"}`);
|
|
1448
|
+
}
|
|
1449
|
+
if (fail) process.exitCode = 1;
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const { contrastRatio, wcagLevel, ASSERTABLE_CHECKS, ADVISORY_ONLY } = await import(
|
|
1453
|
+
"./uicheck.js"
|
|
1454
|
+
);
|
|
1455
|
+
// `uicheck contrast <fg> <bg>` is the named form; bare `uicheck <fg> <bg>` stays
|
|
1456
|
+
// supported (it predates the subcommands and hooks already call it).
|
|
1457
|
+
const [fg, bg] = sub === "contrast" ? [argv[2], argv[3]] : [argv[1], argv[2]];
|
|
1458
|
+
console.log(`${BRAND.brand} uicheck — deterministic UI review\n`);
|
|
1459
|
+
if (fg && bg) {
|
|
1460
|
+
try {
|
|
1461
|
+
const g = wcagLevel(contrastRatio(fg, bg));
|
|
1462
|
+
console.log(
|
|
1463
|
+
` contrast ${fg} on ${bg}: ${g.ratio}:1 → ${g.level}${g.passesAA ? " (passes AA)" : " (FAILS AA)"}`,
|
|
1464
|
+
);
|
|
1465
|
+
} catch (e) {
|
|
1466
|
+
console.error(` ${e.message}`);
|
|
1467
|
+
process.exitCode = 1;
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
console.log(`\n ASSERT (deterministic): ${ASSERTABLE_CHECKS.map((c) => c.id).join(", ")}`);
|
|
1472
|
+
console.log(` ADVISE (subjective, human-only): ${ADVISORY_ONLY.slice(0, 4).join(", ")} …`);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
if (cmd === "dash") {
|
|
1476
|
+
const { serve } = await import("./dash.js");
|
|
1477
|
+
const i = argv.indexOf("--port");
|
|
1478
|
+
const port = i >= 0 ? Number(argv[i + 1]) : 4242;
|
|
1479
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
1480
|
+
console.error("usage: forge dash [--port N]");
|
|
1481
|
+
process.exitCode = 1;
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
const server = serve(process.cwd(), { port });
|
|
1485
|
+
server.on("listening", () => {
|
|
1486
|
+
const addr = /** @type {import("node:net").AddressInfo} */ (server.address());
|
|
1487
|
+
console.log(`${BRAND.brand} dash — read-only lens on .forge/\n`);
|
|
1488
|
+
console.log(` http://127.0.0.1:${addr.port} (localhost-only · Ctrl-C to stop)`);
|
|
1489
|
+
});
|
|
1490
|
+
server.on("error", (err) => {
|
|
1491
|
+
console.error(` ${err.message}`);
|
|
1492
|
+
process.exitCode = 1;
|
|
1493
|
+
});
|
|
1494
|
+
return; // the process stays alive serving — that's the command
|
|
1495
|
+
}
|
|
1496
|
+
if (!(cmd in COMMANDS)) {
|
|
1497
|
+
console.error(`Unknown command: ${cmd}\nRun \`${BRAND.cli} --help\` to see commands.`);
|
|
1498
|
+
process.exitCode = 1;
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
// ponytail: remaining subcommands land in their build phases; the stub keeps the
|
|
1502
|
+
// command surface honest and testable now.
|
|
1503
|
+
console.log(`${BRAND.cli} ${cmd}: not wired yet — coming in a later build phase.`);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
run(process.argv.slice(2)).catch((err) => {
|
|
1507
|
+
console.error(err.message);
|
|
1508
|
+
process.exitCode = 1;
|
|
1509
|
+
});
|