@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 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
@@ -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 sub-bar with an honest report.",
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":"sub-bar HONEST result \u2014 the report and bundle are valid; this is not a tool failure"}` }] };
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 k = kebab(v);
766
- return INTERACTION_STATES.has(k) || BOOLEAN_STATES.has(k) || ENGAGED_STATES.has(k);
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
- const candidates = recorded.some((v) => !avoid(v)) ? recorded.filter((v) => !avoid(v)) : recorded;
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(path36) {
1218
- return `--${path36.join("-")}`;
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 path36 = variableNameToPath(variable.name);
1263
- if (path36.length === 0) {
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: path36 };
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: path36 } of entries) {
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 path36.slice(0, -1)) {
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 = path36[path36.length - 1];
1296
+ const leaf = path38[path38.length - 1];
1295
1297
  if (group[leaf] !== void 0) {
1296
- throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path36.join(".")}" (variable ${variable.id})`);
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: path36.join("."),
1301
- cssVar: tokenPathToCssVar(path36),
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 path36 = ctx.pathById.get(id);
1491
- if (path36 === void 0) ctx.unresolved.add(id);
1492
- return path36;
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 path36 = resolveBinding(ctx, id);
1528
- if (path36 !== void 0) tokens.add(path36);
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 path36 = resolveBinding(ctx, radiusId);
1537
- if (path36 !== void 0) tokens.add(path36);
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 path36 = resolveBinding(ctx, gapId);
1548
- if (path36 !== void 0) {
1549
- layout.gap = path36;
1550
- tokens.add(path36);
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 path36 = resolveBinding(ctx, id);
1560
- if (path36 !== void 0) {
1561
- paddingPaths.push(path36);
1562
- tokens.add(path36);
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 deepestNodeNameAt(set, rep, px, py) {
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
- root = parseMetadataStructure(text);
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?.name;
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
- return tierOf(s, BARS.cert);
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
- ...scores.map((s) => {
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 ok = pixelFailures.length === 0 && behaviorFailures.length === 0 && structuralFailures.length === 0 && cropFailures.length === 0 && occlusionFailures.length === 0 && !evidenceUnverified;
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 (!ok) {
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 provided = verifiedFontFamilies().map((f) => f.family);
9669
- const unprovided = recordedFontFamilies(task.set).filter((r) => !provided.some((p) => p.toLowerCase() === r.toLowerCase()));
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 allPass = obj[0] === total && total > 0;
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 sub-bar with an honest report.",
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
- Paste into .claude/settings.json under permissions.allow:
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: "Print a paste-ready Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools).",
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.json format (the default and currently only format)" },
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: "always (informational)" },
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 existsSync29, readFileSync as readFileSync26 } from "node:fs";
10488
- import path35 from "node:path";
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 = path35.resolve(callerCwd, opts.out);
10492
- const recordedAsPath = path35.resolve(callerCwd, opts.recorded);
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 = existsSync29(path35.join(recordedAsPath, "recording-set.json"));
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 = path35.basename(recordedAsPath);
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) => !existsSync29(path35.join(task.set, c.rep, "get_screenshot.json")) || !existsSync29(path35.join(task.set, c.rep, "get_metadata.json")) || !existsSync29(path35.join(task.set, c.rep, "get_design_context.json"))
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: path35.join(outDirAbs, taskName) }, () => {
10608
- process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path35.join(outDirAbs, taskName)})
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 = path35.join(outDirAbs, taskName);
10632
- if (existsSync29(path35.join(bundleDir, "component.json"))) {
10975
+ const bundleDir = path37.join(outDirAbs, taskName);
10976
+ if (existsSync31(path37.join(bundleDir, "component.json"))) {
10633
10977
  try {
10634
- const prior = readBundleManifest(readFileSync26(path35.join(bundleDir, "component.json"), "utf8")).manifest;
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 ok = result.best !== void 0 && pixelFailures.length === 0 && behaviorFailures.length === 0;
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 (!ok) {
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("Print a paste-ready 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.json format (the default and currently only format)").option("--mcp-url <url>", "Figma MCP endpoint to list live tool names from").action(async (_o, cmd) => {
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({ ...flags, ...local["mcpUrl"] !== void 0 ? { mcpUrl: local["mcpUrl"] } : {} });
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",