@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.
Files changed (140) hide show
  1. package/.claude-plugin/marketplace.json +12 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/.codex-plugin/plugin.json +29 -0
  4. package/.mcp.json +8 -0
  5. package/ARCHITECTURE.md +314 -0
  6. package/CHANGELOG.md +467 -0
  7. package/LICENSE +21 -0
  8. package/ONBOARDING.md +180 -0
  9. package/README.md +286 -0
  10. package/bin/claude-init.sh +90 -0
  11. package/bin/claude-taste.sh +33 -0
  12. package/bin/learn-consolidate.sh +51 -0
  13. package/brand.json +15 -0
  14. package/global/CLAUDE.md +31 -0
  15. package/global/crew/frontend-verifier.md +36 -0
  16. package/global/crew/independent-reviewer.md +29 -0
  17. package/global/crew/scout.md +24 -0
  18. package/global/crew/verifier.md +28 -0
  19. package/global/guards/_guardlib.sh +36 -0
  20. package/global/guards/cortex.sh +14 -0
  21. package/global/guards/cost-budget.sh +41 -0
  22. package/global/guards/doom-loop.sh +25 -0
  23. package/global/guards/format-on-edit.sh +32 -0
  24. package/global/guards/lean-guard.sh +20 -0
  25. package/global/guards/protect-paths.sh +45 -0
  26. package/global/guards/recall-load.sh +22 -0
  27. package/global/guards/secret-redact.sh +18 -0
  28. package/global/guards/session-learner.sh +72 -0
  29. package/global/recall/MEMORY.md +7 -0
  30. package/global/rules/self-correction.md +17 -0
  31. package/global/rules/stack-notes.md +24 -0
  32. package/global/rules/tech-currency.md +19 -0
  33. package/global/settings.template.json +183 -0
  34. package/global/statusline.sh +51 -0
  35. package/global/taste/brutalist.json +9 -0
  36. package/global/taste/brutalist.md +19 -0
  37. package/global/taste/corporate.json +9 -0
  38. package/global/taste/corporate.md +19 -0
  39. package/global/taste/editorial.json +9 -0
  40. package/global/taste/editorial.md +19 -0
  41. package/global/taste/minimalist.json +9 -0
  42. package/global/taste/minimalist.md +20 -0
  43. package/global/taste/playful.json +9 -0
  44. package/global/taste/playful.md +19 -0
  45. package/global/tools/atlas/SKILL.md +27 -0
  46. package/global/tools/code-modernization/SKILL.md +275 -0
  47. package/global/tools/code-modernization/references/cost-impact-preflight.md +54 -0
  48. package/global/tools/code-modernization/references/design-patterns-cheatsheet.md +24 -0
  49. package/global/tools/code-modernization/references/research-protocol.md +42 -0
  50. package/global/tools/code-modernization/scripts/preflight_scan.py +190 -0
  51. package/global/tools/cognitive-substrate/SKILL.md +56 -0
  52. package/global/tools/cognitive-substrate/references/capability-map.md +17 -0
  53. package/global/tools/cost-guard/SKILL.md +50 -0
  54. package/global/tools/design-md/SKILL.md +54 -0
  55. package/global/tools/dev-radar/SKILL.md +56 -0
  56. package/global/tools/explore-plan-code/SKILL.md +24 -0
  57. package/global/tools/lean/SKILL.md +41 -0
  58. package/global/tools/recall/SKILL.md +31 -0
  59. package/global/tools/reuse-first/SKILL.md +64 -0
  60. package/global/tools/self-improve/SKILL.md +44 -0
  61. package/global/tools/taste/SKILL.md +26 -0
  62. package/global/tools/tech-selector/SKILL.md +35 -0
  63. package/global/tools/ui-workflow/SKILL.md +44 -0
  64. package/hooks/hooks.json +107 -0
  65. package/install.sh +88 -0
  66. package/package.json +93 -0
  67. package/public/index.html +45 -0
  68. package/scripts/build-pages.mjs +180 -0
  69. package/scripts/bump.mjs +322 -0
  70. package/skills/cognitive-substrate/SKILL.md +56 -0
  71. package/skills/cognitive-substrate/references/capability-map.md +17 -0
  72. package/source/mcp.json +10 -0
  73. package/source/rules.json +106 -0
  74. package/source/substrate.json +41 -0
  75. package/src/adjudicate.js +84 -0
  76. package/src/anchor.js +210 -0
  77. package/src/atlas.js +487 -0
  78. package/src/brain.js +84 -0
  79. package/src/brand.js +25 -0
  80. package/src/cli.js +1509 -0
  81. package/src/context.js +273 -0
  82. package/src/cortex.js +251 -0
  83. package/src/cortex_distill.js +55 -0
  84. package/src/cortex_features.js +81 -0
  85. package/src/cortex_hook.js +197 -0
  86. package/src/cortex_hook_main.js +139 -0
  87. package/src/cortex_mcp.js +352 -0
  88. package/src/cost_report.js +271 -0
  89. package/src/dash.html +396 -0
  90. package/src/dash.js +220 -0
  91. package/src/diagnose.js +0 -0
  92. package/src/doctor.js +315 -0
  93. package/src/embed.js +244 -0
  94. package/src/emit/_shared.js +39 -0
  95. package/src/emit/aider.js +22 -0
  96. package/src/emit/claude.js +44 -0
  97. package/src/emit/codex.js +17 -0
  98. package/src/emit/continue.js +28 -0
  99. package/src/emit/copilot.js +12 -0
  100. package/src/emit/cursor.js +23 -0
  101. package/src/emit/gemini.js +40 -0
  102. package/src/emit/mcp.js +94 -0
  103. package/src/emit/windsurf.js +22 -0
  104. package/src/emit/zed.js +34 -0
  105. package/src/eval.js +47 -0
  106. package/src/extract.js +82 -0
  107. package/src/harden.js +44 -0
  108. package/src/imagine.js +301 -0
  109. package/src/init.js +178 -0
  110. package/src/lean.js +149 -0
  111. package/src/ledger.js +475 -0
  112. package/src/ledger_bridge.js +279 -0
  113. package/src/ledger_read.js +152 -0
  114. package/src/ledger_store.js +360 -0
  115. package/src/lessons.js +185 -0
  116. package/src/lessons_store.js +137 -0
  117. package/src/metrics.js +54 -0
  118. package/src/model_tiers.js +17 -0
  119. package/src/model_tiers.json +39 -0
  120. package/src/predictor.js +143 -0
  121. package/src/preflight.js +410 -0
  122. package/src/providers.js +320 -0
  123. package/src/recall.js +103 -0
  124. package/src/reuse.js +0 -0
  125. package/src/route.js +323 -0
  126. package/src/scope.js +122 -0
  127. package/src/skillgate.js +89 -0
  128. package/src/speclock.js +64 -0
  129. package/src/substrate.js +492 -0
  130. package/src/sync.js +132 -0
  131. package/src/taste.js +55 -0
  132. package/src/uicheck.js +96 -0
  133. package/src/uifingerprint.js +861 -0
  134. package/src/uivisual.js +334 -0
  135. package/src/util.js +71 -0
  136. package/src/verify.js +117 -0
  137. package/templates/project-layer/.claude/settings.json +22 -0
  138. package/templates/project-layer/.claude/skills/hostlelo-deploy/SKILL.md +38 -0
  139. package/templates/project-layer/AGENTS.md +28 -0
  140. package/templates/project-layer/CLAUDE.md +40 -0
@@ -0,0 +1,334 @@
1
+ // forge uicheck visual — the Playwright visual loop (P6 §5,
2
+ // docs/plans/substrate-v2/07-ui-quality-gate.md). The static fingerprint parses
3
+ // SOURCE CSS/classes; it cannot see rendered reality — cascade results, computed
4
+ // values, runtime theming. This module renders the page in a real browser (when one
5
+ // is available) and feeds the COMPUTED styles of every visible element through the
6
+ // SAME fingerprint pipeline as `uicheck design`, so the two gates share one geometry.
7
+ //
8
+ // ADR-0005 discipline: playwright is an OPTIONAL tier. package.json stays
9
+ // dependency-free; the module resolves playwright-core/playwright dynamically (or an
10
+ // explicit FORGE_PLAYWRIGHT module path) and degrades to a clear "skipped" note —
11
+ // absence is never a crash and never a gate failure.
12
+ //
13
+ // Security: a CLI that fetches arbitrary URLs by default is an exfiltration hazard
14
+ // (a hook could be steered into beaconing repo contents via a crafted URL). http(s)
15
+ // targets are therefore refused unless the host is loopback — `--remote` is the
16
+ // explicit human override.
17
+ import { existsSync, mkdirSync } from "node:fs";
18
+ import { createRequire } from "node:module";
19
+ import { isAbsolute, join, resolve } from "node:path";
20
+ import { pathToFileURL } from "node:url";
21
+ import { BRAND } from "./brand.js";
22
+ import {
23
+ activeTasteStyle,
24
+ fingerprintText,
25
+ loadProjectFingerprint,
26
+ loadTasteProfile,
27
+ profileChecks,
28
+ scaleChecks,
29
+ UI_GATE_DEFAULTS,
30
+ uiGate,
31
+ } from "./uifingerprint.js";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Optional-tier resolution — dynamic, never a package.json dependency.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Resolve a playwright runtime: FORGE_PLAYWRIGHT (an explicit module path or bare
39
+ * name — how tests and scratch installs point at an out-of-tree copy) wins and is
40
+ * authoritative when set; otherwise try `playwright-core` then `playwright`. Returns
41
+ * the module (something with `.chromium.launch`) or null — never throws. Browser
42
+ * binaries follow playwright's own PLAYWRIGHT_BROWSERS_PATH handling.
43
+ * @returns {Promise<{chromium:{launch:Function}}|null>}
44
+ */
45
+ export async function resolvePlaywright() {
46
+ const explicit = process.env.FORGE_PLAYWRIGHT;
47
+ const candidates = explicit ? [explicit] : ["playwright-core", "playwright"];
48
+ for (const spec of candidates) {
49
+ try {
50
+ let entry = spec;
51
+ if (spec.includes("/") || spec.includes("\\")) {
52
+ // A path (to the package dir or its entry file): resolve through require's
53
+ // algorithm so a directory's package.json "main" is honored, then import as
54
+ // a file URL (playwright ships CJS; the default export is module.exports).
55
+ entry = pathToFileURL(
56
+ createRequire(import.meta.url).resolve(isAbsolute(spec) ? spec : resolve(spec)),
57
+ ).href;
58
+ }
59
+ const mod = await import(entry);
60
+ const pw = mod?.default?.chromium ? mod.default : mod;
61
+ if (typeof pw?.chromium?.launch === "function") return pw;
62
+ } catch {}
63
+ }
64
+ return null;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Target resolution — the security guard lives here, BEFORE any browser exists.
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1", "0.0.0.0"]);
72
+ const isLoopbackHost = (host) =>
73
+ LOOPBACK.has(host) ||
74
+ host.endsWith(".localhost") ||
75
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
76
+
77
+ /**
78
+ * Normalize a CLI target to a navigable URL. Local paths become file:// URLs;
79
+ * http(s) URLs are allowed only for loopback hosts unless `remote` is set — the
80
+ * refusal is the default so no hook or agent can quietly point the gate at the
81
+ * network. Never throws.
82
+ * @param {string} target
83
+ * @param {{remote?:boolean, cwd?:string}} [opts]
84
+ * @returns {{ok:true, url:string}|{ok:false, reason:string}}
85
+ */
86
+ export function resolveTarget(target, { remote = false, cwd = process.cwd() } = {}) {
87
+ const t = String(target ?? "").trim();
88
+ if (!t) return { ok: false, reason: "no target given" };
89
+ if (/^https?:\/\//i.test(t)) {
90
+ let u;
91
+ try {
92
+ u = new URL(t);
93
+ } catch {
94
+ return { ok: false, reason: `unparseable URL: ${t}` };
95
+ }
96
+ if (!remote && !isLoopbackHost(u.hostname))
97
+ return {
98
+ ok: false,
99
+ reason: `refusing non-local URL ${u.origin} — fetching arbitrary URLs by default is an exfiltration hazard; pass --remote to override deliberately`,
100
+ };
101
+ return { ok: true, url: u.href };
102
+ }
103
+ if (/^file:\/\//i.test(t)) return { ok: true, url: t };
104
+ if (/^[a-z][a-z0-9+.-]*:/i.test(t))
105
+ return { ok: false, reason: `unsupported URL scheme: ${t} (use a file path or http(s))` };
106
+ const p = isAbsolute(t) ? t : join(cwd, t);
107
+ if (!existsSync(p)) return { ok: false, reason: `no such file: ${t}` };
108
+ return { ok: true, url: pathToFileURL(p).href };
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Computed styles → the shared fingerprint vector.
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * @typedef {{color?:string, backgroundColor?:string, margin?:string, padding?:string,
117
+ * gap?:string, fontFamily?:string, borderRadius?:string, boxShadow?:string}} ComputedRecord
118
+ */
119
+
120
+ // Runs INSIDE the page (a string so the repo typechecks without DOM lib types):
121
+ // walk visible elements and lift exactly the computed properties the fingerprint
122
+ // measures. Values arrive fully resolved — the cascade, var() indirection, and
123
+ // runtime theming have already happened, which is the whole point of the loop.
124
+ // Two artifact classes must NOT leak into the vector, because they are layout
125
+ // residue rather than authored design decisions:
126
+ // - `margin: auto` — getComputedStyle returns the USED value (a centered 1180px
127
+ // container at 1280 reports "0px 50px"), so margins go through the Typed OM
128
+ // (computedStyleMap), which keeps `auto` symbolic; auto sides are dropped.
129
+ // - elements the page never painted (e.g. <option> inside a closed <select>)
130
+ // still carry UA-stylesheet paddings — zero client rects filters them out.
131
+ const EXTRACT_JS = `(() => {
132
+ const out = [];
133
+ const SIDES = ["top", "right", "bottom", "left"];
134
+ for (const el of document.querySelectorAll("body, body *")) {
135
+ const tag = el.tagName;
136
+ if (tag === "SCRIPT" || tag === "STYLE" || tag === "TEMPLATE" || tag === "NOSCRIPT") continue;
137
+ const cs = getComputedStyle(el);
138
+ if (cs.display === "none" || cs.visibility === "hidden") continue;
139
+ if (el.getClientRects().length === 0) continue; // never painted → UA noise only
140
+ let margin = cs.margin;
141
+ if (el.computedStyleMap) {
142
+ const map = el.computedStyleMap();
143
+ margin = SIDES.map((s) => String(map.get("margin-" + s)))
144
+ .filter((v) => v !== "auto")
145
+ .join(" ");
146
+ }
147
+ out.push({
148
+ color: cs.color,
149
+ backgroundColor: cs.backgroundColor,
150
+ margin,
151
+ padding: cs.padding,
152
+ gap: cs.gap,
153
+ fontFamily: cs.fontFamily,
154
+ borderRadius: cs.borderRadius,
155
+ boxShadow: cs.boxShadow,
156
+ });
157
+ }
158
+ return out;
159
+ })()`;
160
+
161
+ // Fully transparent computed colors ("rgba(0, 0, 0, 0)" — every unset background)
162
+ // carry no design decision and must not vote black into the palette.
163
+ const TRANSPARENT_RE = /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)$/;
164
+
165
+ /**
166
+ * Serialize computed-style records into synthetic CSS text with the exact property
167
+ * spellings fingerprintText's extractors match — the rendered path reuses the SAME
168
+ * parser/vector as the static path instead of duplicating its math. Identical
169
+ * records collapse to one block (dedup is cosmetic: the fingerprint already
170
+ * uniquifies, this just bounds the text).
171
+ * @param {ComputedRecord[]} records
172
+ * @returns {string}
173
+ */
174
+ export function computedStylesToCss(records) {
175
+ const seen = new Set();
176
+ const blocks = [];
177
+ for (const r of records ?? []) {
178
+ const decls = [];
179
+ const push = (prop, v) => {
180
+ const s = String(v ?? "").trim();
181
+ if (!s || s === "none" || s === "auto" || /^normal\b/.test(s) || TRANSPARENT_RE.test(s))
182
+ return;
183
+ decls.push(`${prop}: ${s}`);
184
+ };
185
+ push("color", r.color);
186
+ push("background-color", r.backgroundColor);
187
+ push("margin", r.margin);
188
+ push("padding", r.padding);
189
+ push("gap", r.gap);
190
+ push("font-family", r.fontFamily);
191
+ push("border-radius", r.borderRadius);
192
+ push("box-shadow", r.boxShadow);
193
+ if (!decls.length) continue;
194
+ const block = `x { ${decls.join("; ")}; }`;
195
+ if (!seen.has(block)) {
196
+ seen.add(block);
197
+ blocks.push(block);
198
+ }
199
+ }
200
+ return blocks.join("\n");
201
+ }
202
+
203
+ /** file targets → basename sans extension; URLs → host+path; always [a-z0-9-]. */
204
+ function targetSlug(url) {
205
+ const u = new URL(url);
206
+ const base =
207
+ u.protocol === "file:"
208
+ ? (u.pathname.split("/").pop() || "page").replace(/\.[^.]+$/, "")
209
+ : `${u.hostname}${u.pathname}`;
210
+ return (
211
+ base
212
+ .toLowerCase()
213
+ .replace(/[^a-z0-9]+/g, "-")
214
+ .replace(/^-+|-+$/g, "") || "page"
215
+ );
216
+ }
217
+
218
+ /** The two spec viewports: desktop + phone (§5: "2 viewports"). */
219
+ export const DEFAULT_VIEWPORTS = [
220
+ [1280, 800],
221
+ [390, 844],
222
+ ];
223
+
224
+ /**
225
+ * Render `target` headless at each viewport, fingerprint the computed styles of all
226
+ * visible elements (one vector across viewports — a design system is one system),
227
+ * and save a screenshot per viewport under `.forge/ui/`.
228
+ * @param {string} target file path, file:// URL, or (loopback) http(s) URL
229
+ * @param {{viewports?:number[][], timeoutMs?:number, remote?:boolean, root?:string,
230
+ * pw?:{chromium:{launch:Function}}|null}} [opts]
231
+ * @returns {Promise<{ok:true, url:string, fingerprint:import("./uifingerprint.js").Fingerprint,
232
+ * screenshots:string[], elements:number}|{ok:false, skipped?:boolean, reason:string}>}
233
+ */
234
+ export async function renderedFingerprint(target, opts = {}) {
235
+ const {
236
+ viewports = DEFAULT_VIEWPORTS,
237
+ timeoutMs = 15000,
238
+ remote = false,
239
+ root = process.cwd(),
240
+ pw = null,
241
+ } = opts;
242
+ const t = resolveTarget(target, { remote, cwd: root });
243
+ if (!t.ok) return { ok: false, reason: "reason" in t ? t.reason : "bad target" };
244
+ const playwright = pw ?? (await resolvePlaywright());
245
+ if (!playwright)
246
+ return {
247
+ ok: false,
248
+ skipped: true,
249
+ reason: "playwright is not installed (optional tier, ADR-0005)",
250
+ };
251
+ let browser;
252
+ try {
253
+ browser = await playwright.chromium.launch({ headless: true });
254
+ } catch (err) {
255
+ // Installed module but no usable browser binary — same graceful-absence class.
256
+ return { ok: false, skipped: true, reason: `browser launch failed: ${err.message}` };
257
+ }
258
+ try {
259
+ const outDir = join(root, ".forge", "ui");
260
+ mkdirSync(outDir, { recursive: true });
261
+ const slug = targetSlug(t.url);
262
+ /** @type {ComputedRecord[]} */
263
+ const records = [];
264
+ const screenshots = [];
265
+ for (const [width, height] of viewports) {
266
+ const page = await browser.newPage({ viewport: { width, height } });
267
+ await page.goto(t.url, { timeout: timeoutMs, waitUntil: "load" });
268
+ records.push(...(await page.evaluate(EXTRACT_JS)));
269
+ const shot = join(outDir, `${slug}-${width}x${height}.png`);
270
+ await page.screenshot({ path: shot });
271
+ screenshots.push(shot);
272
+ await page.close();
273
+ }
274
+ const fingerprint = fingerprintText(computedStylesToCss(records));
275
+ return { ok: true, url: t.url, fingerprint, screenshots, elements: records.length };
276
+ } catch (err) {
277
+ return { ok: false, reason: `render failed: ${err.message}` };
278
+ } finally {
279
+ await browser.close().catch(() => {});
280
+ }
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // The gate — rendered fingerprint through the SAME pipeline as `uicheck design`.
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Render, fingerprint, and gate: uiGate (slop + conformance vs the ledger's project
289
+ * fingerprint) + scaleChecks + taste-profile checks — identical semantics to
290
+ * `uicheck design`, applied to what the browser actually painted.
291
+ * @param {string} target
292
+ * @param {{taste?:string|null, remote?:boolean, root?:string, viewports?:number[][],
293
+ * timeoutMs?:number, pw?:{chromium:{launch:Function}}|null}} [opts]
294
+ * `taste` is the EXPLICIT profile name (unknown → error, like `design --taste`);
295
+ * when omitted, a `forge taste`-managed DESIGN.md style is picked up automatically.
296
+ * @returns {Promise<{ok:false, skipped?:boolean, reason:string}|{ok:true, fail:boolean,
297
+ * pass:boolean, slop:number, conform:number|null, violations:object[], checks:object[],
298
+ * fingerprint:object, screenshots:string[], elements:number, url:string,
299
+ * hasProjectFingerprint:boolean, taste:string|null, tauSlop:number, tauConform:number}>}
300
+ */
301
+ export async function visualGate(target, opts = {}) {
302
+ const { taste = null, root = process.cwd() } = opts;
303
+ const tasteName = taste ?? activeTasteStyle(root);
304
+ const profile = tasteName ? loadTasteProfile(tasteName) : null;
305
+ if (taste && !profile)
306
+ return {
307
+ ok: false,
308
+ reason: `unknown taste profile "${taste}" — run \`${BRAND.cli} taste\` to list styles`,
309
+ };
310
+ const r = await renderedFingerprint(target, { ...opts, root });
311
+ if (!r.ok) return /** @type {{ok:false, skipped?:boolean, reason:string}} */ (r);
312
+ const projectFp = loadProjectFingerprint(root);
313
+ const tauSlop = profile?.gate?.tau_slop ?? UI_GATE_DEFAULTS.tauSlop;
314
+ const tauConform = profile?.gate?.tau_conform ?? UI_GATE_DEFAULTS.tauConform;
315
+ const gate = uiGate(r.fingerprint, { projectFp, tauSlop, tauConform });
316
+ const checks = [
317
+ ...scaleChecks(r.fingerprint),
318
+ ...(profile ? profileChecks(r.fingerprint, profile) : []),
319
+ ];
320
+ return {
321
+ ok: true,
322
+ fail: !gate.pass || checks.some((c) => !c.pass),
323
+ ...gate,
324
+ checks,
325
+ fingerprint: r.fingerprint,
326
+ screenshots: r.screenshots,
327
+ elements: r.elements,
328
+ url: r.url,
329
+ hasProjectFingerprint: !!projectFp,
330
+ taste: profile ? tasteName : null,
331
+ tauSlop,
332
+ tauConform,
333
+ };
334
+ }
package/src/util.js ADDED
@@ -0,0 +1,71 @@
1
+ // forge util — shared micro-utilities. Extracted from duplicated copies across
2
+ // cortex.js, recall.js, cortex_hook.js, doctor.js, harden.js, route.js, adjudicate.js,
3
+ // scope.js, atlas.js, preflight.js, and cortex_hook_main.js.
4
+
5
+ import { execFileSync } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+
8
+ export const slug = (s) =>
9
+ String(s)
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, "-")
12
+ .replace(/(^-|-$)/g, "");
13
+
14
+ export const clamp01 = (x) => Math.max(0, Math.min(1, x));
15
+
16
+ export const MS_PER_DAY = 86400000;
17
+ export const epochDay = () => Math.floor(Date.now() / MS_PER_DAY);
18
+
19
+ export function hasBin(bin) {
20
+ try {
21
+ execFileSync(bin, ["--version"], { stdio: "ignore" });
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ export function contentHash(text) {
29
+ return createHash("sha256").update(text).digest("hex");
30
+ }
31
+
32
+ let cachedAuthor;
33
+ /** The identity stamped on ledger provenance/evidence — FORGE_AUTHOR env override,
34
+ * else the git identity, else "" (attribution is best-effort, never a hard fail).
35
+ * Cached per process: hooks call this per event and `git config` is a subprocess. */
36
+ export function gitAuthor() {
37
+ if (process.env.FORGE_AUTHOR !== undefined) return process.env.FORGE_AUTHOR;
38
+ if (cachedAuthor !== undefined) return cachedAuthor;
39
+ try {
40
+ const get = (k) =>
41
+ execFileSync("git", ["config", "--get", k], { stdio: ["ignore", "pipe", "ignore"] })
42
+ .toString()
43
+ .trim();
44
+ const name = get("user.name");
45
+ const email = get("user.email");
46
+ cachedAuthor = email ? `${name} <${email}>` : name;
47
+ } catch {
48
+ cachedAuthor = "";
49
+ }
50
+ return cachedAuthor;
51
+ }
52
+
53
+ export const IGNORE_DIRS = new Set([
54
+ "node_modules",
55
+ ".git",
56
+ "dist",
57
+ "build",
58
+ ".next",
59
+ "out",
60
+ "target",
61
+ "__pycache__",
62
+ ".forge",
63
+ "coverage",
64
+ ".venv",
65
+ "vendor",
66
+ ]);
67
+
68
+ export const SRC_EXT = /\.(js|jsx|ts|tsx|mjs|cjs|py)$/;
69
+
70
+ export const CODE_EXT =
71
+ /\.(js|ts|jsx|tsx|mjs|cjs|py|go|rs|java|rb|php|c|cc|cpp|h|hpp|cs|json|ya?ml|toml|md|css|scss|html|vue|svelte)$/i;
package/src/verify.js ADDED
@@ -0,0 +1,117 @@
1
+ // forge verify — the independent verification layer. Deterministic-first and
2
+ // cross-tool: it trusts the project's OWN tests (never a benchmark number) and
3
+ // reuses `atlas` to flag calls to symbols that exist nowhere in the codebase
4
+ // (a cheap, zero-LLM hallucination signal). It emits a provenance stamp so a
5
+ // reviewer reads WHAT was checked, not the authoring transcript.
6
+ import { execFileSync } from "node:child_process";
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { build as buildAtlas, has, isStale, load as loadAtlas } from "./atlas.js";
10
+
11
+ // Shared call-site extractor — one source of truth with atlas.js (they used to duplicate this).
12
+ export { extractCalledSymbols } from "./extract.js";
13
+
14
+ import { extractCalledSymbols } from "./extract.js";
15
+
16
+ /** Pure: which called symbols are defined nowhere in the atlas (possible hallucinations). */
17
+ export function findUnknownSymbols(atlas, symbols) {
18
+ return symbols.filter((s) => !has(atlas, s));
19
+ }
20
+
21
+ function git(args, cwd) {
22
+ try {
23
+ return execFileSync("git", args, { cwd, encoding: "utf8" });
24
+ } catch (err) {
25
+ if (process.env.FORGE_DEBUG === "1")
26
+ process.stderr.write(`forge verify git: ${err?.message ?? err}\n`);
27
+ return "";
28
+ }
29
+ }
30
+
31
+ // Run the project's own tests (JS or Python). The gate trusts these, not a benchmark. Bounded by
32
+ // a timeout (FORGE_VERIFY_TIMEOUT_MS, default 10 min) so a hanging test can't hang the gate — a
33
+ // timeout is reported as an honest "did not complete", never as a pass.
34
+ function runTests(cwd) {
35
+ const timeout = Number(process.env.FORGE_VERIFY_TIMEOUT_MS) || 600000;
36
+ const run = (cmd, args) =>
37
+ execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: "pipe", timeout });
38
+ try {
39
+ if (existsSync(join(cwd, "package.json"))) {
40
+ run("npm", ["test"]);
41
+ return { ran: true, passed: true, runner: "npm test" };
42
+ }
43
+ if (existsSync(join(cwd, "pyproject.toml")) || existsSync(join(cwd, "pytest.ini"))) {
44
+ run("pytest", ["-q"]);
45
+ return { ran: true, passed: true, runner: "pytest" };
46
+ }
47
+ return { ran: false };
48
+ } catch (e) {
49
+ if (e.code === "ETIMEDOUT" || e.signal === "SIGTERM") {
50
+ return { ran: true, passed: false, timedOut: true, output: `test run exceeded ${timeout}ms` };
51
+ }
52
+ return {
53
+ ran: true,
54
+ passed: false,
55
+ output: String(e.stdout || e.message || "").slice(-600),
56
+ };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * M6 — checkpoint cadence as an optimal-stopping threshold rule (spec §6:
62
+ * docs/plans/substrate-v2/06-faculties-and-mechanisms.md). Insert a checkpoint once
63
+ * the expected loss of continuing-while-wrong exceeds the check's price:
64
+ * pErr·tokensPerStep·costPerToken·n > checkCost, i.e. check every
65
+ * n* = ⌈checkCost / (pErr · tokensPerStep · costPerToken)⌉ meaningful steps. No
66
+ * magic constants: pErr is measured per tier from ledger outcome history, the costs
67
+ * are priced — riskier/cheaper tiers get smaller n* automatically. Clamped to
68
+ * [1, 50]: even a near-free check shouldn't fire more than every step, and even a
69
+ * near-riskless run must still checkpoint eventually. Pure.
70
+ * @param {{pErr: number, tokensPerStep: number, costPerToken?: number, checkCost: number}} f
71
+ * pErr = per-step error hazard; tokensPerStep = tokens put at risk per step;
72
+ * checkCost priced in the same token-cost unit.
73
+ * @returns {number} integer steps between checkpoints, in [1, 50]
74
+ */
75
+ export function checkpointCadence({ pErr, tokensPerStep, costPerToken = 1, checkCost }) {
76
+ const n = Math.ceil(checkCost / (pErr * tokensPerStep * costPerToken));
77
+ // Degenerate inputs (NaN from bad measurements) fail SAFE: check every step.
78
+ if (Number.isNaN(n)) return 1;
79
+ return Math.min(50, Math.max(1, n)); // zero risk → Infinity → the 50-step ceiling
80
+ }
81
+
82
+ export function verify({ targetRoot = process.cwd(), base = "HEAD" } = {}) {
83
+ const diff =
84
+ git(["diff", "--unified=0", base], targetRoot) ||
85
+ git(["diff", "--unified=0", "--cached"], targetRoot);
86
+ const added = diff
87
+ .split("\n")
88
+ .filter((l) => l.startsWith("+") && !l.startsWith("+++"))
89
+ .map((l) => l.slice(1))
90
+ .join("\n");
91
+ const changedFiles = git(["diff", "--name-only", base], targetRoot).split("\n").filter(Boolean);
92
+
93
+ // Verify runs AFTER edits — a cached, stale atlas would miss newly-added-but-undefined symbols
94
+ // (false negatives) or flag just-defined ones (false positives). Rebuild when stale; the
95
+ // incremental build only re-parses the files that changed, so this stays cheap.
96
+ const cached = loadAtlas(targetRoot);
97
+ const atlas = cached && !isStale(targetRoot, cached) ? cached : buildAtlas({ root: targetRoot });
98
+ const symbols = extractCalledSymbols(added);
99
+ // When the graph was capped (huge repo, files dropped), "defined nowhere" is unreliable — a
100
+ // symbol may live in a dropped file — so don't assert hallucinations.
101
+ const unknown = atlas.capped ? [] : findUnknownSymbols(atlas, symbols);
102
+ const tests = runTests(targetRoot);
103
+
104
+ const provenance = {
105
+ base,
106
+ changedFiles,
107
+ tests,
108
+ symbolsChecked: symbols.length,
109
+ unknownSymbols: unknown,
110
+ };
111
+ mkdirSync(join(targetRoot, ".forge"), { recursive: true });
112
+ writeFileSync(join(targetRoot, ".forge", "provenance.json"), JSON.stringify(provenance, null, 2));
113
+
114
+ // Hard gate = the project's own tests. Unknown symbols are advisory (heuristic).
115
+ const ok = tests.ran ? tests.passed === true : true;
116
+ return { ok, provenance, unknown, tests, changedFiles };
117
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-settings.json",
3
+ "permissions": {
4
+ "allow": [
5
+ "Bash(npm run dev)",
6
+ "Bash(npm run migrate:status)",
7
+ "Bash(psql:* -c \\\"SELECT*)"
8
+ ],
9
+ "ask": [
10
+ "Bash(npm run migrate:*)",
11
+ "Bash(psql:*)",
12
+ "Bash(docker compose:*)",
13
+ "Bash(ssh:*)"
14
+ ],
15
+ "deny": [
16
+ "Read(./widget/keys/**)",
17
+ "Bash(psql:* DROP *)",
18
+ "Bash(psql:* TRUNCATE *)",
19
+ "Bash(psql:* DELETE FROM *)"
20
+ ]
21
+ }
22
+ }
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: hostlelo-deploy
3
+ description: Safe, staged deploy of Hostlelo services to the VPS. Use when asked to deploy, ship, or release a Hostlelo change (kernel, widget, or Telegram control plane).
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Hostlelo deploy
8
+
9
+ Staged deploy with a check at each gate. Never skip verification. Never deploy
10
+ with a failing build or uncommitted secrets.
11
+
12
+ ## Preflight
13
+ 1. `git status` — clean tree, no `.env`/keys staged.
14
+ 2. Run tests + typecheck + build locally; abort on any failure.
15
+ 3. Confirm target: kernel box vs Hermes vs widget/CDN. Confirm branch.
16
+ 4. If DB schema changed: review the migration, confirm it's reversible, and get
17
+ explicit user OK before running it against prod.
18
+
19
+ ## Deploy
20
+ 5. Push to the deploy branch / trigger the pipeline (use `gh`/CLI, not manual
21
+ clicks). Prefer the project's existing deploy script over ad-hoc commands.
22
+ 6. Apply migrations in order; capture output.
23
+
24
+ ## Verify (required)
25
+ 7. Health check the service (HTTP 200 / expected response) via the public URL
26
+ behind Cloudflare.
27
+ 8. Smoke test the changed surface:
28
+ - Widget: load `widget.js`, confirm a grounded price answer, confirm no
29
+ hallucinated numbers.
30
+ - Telegram: one command round-trips.
31
+ 9. Report: what deployed, migration result, health/smoke evidence, and the
32
+ rollback command if it needs reverting.
33
+
34
+ ## Rollback
35
+ State the exact revert (previous release/tag or `git revert`) before finishing so
36
+ it's one step away if something regresses.
37
+
38
+ <!-- Replace bracketed bits with real host targets/scripts; keep secrets out. -->
@@ -0,0 +1,28 @@
1
+ # AGENTS.md — <project>
2
+
3
+ Cross-tool rules (read by Codex, Cursor, Copilot, Gemini, Aider, Zed, and by
4
+ Claude Code when CLAUDE.md points here). Keep tool-agnostic and thin.
5
+
6
+ ## Stack
7
+ <!-- e.g. Next.js 16 (App Router) · TypeScript · Node · Postgres+pgvector · OpenRouter -->
8
+
9
+ ## Commands
10
+ - Install: `npm ci`
11
+ - Dev: `npm run dev`
12
+ - Test / typecheck / lint: `npm test` · `npm run typecheck` · `npm run lint`
13
+ - Build: `npm run build`
14
+ - DB migrate: `<command>`
15
+
16
+ ## Rules
17
+ - Reuse existing components/utils before writing new; smallest change that works.
18
+ - Verify the current best library from live sources before adding a dependency;
19
+ prefer what the project already uses.
20
+ - Wrap fallible async in try/catch; guard array/object access; explicit errors.
21
+ - UI: build + check mobile AND desktop; clear loading/empty/error states; follow
22
+ `DESIGN.md` if present.
23
+ - Verify before "done": run tests/build/lint and show output; screenshot UI.
24
+ - Never commit secrets or `.env*`; never run destructive DB/FS commands without
25
+ confirmation.
26
+
27
+ ## References
28
+ - Visual rules: @DESIGN.md (if present)
@@ -0,0 +1,40 @@
1
+ # Hostlelo — project instructions
2
+
3
+ Drop this into a Hostlelo repo root. Adds project context on top of the global
4
+ `~/.claude/CLAUDE.md`. Keep it lean; prune anything Claude gets right without it.
5
+
6
+ Shared cross-tool rules (stack, commands, engineering rules) live in **@AGENTS.md**
7
+ so Cursor/Codex/Gemini read the same source. This file holds only Claude-specific
8
+ + Hostlelo-specific bits. Keep CLAUDE.md thin; put general rules in AGENTS.md.
9
+
10
+ ## Stack
11
+ - Runtime: Node.js. DB: self-hosted Postgres (18+) with **pgvector** on the VPS.
12
+ - LLM: routed through **OpenRouter** — never hardcode a single provider; use the
13
+ configured routing/env, and pick cheap models for classify/embed, stronger
14
+ models only for hard generation.
15
+ - Delivery surfaces: customer AI **chat widget** (`widget.js`) and **Telegram**
16
+ owner control plane. Infra: Linode VPS + Cloudflare Tunnel/Access.
17
+
18
+ ## Non-negotiables
19
+ - **Grounding-first.** Customer-facing answers (esp. prices/plans) must come from
20
+ the verified KB, never guessed. If unverified, say so or defer — do not invent
21
+ numbers, features, or availability.
22
+ - **Secrets** live in env / the secrets store, never in code, logs, or commits.
23
+ `.env*` and key files are hook-protected — don't work around it.
24
+ - Neutral, worldwide tone in customer replies (no forced regional greeting).
25
+
26
+ ## Workflow
27
+ - Prefer readymade, well-maintained OSS libraries/SDKs over hand-rolled code —
28
+ it's faster and cheaper to run. Justify any new dependency briefly.
29
+ - Before DB or deploy changes: read the migration/deploy path first; never run
30
+ destructive SQL (`DROP`/`TRUNCATE`) or touch prod without explicit confirmation.
31
+ - Verify every change: run the app's tests/typecheck/build and show output.
32
+ - Deploy via the `hostlelo-deploy` skill (safe, staged, with rollback notes).
33
+
34
+ ## Commands
35
+ <!-- Fill these in from package.json so Claude doesn't guess: -->
36
+ - Install: `npm ci`
37
+ - Dev: `npm run dev`
38
+ - Test / typecheck / lint: `npm test` · `npm run typecheck` · `npm run lint`
39
+ - Build: `npm run build`
40
+ - DB migrate: `<your migrate command>`