@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/uivisual.js
ADDED
|
@@ -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>`
|