@tendrilapp/cli 0.1.18 → 0.1.19

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,28 @@ 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
+ First-run friction: if tendril/Figma tool calls are hitting permission
24
+ prompts, tell the user ONE approval can replace them all and offer
25
+ `tendril_permissions` with `write: true` — it merges the pipeline's
26
+ per-tool allowlist into the project's `.claude/settings.local.json`
27
+ (idempotent, touches nothing else; the user reloads the session to
28
+ apply). Never run it unoffered: it edits the user's settings.
29
+
30
+ Batch runs (several components in one session):
31
+ - Cap concurrent recorders at FOUR. All recorders share one Figma
32
+ desktop MCP server; a measured 8-wide batch hit its rate limit
33
+ (per-piece resume recovered, but the stall is avoidable).
34
+ - Batch the questions: plan ALL sets first, then put every
35
+ defaults-to-confirm and the one model question to the user together
36
+ — never one dialog per component.
37
+ - Create candidate directories with a bare `mkdir -p <dir>` — no
38
+ `&&`-compounds. Compound variants each need their own permission
39
+ approval; the bare form is one grant for the whole batch.
40
+ - Trust only tool output for completion state: check
41
+ `tendril_record_status` against disk, never a subagent's prose (a
42
+ measured batch had a recorder report success with zero reps
43
+ recorded).
44
+
23
45
  ## Recording a new component (needs Figma MCP)
24
46
 
25
47
  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. USE THIS when tendril/Figma tool calls keep hitting permission prompts: offer it to the user once \u2014 ONE approval here replaces a prompt per pipeline call (run 8 measured 16+ prompts for a single component's recording). The user must reload the session for new settings to apply.",
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 ? `
7757
7799
 
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."}`;
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")}` : ""}
7802
+
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 = () => ({
@@ -8257,6 +8302,23 @@ function recordedFontNeeds(setDir) {
8257
8302
  return { family, weights: weights.size === 0 ? [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,22 @@ 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 missingWeights(setDir, cacheDir) {
9265
+ let needs;
9266
+ try {
9267
+ needs = recordedFontNeeds(setDir);
9268
+ } catch {
9269
+ return [];
9270
+ }
9271
+ const provided = new Map(verifiedFontFamilies(cacheDir).map((f) => [f.family.toLowerCase(), f.weights]));
9272
+ const gaps = [];
9273
+ for (const n of needs) {
9274
+ const have = provided.get(n.family.toLowerCase());
9275
+ if (have === void 0) continue;
9276
+ if (n.weights.some((w) => !have.includes(w))) gaps.push({ family: n.family, declared: n.weights, provided: have });
9277
+ }
9278
+ return gaps;
9279
+ }
9190
9280
  var init_font_guidance = __esm({
9191
9281
  "packages/cli/src/font-guidance.ts"() {
9192
9282
  "use strict";
@@ -9198,6 +9288,7 @@ var init_font_guidance = __esm({
9198
9288
  // packages/cli/src/commands/verify.ts
9199
9289
  var verify_exports = {};
9200
9290
  __export(verify_exports, {
9291
+ foldConfigStatus: () => foldConfigStatus,
9201
9292
  interactionCoverage: () => interactionCoverage,
9202
9293
  runVerify: () => runVerify
9203
9294
  });
@@ -9212,6 +9303,26 @@ function interactionCoverage(behaviors) {
9212
9303
  operability: interaction.length > 0 && interaction.every((b) => b.pass) ? "verified" : "unverified"
9213
9304
  };
9214
9305
  }
9306
+ function foldConfigStatus(s, failDemotions, substitutedFamilies) {
9307
+ const { exact: _exact, ...reported } = s;
9308
+ let status = tierOf(s, BARS2.cert);
9309
+ const certDemote = [];
9310
+ let absentInkDemoted = false;
9311
+ if (status === "certified" && s.absentInk !== void 0 && s.absentInk.length > 0) {
9312
+ status = "pass";
9313
+ absentInkDemoted = true;
9314
+ 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)`));
9315
+ }
9316
+ if (status === "certified" && substitutedFamilies.length > 0) {
9317
+ status = "pass";
9318
+ certDemote.push(`substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 certification never measures a substitute face`);
9319
+ }
9320
+ const base = certDemote.length > 0 ? { ...reported, status, demotedBy: certDemote } : { ...reported, status };
9321
+ return {
9322
+ row: failDemotions === void 0 ? base : { ...base, status: "fail", demotedBy: [...certDemote, ...failDemotions] },
9323
+ absentInkDemoted
9324
+ };
9325
+ }
9215
9326
  function taskFromManifest(opts, manifest, setDir) {
9216
9327
  const recordedSlugs = loadManifest(setDir).reps.map((r) => r.slug);
9217
9328
  const adapterSlugs = Object.keys(manifest.propAdapter);
@@ -9357,6 +9468,9 @@ async function runVerify(opts) {
9357
9468
  } else if (opts.bar === "cert" && taskFontFamilies(task.set) === null) {
9358
9469
  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
9470
  }
9471
+ for (const g of missingWeights(task.set)) {
9472
+ 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)`);
9473
+ }
9360
9474
  const missing = task.configs.filter(
9361
9475
  (c) => !existsSync25(path31.join(task.set, c.rep, "get_screenshot.json")) || !existsSync25(path31.join(task.set, c.rep, "get_metadata.json"))
9362
9476
  );
@@ -9387,23 +9501,10 @@ async function runVerify(opts) {
9387
9501
  if (crops !== void 0) {
9388
9502
  for (const c of crops) if (!c.pass) demote(c.id.split(":")[1] ?? "", "composition crop failed (A1.4)");
9389
9503
  }
9504
+ const folded = scores.map((s) => foldConfigStatus(s, demotions.get(s.rep), substitutedFamilies));
9505
+ const absentInkDemoted = scores.filter((_, i) => folded[i].absentInkDemoted).map((s) => s.rep);
9390
9506
  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
- }),
9507
+ ...folded.map((f) => f.row),
9407
9508
  // ADR-010 §2 anti-gaming: recorded configs the adapter does not map
9408
9509
  // are FAILs, never silently absent.
9409
9510
  ...unmapped.map((rep) => ({ rep, similarity: 0, inkRecall: 0, pass: false, status: "fail", error: "not mapped by the bundle's prop adapter" }))
@@ -9416,7 +9517,9 @@ async function runVerify(opts) {
9416
9517
  const occlusionFailures = occlusion.filter((o) => !o.pass);
9417
9518
  const coverage = interactionCoverage(behaviors);
9418
9519
  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;
9520
+ const okExceptDemotion = pixelFailures.length === 0 && behaviorFailures.length === 0 && structuralFailures.length === 0 && cropFailures.length === 0 && occlusionFailures.length === 0 && !evidenceUnverified;
9521
+ const certBlockedByAbsentInk = opts.bar === "cert" ? absentInkDemoted : [];
9522
+ const ok = okExceptDemotion && certBlockedByAbsentInk.length === 0;
9420
9523
  const report = {
9421
9524
  bundle: opts.bundleDir,
9422
9525
  ...opts.task !== void 0 ? { task: opts.task } : {},
@@ -9473,7 +9576,11 @@ async function runVerify(opts) {
9473
9576
  crops: crops ?? { unavailable: regionsOut !== void 0 && "unavailable" in regionsOut ? regionsOut.unavailable : "no role manifest" }
9474
9577
  }
9475
9578
  } : {},
9476
- verdict: ok ? "verified" : "verification-failed"
9579
+ verdict: ok ? "verified" : "verification-failed",
9580
+ // Machine-readable cause (adversarial review): a cert-bar failure
9581
+ // with zero pixel/behavior/composition failures was only
9582
+ // explainable from stderr prose.
9583
+ ...certBlockedByAbsentInk.length > 0 ? { certBlockedByAbsentInk } : {}
9477
9584
  };
9478
9585
  emitData(opts, report, () => {
9479
9586
  for (const s of statuses) {
@@ -9533,7 +9640,7 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
9533
9640
  }
9534
9641
  if (evidenceUnverified) {
9535
9642
  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.
9643
+ `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
9644
  `
9538
9645
  );
9539
9646
  } else if (ic.interactionChecks === 0) {
@@ -9572,21 +9679,29 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
9572
9679
  });
9573
9680
  if (opts.bar === "cert" && substitutedFamilies.length > 0) {
9574
9681
  const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
9682
+ 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
9683
  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) })}
9684
+ 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
9685
  `);
9578
9686
  } else {
9579
- process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
9687
+ process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}
9580
9688
  \u2192 ${fontsUnprovenRemediation(task.set)}
9581
9689
  `);
9582
9690
  }
9583
9691
  process.exitCode = ExitCode.FontsUnproven;
9584
9692
  return;
9585
9693
  }
9586
- if (!ok) {
9694
+ if (certBlockedByAbsentInk.length > 0) {
9587
9695
  warn(
9588
9696
  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)`
9697
+ `${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.`
9698
+ );
9699
+ process.exitCode = ExitCode.VerificationFailed;
9700
+ }
9701
+ if (!okExceptDemotion) {
9702
+ warn(
9703
+ opts,
9704
+ `${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
9705
  );
9591
9706
  process.exitCode = ExitCode.VerificationFailed;
9592
9707
  }
@@ -9616,18 +9731,18 @@ __export(engine_exports, {
9616
9731
  runEngineBrief: () => runEngineBrief,
9617
9732
  runEngineScore: () => runEngineScore
9618
9733
  });
9619
- import { existsSync as existsSync26, mkdirSync as mkdirSync8, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
9734
+ import { appendFileSync, existsSync as existsSync26, mkdirSync as mkdirSync8, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
9620
9735
  import path32 from "node:path";
9621
9736
  function resolveEngineTask(opts, callerCwd) {
9622
9737
  const asPath = path32.resolve(callerCwd, opts.taskOrSet);
9623
9738
  const isSet = existsSync26(path32.join(asPath, "recording-set.json"));
9624
9739
  const registry = TASKS[opts.taskOrSet];
9625
- if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, disclosures: [] };
9740
+ if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, disclosures: [], interactionEvidence: [] };
9626
9741
  if (isSet) {
9627
9742
  try {
9628
9743
  const authored = authorTaskFromSet(asPath);
9629
9744
  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 } };
9745
+ 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
9746
  } catch (err) {
9632
9747
  fail(opts, ExitCode.InputValidation, {
9633
9748
  error: `cannot author a task from ${asPath}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
@@ -9669,12 +9784,14 @@ ${notRecorded}` : "";
9669
9784
  const unprovided = recordedFontFamilies(task.set).filter((r) => !provided.some((p) => p.toLowerCase() === r.toLowerCase()));
9670
9785
  if (unprovided.length > 0) {
9671
9786
  const names = unprovided.map((f) => `'${f}'`).join(", ");
9787
+ const PROPRIETARY = /^(sf pro|sf compact|sf mono|new york|pingfang|segoe ui|helvetica neue|proxima nova|avenir)/i;
9788
+ const allProprietary = unprovided.every((f) => PROPRIETARY.test(f.trim()));
9672
9789
  fontProvisioning = {
9673
9790
  unprovided,
9674
9791
  question: {
9675
9792
  prompt: `This design's text uses ${names}, which is not in the local font kit. How should it be handled?`,
9676
9793
  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.`,
9794
+ allProprietary ? `(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.`,
9678
9795
  '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
9796
  ]
9680
9797
  },
@@ -9733,7 +9850,7 @@ async function runEngineScore(opts) {
9733
9850
  requireEntitlement(opts);
9734
9851
  const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
9735
9852
  const candidateDir = path32.resolve(callerCwd, opts.candidateDir);
9736
- const { task, name, apiPin } = resolveEngineTask(opts, callerCwd);
9853
+ const { task, name, apiPin, interactionEvidence } = resolveEngineTask(opts, callerCwd);
9737
9854
  if (!existsSync26(candidateDir)) {
9738
9855
  fail(opts, ExitCode.InputValidation, {
9739
9856
  error: `candidate directory not found: ${candidateDir}`,
@@ -9752,6 +9869,9 @@ async function runEngineScore(opts) {
9752
9869
  if (substitutedFamilies.length > 0) {
9753
9870
  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
9871
  }
9872
+ for (const g of missingWeights(task.set)) {
9873
+ 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)`);
9874
+ }
9755
9875
  if (opts.rebind !== true && existsSync26(path32.join(candidateDir, "component.json"))) {
9756
9876
  const prior = (() => {
9757
9877
  try {
@@ -9792,12 +9912,13 @@ ${[
9792
9912
  ...quality.findings.map((f) => `- ${f.kind} ${f.file}${f.line === void 0 ? "" : `:${f.line}`} \u2014 ${f.message}`),
9793
9913
  ...quality.tokensAbsent ? ["- no design tokens: no tokens.css and no var(--\u2026) reference; every value is hardcoded"] : []
9794
9914
  ].join("\n")}`;
9795
- const allPass = obj[0] === total && total > 0;
9915
+ const absentAtCertBar = opts.bar === "cert" ? scores.filter((sc) => (sc.absentInk?.length ?? 0) > 0).map((sc) => sc.rep) : [];
9916
+ const allPass = obj[0] === total && total > 0 && absentAtCertBar.length === 0;
9796
9917
  const certBar = BARS3["cert"];
9797
9918
  const parityDemoted = new Set(parity.filter((p) => !p.pass).map((p) => p.id.replace(/^parity:/, "")));
9798
9919
  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
9920
  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.`));
9921
+ 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
9922
  const absentBlock = absentFindings.length > 0 ? `
9802
9923
 
9803
9924
  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 +9940,11 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
9819
9940
  environment: environmentStamp(taskFontFamilies(task.set)),
9820
9941
  substitutedFamilies
9821
9942
  });
9943
+ appendFileSync(
9944
+ path32.join(candidateDir, "score-history.jsonl"),
9945
+ `${JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), bar: opts.bar, pass: obj[0], total, certified: certifiedReps.length, floor: obj[1], mean: obj[2] })}
9946
+ `
9947
+ );
9822
9948
  emitData(
9823
9949
  opts,
9824
9950
  {
@@ -9838,6 +9964,10 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
9838
9964
  bundleManifest: emitted.written[0],
9839
9965
  note: "styles.css was stamped with the bundle provenance comment on line 1 \u2014 preserve it in any post-score edit",
9840
9966
  allPass,
9967
+ // Run 11: generators read allPass:true and reported success on
9968
+ // bundles verify then FAILED on the interaction-evidence gate —
9969
+ // the oracle must say what verify will say, including this.
9970
+ ...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
9971
  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
9972
  },
9843
9973
  () => {
@@ -9849,12 +9979,18 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
9849
9979
  ${obj[0]}/${total} \xB7 certified ${certifiedReps.length}/${total} \xB7 floor=${obj[1].toFixed(3)} \xB7 mean=${obj[2].toFixed(3)} \xB7 evidence: ${evidenceDir}
9850
9980
  `);
9851
9981
  const ic = interactionCoverage(behaviors);
9852
- if (ic.interactionChecks === 0) {
9982
+ if (interactionEvidence.length > 0 && ic.interactionChecks === 0) {
9983
+ 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.
9984
+ `);
9985
+ } else if (ic.interactionChecks === 0) {
9853
9986
  process.stdout.write(`UNVERIFIED operability \u2014 0 interaction checks; the behaviour passes above are page-level style hygiene
9854
9987
  `);
9855
9988
  }
9856
9989
  }
9857
9990
  );
9991
+ if (absentAtCertBar.length > 0) {
9992
+ 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`);
9993
+ }
9858
9994
  if (opts.bar === "cert" && substitutedFamilies.length > 0) {
9859
9995
  const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
9860
9996
  if (opts.json) {
@@ -10135,6 +10271,14 @@ var init_server = __esm({
10135
10271
  return argvOut;
10136
10272
  }
10137
10273
  },
10274
+ {
10275
+ name: "tendril_permissions",
10276
+ 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. USE THIS when tendril/Figma tool calls keep hitting permission prompts: offer it to the user once \u2014 ONE approval here replaces a prompt per pipeline call (run 8 measured 16+ prompts for a single component's recording). The user must reload the session for new settings to apply.",
10277
+ schema: z12.object({
10278
+ write: z12.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries")
10279
+ }),
10280
+ argv: (i) => ["permissions", "--claude", ...i["write"] === true ? ["--write"] : []]
10281
+ },
10138
10282
  {
10139
10283
  name: "tendril_doctor",
10140
10284
  annotations: { readOnlyHint: true },
@@ -10330,7 +10474,7 @@ var init_server = __esm({
10330
10474
  },
10331
10475
  {
10332
10476
  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.",
10477
+ 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
10478
  schema: z12.object({
10335
10479
  bundleDir: str("bundle directory to verify"),
10336
10480
  bar: optStr("pass (default) or cert"),
@@ -10378,8 +10522,33 @@ var permissions_exports = {};
10378
10522
  __export(permissions_exports, {
10379
10523
  PERMISSIONS_DESCRIPTION: () => PERMISSIONS_DESCRIPTION,
10380
10524
  buildPermissions: () => buildPermissions,
10525
+ mergeAllowlist: () => mergeAllowlist,
10381
10526
  runPermissions: () => runPermissions
10382
10527
  });
10528
+ import { existsSync as existsSync29, mkdirSync as mkdirSync9, readFileSync as readFileSync26, writeFileSync as writeFileSync15 } from "node:fs";
10529
+ import os7 from "node:os";
10530
+ import path35 from "node:path";
10531
+ function mergeAllowlist(file, entries) {
10532
+ let settings = {};
10533
+ if (existsSync29(file) && readFileSync26(file, "utf8").trim() !== "") {
10534
+ settings = JSON.parse(readFileSync26(file, "utf8"));
10535
+ if (settings === null || typeof settings !== "object" || Array.isArray(settings)) throw new Error("settings root is not an object");
10536
+ }
10537
+ const permissions = settings["permissions"] ??= {};
10538
+ if (permissions === null || typeof permissions !== "object" || Array.isArray(permissions)) throw new Error("permissions is not an object");
10539
+ const allow = permissions["allow"] ??= [];
10540
+ if (!Array.isArray(allow)) throw new Error("permissions.allow is not an array");
10541
+ const present = new Set(allow.filter((x) => typeof x === "string"));
10542
+ const added = entries.filter((e) => !present.has(e));
10543
+ const alreadyPresent = entries.filter((e) => present.has(e));
10544
+ if (added.length > 0) {
10545
+ allow.push(...added);
10546
+ mkdirSync9(path35.dirname(file), { recursive: true });
10547
+ writeFileSync15(file, `${JSON.stringify(settings, null, 2)}
10548
+ `);
10549
+ }
10550
+ return { added, alreadyPresent };
10551
+ }
10383
10552
  async function buildPermissions(options) {
10384
10553
  const LEGAL_TOOL_NAME = /^[A-Za-z0-9_-]+$/;
10385
10554
  const pipeline = new Set(FIGMA_TOOL_FALLBACK);
@@ -10408,11 +10577,44 @@ async function runPermissions(flags) {
10408
10577
  return;
10409
10578
  }
10410
10579
  const result = await buildPermissions({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} });
10580
+ if (flags.write) {
10581
+ const base = process.env["INIT_CWD"] ?? process.cwd();
10582
+ const file = flags.user ? path35.join(os7.homedir(), ".claude", "settings.json") : path35.join(base, ".claude", "settings.local.json");
10583
+ if (flags.dryRun) {
10584
+ emitData(flags, { file, wouldAdd: result.toolEntries }, () => {
10585
+ process.stdout.write(`dry-run: would merge ${result.toolEntries.length} per-tool entries into ${file}
10586
+ `);
10587
+ });
10588
+ return;
10589
+ }
10590
+ try {
10591
+ const { added, alreadyPresent } = mergeAllowlist(file, result.toolEntries);
10592
+ emitData(flags, { ...result, written: { file, added, alreadyPresent } }, () => {
10593
+ process.stdout.write(
10594
+ added.length === 0 ? `already installed: all ${alreadyPresent.length} Tendril pipeline entries present in ${file}
10595
+ ` : `installed: ${added.length} allowlist entr${added.length === 1 ? "y" : "ies"} added to ${file}${alreadyPresent.length > 0 ? ` (${alreadyPresent.length} already present)` : ""}
10596
+ restart or reopen the Claude Code session to pick up settings changes
10597
+ `
10598
+ );
10599
+ });
10600
+ } catch (err) {
10601
+ fail(flags, ExitCode.InputValidation, {
10602
+ error: `cannot rewrite ${file}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
10603
+ code: "settings-unwritable",
10604
+ 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."
10605
+ });
10606
+ }
10607
+ return;
10608
+ }
10411
10609
  emitData(flags, result, () => {
10412
10610
  const quoted = (xs) => xs.map((x) => ` ${JSON.stringify(x)}`).join(",\n");
10413
10611
  process.stdout.write(
10414
10612
  `Claude Code allowlist for the Tendril pipeline.
10415
- Paste into .claude/settings.json under permissions.allow:
10613
+ One command installs it (project-local, idempotent):
10614
+
10615
+ tendril permissions --claude --write
10616
+
10617
+ Or paste into .claude/settings.json under permissions.allow:
10416
10618
 
10417
10619
  ${quoted(result.toolEntries)}
10418
10620
 
@@ -10431,6 +10633,7 @@ var FIGMA_TOOL_FALLBACK, PERMISSIONS_DESCRIPTION, TENDRIL_PLUGIN_PREFIX, FIGMA_P
10431
10633
  var init_permissions = __esm({
10432
10634
  "packages/cli/src/commands/permissions.ts"() {
10433
10635
  "use strict";
10636
+ init_src3();
10434
10637
  init_src();
10435
10638
  init_server();
10436
10639
  init_describe();
@@ -10439,10 +10642,12 @@ var init_permissions = __esm({
10439
10642
  FIGMA_TOOL_FALLBACK = ["get_metadata", "get_design_context", "get_screenshot", "get_variable_defs", "get_motion_context", "get_figjam"];
10440
10643
  PERMISSIONS_DESCRIPTION = {
10441
10644
  name: "permissions",
10442
- summary: "Print a paste-ready Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools).",
10645
+ summary: "Install (or print) the Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools).",
10443
10646
  args: [],
10444
10647
  flags: [
10445
- { flag: "--claude", description: "Claude Code settings.json format (the default and currently only format)" },
10648
+ { flag: "--claude", description: "Claude Code settings format (the default and currently only format)" },
10649
+ { 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)" },
10650
+ { flag: "--user", description: "With --write: target ~/.claude/settings.json (every project) instead of the project-local file" },
10446
10651
  { flag: "--mcp-url <url>", description: "Figma MCP endpoint to list live tool names from", default: DEFAULT_MCP_URL },
10447
10652
  { flag: "--json", description: "Machine-readable output" }
10448
10653
  ],
@@ -10450,16 +10655,134 @@ var init_permissions = __esm({
10450
10655
  host: '"claude"',
10451
10656
  serverEntries: "string[] \u2014 one entry per MCP server (allows every tool it serves)",
10452
10657
  toolEntries: "string[] \u2014 per-tool entries for selective allowlists",
10453
- directConfigNote: "string \u2014 prefix rewrite for non-plugin (claude mcp add) installs"
10658
+ directConfigNote: "string \u2014 prefix rewrite for non-plugin (claude mcp add) installs",
10659
+ written: "with --write: { file, added, alreadyPresent }"
10454
10660
  },
10455
- exitCodes: { 0: "always (informational)" },
10456
- examples: ["tendril permissions --claude", "tendril permissions --claude --json"]
10661
+ exitCodes: { 0: "printed or written", 3: "with --write: the target file exists but is not JSON this command can safely rewrite" },
10662
+ examples: ["tendril permissions --claude --write", "tendril permissions --claude", "tendril permissions --claude --json"]
10457
10663
  };
10458
10664
  TENDRIL_PLUGIN_PREFIX = "mcp__plugin_tendril_tendril";
10459
10665
  FIGMA_PLUGIN_PREFIX = "mcp__plugin_figma_figma";
10460
10666
  }
10461
10667
  });
10462
10668
 
10669
+ // packages/cli/src/commands/inspect.ts
10670
+ var inspect_exports = {};
10671
+ __export(inspect_exports, {
10672
+ INSPECT_DESCRIPTION: () => INSPECT_DESCRIPTION,
10673
+ runInspect: () => runInspect
10674
+ });
10675
+ import { existsSync as existsSync30, readFileSync as readFileSync27, writeFileSync as writeFileSync16 } from "node:fs";
10676
+ import path36 from "node:path";
10677
+ async function runInspect(opts) {
10678
+ if (opts.describe) {
10679
+ printDescription(INSPECT_DESCRIPTION);
10680
+ return;
10681
+ }
10682
+ const bundleDir = path36.resolve(opts.bundleDir);
10683
+ const evidenceDir = path36.join(bundleDir, "verify-evidence");
10684
+ const manifestPath2 = path36.join(bundleDir, "component.json");
10685
+ if (!existsSync30(evidenceDir) || !existsSync30(manifestPath2)) {
10686
+ fail(opts, ExitCode.InputValidation, {
10687
+ error: `nothing to inspect in ${bundleDir} \u2014 ${existsSync30(manifestPath2) ? "no verify-evidence directory" : "no component.json"}`,
10688
+ code: "no-evidence",
10689
+ remediation: "Run `tendril verify <bundleDir>` first \u2014 inspect reads the ref/render evidence that run writes."
10690
+ });
10691
+ }
10692
+ const { manifest } = readBundleManifest(readFileSync27(manifestPath2, "utf8"));
10693
+ if (manifest === void 0) {
10694
+ fail(opts, ExitCode.InputValidation, {
10695
+ error: "component.json did not parse as a bundle manifest",
10696
+ code: "no-mount-contract",
10697
+ remediation: "Re-emit the bundle (score/verify rewrite component.json), then re-run inspect."
10698
+ });
10699
+ }
10700
+ const setDir = path36.resolve(opts.set ?? manifest.provenance.recordingSet.path);
10701
+ const reps = Object.keys(manifest.propAdapter).filter((rep) => existsSync30(path36.join(evidenceDir, `${rep}-ref.png`)) && existsSync30(path36.join(evidenceDir, `${rep}-render.png`)));
10702
+ if (reps.length === 0) {
10703
+ fail(opts, ExitCode.InputValidation, {
10704
+ error: "verify-evidence holds no ref/render pairs for this bundle's configs",
10705
+ code: "no-evidence",
10706
+ remediation: "Run `tendril verify <bundleDir>` first \u2014 inspect reads the ref/render evidence that run writes."
10707
+ });
10708
+ }
10709
+ let crops = 0;
10710
+ const sections = [];
10711
+ for (const rep of reps) {
10712
+ const ref = new Uint8Array(readFileSync27(path36.join(evidenceDir, `${rep}-ref.png`)));
10713
+ const render = new Uint8Array(readFileSync27(path36.join(evidenceDir, `${rep}-render.png`)));
10714
+ const nodes = smallSemanticNodes(setDir, rep, opts.maxArea ?? 1024).slice(0, 12);
10715
+ const cells = [];
10716
+ for (const [i, n] of nodes.entries()) {
10717
+ const rect = { x: n.x, y: n.y, w: n.w, h: n.h };
10718
+ try {
10719
+ writeFileSync16(path36.join(evidenceDir, `${rep}-inspect-${i}-ref.png`), zoomCrop(ref, rect));
10720
+ writeFileSync16(path36.join(evidenceDir, `${rep}-inspect-${i}-render.png`), zoomCrop(render, rect));
10721
+ } catch {
10722
+ continue;
10723
+ }
10724
+ crops += 1;
10725
+ cells.push(
10726
+ `<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>`
10727
+ );
10728
+ }
10729
+ sections.push(
10730
+ `<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>`
10731
+ );
10732
+ }
10733
+ const sheet = path36.join(evidenceDir, "inspect.html");
10734
+ writeFileSync16(
10735
+ sheet,
10736
+ `<!doctype html><meta charset="utf-8"><title>${esc(manifest.name)} \u2014 tendril inspect</title><style>
10737
+ body{font:14px/1.45 system-ui,sans-serif;margin:24px;background:#fff;color:#111}
10738
+ h1{font-size:20px} h2{font-size:16px;border-top:1px solid #ddd;padding-top:16px}
10739
+ .pair,.full{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-start}
10740
+ .full img{max-width:400px;border:1px solid #ccc} .pair img{border:1px solid #ccc;image-rendering:pixelated}
10741
+ figure{margin:0 0 16px} figcaption{margin-bottom:4px} em{display:block;color:#666;font-style:normal;font-size:12px}
10742
+ .grid{display:flex;flex-wrap:wrap;gap:20px;margin-top:12px} .none{color:#666}
10743
+ </style><h1>${esc(manifest.name)} \u2014 detail sheet (small recorded nodes, recorded vs rendered)</h1>
10744
+ <p>Every crop is a recorded node small enough that global metrics weight it as a rounding error.
10745
+ Scan the pairs: anything present on the left and missing/invisible on the right is a defect,
10746
+ whatever the scores said. Verdicts come from <code>tendril verify</code> \u2014 this sheet only shows.</p>
10747
+ ${sections.join("\n")}
10748
+ `
10749
+ );
10750
+ emitData(opts, { sheet, configs: reps.length, crops }, () => {
10751
+ process.stdout.write(`inspect sheet: ${sheet}
10752
+ ${reps.length} config(s), ${crops} detail crop pair(s) \u2014 open the sheet and scan recorded vs rendered
10753
+ `);
10754
+ });
10755
+ }
10756
+ var INSPECT_DESCRIPTION, esc;
10757
+ var init_inspect = __esm({
10758
+ "packages/cli/src/commands/inspect.ts"() {
10759
+ "use strict";
10760
+ init_src3();
10761
+ init_src6();
10762
+ init_src4();
10763
+ init_describe();
10764
+ init_output();
10765
+ INSPECT_DESCRIPTION = {
10766
+ name: "inspect",
10767
+ summary: "Build an eye-verifiable detail sheet from verify evidence: magnified ref-vs-render crops of every small recorded node (icons, controls, marks).",
10768
+ args: [{ name: "bundleDir", required: true, description: "bundle directory (must carry component.json and a verify-evidence dir from a prior `tendril verify`)" }],
10769
+ flags: [
10770
+ { flag: "--set <dir>", description: "recording set override (default: the bundle's provenance path)" },
10771
+ { flag: "--max-area <px2>", description: "node area ceiling for the detail sweep", default: "1024" },
10772
+ { flag: "--json", description: "Machine-readable output" }
10773
+ ],
10774
+ output: {
10775
+ sheet: "string \u2014 path to the generated inspect.html",
10776
+ configs: "number \u2014 configs with evidence found",
10777
+ crops: "number \u2014 detail crop pairs written"
10778
+ },
10779
+ exitCodes: { 0: "sheet written", 3: "no verify evidence to inspect (run `tendril verify` first)" },
10780
+ examples: ["tendril inspect ./src/components/Banner", "tendril inspect ./bundle --set ./tendril/recordings/banner"]
10781
+ };
10782
+ esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
10783
+ }
10784
+ });
10785
+
10463
10786
  // packages/cli/src/commands/generate-route.ts
10464
10787
  var generate_route_exports = {};
10465
10788
  __export(generate_route_exports, {
@@ -10484,17 +10807,17 @@ __export(generate_recorded_exports, {
10484
10807
  runGenerateRecorded: () => runGenerateRecorded
10485
10808
  });
10486
10809
  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";
10810
+ import { existsSync as existsSync31, readFileSync as readFileSync28 } from "node:fs";
10811
+ import path37 from "node:path";
10489
10812
  async function runGenerateRecorded(opts) {
10490
10813
  const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
10491
- const outDirAbs = path35.resolve(callerCwd, opts.out);
10492
- const recordedAsPath = path35.resolve(callerCwd, opts.recorded);
10814
+ const outDirAbs = path37.resolve(callerCwd, opts.out);
10815
+ const recordedAsPath = path37.resolve(callerCwd, opts.recorded);
10493
10816
  let task;
10494
10817
  let taskName;
10495
10818
  let authoredApi;
10496
10819
  let composition;
10497
- const isSet = existsSync29(path35.join(recordedAsPath, "recording-set.json"));
10820
+ const isSet = existsSync31(path37.join(recordedAsPath, "recording-set.json"));
10498
10821
  const registry = TASKS[opts.recorded];
10499
10822
  if (registry !== void 0 && !isSet) {
10500
10823
  task = registry;
@@ -10503,7 +10826,7 @@ async function runGenerateRecorded(opts) {
10503
10826
  try {
10504
10827
  const authored = authorTaskFromSet(recordedAsPath);
10505
10828
  task = authored.task;
10506
- taskName = path35.basename(recordedAsPath);
10829
+ taskName = path37.basename(recordedAsPath);
10507
10830
  authoredApi = authored.api;
10508
10831
  const roles = RolesSchema.safeParse(loadManifest(recordedAsPath).roles);
10509
10832
  if (roles.success) composition = roles.data;
@@ -10533,8 +10856,11 @@ async function runGenerateRecorded(opts) {
10533
10856
  if (substitutedFamilies.length > 0) {
10534
10857
  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
10858
  }
10859
+ for (const g of missingWeights(task.set)) {
10860
+ 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)`);
10861
+ }
10536
10862
  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"))
10863
+ (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
10864
  );
10539
10865
  if (missing.length > 0) {
10540
10866
  fail(opts, ExitCode.RecordingIncomplete, {
@@ -10604,8 +10930,8 @@ async function runGenerateRecorded(opts) {
10604
10930
  ` : `${line}
10605
10931
  `);
10606
10932
  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)})
10933
+ emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite: path37.join(outDirAbs, taskName) }, () => {
10934
+ process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path37.join(outDirAbs, taskName)})
10609
10935
  `);
10610
10936
  });
10611
10937
  return;
@@ -10628,10 +10954,10 @@ async function runGenerateRecorded(opts) {
10628
10954
  });
10629
10955
  }
10630
10956
  }
10631
- const bundleDir = path35.join(outDirAbs, taskName);
10632
- if (existsSync29(path35.join(bundleDir, "component.json"))) {
10957
+ const bundleDir = path37.join(outDirAbs, taskName);
10958
+ if (existsSync31(path37.join(bundleDir, "component.json"))) {
10633
10959
  try {
10634
- const prior = readBundleManifest(readFileSync26(path35.join(bundleDir, "component.json"), "utf8")).manifest;
10960
+ const prior = readBundleManifest(readFileSync28(path37.join(bundleDir, "component.json"), "utf8")).manifest;
10635
10961
  if (prior !== void 0 && prior.provenance.recordingSet.hash !== recordingSetHash(task.set, task.configs)) {
10636
10962
  fail(opts, ExitCode.InputValidation, {
10637
10963
  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 +10970,7 @@ async function runGenerateRecorded(opts) {
10644
10970
  }
10645
10971
  }
10646
10972
  const result = await runEngineLoop({
10973
+ absentInkGates: opts.bar === "cert",
10647
10974
  engine,
10648
10975
  segments,
10649
10976
  brief,
@@ -10663,12 +10990,13 @@ async function runGenerateRecorded(opts) {
10663
10990
  });
10664
10991
  const statuses = result.finalScores.map((s) => ({
10665
10992
  ...s,
10666
- status: s.similarity >= BARS4.cert.sim && s.inkRecall >= BARS4.cert.ink && substitutedFamilies.length === 0 ? "certified" : s.pass ? "pass" : "fail"
10993
+ 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
10994
  }));
10668
10995
  const behaviorFailures = result.finalBehaviors.filter((b) => !b.pass);
10669
10996
  const pixelFailures = statuses.filter((s) => s.status === "fail");
10670
10997
  const certified = statuses.filter((s) => s.status === "certified").length;
10671
- const ok = result.best !== void 0 && pixelFailures.length === 0 && behaviorFailures.length === 0;
10998
+ const absentAtCertBar = opts.bar === "cert" ? result.finalScores.filter((s) => (s.absentInk?.length ?? 0) > 0).map((s) => s.rep) : [];
10999
+ const ok = result.best !== void 0 && pixelFailures.length === 0 && behaviorFailures.length === 0 && absentAtCertBar.length === 0;
10672
11000
  let trustStatement;
10673
11001
  if (result.best !== void 0) {
10674
11002
  const emitted = emitBundleV1({
@@ -10732,18 +11060,23 @@ ${certified}/${statuses.length} certified \xB7 ${statuses.length - pixelFailures
10732
11060
  );
10733
11061
  if (opts.bar === "cert" && substitutedFamilies.length > 0) {
10734
11062
  const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
11063
+ const alsoAbsent = absentAtCertBar.length > 0 ? ` \u2014 and ${absentAtCertBar.length} config(s) also carry absent-ink clusters; certification requires fixing those too` : "";
10735
11064
  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) })}
11065
+ 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
11066
  `);
10738
11067
  } else {
10739
- process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
11068
+ process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}
10740
11069
  \u2192 ${fontsUnprovenRemediation(task.set)}
10741
11070
  `);
10742
11071
  }
10743
11072
  process.exitCode = ExitCode.FontsUnproven;
10744
11073
  return;
10745
11074
  }
10746
- if (!ok) {
11075
+ if (absentAtCertBar.length > 0) {
11076
+ 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`);
11077
+ process.exitCode = ExitCode.VerificationFailed;
11078
+ }
11079
+ if (!ok && (pixelFailures.length > 0 || behaviorFailures.length > 0 || result.best === void 0)) {
10747
11080
  warn(opts, `${pixelFailures.length} config(s) and ${behaviorFailures.length} behavior(s) below the ${opts.bar} bar \u2014 bundle written, verdict honest (Q4)`);
10748
11081
  process.exitCode = ExitCode.VerificationFailed;
10749
11082
  }
@@ -11789,11 +12122,27 @@ function buildProgram() {
11789
12122
  ...local["out"] !== void 0 ? { out: local["out"] } : {}
11790
12123
  });
11791
12124
  });
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) => {
12125
+ 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
12126
  const flags = globalFlags(cmd.parent);
11794
12127
  const local = cmd.opts();
11795
12128
  const { runPermissions: runPermissions2 } = await Promise.resolve().then(() => (init_permissions(), permissions_exports));
11796
- await runPermissions2({ ...flags, ...local["mcpUrl"] !== void 0 ? { mcpUrl: local["mcpUrl"] } : {} });
12129
+ await runPermissions2({
12130
+ ...flags,
12131
+ ...local["write"] !== void 0 ? { write: local["write"] } : {},
12132
+ ...local["user"] !== void 0 ? { user: local["user"] } : {},
12133
+ ...local["mcpUrl"] !== void 0 ? { mcpUrl: local["mcpUrl"] } : {}
12134
+ });
12135
+ });
12136
+ 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) => {
12137
+ const flags = globalFlags(cmd.parent);
12138
+ const local = cmd.opts();
12139
+ const { runInspect: runInspect2 } = await Promise.resolve().then(() => (init_inspect(), inspect_exports));
12140
+ await runInspect2({
12141
+ ...flags,
12142
+ bundleDir,
12143
+ ...local["set"] !== void 0 ? { set: local["set"] } : {},
12144
+ ...local["maxArea"] !== void 0 ? { maxArea: Number(local["maxArea"]) } : {}
12145
+ });
11797
12146
  });
11798
12147
  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
12148
  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.19",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",