@tendrilapp/cli 0.1.18 → 0.1.20
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/dist/SKILL.md +45 -0
- package/dist/tendril-mcp.js +10 -2
- package/dist/tendril.js +476 -109
- package/package.json +1 -1
package/dist/SKILL.md
CHANGED
|
@@ -20,6 +20,51 @@ Non-negotiables (the CLI enforces these; do not fight them):
|
|
|
20
20
|
- Sub-bar results are honest, not failures to hide: exit 5 ships the
|
|
21
21
|
bundle with real scores. Report them as they are.
|
|
22
22
|
|
|
23
|
+
Permissions, FIRST THING in every pipeline run (the trigger is a FILE
|
|
24
|
+
CHECK, not prompt-watching — you cannot observe permission prompts, so
|
|
25
|
+
never condition on them; a measured session full of prompts had a
|
|
26
|
+
prompt-conditioned offer never fire): check whether ANY Claude
|
|
27
|
+
settings file the host merges — the project's
|
|
28
|
+
`.claude/settings.local.json` or `.claude/settings.json`, or the
|
|
29
|
+
user's `~/.claude/settings.json` — contains tendril MCP entries
|
|
30
|
+
(plugin installs use `mcp__plugin_tendril_tendril__*`; direct
|
|
31
|
+
`claude mcp add` installs use `mcp__<server-name>__*`). If none does,
|
|
32
|
+
BEFORE the first recording call, tell the user one approval can
|
|
33
|
+
replace every pipeline prompt and offer `tendril_permissions` with
|
|
34
|
+
`write: true` — it merges the per-tool allowlist into the project's
|
|
35
|
+
settings.local.json (idempotent, touches nothing else; note it writes
|
|
36
|
+
PLUGIN-prefixed names — a direct-install user should run
|
|
37
|
+
`tendril permissions --claude` and adapt the prefix instead) and the
|
|
38
|
+
user must reload the session to apply; say that out loud. Offer ONCE
|
|
39
|
+
per session; never run it unoffered (it edits the user's settings),
|
|
40
|
+
and if declined, proceed without mentioning it again.
|
|
41
|
+
|
|
42
|
+
Long generator/score waits: silence is not a health signal. The scorer
|
|
43
|
+
appends a timestamped line to `<candidateDir>/score-history.jsonl`
|
|
44
|
+
after EVERY completed round — the file APPEARS ONLY AFTER THE FIRST
|
|
45
|
+
score, so its absence early in a run is normal, not frozen. Poll it
|
|
46
|
+
(and the candidate dir's mtimes) about every 30 seconds and relay
|
|
47
|
+
progress to the user ("round 3 scored: 15/21 at floor 0.885"). A
|
|
48
|
+
healthy run can take 30+ minutes; never kill on elapsed time alone —
|
|
49
|
+
count staleness from the LAST CHANGE to either signal, not from run
|
|
50
|
+
start, and when past ~20 minutes of true staleness, ask the user
|
|
51
|
+
before killing.
|
|
52
|
+
|
|
53
|
+
Batch runs (several components in one session):
|
|
54
|
+
- Cap concurrent recorders at FOUR. All recorders share one Figma
|
|
55
|
+
desktop MCP server; a measured 8-wide batch hit its rate limit
|
|
56
|
+
(per-piece resume recovered, but the stall is avoidable).
|
|
57
|
+
- Batch the questions: plan ALL sets first, then put every
|
|
58
|
+
defaults-to-confirm and the one model question to the user together
|
|
59
|
+
— never one dialog per component.
|
|
60
|
+
- Create candidate directories with a bare `mkdir -p <dir>` — no
|
|
61
|
+
`&&`-compounds. Compound variants each need their own permission
|
|
62
|
+
approval; the bare form is one grant for the whole batch.
|
|
63
|
+
- Trust only tool output for completion state: check
|
|
64
|
+
`tendril_record_status` against disk, never a subagent's prose (a
|
|
65
|
+
measured batch had a recorder report success with zero reps
|
|
66
|
+
recorded).
|
|
67
|
+
|
|
23
68
|
## Recording a new component (needs Figma MCP)
|
|
24
69
|
|
|
25
70
|
1. Fetch the component set's frame metadata via Figma MCP
|
package/dist/tendril-mcp.js
CHANGED
|
@@ -64,6 +64,14 @@ var TOOLS = [
|
|
|
64
64
|
return argvOut;
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
|
+
{
|
|
68
|
+
name: "tendril_permissions",
|
|
69
|
+
description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing. OFFER THIS AT PIPELINE START whenever NO merged Claude settings file (project .claude/settings.local.json or .claude/settings.json, or user ~/.claude/settings.json) contains tendril MCP entries \u2014 plugin installs use mcp__plugin_tendril_tendril__*, direct claude-mcp-add installs use mcp__<server>__* (for those, write:true installs PLUGIN-prefixed names that will not match: list without writing and adapt the prefix instead). A FILE check, never prompt-watching \u2014 agents cannot observe permission prompts. ONE approval here replaces a prompt per pipeline call. Never run it unoffered; the user must reload the session for new settings to apply \u2014 say so.",
|
|
70
|
+
schema: z.object({
|
|
71
|
+
write: z.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries")
|
|
72
|
+
}),
|
|
73
|
+
argv: (i) => ["permissions", "--claude", ...i["write"] === true ? ["--write"] : []]
|
|
74
|
+
},
|
|
67
75
|
{
|
|
68
76
|
name: "tendril_doctor",
|
|
69
77
|
annotations: { readOnlyHint: true },
|
|
@@ -259,7 +267,7 @@ var TOOLS = [
|
|
|
259
267
|
},
|
|
260
268
|
{
|
|
261
269
|
name: "tendril_verify",
|
|
262
|
-
description: "Verify/check that a component matches its Figma design \u2014 use when the user asks whether an implementation is faithful to the design, or to re-certify an existing Tendril bundle. Recomputes full verification (per-config status, behaviors, composition, evidence artifacts). Free, account-less, network-less \u2014 the trust anchor. Exit 5 means
|
|
270
|
+
description: "Verify/check that a component matches its Figma design \u2014 use when the user asks whether an implementation is faithful to the design, or to re-certify an existing Tendril bundle. Recomputes full verification (per-config status, behaviors, composition, evidence artifacts). Free, account-less, network-less \u2014 the trust anchor. Exit 5 means below the target bar with an honest report \u2014 sub-bar scores, or at bar cert a config demoted by absent-ink clusters.",
|
|
263
271
|
schema: z.object({
|
|
264
272
|
bundleDir: str("bundle directory to verify"),
|
|
265
273
|
bar: optStr("pass (default) or cert"),
|
|
@@ -361,7 +369,7 @@ function toolResult(result) {
|
|
|
361
369
|
const body = (result.stdout.trim() !== "" ? result.stdout : result.stderr) + stale;
|
|
362
370
|
if (result.exitCode === 5) {
|
|
363
371
|
return { content: [{ type: "text", text: `${body}
|
|
364
|
-
{"exitCode":5,"note":"
|
|
372
|
+
{"exitCode":5,"note":"HONEST below-target result \u2014 the report and bundle are valid; this is not a tool failure. Either sub-bar scores, or (at --bar cert) a config demoted by absent-ink clusters despite at-bar numbers \u2014 the report names which."}` }] };
|
|
365
373
|
}
|
|
366
374
|
const text = result.ok ? body : `${body}
|
|
367
375
|
{"exitCode":${result.exitCode},"note":"CLI exit-code contract: 3 input, 4 confirmation required, 6 fonts unproven, 7 recording incomplete"}`;
|
package/dist/tendril.js
CHANGED
|
@@ -762,11 +762,13 @@ function resolveAxisDefaultWithRule(domain, recorded, override) {
|
|
|
762
762
|
if (off !== void 0) return { value: off, rule: "boolean-off" };
|
|
763
763
|
}
|
|
764
764
|
const avoid = (v) => {
|
|
765
|
-
const
|
|
766
|
-
return INTERACTION_STATES.has(
|
|
765
|
+
const tokens = kebab(v).split("-");
|
|
766
|
+
return tokens.some((t) => INTERACTION_STATES.has(t) || BOOLEAN_STATES.has(t) || ENGAGED_STATES.has(t));
|
|
767
767
|
};
|
|
768
768
|
const anyAvoided = recorded.some((v) => avoid(v)) && recorded.some((v) => !avoid(v));
|
|
769
|
-
|
|
769
|
+
let candidates = recorded.some((v) => !avoid(v)) ? recorded.filter((v) => !avoid(v)) : recorded;
|
|
770
|
+
const dotted = candidates.filter((v) => v.split(/\s*-\s*|\s+/).some((part) => part.startsWith(".")));
|
|
771
|
+
if (dotted.length > 0) candidates = dotted;
|
|
770
772
|
const counts = /* @__PURE__ */ new Map();
|
|
771
773
|
for (const v of candidates) counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
772
774
|
let best;
|
|
@@ -789,7 +791,7 @@ var init_axis_defaults = __esm({
|
|
|
789
791
|
"use strict";
|
|
790
792
|
INTERACTION_STATES = /* @__PURE__ */ new Set(["hover", "focus", "focus-visible", "active", "pressed"]);
|
|
791
793
|
BOOLEAN_STATES = /* @__PURE__ */ new Set(["disabled", "loading"]);
|
|
792
|
-
ENGAGED_STATES = /* @__PURE__ */ new Set(["selected", "checked", "indeterminate", "open", "expanded"]);
|
|
794
|
+
ENGAGED_STATES = /* @__PURE__ */ new Set(["selected", "checked", "indeterminate", "mixed", "open", "expanded"]);
|
|
793
795
|
INTERACTION_EVIDENCE_VALUES = /* @__PURE__ */ new Set([
|
|
794
796
|
...INTERACTION_STATES,
|
|
795
797
|
...BOOLEAN_STATES,
|
|
@@ -1214,8 +1216,8 @@ var init_src = __esm({
|
|
|
1214
1216
|
function variableNameToPath(name) {
|
|
1215
1217
|
return name.split("/").map(canonicalCssIdentPart).filter((part) => part.length > 0);
|
|
1216
1218
|
}
|
|
1217
|
-
function tokenPathToCssVar(
|
|
1218
|
-
return `--${
|
|
1219
|
+
function tokenPathToCssVar(path38) {
|
|
1220
|
+
return `--${path38.join("-")}`;
|
|
1219
1221
|
}
|
|
1220
1222
|
function toDtcgToken(variable, defaultMode) {
|
|
1221
1223
|
const modes = Object.keys(variable.valuesByMode);
|
|
@@ -1259,11 +1261,11 @@ function toDtcgToken(variable, defaultMode) {
|
|
|
1259
1261
|
}
|
|
1260
1262
|
function mapVariablesToDtcg(variables, defaultMode = "light") {
|
|
1261
1263
|
const entries = variables.map((variable) => {
|
|
1262
|
-
const
|
|
1263
|
-
if (
|
|
1264
|
+
const path38 = variableNameToPath(variable.name);
|
|
1265
|
+
if (path38.length === 0) {
|
|
1264
1266
|
throw new DtcgMappingError("empty-name", `Figma variable ${variable.id} has an empty name`);
|
|
1265
1267
|
}
|
|
1266
|
-
return { variable, path:
|
|
1268
|
+
return { variable, path: path38 };
|
|
1267
1269
|
});
|
|
1268
1270
|
const groupPrefixes = /* @__PURE__ */ new Set();
|
|
1269
1271
|
for (const e of entries) {
|
|
@@ -1284,21 +1286,21 @@ function mapVariablesToDtcg(variables, defaultMode = "light") {
|
|
|
1284
1286
|
}
|
|
1285
1287
|
const tokens = {};
|
|
1286
1288
|
const flat = [];
|
|
1287
|
-
for (const { variable, path:
|
|
1289
|
+
for (const { variable, path: path38 } of entries) {
|
|
1288
1290
|
const token = toDtcgToken(variable, defaultMode);
|
|
1289
1291
|
let group = tokens;
|
|
1290
|
-
for (const segment of
|
|
1292
|
+
for (const segment of path38.slice(0, -1)) {
|
|
1291
1293
|
const existing = group[segment];
|
|
1292
1294
|
group = existing ?? (group[segment] = {});
|
|
1293
1295
|
}
|
|
1294
|
-
const leaf =
|
|
1296
|
+
const leaf = path38[path38.length - 1];
|
|
1295
1297
|
if (group[leaf] !== void 0) {
|
|
1296
|
-
throw new DtcgMappingError("duplicate-path", `Duplicate token path "${
|
|
1298
|
+
throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path38.join(".")}" (variable ${variable.id})`);
|
|
1297
1299
|
}
|
|
1298
1300
|
group[leaf] = token;
|
|
1299
1301
|
flat.push({
|
|
1300
|
-
path:
|
|
1301
|
-
cssVar: tokenPathToCssVar(
|
|
1302
|
+
path: path38.join("."),
|
|
1303
|
+
cssVar: tokenPathToCssVar(path38),
|
|
1302
1304
|
type: token.$type,
|
|
1303
1305
|
value: token.$value
|
|
1304
1306
|
});
|
|
@@ -1487,9 +1489,9 @@ function boundId(value) {
|
|
|
1487
1489
|
return isObject(value) && typeof value["boundVariableId"] === "string" ? value["boundVariableId"] : void 0;
|
|
1488
1490
|
}
|
|
1489
1491
|
function resolveBinding(ctx, id) {
|
|
1490
|
-
const
|
|
1491
|
-
if (
|
|
1492
|
-
return
|
|
1492
|
+
const path38 = ctx.pathById.get(id);
|
|
1493
|
+
if (path38 === void 0) ctx.unresolved.add(id);
|
|
1494
|
+
return path38;
|
|
1493
1495
|
}
|
|
1494
1496
|
function parseVariantProps(name) {
|
|
1495
1497
|
if (!name.includes("=")) return void 0;
|
|
@@ -1524,8 +1526,8 @@ function walk(ctx, raw) {
|
|
|
1524
1526
|
if (!isObject(paint) || paint["visible"] === false) continue;
|
|
1525
1527
|
const id = boundId(paint);
|
|
1526
1528
|
if (id !== void 0) {
|
|
1527
|
-
const
|
|
1528
|
-
if (
|
|
1529
|
+
const path38 = resolveBinding(ctx, id);
|
|
1530
|
+
if (path38 !== void 0) tokens.add(path38);
|
|
1529
1531
|
} else if (typeof paint["color"] === "string") {
|
|
1530
1532
|
ctx.hardcoded.push({ node: name, property, value: paint["color"] });
|
|
1531
1533
|
}
|
|
@@ -1533,8 +1535,8 @@ function walk(ctx, raw) {
|
|
|
1533
1535
|
}
|
|
1534
1536
|
const radiusId = boundId(raw["cornerRadius"]);
|
|
1535
1537
|
if (radiusId !== void 0) {
|
|
1536
|
-
const
|
|
1537
|
-
if (
|
|
1538
|
+
const path38 = resolveBinding(ctx, radiusId);
|
|
1539
|
+
if (path38 !== void 0) tokens.add(path38);
|
|
1538
1540
|
} else if (typeof raw["cornerRadius"] === "number" && raw["cornerRadius"] !== 0) {
|
|
1539
1541
|
ctx.hardcoded.push({ node: name, property: "border-radius", value: `${raw["cornerRadius"]}px` });
|
|
1540
1542
|
}
|
|
@@ -1544,10 +1546,10 @@ function walk(ctx, raw) {
|
|
|
1544
1546
|
layout = { mode: layoutMode === "HORIZONTAL" ? "flex-row" : "flex-column" };
|
|
1545
1547
|
const gapId = boundId(raw["itemSpacing"]);
|
|
1546
1548
|
if (gapId !== void 0) {
|
|
1547
|
-
const
|
|
1548
|
-
if (
|
|
1549
|
-
layout.gap =
|
|
1550
|
-
tokens.add(
|
|
1549
|
+
const path38 = resolveBinding(ctx, gapId);
|
|
1550
|
+
if (path38 !== void 0) {
|
|
1551
|
+
layout.gap = path38;
|
|
1552
|
+
tokens.add(path38);
|
|
1551
1553
|
}
|
|
1552
1554
|
} else if (typeof raw["itemSpacing"] === "number" && raw["itemSpacing"] !== 0) {
|
|
1553
1555
|
ctx.hardcoded.push({ node: name, property: "gap", value: `${raw["itemSpacing"]}px` });
|
|
@@ -1556,10 +1558,10 @@ function walk(ctx, raw) {
|
|
|
1556
1558
|
for (const field of PADDING_FIELDS) {
|
|
1557
1559
|
const id = boundId(raw[field]);
|
|
1558
1560
|
if (id !== void 0) {
|
|
1559
|
-
const
|
|
1560
|
-
if (
|
|
1561
|
-
paddingPaths.push(
|
|
1562
|
-
tokens.add(
|
|
1561
|
+
const path38 = resolveBinding(ctx, id);
|
|
1562
|
+
if (path38 !== void 0) {
|
|
1563
|
+
paddingPaths.push(path38);
|
|
1564
|
+
tokens.add(path38);
|
|
1563
1565
|
}
|
|
1564
1566
|
} else if (typeof raw[field] === "number" && raw[field] !== 0) {
|
|
1565
1567
|
ctx.hardcoded.push({ node: name, property: field, value: `${raw[field]}px` });
|
|
@@ -4378,14 +4380,17 @@ function chooseSeat(cropAt, ref, padW, padH, backdrop, origin, extents) {
|
|
|
4378
4380
|
}
|
|
4379
4381
|
return { comparison, seat, scored: cropAt(origin - seat.x, origin - seat.y) };
|
|
4380
4382
|
}
|
|
4381
|
-
function
|
|
4382
|
-
let root;
|
|
4383
|
+
function metadataRoot(set, rep) {
|
|
4383
4384
|
try {
|
|
4384
4385
|
const text = JSON.parse(readFileSync8(path15.join(set, rep, "get_metadata.json"), "utf8")).content.map((c) => c.text ?? "").join("\n");
|
|
4385
|
-
|
|
4386
|
+
return parseMetadataStructure(text);
|
|
4386
4387
|
} catch {
|
|
4387
4388
|
return void 0;
|
|
4388
4389
|
}
|
|
4390
|
+
}
|
|
4391
|
+
function deepestNodeNameAt(set, rep, px, py) {
|
|
4392
|
+
const root = metadataRoot(set, rep);
|
|
4393
|
+
if (root === void 0) return void 0;
|
|
4389
4394
|
let best;
|
|
4390
4395
|
const walk2 = (node, ox, oy, isRoot) => {
|
|
4391
4396
|
if (node.hidden === true) return;
|
|
@@ -4401,7 +4406,35 @@ function deepestNodeNameAt(set, rep, px, py) {
|
|
|
4401
4406
|
for (const child of node.children) walk2(child, nx, ny, false);
|
|
4402
4407
|
};
|
|
4403
4408
|
walk2(root, 0, 0, true);
|
|
4404
|
-
return best
|
|
4409
|
+
if (best !== void 0) return best.name;
|
|
4410
|
+
if (root.name !== "" && px >= 0 && py >= 0 && px < (root.width ?? 0) && py < (root.height ?? 0)) {
|
|
4411
|
+
return `${root.name} (${root.id}) (edge region, no named child at this point)`;
|
|
4412
|
+
}
|
|
4413
|
+
return void 0;
|
|
4414
|
+
}
|
|
4415
|
+
function smallSemanticNodes(set, rep, maxArea = 1024) {
|
|
4416
|
+
const root = metadataRoot(set, rep);
|
|
4417
|
+
if (root === void 0) return [];
|
|
4418
|
+
const found = [];
|
|
4419
|
+
const walk2 = (node, ox, oy, isRoot) => {
|
|
4420
|
+
if (node.hidden === true) return;
|
|
4421
|
+
const nx = isRoot ? 0 : ox + (node.x ?? 0);
|
|
4422
|
+
const ny = isRoot ? 0 : oy + (node.y ?? 0);
|
|
4423
|
+
const w = node.width ?? 0;
|
|
4424
|
+
const h = node.height ?? 0;
|
|
4425
|
+
if (!isRoot && node.name !== "" && w >= 2 && h >= 2 && w * h <= maxArea) {
|
|
4426
|
+
found.push({ name: node.name, id: node.id, x: nx, y: ny, w, h });
|
|
4427
|
+
}
|
|
4428
|
+
for (const child of node.children) walk2(child, nx, ny, false);
|
|
4429
|
+
};
|
|
4430
|
+
walk2(root, 0, 0, true);
|
|
4431
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4432
|
+
return found.sort((a, b) => a.w * a.h - b.w * b.h).filter((n) => {
|
|
4433
|
+
const key = `${n.x}:${n.y}:${n.w}:${n.h}`;
|
|
4434
|
+
if (seen.has(key)) return false;
|
|
4435
|
+
seen.add(key);
|
|
4436
|
+
return true;
|
|
4437
|
+
});
|
|
4405
4438
|
}
|
|
4406
4439
|
function repMeta(set, rep) {
|
|
4407
4440
|
const text = JSON.parse(readFileSync8(path15.join(set, rep, "get_metadata.json"), "utf8")).content.map((c) => c.text ?? "").join("\n");
|
|
@@ -6810,7 +6843,13 @@ var init_bundle = __esm({
|
|
|
6810
6843
|
// insufficient identity (two builds, one path, incomparable
|
|
6811
6844
|
// numbers); optional so pre-epoch bundles keep validating.
|
|
6812
6845
|
chromeVersion: z10.string().nullable().optional(),
|
|
6813
|
-
fontsManifestSha256: z10.string().nullable()
|
|
6846
|
+
fontsManifestSha256: z10.string().nullable(),
|
|
6847
|
+
// Run 11: a bundle scored under a substitute face carried CSS
|
|
6848
|
+
// compensation (font-variation-settings tuned to the substitute)
|
|
6849
|
+
// with nothing marking it conditional — the provenance now names
|
|
6850
|
+
// the families that were substituted at scoring time. Optional so
|
|
6851
|
+
// pre-0.1.19 bundles keep validating; absent means none.
|
|
6852
|
+
substitutedFamilies: z10.array(z10.string()).optional()
|
|
6814
6853
|
}),
|
|
6815
6854
|
/** Coverage denominator (Q2 honesty): recorded vs full-lattice size
|
|
6816
6855
|
* when the lattice is known (null when the kit exposes no lattice). */
|
|
@@ -7746,6 +7785,9 @@ function buildFeedback(scores, behaviors, bar, mode = "fenced") {
|
|
|
7746
7785
|
const reg = s.region !== void 0 ? ` worst-region=[x ${s.region.x0}-${s.region.x1}, y ${s.region.y0}-${s.region.y1}, density ${s.region.density}]` : "";
|
|
7747
7786
|
return base + err + reg;
|
|
7748
7787
|
});
|
|
7788
|
+
const absentLines = scores.flatMap(
|
|
7789
|
+
(s) => (s.absentInk ?? []).map((c) => `MISSING ${s.rep}: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with NO render ink within reach at [x ${c.x}-${c.x + c.w}, y ${c.y}-${c.y + c.h}]`)
|
|
7790
|
+
);
|
|
7749
7791
|
return `Scores (bar: sim \u2265${bar.sim} AND ink \u2265${bar.ink} per config):
|
|
7750
7792
|
${lines.join("\n")}${behLines.length > 0 ? `
|
|
7751
7793
|
|
|
@@ -7753,9 +7795,12 @@ BEHAVIORAL invariants (machine-verified, GATING \u2014 fix these with real handl
|
|
|
7753
7795
|
${behLines.join("\n")}` : ""}${preludeLines.length > 0 ? `
|
|
7754
7796
|
|
|
7755
7797
|
PRELUDE parity (computed-style checks, GATING \u2014 fix the named CSS property on the named element):
|
|
7756
|
-
${preludeLines.join("\n")}` : ""}
|
|
7798
|
+
${preludeLines.join("\n")}` : ""}${absentLines.length > 0 ? `
|
|
7799
|
+
|
|
7800
|
+
MISSING FEATURES (absent-ink clusters \u2014 recorded marks your render leaves out or paints invisibly; GATING at the cert bar. Fix the named node's ink \u2014 a config carrying one cannot certify):
|
|
7801
|
+
${absentLines.join("\n")}` : ""}
|
|
7757
7802
|
|
|
7758
|
-
Fix the FAIL configs (region coordinates are in the recorded screenshot's frame; ink<1 means recorded foreground pixels your render does not cover). Do not regress PASS configs. ${mode === "files" ? "Update the files in your candidate directory and re-run the score." : "Reply with the complete corrected files in the same FILE format."}`;
|
|
7803
|
+
Fix the FAIL configs (region coordinates are in the recorded screenshot's frame; ink<1 means recorded foreground pixels your render does not cover). Reading the pair: ink=1 with LOW sim means nothing is missing \u2014 shapes or positions are wrong, do not chase missing ink; LOW ink with high sim means recorded marks are absent or painted invisibly. Do not regress PASS configs. ${mode === "files" ? "Update the files in your candidate directory and re-run the score." : "Reply with the complete corrected files in the same FILE format."}`;
|
|
7759
7804
|
}
|
|
7760
7805
|
function archivePriorRun(outDir) {
|
|
7761
7806
|
if (!existsSync20(path26.join(outDir, "run-log.json")) && !existsSync20(path26.join(outDir, "loop-state.json"))) return void 0;
|
|
@@ -7825,14 +7870,14 @@ async function runEngineLoop(opts) {
|
|
|
7825
7870
|
progress(
|
|
7826
7871
|
`iter ${iter}: pass=${obj[0]}/${total} floor=${obj[1].toFixed(3)} mean=${obj[2].toFixed(3)} ${accepted ? "ACCEPT" : "reject"} $${usd.toFixed(3)} (total $${spentUsd.toFixed(3)})${modelMs !== void 0 ? ` model=${Math.round(modelMs / 1e3)}s` : ""} score=${Math.round(scoreMs / 1e3)}s`
|
|
7827
7872
|
);
|
|
7828
|
-
return { allPass: obj[0] === total && total > 0 };
|
|
7873
|
+
return { allPass: obj[0] === total && total > 0 && (opts.absentInkGates !== true || scores.every((s) => (s.absentInk?.length ?? 0) === 0)) };
|
|
7829
7874
|
};
|
|
7830
7875
|
if (opts.seed !== void 0 && !resuming) {
|
|
7831
7876
|
const { allPass } = await scoreCandidate(opts.seed, 0, 0);
|
|
7832
7877
|
if (allPass) stopReason = "all-pass";
|
|
7833
7878
|
}
|
|
7834
7879
|
const lastLog = log[log.length - 1];
|
|
7835
|
-
if (resuming && lastLog !== void 0 && lastLog.parseError === void 0 && lastLog.scores.length > 0 && lastLog.objective[0] === lastLog.scores.length + (lastLog.behaviors?.length ?? 0)) {
|
|
7880
|
+
if (resuming && lastLog !== void 0 && lastLog.parseError === void 0 && lastLog.scores.length > 0 && lastLog.objective[0] === lastLog.scores.length + (lastLog.behaviors?.length ?? 0) && (opts.absentInkGates !== true || lastLog.scores.every((s) => (s.absentInk?.length ?? 0) === 0))) {
|
|
7836
7881
|
stopReason = "all-pass";
|
|
7837
7882
|
}
|
|
7838
7883
|
const state = () => ({
|
|
@@ -8211,7 +8256,7 @@ function dismissEvidence(setDir, repSlugs) {
|
|
|
8211
8256
|
}
|
|
8212
8257
|
return void 0;
|
|
8213
8258
|
}
|
|
8214
|
-
function recordedFontNeeds(setDir) {
|
|
8259
|
+
function recordedFontNeeds(setDir, opts = {}) {
|
|
8215
8260
|
const byFamily = /* @__PURE__ */ new Map();
|
|
8216
8261
|
const unpaired = /* @__PURE__ */ new Set();
|
|
8217
8262
|
const famAdd = (raw, weight) => {
|
|
@@ -8253,10 +8298,27 @@ function recordedFontNeeds(setDir) {
|
|
|
8253
8298
|
if (existsSync21(defs)) fromDefs(envelopeText(defs));
|
|
8254
8299
|
}
|
|
8255
8300
|
return [...byFamily.entries()].map(([family, paired]) => {
|
|
8256
|
-
const weights = /* @__PURE__ */ new Set([...paired, ...unpaired]);
|
|
8257
|
-
return { family, weights: weights.size === 0 ? [400] : [...weights].sort((a, b) => a - b) };
|
|
8301
|
+
const weights = /* @__PURE__ */ new Set([...paired, ...opts.pairedOnly === true ? [] : unpaired]);
|
|
8302
|
+
return { family, weights: weights.size === 0 ? opts.pairedOnly === true ? [] : [400] : [...weights].sort((a, b) => a - b) };
|
|
8258
8303
|
});
|
|
8259
8304
|
}
|
|
8305
|
+
function symbolFontGlyphCount(setDir, reps) {
|
|
8306
|
+
const PUA = /[\uE000-\uF8FF\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/u;
|
|
8307
|
+
const glyphs = /* @__PURE__ */ new Set();
|
|
8308
|
+
for (const rep of reps) {
|
|
8309
|
+
const file = path27.join(setDir, rep, "get_metadata.json");
|
|
8310
|
+
if (!existsSync21(file)) continue;
|
|
8311
|
+
try {
|
|
8312
|
+
const text = JSON.parse(readFileSync18(file, "utf8")).content.map((c) => c.text ?? "").join("\n");
|
|
8313
|
+
for (const m of text.matchAll(/<text\s[^>]*name="([^"]*)"/g)) {
|
|
8314
|
+
const name = decodeXmlEntities(m[1]).replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)));
|
|
8315
|
+
if (PUA.test(name)) glyphs.add(name);
|
|
8316
|
+
}
|
|
8317
|
+
} catch {
|
|
8318
|
+
}
|
|
8319
|
+
}
|
|
8320
|
+
return glyphs.size;
|
|
8321
|
+
}
|
|
8260
8322
|
function recordedFontFamilies(setDir) {
|
|
8261
8323
|
return recordedFontNeeds(setDir).map((n) => n.family);
|
|
8262
8324
|
}
|
|
@@ -8453,6 +8515,12 @@ function authorTaskFromSet(setDir, opts = {}) {
|
|
|
8453
8515
|
return void 0;
|
|
8454
8516
|
}).filter((x) => x !== void 0);
|
|
8455
8517
|
const { behaviors, prelude, disclosures } = authorBehaviors(api, { ...sentinels.length > 0 ? { sentinels } : {} });
|
|
8518
|
+
const puaGlyphs = symbolFontGlyphCount(setDir, manifest.reps.map((r) => r.slug));
|
|
8519
|
+
if (puaGlyphs > 0) {
|
|
8520
|
+
disclosures.push(
|
|
8521
|
+
`SYMBOL-FONT ICONS: ${puaGlyphs} distinct recorded icon glyph(s) come from a symbol font (private-use codepoints) \u2014 the recording carries NO vector geometry for them. Reconstruct each mark against the reference pixels with extreme care, compare magnified crops after every score (run 11 shipped a checkmark where the reference was a dot, and a diamond where it was two chevrons \u2014 both scored above bar), and NEVER give two configs sharing the same recorded glyph different shapes`
|
|
8522
|
+
);
|
|
8523
|
+
}
|
|
8456
8524
|
if (dismissName !== void 0) {
|
|
8457
8525
|
disclosures.push(`dismiss affordance detected from the recording (${dismissName}) \u2014 onDismiss authored; dismiss-notifies-and-commits enforces the notification contract`);
|
|
8458
8526
|
}
|
|
@@ -8825,12 +8893,17 @@ function cssFontFamilies(css) {
|
|
|
8825
8893
|
}
|
|
8826
8894
|
return [...out];
|
|
8827
8895
|
}
|
|
8828
|
-
function fontsCssFor(faces, bundleDir, cacheDir = DEFAULT_FONT_CACHE) {
|
|
8896
|
+
function fontsCssFor(faces, bundleDir, cacheDir = DEFAULT_FONT_CACHE, substitutedFamilies = []) {
|
|
8829
8897
|
const header = [
|
|
8830
8898
|
"/* tendril fonts.css \u2014 the exact faces this bundle was scored with (sha-pinned in",
|
|
8831
8899
|
" component.json requiredFonts). Load it alongside styles.css so the component",
|
|
8832
8900
|
" renders in the recorded typeface \u2014 without it the browser substitutes, which is",
|
|
8833
|
-
" exactly the drift verification exists to rule out. Not read by scoring. */"
|
|
8901
|
+
" exactly the drift verification exists to rule out. Not read by scoring. */",
|
|
8902
|
+
...substitutedFamilies.length > 0 ? [
|
|
8903
|
+
`/* GENERATED UNDER FONT SUBSTITUTION: ${substitutedFamilies.map((f) => `'${f}'`).join(", ")} was/were NOT provisioned \u2014`,
|
|
8904
|
+
" scores measured a substitute face and no config certified. This file covers only",
|
|
8905
|
+
" cache-provided faces; resolve the real families and re-score for the full set. */"
|
|
8906
|
+
] : []
|
|
8834
8907
|
];
|
|
8835
8908
|
const lines = [];
|
|
8836
8909
|
for (const face of faces) {
|
|
@@ -8903,7 +8976,8 @@ function recordingSetHash(setDir, configs) {
|
|
|
8903
8976
|
);
|
|
8904
8977
|
}
|
|
8905
8978
|
function statusOf(s) {
|
|
8906
|
-
|
|
8979
|
+
const tier = tierOf(s, BARS.cert);
|
|
8980
|
+
return tier === "certified" && (s.absentInk?.length ?? 0) > 0 ? "pass" : tier;
|
|
8907
8981
|
}
|
|
8908
8982
|
function emitBundleV1(opts) {
|
|
8909
8983
|
const substituted = (opts.substitutedFamilies ?? []).length > 0;
|
|
@@ -8953,7 +9027,7 @@ function emitBundleV1(opts) {
|
|
|
8953
9027
|
...opts.licenseNote !== void 0 ? { licenseNote: opts.licenseNote } : {},
|
|
8954
9028
|
hash: recordingSetHash(opts.task.set, opts.task.configs)
|
|
8955
9029
|
},
|
|
8956
|
-
environment: opts.environment,
|
|
9030
|
+
environment: { ...opts.environment, ...(opts.substitutedFamilies ?? []).length > 0 ? { substitutedFamilies: opts.substitutedFamilies } : {} },
|
|
8957
9031
|
coverage: { recordedConfigs: statuses.length, latticeConfigs: lattice },
|
|
8958
9032
|
generatedAt: opts.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
8959
9033
|
...opts.spentUsd !== void 0 ? { spentUsd: opts.spentUsd } : {}
|
|
@@ -8986,7 +9060,7 @@ ${stripped}`);
|
|
|
8986
9060
|
const fontsCssPath = path29.join(opts.bundleDir, "fonts.css");
|
|
8987
9061
|
rmSync3(path29.join(opts.bundleDir, "fonts"), { recursive: true, force: true });
|
|
8988
9062
|
rmSync3(fontsCssPath, { force: true });
|
|
8989
|
-
const fontsCss = fontsCssFor(requiredFontsManifest(families, opts.fontCacheDir), opts.bundleDir, opts.fontCacheDir);
|
|
9063
|
+
const fontsCss = fontsCssFor(requiredFontsManifest(families, opts.fontCacheDir), opts.bundleDir, opts.fontCacheDir, opts.substitutedFamilies ?? []);
|
|
8990
9064
|
if (fontsCss !== null) {
|
|
8991
9065
|
writeFileSync11(fontsCssPath, fontsCss);
|
|
8992
9066
|
written.push(fontsCssPath);
|
|
@@ -9187,6 +9261,31 @@ function unprovisionedFamilies(setDir, cacheDir) {
|
|
|
9187
9261
|
const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
|
|
9188
9262
|
return declared.filter((f) => !provided.has(f.toLowerCase()));
|
|
9189
9263
|
}
|
|
9264
|
+
function unprovisionedFaces(setDir, cacheDir) {
|
|
9265
|
+
return [
|
|
9266
|
+
...unprovisionedFamilies(setDir, cacheDir),
|
|
9267
|
+
...missingWeights(setDir, cacheDir).map((g) => {
|
|
9268
|
+
const missing = g.declared.filter((w) => !g.provided.includes(w));
|
|
9269
|
+
return `${g.family} (${missing.length === 1 ? "weight" : "weights"} ${missing.join(", ")})`;
|
|
9270
|
+
})
|
|
9271
|
+
];
|
|
9272
|
+
}
|
|
9273
|
+
function missingWeights(setDir, cacheDir) {
|
|
9274
|
+
let needs;
|
|
9275
|
+
try {
|
|
9276
|
+
needs = recordedFontNeeds(setDir, { pairedOnly: true });
|
|
9277
|
+
} catch {
|
|
9278
|
+
return [];
|
|
9279
|
+
}
|
|
9280
|
+
const provided = new Map(verifiedFontFamilies(cacheDir).map((f) => [f.family.toLowerCase(), f.weights]));
|
|
9281
|
+
const gaps = [];
|
|
9282
|
+
for (const n of needs) {
|
|
9283
|
+
const have = provided.get(n.family.toLowerCase());
|
|
9284
|
+
if (have === void 0) continue;
|
|
9285
|
+
if (n.weights.some((w) => !have.includes(w))) gaps.push({ family: n.family, declared: n.weights, provided: have });
|
|
9286
|
+
}
|
|
9287
|
+
return gaps;
|
|
9288
|
+
}
|
|
9190
9289
|
var init_font_guidance = __esm({
|
|
9191
9290
|
"packages/cli/src/font-guidance.ts"() {
|
|
9192
9291
|
"use strict";
|
|
@@ -9198,6 +9297,7 @@ var init_font_guidance = __esm({
|
|
|
9198
9297
|
// packages/cli/src/commands/verify.ts
|
|
9199
9298
|
var verify_exports = {};
|
|
9200
9299
|
__export(verify_exports, {
|
|
9300
|
+
foldConfigStatus: () => foldConfigStatus,
|
|
9201
9301
|
interactionCoverage: () => interactionCoverage,
|
|
9202
9302
|
runVerify: () => runVerify
|
|
9203
9303
|
});
|
|
@@ -9212,6 +9312,26 @@ function interactionCoverage(behaviors) {
|
|
|
9212
9312
|
operability: interaction.length > 0 && interaction.every((b) => b.pass) ? "verified" : "unverified"
|
|
9213
9313
|
};
|
|
9214
9314
|
}
|
|
9315
|
+
function foldConfigStatus(s, failDemotions, substitutedFamilies) {
|
|
9316
|
+
const { exact: _exact, ...reported } = s;
|
|
9317
|
+
let status = tierOf(s, BARS2.cert);
|
|
9318
|
+
const certDemote = [];
|
|
9319
|
+
let absentInkDemoted = false;
|
|
9320
|
+
if (status === "certified" && s.absentInk !== void 0 && s.absentInk.length > 0) {
|
|
9321
|
+
status = "pass";
|
|
9322
|
+
absentInkDemoted = true;
|
|
9323
|
+
certDemote.push(...s.absentInk.map((c) => `absent ink: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with no render ink within reach (missing or invisible feature)`));
|
|
9324
|
+
}
|
|
9325
|
+
if (status === "certified" && substitutedFamilies.length > 0) {
|
|
9326
|
+
status = "pass";
|
|
9327
|
+
certDemote.push(`substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 certification never measures a substitute face`);
|
|
9328
|
+
}
|
|
9329
|
+
const base = certDemote.length > 0 ? { ...reported, status, demotedBy: certDemote } : { ...reported, status };
|
|
9330
|
+
return {
|
|
9331
|
+
row: failDemotions === void 0 ? base : { ...base, status: "fail", demotedBy: [...certDemote, ...failDemotions] },
|
|
9332
|
+
absentInkDemoted
|
|
9333
|
+
};
|
|
9334
|
+
}
|
|
9215
9335
|
function taskFromManifest(opts, manifest, setDir) {
|
|
9216
9336
|
const recordedSlugs = loadManifest(setDir).reps.map((r) => r.slug);
|
|
9217
9337
|
const adapterSlugs = Object.keys(manifest.propAdapter);
|
|
@@ -9357,6 +9477,10 @@ async function runVerify(opts) {
|
|
|
9357
9477
|
} else if (opts.bar === "cert" && taskFontFamilies(task.set) === null) {
|
|
9358
9478
|
warn(opts, "family coverage could not be established from this recording (no declared families) \u2014 the certification gate covers only cache non-emptiness here");
|
|
9359
9479
|
}
|
|
9480
|
+
const weightGaps = missingWeights(task.set);
|
|
9481
|
+
for (const g of weightGaps) {
|
|
9482
|
+
warn(opts, `font weights (advisory): the recording implies weights ${g.declared.join(", ")} for "${g.family}" \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (certification stays family-gated; implied weights can leak across families)`);
|
|
9483
|
+
}
|
|
9360
9484
|
const missing = task.configs.filter(
|
|
9361
9485
|
(c) => !existsSync25(path31.join(task.set, c.rep, "get_screenshot.json")) || !existsSync25(path31.join(task.set, c.rep, "get_metadata.json"))
|
|
9362
9486
|
);
|
|
@@ -9387,23 +9511,10 @@ async function runVerify(opts) {
|
|
|
9387
9511
|
if (crops !== void 0) {
|
|
9388
9512
|
for (const c of crops) if (!c.pass) demote(c.id.split(":")[1] ?? "", "composition crop failed (A1.4)");
|
|
9389
9513
|
}
|
|
9514
|
+
const folded = scores.map((s) => foldConfigStatus(s, demotions.get(s.rep), substitutedFamilies));
|
|
9515
|
+
const absentInkDemoted = scores.filter((_, i) => folded[i].absentInkDemoted).map((s) => s.rep);
|
|
9390
9516
|
const statuses = [
|
|
9391
|
-
...
|
|
9392
|
-
const { exact: _exact, ...reported } = s;
|
|
9393
|
-
let status = tierOf(s, BARS2.cert);
|
|
9394
|
-
const certDemote = [];
|
|
9395
|
-
if (status === "certified" && s.absentInk !== void 0 && s.absentInk.length > 0) {
|
|
9396
|
-
status = "pass";
|
|
9397
|
-
certDemote.push(...s.absentInk.map((c) => `absent ink: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with no render ink within reach (missing or invisible feature)`));
|
|
9398
|
-
}
|
|
9399
|
-
if (status === "certified" && substitutedFamilies.length > 0) {
|
|
9400
|
-
status = "pass";
|
|
9401
|
-
certDemote.push(`substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 certification never measures a substitute face`);
|
|
9402
|
-
}
|
|
9403
|
-
const base = certDemote.length > 0 ? { ...reported, status, demotedBy: certDemote } : { ...reported, status };
|
|
9404
|
-
const reasons = demotions.get(s.rep);
|
|
9405
|
-
return reasons === void 0 ? base : { ...base, status: "fail", demotedBy: [...certDemote.length > 0 ? certDemote : [], ...reasons] };
|
|
9406
|
-
}),
|
|
9517
|
+
...folded.map((f) => f.row),
|
|
9407
9518
|
// ADR-010 §2 anti-gaming: recorded configs the adapter does not map
|
|
9408
9519
|
// are FAILs, never silently absent.
|
|
9409
9520
|
...unmapped.map((rep) => ({ rep, similarity: 0, inkRecall: 0, pass: false, status: "fail", error: "not mapped by the bundle's prop adapter" }))
|
|
@@ -9416,7 +9527,9 @@ async function runVerify(opts) {
|
|
|
9416
9527
|
const occlusionFailures = occlusion.filter((o) => !o.pass);
|
|
9417
9528
|
const coverage = interactionCoverage(behaviors);
|
|
9418
9529
|
const evidenceUnverified = interactionEvidence.length > 0 && coverage.interactionChecks === 0;
|
|
9419
|
-
const
|
|
9530
|
+
const okExceptDemotion = pixelFailures.length === 0 && behaviorFailures.length === 0 && structuralFailures.length === 0 && cropFailures.length === 0 && occlusionFailures.length === 0 && !evidenceUnverified;
|
|
9531
|
+
const certBlockedByAbsentInk = opts.bar === "cert" ? absentInkDemoted : [];
|
|
9532
|
+
const ok = okExceptDemotion && certBlockedByAbsentInk.length === 0;
|
|
9420
9533
|
const report = {
|
|
9421
9534
|
bundle: opts.bundleDir,
|
|
9422
9535
|
...opts.task !== void 0 ? { task: opts.task } : {},
|
|
@@ -9473,7 +9586,12 @@ async function runVerify(opts) {
|
|
|
9473
9586
|
crops: crops ?? { unavailable: regionsOut !== void 0 && "unavailable" in regionsOut ? regionsOut.unavailable : "no role manifest" }
|
|
9474
9587
|
}
|
|
9475
9588
|
} : {},
|
|
9476
|
-
verdict: ok ? "verified" : "verification-failed"
|
|
9589
|
+
verdict: ok ? "verified" : "verification-failed",
|
|
9590
|
+
// Machine-readable cause (adversarial review): a cert-bar failure
|
|
9591
|
+
// with zero pixel/behavior/composition failures was only
|
|
9592
|
+
// explainable from stderr prose.
|
|
9593
|
+
...certBlockedByAbsentInk.length > 0 ? { certBlockedByAbsentInk } : {},
|
|
9594
|
+
...weightGaps.length > 0 ? { fontWeightGaps: weightGaps } : {}
|
|
9477
9595
|
};
|
|
9478
9596
|
emitData(opts, report, () => {
|
|
9479
9597
|
for (const s of statuses) {
|
|
@@ -9533,7 +9651,7 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
|
|
|
9533
9651
|
}
|
|
9534
9652
|
if (evidenceUnverified) {
|
|
9535
9653
|
process.stdout.write(
|
|
9536
|
-
`FAIL interaction-evidence \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and ZERO interaction behaviors were verified. The authoring vocabulary did not map them; this is an instrument failure, not a verified component.
|
|
9654
|
+
`FAIL interaction-evidence \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and ZERO interaction behaviors were verified. The authoring vocabulary did not map them; this is an instrument failure, not a verified component. Mappable today: ${[...INTERACTION_STATES, ...BOOLEAN_STATES].join("/")} \u2014 on an axis named State (checked/selected state axes are recorded as evidence but not yet authorable \u2014 widening is on the roadmap; this is not fixable from component code).
|
|
9537
9655
|
`
|
|
9538
9656
|
);
|
|
9539
9657
|
} else if (ic.interactionChecks === 0) {
|
|
@@ -9565,28 +9683,42 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
|
|
|
9565
9683
|
const shippedFonts = requiredFontsManifest(cssFontFamilies(["styles.css", "tokens.css"].map((f) => path31.join(opts.bundleDir, f)).filter((f) => existsSync25(f)).map((f) => readFileSync22(f, "utf8")).join("\n")));
|
|
9566
9684
|
if (shippedFonts.length > 0 && substitutedFamilies.length === 0) {
|
|
9567
9685
|
process.stdout.write(`fonts: scored with Tendril-cache faces \u2014 a consuming app must provision the same families (the bundle ships fonts.css when faces are shippable; sha-pinned list in component.json requiredFonts)
|
|
9686
|
+
`);
|
|
9687
|
+
}
|
|
9688
|
+
for (const g of weightGaps) {
|
|
9689
|
+
process.stdout.write(`fonts (weight gap): "${g.family}" declares ${g.declared.join(", ")} \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (advisory, never a gate). Fix: tendril fonts resolve --set ${task.set}
|
|
9568
9690
|
`);
|
|
9569
9691
|
}
|
|
9570
9692
|
process.stdout.write(`evidence: ${evidenceDir} (render/ref/diff per config)
|
|
9693
|
+
`);
|
|
9694
|
+
process.stdout.write(`eye check: tendril inspect ${opts.bundleDir} \u2014 magnified recorded-vs-rendered crops of every small node; scores cannot see shape
|
|
9571
9695
|
`);
|
|
9572
9696
|
});
|
|
9573
9697
|
if (opts.bar === "cert" && substitutedFamilies.length > 0) {
|
|
9574
9698
|
const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
|
|
9699
|
+
const alsoAbsent = absentInkDemoted.length > 0 ? ` \u2014 and ${absentInkDemoted.length} config(s) also carry absent-ink demotions (named in the report); certification requires fixing those too` : "";
|
|
9575
9700
|
if (opts.json) {
|
|
9576
|
-
process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
|
|
9701
|
+
process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
|
|
9577
9702
|
`);
|
|
9578
9703
|
} else {
|
|
9579
|
-
process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
|
|
9704
|
+
process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}
|
|
9580
9705
|
\u2192 ${fontsUnprovenRemediation(task.set)}
|
|
9581
9706
|
`);
|
|
9582
9707
|
}
|
|
9583
9708
|
process.exitCode = ExitCode.FontsUnproven;
|
|
9584
9709
|
return;
|
|
9585
9710
|
}
|
|
9586
|
-
if (
|
|
9711
|
+
if (certBlockedByAbsentInk.length > 0) {
|
|
9712
|
+
warn(
|
|
9713
|
+
opts,
|
|
9714
|
+
`${certBlockedByAbsentInk.length} config(s) demoted by absent-ink clusters (${certBlockedByAbsentInk.join(", ")}) \u2014 certification never ships a missing or invisible feature; each cluster is named in the report above. Fix the component and re-verify, or verify at --bar pass for the disclosed result.`
|
|
9715
|
+
);
|
|
9716
|
+
process.exitCode = ExitCode.VerificationFailed;
|
|
9717
|
+
}
|
|
9718
|
+
if (!okExceptDemotion) {
|
|
9587
9719
|
warn(
|
|
9588
9720
|
opts,
|
|
9589
|
-
`${pixelFailures.length} config(s), ${behaviorFailures.length} behavior(s), ${structuralFailures.length + cropFailures.length + occlusionFailures.length} composition check(s) below the ${opts.bar} bar \u2014 bundle written, verdict honest (Q4)`
|
|
9721
|
+
`${pixelFailures.length} config(s), ${behaviorFailures.length} behavior(s), ${structuralFailures.length + cropFailures.length + occlusionFailures.length} composition check(s) below the ${opts.bar} bar${evidenceUnverified ? "; PLUS the interaction-evidence instrument gate failed (operability could not be verified \u2014 see FAIL interaction-evidence above)" : ""} \u2014 bundle written, verdict honest (Q4)`
|
|
9590
9722
|
);
|
|
9591
9723
|
process.exitCode = ExitCode.VerificationFailed;
|
|
9592
9724
|
}
|
|
@@ -9616,18 +9748,18 @@ __export(engine_exports, {
|
|
|
9616
9748
|
runEngineBrief: () => runEngineBrief,
|
|
9617
9749
|
runEngineScore: () => runEngineScore
|
|
9618
9750
|
});
|
|
9619
|
-
import { existsSync as existsSync26, mkdirSync as mkdirSync8, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
|
|
9751
|
+
import { appendFileSync, existsSync as existsSync26, mkdirSync as mkdirSync8, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
|
|
9620
9752
|
import path32 from "node:path";
|
|
9621
9753
|
function resolveEngineTask(opts, callerCwd) {
|
|
9622
9754
|
const asPath = path32.resolve(callerCwd, opts.taskOrSet);
|
|
9623
9755
|
const isSet = existsSync26(path32.join(asPath, "recording-set.json"));
|
|
9624
9756
|
const registry = TASKS[opts.taskOrSet];
|
|
9625
|
-
if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, disclosures: [] };
|
|
9757
|
+
if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, disclosures: [], interactionEvidence: [] };
|
|
9626
9758
|
if (isSet) {
|
|
9627
9759
|
try {
|
|
9628
9760
|
const authored = authorTaskFromSet(asPath);
|
|
9629
9761
|
for (const d of authored.disclosures) warn(opts, d);
|
|
9630
|
-
return { task: authored.task, name: path32.basename(asPath), disclosures: authored.disclosures, apiPin: { props: authored.api.apiPin.props, forcedStates: authored.api.apiPin.forcedStates } };
|
|
9762
|
+
return { task: authored.task, name: path32.basename(asPath), disclosures: authored.disclosures, interactionEvidence: authored.api.interactionEvidence, apiPin: { props: authored.api.apiPin.props, forcedStates: authored.api.apiPin.forcedStates } };
|
|
9631
9763
|
} catch (err) {
|
|
9632
9764
|
fail(opts, ExitCode.InputValidation, {
|
|
9633
9765
|
error: `cannot author a task from ${asPath}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
|
|
@@ -9665,20 +9797,23 @@ DO NOT INVENT PIXELS FOR THESE POSES. Implement them ONLY as the composition of
|
|
|
9665
9797
|
${notRecorded}` : "";
|
|
9666
9798
|
let fontProvisioning;
|
|
9667
9799
|
if (existsSync26(manifestPath2)) {
|
|
9668
|
-
const
|
|
9669
|
-
const unprovided =
|
|
9800
|
+
const missingFams = unprovisionedFamilies(task.set);
|
|
9801
|
+
const unprovided = unprovisionedFaces(task.set);
|
|
9802
|
+
const weightOnly = missingFams.length === 0;
|
|
9670
9803
|
if (unprovided.length > 0) {
|
|
9671
9804
|
const names = unprovided.map((f) => `'${f}'`).join(", ");
|
|
9805
|
+
const PROPRIETARY = /^(sf pro|sf compact|sf mono|new york|pingfang|segoe ui|helvetica neue|proxima nova|avenir)/i;
|
|
9806
|
+
const allProprietary = unprovided.every((f) => PROPRIETARY.test(f.trim()));
|
|
9672
9807
|
fontProvisioning = {
|
|
9673
9808
|
unprovided,
|
|
9674
9809
|
question: {
|
|
9675
|
-
prompt: `This design's text uses ${names}, which is not in the local font kit. How should it be handled?`,
|
|
9810
|
+
prompt: weightOnly ? `This design implies font ${unprovided.length === 1 ? "weight" : "weights"} the local kit lacks: ${names} (the ${missingFams.length === 0 && unprovided.length === 1 ? "family itself is" : "families themselves are"} provisioned). How should it be handled?` : `This design's text uses ${names}, which is not in the local font kit. How should it be handled?`,
|
|
9676
9811
|
options: [
|
|
9677
|
-
`(Recommended) Run \`tendril fonts resolve --set ${task.set}\` \u2014 open-source families are fetched automatically. Any face that fails there is one you license privately: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold (the file never leaves this machine). Then re-run this brief \u2014 exact text fidelity.`,
|
|
9678
|
-
'Continue with a substitute face: generation proceeds in a provided family, no config can score "certified" under it (cert-bar runs exit fonts-unproven after their report), the substitution is disclosed, and small text differences are expected and not fixable from CSS.'
|
|
9812
|
+
allProprietary && !weightOnly ? `(Recommended) ${names} ${unprovided.length === 1 ? "is a proprietary face" : "are proprietary faces"} \u2014 Google Fonts cannot serve ${unprovided.length === 1 ? "it" : "them"}, so skip \`fonts resolve\`: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold a licence for (the file never leaves this machine), then re-run this brief \u2014 exact text fidelity.` : `(Recommended) Run \`tendril fonts resolve --set ${task.set}\` \u2014 open-source families are fetched automatically. Any face that fails there is one you license privately: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold (the file never leaves this machine). Then re-run this brief \u2014 exact text fidelity.`,
|
|
9813
|
+
weightOnly ? "Continue with nearest-weight rendering: the family is provisioned, certification remains possible, and the gap rides the report as an advisory (fontWeightGaps); resolving removes it." : 'Continue with a substitute face: generation proceeds in a provided family, no config can score "certified" under it (cert-bar runs exit fonts-unproven after their report), the substitution is disclosed, and small text differences are expected and not fixable from CSS.'
|
|
9679
9814
|
]
|
|
9680
9815
|
},
|
|
9681
|
-
nonInteractive: opts.bar === "cert" ? `Resolve the families first (\`tendril fonts resolve --set ${task.set}\`) \u2014 this loop targets the cert bar, and scoring exits fonts-unproven under a substitute, so continuing without resolving is a dead end.` : "Continue with the substitute and state the substitution in your report."
|
|
9816
|
+
nonInteractive: weightOnly ? `Run \`tendril fonts resolve --set ${task.set}\` if quick, or continue \u2014 the weight gap is an advisory, never a gate.` : opts.bar === "cert" ? `Resolve the families first (\`tendril fonts resolve --set ${task.set}\`) \u2014 this loop targets the cert bar, and scoring exits fonts-unproven under a substitute, so continuing without resolving is a dead end.` : "Continue with the substitute and state the substitution in your report."
|
|
9682
9817
|
};
|
|
9683
9818
|
}
|
|
9684
9819
|
}
|
|
@@ -9733,7 +9868,7 @@ async function runEngineScore(opts) {
|
|
|
9733
9868
|
requireEntitlement(opts);
|
|
9734
9869
|
const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
|
|
9735
9870
|
const candidateDir = path32.resolve(callerCwd, opts.candidateDir);
|
|
9736
|
-
const { task, name, apiPin } = resolveEngineTask(opts, callerCwd);
|
|
9871
|
+
const { task, name, apiPin, interactionEvidence } = resolveEngineTask(opts, callerCwd);
|
|
9737
9872
|
if (!existsSync26(candidateDir)) {
|
|
9738
9873
|
fail(opts, ExitCode.InputValidation, {
|
|
9739
9874
|
error: `candidate directory not found: ${candidateDir}`,
|
|
@@ -9752,6 +9887,9 @@ async function runEngineScore(opts) {
|
|
|
9752
9887
|
if (substitutedFamilies.length > 0) {
|
|
9753
9888
|
warn(opts, `substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 scores measure a substitute face and no config can be certified under one (the stamp covers only the provisioned subset); resolve the families to certify`);
|
|
9754
9889
|
}
|
|
9890
|
+
for (const g of missingWeights(task.set)) {
|
|
9891
|
+
warn(opts, `font weights (advisory): the recording implies weights ${g.declared.join(", ")} for "${g.family}" \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (certification stays family-gated; implied weights can leak across families)`);
|
|
9892
|
+
}
|
|
9755
9893
|
if (opts.rebind !== true && existsSync26(path32.join(candidateDir, "component.json"))) {
|
|
9756
9894
|
const prior = (() => {
|
|
9757
9895
|
try {
|
|
@@ -9792,12 +9930,13 @@ ${[
|
|
|
9792
9930
|
...quality.findings.map((f) => `- ${f.kind} ${f.file}${f.line === void 0 ? "" : `:${f.line}`} \u2014 ${f.message}`),
|
|
9793
9931
|
...quality.tokensAbsent ? ["- no design tokens: no tokens.css and no var(--\u2026) reference; every value is hardcoded"] : []
|
|
9794
9932
|
].join("\n")}`;
|
|
9795
|
-
const
|
|
9933
|
+
const absentAtCertBar = opts.bar === "cert" ? scores.filter((sc) => (sc.absentInk?.length ?? 0) > 0).map((sc) => sc.rep) : [];
|
|
9934
|
+
const allPass = obj[0] === total && total > 0 && absentAtCertBar.length === 0;
|
|
9796
9935
|
const certBar = BARS3["cert"];
|
|
9797
9936
|
const parityDemoted = new Set(parity.filter((p) => !p.pass).map((p) => p.id.replace(/^parity:/, "")));
|
|
9798
9937
|
const certifiedReps = substitutedFamilies.length > 0 ? [] : scores.filter((sc) => tierOf(sc, certBar) === "certified" && !parityDemoted.has(sc.rep) && (sc.absentInk === void 0 || sc.absentInk.length === 0)).map((sc) => sc.rep);
|
|
9799
9938
|
const certifiedSet = new Set(certifiedReps);
|
|
9800
|
-
const absentFindings = scores.flatMap((sc) => (sc.absentInk ?? []).map((c) => `- ${sc.rep}: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with NO render ink within reach at [x ${c.x}-${c.x + c.w}, y ${c.y}-${c.y + c.h}] (missing or invisible feature; magnified crops in the evidence dir; coordinates are reference-space \u2014 subtract the config's seat for box-space). A certified-tier config with such a finding is demoted to pass.`));
|
|
9939
|
+
const absentFindings = scores.flatMap((sc) => (sc.absentInk ?? []).map((c) => `- ${sc.rep}: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with NO render ink within reach at [x ${c.x}-${c.x + c.w}, y ${c.y}-${c.y + c.h}] (missing or invisible feature; magnified crops in the evidence dir; coordinates are reference-space \u2014 subtract the config's seat for box-space). A certified-tier config with such a finding is demoted to pass, and at --bar cert it FAILS the run (exit 5).`));
|
|
9801
9940
|
const absentBlock = absentFindings.length > 0 ? `
|
|
9802
9941
|
|
|
9803
9942
|
MISSING FEATURES (absent-ink clusters \u2014 recorded ink your render leaves nowhere near covered; fix these first, the global numbers cannot see them):
|
|
@@ -9819,6 +9958,11 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
|
|
|
9819
9958
|
environment: environmentStamp(taskFontFamilies(task.set)),
|
|
9820
9959
|
substitutedFamilies
|
|
9821
9960
|
});
|
|
9961
|
+
appendFileSync(
|
|
9962
|
+
path32.join(candidateDir, "score-history.jsonl"),
|
|
9963
|
+
`${JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), bar: opts.bar, pass: obj[0], total, certified: certifiedReps.length, floor: obj[1], mean: obj[2] })}
|
|
9964
|
+
`
|
|
9965
|
+
);
|
|
9822
9966
|
emitData(
|
|
9823
9967
|
opts,
|
|
9824
9968
|
{
|
|
@@ -9838,6 +9982,10 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
|
|
|
9838
9982
|
bundleManifest: emitted.written[0],
|
|
9839
9983
|
note: "styles.css was stamped with the bundle provenance comment on line 1 \u2014 preserve it in any post-score edit",
|
|
9840
9984
|
allPass,
|
|
9985
|
+
// Run 11: generators read allPass:true and reported success on
|
|
9986
|
+
// bundles verify then FAILED on the interaction-evidence gate —
|
|
9987
|
+
// the oracle must say what verify will say, including this.
|
|
9988
|
+
...interactionEvidence.length > 0 && interactionCoverage(behaviors).interactionChecks === 0 ? { interactionEvidenceUnverified: true, verifyWillFail: "interaction-evidence \u2014 the recording proves interactive poses none of the authored behaviors cover; not fixable from component code; REPORT it, do not iterate on it" } : {},
|
|
9841
9989
|
certification: { certified: certifiedReps.length, total: scores.length, bar: certBar, note: "tierOf on exact values + parity and absent-ink demotion; verify's composition checks can demote further" }
|
|
9842
9990
|
},
|
|
9843
9991
|
() => {
|
|
@@ -9849,12 +9997,18 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
|
|
|
9849
9997
|
${obj[0]}/${total} \xB7 certified ${certifiedReps.length}/${total} \xB7 floor=${obj[1].toFixed(3)} \xB7 mean=${obj[2].toFixed(3)} \xB7 evidence: ${evidenceDir}
|
|
9850
9998
|
`);
|
|
9851
9999
|
const ic = interactionCoverage(behaviors);
|
|
9852
|
-
if (ic.interactionChecks === 0) {
|
|
10000
|
+
if (interactionEvidence.length > 0 && ic.interactionChecks === 0) {
|
|
10001
|
+
process.stdout.write(`INSTRUMENT GAP \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and zero interaction behaviors were authored; verify WILL fail this bundle (interaction-evidence). Not fixable from component code \u2014 report it, do not iterate on it.
|
|
10002
|
+
`);
|
|
10003
|
+
} else if (ic.interactionChecks === 0) {
|
|
9853
10004
|
process.stdout.write(`UNVERIFIED operability \u2014 0 interaction checks; the behaviour passes above are page-level style hygiene
|
|
9854
10005
|
`);
|
|
9855
10006
|
}
|
|
9856
10007
|
}
|
|
9857
10008
|
);
|
|
10009
|
+
if (absentAtCertBar.length > 0) {
|
|
10010
|
+
warn(opts, `${absentAtCertBar.length} config(s) carry absent-ink clusters (${absentAtCertBar.join(", ")}) \u2014 at --bar cert these fail the run; the MISSING FEATURES block above names each one`);
|
|
10011
|
+
}
|
|
9858
10012
|
if (opts.bar === "cert" && substitutedFamilies.length > 0) {
|
|
9859
10013
|
const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
|
|
9860
10014
|
if (opts.json) {
|
|
@@ -10135,6 +10289,14 @@ var init_server = __esm({
|
|
|
10135
10289
|
return argvOut;
|
|
10136
10290
|
}
|
|
10137
10291
|
},
|
|
10292
|
+
{
|
|
10293
|
+
name: "tendril_permissions",
|
|
10294
|
+
description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing. OFFER THIS AT PIPELINE START whenever NO merged Claude settings file (project .claude/settings.local.json or .claude/settings.json, or user ~/.claude/settings.json) contains tendril MCP entries \u2014 plugin installs use mcp__plugin_tendril_tendril__*, direct claude-mcp-add installs use mcp__<server>__* (for those, write:true installs PLUGIN-prefixed names that will not match: list without writing and adapt the prefix instead). A FILE check, never prompt-watching \u2014 agents cannot observe permission prompts. ONE approval here replaces a prompt per pipeline call. Never run it unoffered; the user must reload the session for new settings to apply \u2014 say so.",
|
|
10295
|
+
schema: z12.object({
|
|
10296
|
+
write: z12.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries")
|
|
10297
|
+
}),
|
|
10298
|
+
argv: (i) => ["permissions", "--claude", ...i["write"] === true ? ["--write"] : []]
|
|
10299
|
+
},
|
|
10138
10300
|
{
|
|
10139
10301
|
name: "tendril_doctor",
|
|
10140
10302
|
annotations: { readOnlyHint: true },
|
|
@@ -10330,7 +10492,7 @@ var init_server = __esm({
|
|
|
10330
10492
|
},
|
|
10331
10493
|
{
|
|
10332
10494
|
name: "tendril_verify",
|
|
10333
|
-
description: "Verify/check that a component matches its Figma design \u2014 use when the user asks whether an implementation is faithful to the design, or to re-certify an existing Tendril bundle. Recomputes full verification (per-config status, behaviors, composition, evidence artifacts). Free, account-less, network-less \u2014 the trust anchor. Exit 5 means
|
|
10495
|
+
description: "Verify/check that a component matches its Figma design \u2014 use when the user asks whether an implementation is faithful to the design, or to re-certify an existing Tendril bundle. Recomputes full verification (per-config status, behaviors, composition, evidence artifacts). Free, account-less, network-less \u2014 the trust anchor. Exit 5 means below the target bar with an honest report \u2014 sub-bar scores, or at bar cert a config demoted by absent-ink clusters.",
|
|
10334
10496
|
schema: z12.object({
|
|
10335
10497
|
bundleDir: str("bundle directory to verify"),
|
|
10336
10498
|
bar: optStr("pass (default) or cert"),
|
|
@@ -10378,8 +10540,33 @@ var permissions_exports = {};
|
|
|
10378
10540
|
__export(permissions_exports, {
|
|
10379
10541
|
PERMISSIONS_DESCRIPTION: () => PERMISSIONS_DESCRIPTION,
|
|
10380
10542
|
buildPermissions: () => buildPermissions,
|
|
10543
|
+
mergeAllowlist: () => mergeAllowlist,
|
|
10381
10544
|
runPermissions: () => runPermissions
|
|
10382
10545
|
});
|
|
10546
|
+
import { existsSync as existsSync29, mkdirSync as mkdirSync9, readFileSync as readFileSync26, writeFileSync as writeFileSync15 } from "node:fs";
|
|
10547
|
+
import os7 from "node:os";
|
|
10548
|
+
import path35 from "node:path";
|
|
10549
|
+
function mergeAllowlist(file, entries) {
|
|
10550
|
+
let settings = {};
|
|
10551
|
+
if (existsSync29(file) && readFileSync26(file, "utf8").trim() !== "") {
|
|
10552
|
+
settings = JSON.parse(readFileSync26(file, "utf8"));
|
|
10553
|
+
if (settings === null || typeof settings !== "object" || Array.isArray(settings)) throw new Error("settings root is not an object");
|
|
10554
|
+
}
|
|
10555
|
+
const permissions = settings["permissions"] ??= {};
|
|
10556
|
+
if (permissions === null || typeof permissions !== "object" || Array.isArray(permissions)) throw new Error("permissions is not an object");
|
|
10557
|
+
const allow = permissions["allow"] ??= [];
|
|
10558
|
+
if (!Array.isArray(allow)) throw new Error("permissions.allow is not an array");
|
|
10559
|
+
const present = new Set(allow.filter((x) => typeof x === "string"));
|
|
10560
|
+
const added = entries.filter((e) => !present.has(e));
|
|
10561
|
+
const alreadyPresent = entries.filter((e) => present.has(e));
|
|
10562
|
+
if (added.length > 0) {
|
|
10563
|
+
allow.push(...added);
|
|
10564
|
+
mkdirSync9(path35.dirname(file), { recursive: true });
|
|
10565
|
+
writeFileSync15(file, `${JSON.stringify(settings, null, 2)}
|
|
10566
|
+
`);
|
|
10567
|
+
}
|
|
10568
|
+
return { added, alreadyPresent };
|
|
10569
|
+
}
|
|
10383
10570
|
async function buildPermissions(options) {
|
|
10384
10571
|
const LEGAL_TOOL_NAME = /^[A-Za-z0-9_-]+$/;
|
|
10385
10572
|
const pipeline = new Set(FIGMA_TOOL_FALLBACK);
|
|
@@ -10408,11 +10595,44 @@ async function runPermissions(flags) {
|
|
|
10408
10595
|
return;
|
|
10409
10596
|
}
|
|
10410
10597
|
const result = await buildPermissions({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} });
|
|
10598
|
+
if (flags.write) {
|
|
10599
|
+
const base = process.env["INIT_CWD"] ?? process.cwd();
|
|
10600
|
+
const file = flags.user ? path35.join(os7.homedir(), ".claude", "settings.json") : path35.join(base, ".claude", "settings.local.json");
|
|
10601
|
+
if (flags.dryRun) {
|
|
10602
|
+
emitData(flags, { file, wouldAdd: result.toolEntries }, () => {
|
|
10603
|
+
process.stdout.write(`dry-run: would merge ${result.toolEntries.length} per-tool entries into ${file}
|
|
10604
|
+
`);
|
|
10605
|
+
});
|
|
10606
|
+
return;
|
|
10607
|
+
}
|
|
10608
|
+
try {
|
|
10609
|
+
const { added, alreadyPresent } = mergeAllowlist(file, result.toolEntries);
|
|
10610
|
+
emitData(flags, { ...result, written: { file, added, alreadyPresent } }, () => {
|
|
10611
|
+
process.stdout.write(
|
|
10612
|
+
added.length === 0 ? `already installed: all ${alreadyPresent.length} Tendril pipeline entries present in ${file}
|
|
10613
|
+
` : `installed: ${added.length} allowlist entr${added.length === 1 ? "y" : "ies"} added to ${file}${alreadyPresent.length > 0 ? ` (${alreadyPresent.length} already present)` : ""}
|
|
10614
|
+
restart or reopen the Claude Code session to pick up settings changes
|
|
10615
|
+
`
|
|
10616
|
+
);
|
|
10617
|
+
});
|
|
10618
|
+
} catch (err) {
|
|
10619
|
+
fail(flags, ExitCode.InputValidation, {
|
|
10620
|
+
error: `cannot rewrite ${file}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
|
|
10621
|
+
code: "settings-unwritable",
|
|
10622
|
+
remediation: "The file is not plain JSON this command can faithfully re-emit (comments or an unexpected shape). Add the entries by hand: run without --write for the paste-ready block."
|
|
10623
|
+
});
|
|
10624
|
+
}
|
|
10625
|
+
return;
|
|
10626
|
+
}
|
|
10411
10627
|
emitData(flags, result, () => {
|
|
10412
10628
|
const quoted = (xs) => xs.map((x) => ` ${JSON.stringify(x)}`).join(",\n");
|
|
10413
10629
|
process.stdout.write(
|
|
10414
10630
|
`Claude Code allowlist for the Tendril pipeline.
|
|
10415
|
-
|
|
10631
|
+
One command installs it (project-local, idempotent):
|
|
10632
|
+
|
|
10633
|
+
tendril permissions --claude --write
|
|
10634
|
+
|
|
10635
|
+
Or paste into .claude/settings.json under permissions.allow:
|
|
10416
10636
|
|
|
10417
10637
|
${quoted(result.toolEntries)}
|
|
10418
10638
|
|
|
@@ -10431,6 +10651,7 @@ var FIGMA_TOOL_FALLBACK, PERMISSIONS_DESCRIPTION, TENDRIL_PLUGIN_PREFIX, FIGMA_P
|
|
|
10431
10651
|
var init_permissions = __esm({
|
|
10432
10652
|
"packages/cli/src/commands/permissions.ts"() {
|
|
10433
10653
|
"use strict";
|
|
10654
|
+
init_src3();
|
|
10434
10655
|
init_src();
|
|
10435
10656
|
init_server();
|
|
10436
10657
|
init_describe();
|
|
@@ -10439,10 +10660,12 @@ var init_permissions = __esm({
|
|
|
10439
10660
|
FIGMA_TOOL_FALLBACK = ["get_metadata", "get_design_context", "get_screenshot", "get_variable_defs", "get_motion_context", "get_figjam"];
|
|
10440
10661
|
PERMISSIONS_DESCRIPTION = {
|
|
10441
10662
|
name: "permissions",
|
|
10442
|
-
summary: "
|
|
10663
|
+
summary: "Install (or print) the Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools).",
|
|
10443
10664
|
args: [],
|
|
10444
10665
|
flags: [
|
|
10445
|
-
{ flag: "--claude", description: "Claude Code settings
|
|
10666
|
+
{ flag: "--claude", description: "Claude Code settings format (the default and currently only format)" },
|
|
10667
|
+
{ flag: "--write", description: "Merge the per-tool entries into .claude/settings.local.json in the current project (idempotent; creates the file; never touches other keys)" },
|
|
10668
|
+
{ flag: "--user", description: "With --write: target ~/.claude/settings.json (every project) instead of the project-local file" },
|
|
10446
10669
|
{ flag: "--mcp-url <url>", description: "Figma MCP endpoint to list live tool names from", default: DEFAULT_MCP_URL },
|
|
10447
10670
|
{ flag: "--json", description: "Machine-readable output" }
|
|
10448
10671
|
],
|
|
@@ -10450,16 +10673,134 @@ var init_permissions = __esm({
|
|
|
10450
10673
|
host: '"claude"',
|
|
10451
10674
|
serverEntries: "string[] \u2014 one entry per MCP server (allows every tool it serves)",
|
|
10452
10675
|
toolEntries: "string[] \u2014 per-tool entries for selective allowlists",
|
|
10453
|
-
directConfigNote: "string \u2014 prefix rewrite for non-plugin (claude mcp add) installs"
|
|
10676
|
+
directConfigNote: "string \u2014 prefix rewrite for non-plugin (claude mcp add) installs",
|
|
10677
|
+
written: "with --write: { file, added, alreadyPresent }"
|
|
10454
10678
|
},
|
|
10455
|
-
exitCodes: { 0: "
|
|
10456
|
-
examples: ["tendril permissions --claude", "tendril permissions --claude --json"]
|
|
10679
|
+
exitCodes: { 0: "printed or written", 3: "with --write: the target file exists but is not JSON this command can safely rewrite" },
|
|
10680
|
+
examples: ["tendril permissions --claude --write", "tendril permissions --claude", "tendril permissions --claude --json"]
|
|
10457
10681
|
};
|
|
10458
10682
|
TENDRIL_PLUGIN_PREFIX = "mcp__plugin_tendril_tendril";
|
|
10459
10683
|
FIGMA_PLUGIN_PREFIX = "mcp__plugin_figma_figma";
|
|
10460
10684
|
}
|
|
10461
10685
|
});
|
|
10462
10686
|
|
|
10687
|
+
// packages/cli/src/commands/inspect.ts
|
|
10688
|
+
var inspect_exports = {};
|
|
10689
|
+
__export(inspect_exports, {
|
|
10690
|
+
INSPECT_DESCRIPTION: () => INSPECT_DESCRIPTION,
|
|
10691
|
+
runInspect: () => runInspect
|
|
10692
|
+
});
|
|
10693
|
+
import { existsSync as existsSync30, readFileSync as readFileSync27, writeFileSync as writeFileSync16 } from "node:fs";
|
|
10694
|
+
import path36 from "node:path";
|
|
10695
|
+
async function runInspect(opts) {
|
|
10696
|
+
if (opts.describe) {
|
|
10697
|
+
printDescription(INSPECT_DESCRIPTION);
|
|
10698
|
+
return;
|
|
10699
|
+
}
|
|
10700
|
+
const bundleDir = path36.resolve(opts.bundleDir);
|
|
10701
|
+
const evidenceDir = path36.join(bundleDir, "verify-evidence");
|
|
10702
|
+
const manifestPath2 = path36.join(bundleDir, "component.json");
|
|
10703
|
+
if (!existsSync30(evidenceDir) || !existsSync30(manifestPath2)) {
|
|
10704
|
+
fail(opts, ExitCode.InputValidation, {
|
|
10705
|
+
error: `nothing to inspect in ${bundleDir} \u2014 ${existsSync30(manifestPath2) ? "no verify-evidence directory" : "no component.json"}`,
|
|
10706
|
+
code: "no-evidence",
|
|
10707
|
+
remediation: "Run `tendril verify <bundleDir>` first \u2014 inspect reads the ref/render evidence that run writes."
|
|
10708
|
+
});
|
|
10709
|
+
}
|
|
10710
|
+
const { manifest } = readBundleManifest(readFileSync27(manifestPath2, "utf8"));
|
|
10711
|
+
if (manifest === void 0) {
|
|
10712
|
+
fail(opts, ExitCode.InputValidation, {
|
|
10713
|
+
error: "component.json did not parse as a bundle manifest",
|
|
10714
|
+
code: "no-mount-contract",
|
|
10715
|
+
remediation: "Re-emit the bundle (score/verify rewrite component.json), then re-run inspect."
|
|
10716
|
+
});
|
|
10717
|
+
}
|
|
10718
|
+
const setDir = path36.resolve(opts.set ?? manifest.provenance.recordingSet.path);
|
|
10719
|
+
const reps = Object.keys(manifest.propAdapter).filter((rep) => existsSync30(path36.join(evidenceDir, `${rep}-ref.png`)) && existsSync30(path36.join(evidenceDir, `${rep}-render.png`)));
|
|
10720
|
+
if (reps.length === 0) {
|
|
10721
|
+
fail(opts, ExitCode.InputValidation, {
|
|
10722
|
+
error: "verify-evidence holds no ref/render pairs for this bundle's configs",
|
|
10723
|
+
code: "no-evidence",
|
|
10724
|
+
remediation: "Run `tendril verify <bundleDir>` first \u2014 inspect reads the ref/render evidence that run writes."
|
|
10725
|
+
});
|
|
10726
|
+
}
|
|
10727
|
+
let crops = 0;
|
|
10728
|
+
const sections = [];
|
|
10729
|
+
for (const rep of reps) {
|
|
10730
|
+
const ref = new Uint8Array(readFileSync27(path36.join(evidenceDir, `${rep}-ref.png`)));
|
|
10731
|
+
const render = new Uint8Array(readFileSync27(path36.join(evidenceDir, `${rep}-render.png`)));
|
|
10732
|
+
const nodes = smallSemanticNodes(setDir, rep, opts.maxArea ?? 1024).slice(0, 12);
|
|
10733
|
+
const cells = [];
|
|
10734
|
+
for (const [i, n] of nodes.entries()) {
|
|
10735
|
+
const rect = { x: n.x, y: n.y, w: n.w, h: n.h };
|
|
10736
|
+
try {
|
|
10737
|
+
writeFileSync16(path36.join(evidenceDir, `${rep}-inspect-${i}-ref.png`), zoomCrop(ref, rect));
|
|
10738
|
+
writeFileSync16(path36.join(evidenceDir, `${rep}-inspect-${i}-render.png`), zoomCrop(render, rect));
|
|
10739
|
+
} catch {
|
|
10740
|
+
continue;
|
|
10741
|
+
}
|
|
10742
|
+
crops += 1;
|
|
10743
|
+
cells.push(
|
|
10744
|
+
`<figure><figcaption>${esc(n.name)} <code>${esc(n.id)}</code> \xB7 ${n.w}\xD7${n.h}</figcaption><div class="pair"><span><em>recorded</em><img src="./${rep}-inspect-${i}-ref.png" alt="recorded ${esc(n.name)}"></span><span><em>rendered</em><img src="./${rep}-inspect-${i}-render.png" alt="rendered ${esc(n.name)}"></span></div></figure>`
|
|
10745
|
+
);
|
|
10746
|
+
}
|
|
10747
|
+
sections.push(
|
|
10748
|
+
`<section><h2>${esc(rep)}</h2><div class="full"><span><em>recorded</em><img src="./${rep}-ref.png"></span><span><em>rendered</em><img src="./${rep}-render.png"></span><span><em>diff</em><img src="./${rep}-diff.png"></span></div>` + (cells.length > 0 ? `<div class="grid">${cells.join("")}</div>` : `<p class="none">no small recorded nodes in this config's sweep</p>`) + `</section>`
|
|
10749
|
+
);
|
|
10750
|
+
}
|
|
10751
|
+
const sheet = path36.join(evidenceDir, "inspect.html");
|
|
10752
|
+
writeFileSync16(
|
|
10753
|
+
sheet,
|
|
10754
|
+
`<!doctype html><meta charset="utf-8"><title>${esc(manifest.name)} \u2014 tendril inspect</title><style>
|
|
10755
|
+
body{font:14px/1.45 system-ui,sans-serif;margin:24px;background:#fff;color:#111}
|
|
10756
|
+
h1{font-size:20px} h2{font-size:16px;border-top:1px solid #ddd;padding-top:16px}
|
|
10757
|
+
.pair,.full{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-start}
|
|
10758
|
+
.full img{max-width:400px;border:1px solid #ccc} .pair img{border:1px solid #ccc;image-rendering:pixelated}
|
|
10759
|
+
figure{margin:0 0 16px} figcaption{margin-bottom:4px} em{display:block;color:#666;font-style:normal;font-size:12px}
|
|
10760
|
+
.grid{display:flex;flex-wrap:wrap;gap:20px;margin-top:12px} .none{color:#666}
|
|
10761
|
+
</style><h1>${esc(manifest.name)} \u2014 detail sheet (small recorded nodes, recorded vs rendered)</h1>
|
|
10762
|
+
<p>Every crop is a recorded node small enough that global metrics weight it as a rounding error.
|
|
10763
|
+
Scan the pairs: anything present on the left and missing/invisible on the right is a defect,
|
|
10764
|
+
whatever the scores said. Verdicts come from <code>tendril verify</code> \u2014 this sheet only shows.</p>
|
|
10765
|
+
${sections.join("\n")}
|
|
10766
|
+
`
|
|
10767
|
+
);
|
|
10768
|
+
emitData(opts, { sheet, configs: reps.length, crops }, () => {
|
|
10769
|
+
process.stdout.write(`inspect sheet: ${sheet}
|
|
10770
|
+
${reps.length} config(s), ${crops} detail crop pair(s) \u2014 open the sheet and scan recorded vs rendered
|
|
10771
|
+
`);
|
|
10772
|
+
});
|
|
10773
|
+
}
|
|
10774
|
+
var INSPECT_DESCRIPTION, esc;
|
|
10775
|
+
var init_inspect = __esm({
|
|
10776
|
+
"packages/cli/src/commands/inspect.ts"() {
|
|
10777
|
+
"use strict";
|
|
10778
|
+
init_src3();
|
|
10779
|
+
init_src6();
|
|
10780
|
+
init_src4();
|
|
10781
|
+
init_describe();
|
|
10782
|
+
init_output();
|
|
10783
|
+
INSPECT_DESCRIPTION = {
|
|
10784
|
+
name: "inspect",
|
|
10785
|
+
summary: "Build an eye-verifiable detail sheet from verify evidence: magnified ref-vs-render crops of every small recorded node (icons, controls, marks).",
|
|
10786
|
+
args: [{ name: "bundleDir", required: true, description: "bundle directory (must carry component.json and a verify-evidence dir from a prior `tendril verify`)" }],
|
|
10787
|
+
flags: [
|
|
10788
|
+
{ flag: "--set <dir>", description: "recording set override (default: the bundle's provenance path)" },
|
|
10789
|
+
{ flag: "--max-area <px2>", description: "node area ceiling for the detail sweep", default: "1024" },
|
|
10790
|
+
{ flag: "--json", description: "Machine-readable output" }
|
|
10791
|
+
],
|
|
10792
|
+
output: {
|
|
10793
|
+
sheet: "string \u2014 path to the generated inspect.html",
|
|
10794
|
+
configs: "number \u2014 configs with evidence found",
|
|
10795
|
+
crops: "number \u2014 detail crop pairs written"
|
|
10796
|
+
},
|
|
10797
|
+
exitCodes: { 0: "sheet written", 3: "no verify evidence to inspect (run `tendril verify` first)" },
|
|
10798
|
+
examples: ["tendril inspect ./src/components/Banner", "tendril inspect ./bundle --set ./tendril/recordings/banner"]
|
|
10799
|
+
};
|
|
10800
|
+
esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
10801
|
+
}
|
|
10802
|
+
});
|
|
10803
|
+
|
|
10463
10804
|
// packages/cli/src/commands/generate-route.ts
|
|
10464
10805
|
var generate_route_exports = {};
|
|
10465
10806
|
__export(generate_route_exports, {
|
|
@@ -10484,17 +10825,17 @@ __export(generate_recorded_exports, {
|
|
|
10484
10825
|
runGenerateRecorded: () => runGenerateRecorded
|
|
10485
10826
|
});
|
|
10486
10827
|
import { confirm as confirm3, isCancel as isCancel3 } from "@clack/prompts";
|
|
10487
|
-
import { existsSync as
|
|
10488
|
-
import
|
|
10828
|
+
import { existsSync as existsSync31, readFileSync as readFileSync28 } from "node:fs";
|
|
10829
|
+
import path37 from "node:path";
|
|
10489
10830
|
async function runGenerateRecorded(opts) {
|
|
10490
10831
|
const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
|
|
10491
|
-
const outDirAbs =
|
|
10492
|
-
const recordedAsPath =
|
|
10832
|
+
const outDirAbs = path37.resolve(callerCwd, opts.out);
|
|
10833
|
+
const recordedAsPath = path37.resolve(callerCwd, opts.recorded);
|
|
10493
10834
|
let task;
|
|
10494
10835
|
let taskName;
|
|
10495
10836
|
let authoredApi;
|
|
10496
10837
|
let composition;
|
|
10497
|
-
const isSet =
|
|
10838
|
+
const isSet = existsSync31(path37.join(recordedAsPath, "recording-set.json"));
|
|
10498
10839
|
const registry = TASKS[opts.recorded];
|
|
10499
10840
|
if (registry !== void 0 && !isSet) {
|
|
10500
10841
|
task = registry;
|
|
@@ -10503,7 +10844,7 @@ async function runGenerateRecorded(opts) {
|
|
|
10503
10844
|
try {
|
|
10504
10845
|
const authored = authorTaskFromSet(recordedAsPath);
|
|
10505
10846
|
task = authored.task;
|
|
10506
|
-
taskName =
|
|
10847
|
+
taskName = path37.basename(recordedAsPath);
|
|
10507
10848
|
authoredApi = authored.api;
|
|
10508
10849
|
const roles = RolesSchema.safeParse(loadManifest(recordedAsPath).roles);
|
|
10509
10850
|
if (roles.success) composition = roles.data;
|
|
@@ -10533,8 +10874,11 @@ async function runGenerateRecorded(opts) {
|
|
|
10533
10874
|
if (substitutedFamilies.length > 0) {
|
|
10534
10875
|
warn(opts, `substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 scores measure a substitute face and no config can be certified under one (the stamp covers only the provisioned subset); resolve the families to certify`);
|
|
10535
10876
|
}
|
|
10877
|
+
for (const g of missingWeights(task.set)) {
|
|
10878
|
+
warn(opts, `font weights (advisory): the recording implies weights ${g.declared.join(", ")} for "${g.family}" \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (certification stays family-gated; implied weights can leak across families)`);
|
|
10879
|
+
}
|
|
10536
10880
|
const missing = task.configs.filter(
|
|
10537
|
-
(c) => !
|
|
10881
|
+
(c) => !existsSync31(path37.join(task.set, c.rep, "get_screenshot.json")) || !existsSync31(path37.join(task.set, c.rep, "get_metadata.json")) || !existsSync31(path37.join(task.set, c.rep, "get_design_context.json"))
|
|
10538
10882
|
);
|
|
10539
10883
|
if (missing.length > 0) {
|
|
10540
10884
|
fail(opts, ExitCode.RecordingIncomplete, {
|
|
@@ -10604,8 +10948,8 @@ async function runGenerateRecorded(opts) {
|
|
|
10604
10948
|
` : `${line}
|
|
10605
10949
|
`);
|
|
10606
10950
|
if (opts.dryRun) {
|
|
10607
|
-
emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite:
|
|
10608
|
-
process.stdout.write(`dry-run: nothing sent, nothing written (would write ${
|
|
10951
|
+
emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite: path37.join(outDirAbs, taskName) }, () => {
|
|
10952
|
+
process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path37.join(outDirAbs, taskName)})
|
|
10609
10953
|
`);
|
|
10610
10954
|
});
|
|
10611
10955
|
return;
|
|
@@ -10628,10 +10972,10 @@ async function runGenerateRecorded(opts) {
|
|
|
10628
10972
|
});
|
|
10629
10973
|
}
|
|
10630
10974
|
}
|
|
10631
|
-
const bundleDir =
|
|
10632
|
-
if (
|
|
10975
|
+
const bundleDir = path37.join(outDirAbs, taskName);
|
|
10976
|
+
if (existsSync31(path37.join(bundleDir, "component.json"))) {
|
|
10633
10977
|
try {
|
|
10634
|
-
const prior = readBundleManifest(
|
|
10978
|
+
const prior = readBundleManifest(readFileSync28(path37.join(bundleDir, "component.json"), "utf8")).manifest;
|
|
10635
10979
|
if (prior !== void 0 && prior.provenance.recordingSet.hash !== recordingSetHash(task.set, task.configs)) {
|
|
10636
10980
|
fail(opts, ExitCode.InputValidation, {
|
|
10637
10981
|
error: `${bundleDir} already holds a bundle bound to recording set "${prior.provenance.recordingSet.path}" \u2014 generating here against a different set would silently rewrite its verification identity`,
|
|
@@ -10644,6 +10988,7 @@ async function runGenerateRecorded(opts) {
|
|
|
10644
10988
|
}
|
|
10645
10989
|
}
|
|
10646
10990
|
const result = await runEngineLoop({
|
|
10991
|
+
absentInkGates: opts.bar === "cert",
|
|
10647
10992
|
engine,
|
|
10648
10993
|
segments,
|
|
10649
10994
|
brief,
|
|
@@ -10663,12 +11008,13 @@ async function runGenerateRecorded(opts) {
|
|
|
10663
11008
|
});
|
|
10664
11009
|
const statuses = result.finalScores.map((s) => ({
|
|
10665
11010
|
...s,
|
|
10666
|
-
status: s.similarity >= BARS4.cert.sim && s.inkRecall >= BARS4.cert.ink && substitutedFamilies.length === 0 ? "certified" : s.pass ? "pass" : "fail"
|
|
11011
|
+
status: (s.exact?.similarity ?? s.similarity) >= BARS4.cert.sim && (s.exact?.inkRecall ?? s.inkRecall) >= BARS4.cert.ink && substitutedFamilies.length === 0 && (s.absentInk?.length ?? 0) === 0 ? "certified" : s.pass ? "pass" : "fail"
|
|
10667
11012
|
}));
|
|
10668
11013
|
const behaviorFailures = result.finalBehaviors.filter((b) => !b.pass);
|
|
10669
11014
|
const pixelFailures = statuses.filter((s) => s.status === "fail");
|
|
10670
11015
|
const certified = statuses.filter((s) => s.status === "certified").length;
|
|
10671
|
-
const
|
|
11016
|
+
const absentAtCertBar = opts.bar === "cert" ? result.finalScores.filter((s) => (s.absentInk?.length ?? 0) > 0).map((s) => s.rep) : [];
|
|
11017
|
+
const ok = result.best !== void 0 && pixelFailures.length === 0 && behaviorFailures.length === 0 && absentAtCertBar.length === 0;
|
|
10672
11018
|
let trustStatement;
|
|
10673
11019
|
if (result.best !== void 0) {
|
|
10674
11020
|
const emitted = emitBundleV1({
|
|
@@ -10732,18 +11078,23 @@ ${certified}/${statuses.length} certified \xB7 ${statuses.length - pixelFailures
|
|
|
10732
11078
|
);
|
|
10733
11079
|
if (opts.bar === "cert" && substitutedFamilies.length > 0) {
|
|
10734
11080
|
const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
|
|
11081
|
+
const alsoAbsent = absentAtCertBar.length > 0 ? ` \u2014 and ${absentAtCertBar.length} config(s) also carry absent-ink clusters; certification requires fixing those too` : "";
|
|
10735
11082
|
if (opts.json) {
|
|
10736
|
-
process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
|
|
11083
|
+
process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
|
|
10737
11084
|
`);
|
|
10738
11085
|
} else {
|
|
10739
|
-
process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
|
|
11086
|
+
process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}
|
|
10740
11087
|
\u2192 ${fontsUnprovenRemediation(task.set)}
|
|
10741
11088
|
`);
|
|
10742
11089
|
}
|
|
10743
11090
|
process.exitCode = ExitCode.FontsUnproven;
|
|
10744
11091
|
return;
|
|
10745
11092
|
}
|
|
10746
|
-
if (
|
|
11093
|
+
if (absentAtCertBar.length > 0) {
|
|
11094
|
+
warn(opts, `${absentAtCertBar.length} config(s) carry absent-ink clusters (${absentAtCertBar.join(", ")}) \u2014 certification never ships a missing or invisible feature (1B); fix the component or run at --bar pass for the disclosed result`);
|
|
11095
|
+
process.exitCode = ExitCode.VerificationFailed;
|
|
11096
|
+
}
|
|
11097
|
+
if (!ok && (pixelFailures.length > 0 || behaviorFailures.length > 0 || result.best === void 0)) {
|
|
10747
11098
|
warn(opts, `${pixelFailures.length} config(s) and ${behaviorFailures.length} behavior(s) below the ${opts.bar} bar \u2014 bundle written, verdict honest (Q4)`);
|
|
10748
11099
|
process.exitCode = ExitCode.VerificationFailed;
|
|
10749
11100
|
}
|
|
@@ -11789,11 +12140,27 @@ function buildProgram() {
|
|
|
11789
12140
|
...local["out"] !== void 0 ? { out: local["out"] } : {}
|
|
11790
12141
|
});
|
|
11791
12142
|
});
|
|
11792
|
-
program.command("permissions").description("
|
|
12143
|
+
program.command("permissions").description("Install (or print) the Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools) \u2014 no more transcribing tool names from prompts.").option("--claude", "Claude Code settings format (the default and currently only format)").option("--write", "merge the per-tool entries into .claude/settings.local.json in the current project (idempotent; never touches other keys)").option("--user", "with --write: target ~/.claude/settings.json (every project) instead").option("--mcp-url <url>", "Figma MCP endpoint to list live tool names from").action(async (_o, cmd) => {
|
|
11793
12144
|
const flags = globalFlags(cmd.parent);
|
|
11794
12145
|
const local = cmd.opts();
|
|
11795
12146
|
const { runPermissions: runPermissions2 } = await Promise.resolve().then(() => (init_permissions(), permissions_exports));
|
|
11796
|
-
await runPermissions2({
|
|
12147
|
+
await runPermissions2({
|
|
12148
|
+
...flags,
|
|
12149
|
+
...local["write"] !== void 0 ? { write: local["write"] } : {},
|
|
12150
|
+
...local["user"] !== void 0 ? { user: local["user"] } : {},
|
|
12151
|
+
...local["mcpUrl"] !== void 0 ? { mcpUrl: local["mcpUrl"] } : {}
|
|
12152
|
+
});
|
|
12153
|
+
});
|
|
12154
|
+
program.command("inspect").description("Build an eye-verifiable detail sheet from verify evidence: magnified recorded-vs-rendered crops of every small recorded node (icons, controls, marks) plus full-frame triples, in one static HTML page.").argument("<bundleDir>", "bundle directory with component.json and verify-evidence").option("--set <dir>", "recording set override (default: the bundle's provenance path)").option("--max-area <px2>", "node area ceiling for the detail sweep", "1024").action(async (bundleDir, _o, cmd) => {
|
|
12155
|
+
const flags = globalFlags(cmd.parent);
|
|
12156
|
+
const local = cmd.opts();
|
|
12157
|
+
const { runInspect: runInspect2 } = await Promise.resolve().then(() => (init_inspect(), inspect_exports));
|
|
12158
|
+
await runInspect2({
|
|
12159
|
+
...flags,
|
|
12160
|
+
bundleDir,
|
|
12161
|
+
...local["set"] !== void 0 ? { set: local["set"] } : {},
|
|
12162
|
+
...local["maxArea"] !== void 0 ? { maxArea: Number(local["maxArea"]) } : {}
|
|
12163
|
+
});
|
|
11797
12164
|
});
|
|
11798
12165
|
program.command("verify").description("Recompute verification for a generated bundle against its recording set (per-config status, behaviors, honest exit codes).").argument("<bundleDir>", "bundle directory to verify").option("--task <name>", "legacy task adapter for bundles WITHOUT component.json (bundle v1 carries its own prop manifest)").option("--set <dir>", "recording set directory (overrides the bundle's provenance path)").option("--bar <bar>", "target bar: pass (0.95) or cert (0.97)", "pass").action(async (bundleDir, _opts, cmd) => {
|
|
11799
12166
|
const flags = globalFlags(cmd);
|