@tendrilapp/cli 0.1.16 → 0.1.18

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/tendril.js CHANGED
@@ -1214,8 +1214,8 @@ var init_src = __esm({
1214
1214
  function variableNameToPath(name) {
1215
1215
  return name.split("/").map(canonicalCssIdentPart).filter((part) => part.length > 0);
1216
1216
  }
1217
- function tokenPathToCssVar(path35) {
1218
- return `--${path35.join("-")}`;
1217
+ function tokenPathToCssVar(path36) {
1218
+ return `--${path36.join("-")}`;
1219
1219
  }
1220
1220
  function toDtcgToken(variable, defaultMode) {
1221
1221
  const modes = Object.keys(variable.valuesByMode);
@@ -1259,11 +1259,11 @@ function toDtcgToken(variable, defaultMode) {
1259
1259
  }
1260
1260
  function mapVariablesToDtcg(variables, defaultMode = "light") {
1261
1261
  const entries = variables.map((variable) => {
1262
- const path35 = variableNameToPath(variable.name);
1263
- if (path35.length === 0) {
1262
+ const path36 = variableNameToPath(variable.name);
1263
+ if (path36.length === 0) {
1264
1264
  throw new DtcgMappingError("empty-name", `Figma variable ${variable.id} has an empty name`);
1265
1265
  }
1266
- return { variable, path: path35 };
1266
+ return { variable, path: path36 };
1267
1267
  });
1268
1268
  const groupPrefixes = /* @__PURE__ */ new Set();
1269
1269
  for (const e of entries) {
@@ -1284,21 +1284,21 @@ function mapVariablesToDtcg(variables, defaultMode = "light") {
1284
1284
  }
1285
1285
  const tokens = {};
1286
1286
  const flat = [];
1287
- for (const { variable, path: path35 } of entries) {
1287
+ for (const { variable, path: path36 } of entries) {
1288
1288
  const token = toDtcgToken(variable, defaultMode);
1289
1289
  let group = tokens;
1290
- for (const segment of path35.slice(0, -1)) {
1290
+ for (const segment of path36.slice(0, -1)) {
1291
1291
  const existing = group[segment];
1292
1292
  group = existing ?? (group[segment] = {});
1293
1293
  }
1294
- const leaf = path35[path35.length - 1];
1294
+ const leaf = path36[path36.length - 1];
1295
1295
  if (group[leaf] !== void 0) {
1296
- throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path35.join(".")}" (variable ${variable.id})`);
1296
+ throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path36.join(".")}" (variable ${variable.id})`);
1297
1297
  }
1298
1298
  group[leaf] = token;
1299
1299
  flat.push({
1300
- path: path35.join("."),
1301
- cssVar: tokenPathToCssVar(path35),
1300
+ path: path36.join("."),
1301
+ cssVar: tokenPathToCssVar(path36),
1302
1302
  type: token.$type,
1303
1303
  value: token.$value
1304
1304
  });
@@ -1487,9 +1487,9 @@ function boundId(value) {
1487
1487
  return isObject(value) && typeof value["boundVariableId"] === "string" ? value["boundVariableId"] : void 0;
1488
1488
  }
1489
1489
  function resolveBinding(ctx, id) {
1490
- const path35 = ctx.pathById.get(id);
1491
- if (path35 === void 0) ctx.unresolved.add(id);
1492
- return path35;
1490
+ const path36 = ctx.pathById.get(id);
1491
+ if (path36 === void 0) ctx.unresolved.add(id);
1492
+ return path36;
1493
1493
  }
1494
1494
  function parseVariantProps(name) {
1495
1495
  if (!name.includes("=")) return void 0;
@@ -1524,8 +1524,8 @@ function walk(ctx, raw) {
1524
1524
  if (!isObject(paint) || paint["visible"] === false) continue;
1525
1525
  const id = boundId(paint);
1526
1526
  if (id !== void 0) {
1527
- const path35 = resolveBinding(ctx, id);
1528
- if (path35 !== void 0) tokens.add(path35);
1527
+ const path36 = resolveBinding(ctx, id);
1528
+ if (path36 !== void 0) tokens.add(path36);
1529
1529
  } else if (typeof paint["color"] === "string") {
1530
1530
  ctx.hardcoded.push({ node: name, property, value: paint["color"] });
1531
1531
  }
@@ -1533,8 +1533,8 @@ function walk(ctx, raw) {
1533
1533
  }
1534
1534
  const radiusId = boundId(raw["cornerRadius"]);
1535
1535
  if (radiusId !== void 0) {
1536
- const path35 = resolveBinding(ctx, radiusId);
1537
- if (path35 !== void 0) tokens.add(path35);
1536
+ const path36 = resolveBinding(ctx, radiusId);
1537
+ if (path36 !== void 0) tokens.add(path36);
1538
1538
  } else if (typeof raw["cornerRadius"] === "number" && raw["cornerRadius"] !== 0) {
1539
1539
  ctx.hardcoded.push({ node: name, property: "border-radius", value: `${raw["cornerRadius"]}px` });
1540
1540
  }
@@ -1544,10 +1544,10 @@ function walk(ctx, raw) {
1544
1544
  layout = { mode: layoutMode === "HORIZONTAL" ? "flex-row" : "flex-column" };
1545
1545
  const gapId = boundId(raw["itemSpacing"]);
1546
1546
  if (gapId !== void 0) {
1547
- const path35 = resolveBinding(ctx, gapId);
1548
- if (path35 !== void 0) {
1549
- layout.gap = path35;
1550
- tokens.add(path35);
1547
+ const path36 = resolveBinding(ctx, gapId);
1548
+ if (path36 !== void 0) {
1549
+ layout.gap = path36;
1550
+ tokens.add(path36);
1551
1551
  }
1552
1552
  } else if (typeof raw["itemSpacing"] === "number" && raw["itemSpacing"] !== 0) {
1553
1553
  ctx.hardcoded.push({ node: name, property: "gap", value: `${raw["itemSpacing"]}px` });
@@ -1556,10 +1556,10 @@ function walk(ctx, raw) {
1556
1556
  for (const field of PADDING_FIELDS) {
1557
1557
  const id = boundId(raw[field]);
1558
1558
  if (id !== void 0) {
1559
- const path35 = resolveBinding(ctx, id);
1560
- if (path35 !== void 0) {
1561
- paddingPaths.push(path35);
1562
- tokens.add(path35);
1559
+ const path36 = resolveBinding(ctx, id);
1560
+ if (path36 !== void 0) {
1561
+ paddingPaths.push(path36);
1562
+ tokens.add(path36);
1563
1563
  }
1564
1564
  } else if (typeof raw[field] === "number" && raw[field] !== 0) {
1565
1565
  ctx.hardcoded.push({ node: name, property: field, value: `${raw[field]}px` });
@@ -1968,14 +1968,15 @@ var init_tsc_check = __esm({
1968
1968
  // packages/verify/src/token-lint.ts
1969
1969
  import strictValue from "stylelint-declaration-strict-value";
1970
1970
  import stylelint from "stylelint";
1971
- async function runTokenLint(css, fileLabel = "generated.css", definedVars2) {
1971
+ async function runTokenLint(css, fileLabel = "generated.css", definedVars2, recordedMapEmpty = false) {
1972
1972
  const result = await stylelint.lint({
1973
1973
  code: css,
1974
1974
  codeFilename: fileLabel,
1975
1975
  config: STYLELINT_CONFIG
1976
1976
  });
1977
+ const mapKnownEmpty = recordedMapEmpty || definedVars2 !== void 0 && definedVars2.size === 0;
1977
1978
  const violations = result.results.flatMap(
1978
- (r) => r.warnings.map((w) => ({
1979
+ (r) => r.warnings.filter((w) => !(mapKnownEmpty && w.rule === "scale-unlimited/declaration-strict-value")).map((w) => ({
1979
1980
  file: fileLabel,
1980
1981
  line: w.line,
1981
1982
  property: w.rule,
@@ -2392,6 +2393,111 @@ function cornersMatchCanvas(ref, canvas) {
2392
2393
  (c) => Math.abs(c[0] - canvas[0]) + Math.abs(c[1] - canvas[1]) + Math.abs(c[2] - canvas[2]) <= INK_DELTA
2393
2394
  );
2394
2395
  }
2396
+ function absentInkClusters(render, reference, background = [255, 255, 255]) {
2397
+ const a = PNG.sync.read(Buffer.from(render));
2398
+ const b = PNG.sync.read(Buffer.from(reference));
2399
+ const width = Math.max(a.width, b.width);
2400
+ const height = Math.max(a.height, b.height);
2401
+ const canvasA = onCanvas(a, width, height);
2402
+ const canvasB = onCanvas(b, width, height);
2403
+ const ring = ringModal(canvasB);
2404
+ const backgrounds = channelDistance(ring, background) > INK_DELTA ? [background, ring] : [background];
2405
+ const found = [];
2406
+ for (const bg of backgrounds) {
2407
+ const renderInk = new Uint8Array(width * height);
2408
+ for (let y = 0; y < height; y++) {
2409
+ for (let x = 0; x < width; x++) {
2410
+ if (isInk(canvasA.data, (width * y + x) * 4, bg)) renderInk[width * y + x] = 1;
2411
+ }
2412
+ }
2413
+ const candidate = new Uint8Array(width * height);
2414
+ for (let y = 0; y < height; y++) {
2415
+ for (let x = 0; x < width; x++) {
2416
+ if (!isInk(canvasB.data, (width * y + x) * 4, bg)) continue;
2417
+ let covered = false;
2418
+ scan: for (let dy = -ABSENT_RADIUS; dy <= ABSENT_RADIUS; dy++) {
2419
+ for (let dx = -ABSENT_RADIUS; dx <= ABSENT_RADIUS; dx++) {
2420
+ const nx = x + dx;
2421
+ const ny = y + dy;
2422
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
2423
+ if (renderInk[width * ny + nx] === 1) {
2424
+ covered = true;
2425
+ break scan;
2426
+ }
2427
+ }
2428
+ }
2429
+ if (!covered) candidate[width * y + x] = 1;
2430
+ }
2431
+ }
2432
+ const seen = new Uint8Array(width * height);
2433
+ for (let sy = 0; sy < height; sy++) {
2434
+ for (let sx = 0; sx < width; sx++) {
2435
+ const si = width * sy + sx;
2436
+ if (candidate[si] !== 1 || seen[si] === 1) continue;
2437
+ let x0 = sx;
2438
+ let y0 = sy;
2439
+ let x1 = sx;
2440
+ let y1 = sy;
2441
+ let px = 0;
2442
+ const stack = [si];
2443
+ seen[si] = 1;
2444
+ while (stack.length > 0) {
2445
+ const i = stack.pop();
2446
+ const cx = i % width;
2447
+ const cy = (i - cx) / width;
2448
+ px += 1;
2449
+ if (cx < x0) x0 = cx;
2450
+ if (cy < y0) y0 = cy;
2451
+ if (cx > x1) x1 = cx;
2452
+ if (cy > y1) y1 = cy;
2453
+ for (let dy = -1; dy <= 1; dy++) {
2454
+ for (let dx = -1; dx <= 1; dx++) {
2455
+ const nx = cx + dx;
2456
+ const ny = cy + dy;
2457
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
2458
+ const ni = width * ny + nx;
2459
+ if (candidate[ni] === 1 && seen[ni] === 0) {
2460
+ seen[ni] = 1;
2461
+ stack.push(ni);
2462
+ }
2463
+ }
2464
+ }
2465
+ }
2466
+ if (px >= ABSENT_MIN_PX) found.push({ x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1, px, memberX: sx, memberY: sy });
2467
+ }
2468
+ }
2469
+ }
2470
+ const merged = [];
2471
+ for (const c of found.sort((c1, c2) => c2.px - c1.px)) {
2472
+ const overlaps = merged.some((m) => c.x < m.x + m.w && m.x < c.x + c.w && c.y < m.y + m.h && m.y < c.y + c.h);
2473
+ if (!overlaps) merged.push(c);
2474
+ }
2475
+ return merged;
2476
+ }
2477
+ function zoomCrop(png, rect, scale = 8, pad = 6) {
2478
+ const src = PNG.sync.read(Buffer.from(png));
2479
+ const x0 = Math.max(0, rect.x - pad);
2480
+ const y0 = Math.max(0, rect.y - pad);
2481
+ const x1 = Math.min(src.width, rect.x + rect.w + pad);
2482
+ const y1 = Math.min(src.height, rect.y + rect.h + pad);
2483
+ const w = Math.max(1, x1 - x0);
2484
+ const h = Math.max(1, y1 - y0);
2485
+ const out = new PNG({ width: w * scale, height: h * scale });
2486
+ for (let y = 0; y < h * scale; y++) {
2487
+ for (let x = 0; x < w * scale; x++) {
2488
+ const sx = x0 + Math.floor(x / scale);
2489
+ const sy = y0 + Math.floor(y / scale);
2490
+ const si = (src.width * sy + sx) * 4;
2491
+ const di = (out.width * y + x) * 4;
2492
+ const alpha = src.data[si + 3] ?? 0;
2493
+ out.data[di] = Math.round(((src.data[si] ?? 0) * alpha + 255 * (255 - alpha)) / 255);
2494
+ out.data[di + 1] = Math.round(((src.data[si + 1] ?? 0) * alpha + 255 * (255 - alpha)) / 255);
2495
+ out.data[di + 2] = Math.round(((src.data[si + 2] ?? 0) * alpha + 255 * (255 - alpha)) / 255);
2496
+ out.data[di + 3] = 255;
2497
+ }
2498
+ }
2499
+ return Uint8Array.from(PNG.sync.write(out));
2500
+ }
2395
2501
  function measureCanvasCoupling(ref, renderA, renderB, backdrop, box) {
2396
2502
  const rRaw = PNG.sync.read(Buffer.from(ref));
2397
2503
  const r = onCanvas(rRaw, rRaw.width, rRaw.height);
@@ -2606,11 +2712,13 @@ function cropPng(png, x, y, width, height) {
2606
2712
  }
2607
2713
  return new Uint8Array(PNG.sync.write(out));
2608
2714
  }
2609
- var INK_DELTA;
2715
+ var INK_DELTA, ABSENT_RADIUS, ABSENT_MIN_PX;
2610
2716
  var init_image_diff = __esm({
2611
2717
  "packages/verify/src/image-diff.ts"() {
2612
2718
  "use strict";
2613
2719
  INK_DELTA = 30;
2720
+ ABSENT_RADIUS = 3;
2721
+ ABSENT_MIN_PX = 6;
2614
2722
  }
2615
2723
  });
2616
2724
 
@@ -3301,7 +3409,29 @@ function resolvedFontFamilies(cacheDir = DEFAULT_FONT_CACHE) {
3301
3409
  function requiredFontsManifest(families, cacheDir = DEFAULT_FONT_CACHE) {
3302
3410
  const mPath = path9.join(cacheDir, "manifest.json");
3303
3411
  const manifest = existsSync5(mPath) ? JSON.parse(readFileSync3(mPath, "utf8")) : [];
3304
- return manifest.filter((f) => families.includes(f.family));
3412
+ const wanted = new Set(families.map((f) => f.toLowerCase()));
3413
+ return manifest.filter((f) => wanted.has(f.family.toLowerCase()));
3414
+ }
3415
+ function verifiedFontFamilies(cacheDir = DEFAULT_FONT_CACHE) {
3416
+ const mPath = path9.join(cacheDir, "manifest.json");
3417
+ if (!existsSync5(mPath)) return [];
3418
+ let entries;
3419
+ try {
3420
+ entries = JSON.parse(readFileSync3(mPath, "utf8"));
3421
+ } catch {
3422
+ return [];
3423
+ }
3424
+ const byFamily = /* @__PURE__ */ new Map();
3425
+ for (const e of entries) {
3426
+ if (typeof e.family !== "string" || typeof e.weight !== "number" || typeof e.file !== "string") continue;
3427
+ const file = path9.isAbsolute(e.file) && existsSync5(e.file) ? e.file : path9.resolve(cacheDir, path9.basename(e.file));
3428
+ if (!existsSync5(file)) continue;
3429
+ if (createHash("sha256").update(readFileSync3(file)).digest("hex") !== e.sha256) continue;
3430
+ const set = byFamily.get(e.family) ?? /* @__PURE__ */ new Set();
3431
+ set.add(e.weight);
3432
+ byFamily.set(e.family, set);
3433
+ }
3434
+ return [...byFamily.entries()].map(([family, w]) => ({ family, weights: [...w].sort((a, b) => a - b) }));
3305
3435
  }
3306
3436
  var DEFAULT_FONT_CACHE, UA;
3307
3437
  var init_font_resolve = __esm({
@@ -3313,11 +3443,21 @@ var init_font_resolve = __esm({
3313
3443
  });
3314
3444
 
3315
3445
  // packages/verify/src/font-faces.ts
3446
+ import { createHash as createHash2 } from "node:crypto";
3316
3447
  import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
3317
3448
  import path10 from "node:path";
3318
3449
  function fontFaceCss(manifestPath2 = path10.join(fontCacheDir(), "manifest.json")) {
3319
3450
  if (!existsSync6(manifestPath2)) return "";
3320
- const manifest = JSON.parse(readFileSync4(manifestPath2, "utf8"));
3451
+ const claimed = JSON.parse(readFileSync4(manifestPath2, "utf8"));
3452
+ const resolveEntry = (f) => {
3453
+ if (path10.isAbsolute(f) && existsSync6(f)) return f;
3454
+ return path10.resolve(path10.dirname(manifestPath2), path10.basename(f));
3455
+ };
3456
+ const manifest = claimed.filter((f) => {
3457
+ const file = resolveEntry(f.file);
3458
+ if (!existsSync6(file)) return false;
3459
+ return createHash2("sha256").update(readFileSync4(file)).digest("hex") === f.sha256;
3460
+ });
3321
3461
  const resolveFile = (f) => {
3322
3462
  if (path10.isAbsolute(f) && existsSync6(f)) return f;
3323
3463
  return path10.resolve(path10.dirname(manifestPath2), path10.basename(f));
@@ -4088,7 +4228,28 @@ function definedVars(tokensCss) {
4088
4228
  for (const m of tokensCss.matchAll(/(--[\w-]+)\s*:/g)) names.add(m[1]);
4089
4229
  return names;
4090
4230
  }
4091
- async function checkBundleQuality(bundleDir, entry) {
4231
+ function recordedTokenMapEmpty(setDir, reps) {
4232
+ const readMap = (file) => {
4233
+ if (!existsSync9(file)) return void 0;
4234
+ try {
4235
+ const parsed = JSON.parse(envelopeFirstTextPart(JSON.parse(readFileSync7(file, "utf8"))) || "{}");
4236
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
4237
+ } catch {
4238
+ return {};
4239
+ }
4240
+ };
4241
+ const setLevel = readMap(path14.join(setDir, "get_variable_defs.json"));
4242
+ if (setLevel !== void 0) return Object.keys(setLevel).length === 0;
4243
+ let recorded = false;
4244
+ for (const rep of reps) {
4245
+ const m = readMap(path14.join(setDir, rep, "get_variable_defs.json"));
4246
+ if (m === void 0) continue;
4247
+ recorded = true;
4248
+ if (Object.keys(m).length > 0) return false;
4249
+ }
4250
+ return recorded;
4251
+ }
4252
+ async function checkBundleQuality(bundleDir, entry, set) {
4092
4253
  const findings = [];
4093
4254
  const entryPath = path14.join(bundleDir, entry);
4094
4255
  const cssPath = path14.join(bundleDir, "styles.css");
@@ -4108,7 +4269,8 @@ async function checkBundleQuality(bundleDir, entry) {
4108
4269
  }
4109
4270
  }
4110
4271
  if (css !== "") {
4111
- for (const v of (await runTokenLint(css, "styles.css", definedVars(tokensCss))).violations) {
4272
+ const recordedEmpty = set !== void 0 && recordedTokenMapEmpty(set.dir, set.reps);
4273
+ for (const v of (await runTokenLint(css, "styles.css", definedVars(tokensCss), recordedEmpty)).violations) {
4112
4274
  findings.push({ kind: "token-lint", file: "styles.css", line: v.line, message: v.message });
4113
4275
  }
4114
4276
  }
@@ -4117,6 +4279,7 @@ async function checkBundleQuality(bundleDir, entry) {
4117
4279
  var init_bundle_quality = __esm({
4118
4280
  "packages/verify/src/bundle-quality.ts"() {
4119
4281
  "use strict";
4282
+ init_src();
4120
4283
  init_runtime();
4121
4284
  init_tsc_check();
4122
4285
  init_token_lint();
@@ -4215,6 +4378,31 @@ function chooseSeat(cropAt, ref, padW, padH, backdrop, origin, extents) {
4215
4378
  }
4216
4379
  return { comparison, seat, scored: cropAt(origin - seat.x, origin - seat.y) };
4217
4380
  }
4381
+ function deepestNodeNameAt(set, rep, px, py) {
4382
+ let root;
4383
+ try {
4384
+ 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
+ } catch {
4387
+ return void 0;
4388
+ }
4389
+ let best;
4390
+ const walk2 = (node, ox, oy, isRoot) => {
4391
+ if (node.hidden === true) return;
4392
+ const nx = isRoot ? 0 : ox + (node.x ?? 0);
4393
+ const ny = isRoot ? 0 : oy + (node.y ?? 0);
4394
+ const w = node.width ?? 0;
4395
+ const h = node.height ?? 0;
4396
+ const contains = px >= nx && py >= ny && px < nx + w && py < ny + h;
4397
+ if (contains && !isRoot && node.name !== "") {
4398
+ const area = w * h;
4399
+ if (best === void 0 || area <= best.area) best = { name: `${node.name} (${node.id})`, area };
4400
+ }
4401
+ for (const child of node.children) walk2(child, nx, ny, false);
4402
+ };
4403
+ walk2(root, 0, 0, true);
4404
+ return best?.name;
4405
+ }
4218
4406
  function repMeta(set, rep) {
4219
4407
  const text = JSON.parse(readFileSync8(path15.join(set, rep, "get_metadata.json"), "utf8")).content.map((c) => c.text ?? "").join("\n");
4220
4408
  const root = parseMetadataStructure(text);
@@ -4335,10 +4523,18 @@ body{margin:0;background:rgb(${backdrop.join(",")})}
4335
4523
  const slab = realPadBoth && coupling.padCorners.masked >= 8 && coupling.padCorners.opaque / coupling.padCorners.masked >= 0.9;
4336
4524
  const pass = r.similarity >= bar.sim && r.inkRecall >= bar.ink && !slab;
4337
4525
  const region = pass ? void 0 : localizeDifference(scored, ref);
4526
+ const absent = absentInkClusters(scored, ref, backdrop).map((c) => {
4527
+ const name = deepestNodeNameAt(task.set, cfg.rep, c.memberX - seat.x, c.memberY - seat.y);
4528
+ return name === void 0 ? c : { ...c, name };
4529
+ });
4338
4530
  if (opts.evidenceDir !== void 0) {
4339
4531
  writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-render.png`), scored);
4340
4532
  writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-ref.png`), ref);
4341
4533
  writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-diff.png`), diffPng(scored, ref));
4534
+ for (const [i, c] of absent.slice(0, 3).entries()) {
4535
+ writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-absent-${i}-ref.png`), zoomCrop(ref, c));
4536
+ writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-absent-${i}-render.png`), zoomCrop(scored, c));
4537
+ }
4342
4538
  }
4343
4539
  return {
4344
4540
  rep: cfg.rep,
@@ -4349,6 +4545,7 @@ body{margin:0;background:rgb(${backdrop.join(",")})}
4349
4545
  ...slab ? { error: `paints the page canvas to the pad corners (${coupling.padCorners.opaque}/${coupling.padCorners.masked} opaque) \u2014 a square slab carrying the recorded canvas past the frame, wrong on any real page` } : {},
4350
4546
  ...region === void 0 ? {} : { region: { x0: region.x0, y0: region.y0, x1: region.x1, y1: region.y1, density: Math.round(region.density * 100) / 100 } },
4351
4547
  ...seat.x === 0 && seat.y === 0 ? {} : { seat },
4548
+ ...absent.length > 0 ? { absentInk: absent } : {},
4352
4549
  ...coupling.padMasked + coupling.inBoxMasked > 0 ? { canvasCoupling: coupling } : {}
4353
4550
  };
4354
4551
  } finally {
@@ -5032,7 +5229,7 @@ var init_src4 = __esm({
5032
5229
  // packages/cli/src/environment.ts
5033
5230
  import { existsSync as existsSync14, readFileSync as readFileSync11 } from "node:fs";
5034
5231
  import path19 from "node:path";
5035
- import { createHash as createHash2 } from "node:crypto";
5232
+ import { createHash as createHash3 } from "node:crypto";
5036
5233
  import { fileURLToPath as fileURLToPath4 } from "node:url";
5037
5234
  function cliVersion() {
5038
5235
  try {
@@ -5049,7 +5246,7 @@ function environmentStamp(taskFamilies) {
5049
5246
  const entries = JSON.parse(readFileSync11(manifestPath2, "utf8"));
5050
5247
  const scoped = taskFamilies === void 0 || taskFamilies === null ? entries : entries.filter((f) => taskFamilies.some((fam) => fam.toLowerCase() === f.family.toLowerCase()));
5051
5248
  const faces = scoped.map((f) => `${f.family}:${f.weight}:${f.sha256}`).sort();
5052
- fontsHash = faces.length === 0 ? null : createHash2("sha256").update(faces.join("\n")).digest("hex").slice(0, 16);
5249
+ fontsHash = faces.length === 0 ? null : createHash3("sha256").update(faces.join("\n")).digest("hex").slice(0, 16);
5053
5250
  } catch {
5054
5251
  fontsHash = null;
5055
5252
  }
@@ -5068,6 +5265,25 @@ var init_environment = __esm({
5068
5265
  }
5069
5266
  });
5070
5267
 
5268
+ // packages/cli/src/describe.ts
5269
+ function printDescription(description) {
5270
+ process.stdout.write(`${JSON.stringify(description, null, 2)}
5271
+ `);
5272
+ }
5273
+ var COMMON_EXIT_CODES;
5274
+ var init_describe = __esm({
5275
+ "packages/cli/src/describe.ts"() {
5276
+ "use strict";
5277
+ COMMON_EXIT_CODES = {
5278
+ 0: "success",
5279
+ 1: "general error",
5280
+ 2: "authentication error",
5281
+ 3: "input validation error",
5282
+ 4: "confirmation required (re-run with --yes or answer the prompt)"
5283
+ };
5284
+ }
5285
+ });
5286
+
5071
5287
  // packages/cli/src/env.ts
5072
5288
  import { existsSync as existsSync15, readFileSync as readFileSync12 } from "node:fs";
5073
5289
  import path20 from "node:path";
@@ -5244,6 +5460,211 @@ var init_entitlement = __esm({
5244
5460
  }
5245
5461
  });
5246
5462
 
5463
+ // packages/cli/src/commands/doctor.ts
5464
+ import { spawnSync } from "node:child_process";
5465
+ import { existsSync as existsSync17, readFileSync as readFileSync14, readdirSync as readdirSync3 } from "node:fs";
5466
+ import os4 from "node:os";
5467
+ import path22 from "node:path";
5468
+ async function runDoctorChecks(options) {
5469
+ const checks = [];
5470
+ const mcpUrl = options.mcpUrl ?? DEFAULT_MCP_URL;
5471
+ const client = new McpHttpClient({
5472
+ url: mcpUrl,
5473
+ ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
5474
+ });
5475
+ try {
5476
+ const info = await client.initialize();
5477
+ const tools = await client.listTools();
5478
+ checks.push({
5479
+ name: "figma-desktop-mcp",
5480
+ ok: true,
5481
+ detail: `${info.name} ${info.version} (protocol ${info.protocolVersion}), ${tools.length} tools: ${tools.map((t) => t.name).join(", ")}`
5482
+ });
5483
+ } catch (err) {
5484
+ checks.push({
5485
+ name: "figma-desktop-mcp",
5486
+ ok: false,
5487
+ detail: err instanceof Error ? err.message : String(err),
5488
+ remediation: err instanceof McpUnavailableError ? "Open the Figma desktop app, sign in, then Figma menu \u2192 Preferences \u2192 Enable local MCP server (Dev or Full seat on a paid plan)." : "The server answered but the exchange failed \u2014 check the signed-in account's seat, then retry."
5489
+ });
5490
+ }
5491
+ const openrouterKey = resolveCredential("OPENROUTER_API_KEY");
5492
+ checks.push(
5493
+ openrouterKey ? { name: "openrouter-key", ok: true, detail: "OPENROUTER_API_KEY configured" } : {
5494
+ name: "openrouter-key",
5495
+ ok: false,
5496
+ detail: "OPENROUTER_API_KEY not set \u2014 optional; only the curated API-model engine needs it",
5497
+ remediation: "Run `tendril init` to store your OpenRouter key (BYOK) if you plan to use the curated engine."
5498
+ }
5499
+ );
5500
+ try {
5501
+ const chrome = resolveChrome();
5502
+ checks.push({ name: "browser", ok: true, detail: `${chromeVersion() ?? "version unknown"} at ${chrome}` });
5503
+ } catch (err) {
5504
+ checks.push({
5505
+ name: "browser",
5506
+ ok: false,
5507
+ detail: err instanceof Error ? err.message : String(err),
5508
+ remediation: "Install Google Chrome (or Chromium), or set CHROME_PATH to a Chromium-family browser binary."
5509
+ });
5510
+ }
5511
+ const fontManifest = path22.join(fontCacheDir(), "manifest.json");
5512
+ checks.push(
5513
+ existsSync17(fontManifest) ? { name: "font-cache", ok: true, detail: `resolved font cache at ${fontCacheDir()} (${JSON.parse(readFileSync14(fontManifest, "utf8")).length} faces)` } : {
5514
+ name: "font-cache",
5515
+ ok: true,
5516
+ detail: `font cache empty at ${fontCacheDir()} \u2014 normal on a fresh machine; faces resolve per design system at first use`,
5517
+ remediation: "Nothing to do now: `tendril fonts resolve --set <recording-dir>` fetches exactly what a recording declares, and generate/verify name that command when they need it."
5518
+ }
5519
+ );
5520
+ const pluginRoot = path22.join(os4.homedir(), ".claude", "plugins", "cache", "tendrilapp", "tendril");
5521
+ if (existsSync17(pluginRoot)) {
5522
+ try {
5523
+ const versions = readdirSync3(pluginRoot).filter((v) => /^\d+\.\d+\.\d+$/.test(v));
5524
+ const newest = versions.sort((a, b) => versionIsNewer(a, b) ? 1 : -1)[0];
5525
+ if (newest !== void 0) {
5526
+ const skewed = versionIsNewer(newest, cliVersion());
5527
+ checks.push(
5528
+ skewed ? {
5529
+ name: "plugin-skew",
5530
+ ok: false,
5531
+ detail: `Claude Code plugin cache holds v${newest} while this CLI is ${cliVersion()} \u2014 the plugin's skill/agents are STALE and will silently shadow current doctrine`,
5532
+ remediation: "Update the plugin \u2014 REFRESH THE MARKETPLACE FIRST (its local clone goes stale and a reinstall faithfully reinstalls the old version): terminal Claude Code \u2014 `claude plugin marketplace update tendrilapp` then `claude plugin update tendril`; VS Code extension \u2014 /plugins panel \u2192 Marketplaces tab \u2192 refresh tendrilapp, THEN Plugins tab \u2192 uninstall + reinstall tendril, reopen the chat panel. If the refresh doesn't take, remove the tendrilapp marketplace entirely and re-add TendrilApp/claude-plugin (a fresh clone cannot be stale)."
5533
+ } : { name: "plugin-skew", ok: true, detail: `Claude Code plugin v${newest} matches this CLI` }
5534
+ );
5535
+ }
5536
+ } catch {
5537
+ }
5538
+ }
5539
+ const pathBinary = cliVersion() === "0.0.0" ? null : findPathTendril(process.env["PATH"] ?? "", process.platform);
5540
+ if (pathBinary !== null) {
5541
+ const reported = probeVersion(pathBinary);
5542
+ if (reported !== null) {
5543
+ checks.push(
5544
+ reported === cliVersion() ? { name: "path-skew", ok: true, detail: `tendril on PATH (${pathBinary}) is this CLI (${reported})` } : {
5545
+ name: "path-skew",
5546
+ ok: false,
5547
+ detail: `tendril on PATH (${pathBinary}) reports ${reported} while this CLI is ${cliVersion()} \u2014 a stale global install silently shadows the current release in terminals`,
5548
+ remediation: "npm rm -g tendrilapp (or npm install -g @tendrilapp/cli@latest) so the PATH binary matches; plugin/npx sessions already run the current release."
5549
+ }
5550
+ );
5551
+ }
5552
+ }
5553
+ const ent = checkEntitlement();
5554
+ checks.push(
5555
+ ent.ok ? ent.mode === "pre-launch" ? { name: "entitlement", ok: true, detail: "pre-launch build \u2014 entitlement gates present but unarmed (no public key shipped); record/generate run freely" } : { name: "entitlement", ok: true, detail: `active plan "${ent.claims.plan}" until ${new Date(ent.claims.exp).toISOString().slice(0, 10)}${ent.stale ? " (stale \u2014 renews at next opportunity)" : ""}` } : { name: "entitlement", ok: false, detail: ent.error, remediation: ent.remediation }
5556
+ );
5557
+ const figmaToken = resolveCredential("FIGMA_TOKEN");
5558
+ checks.push({
5559
+ name: "figma-pat",
5560
+ ok: true,
5561
+ detail: figmaToken ? "FIGMA_TOKEN configured (REST fallback available)" : "FIGMA_TOKEN not set \u2014 optional; only needed for the REST fallback transport"
5562
+ });
5563
+ return { ok: checks.filter((c) => c.name !== "figma-pat" && c.name !== "openrouter-key" && c.name !== "path-skew").every((c) => c.ok), checks };
5564
+ }
5565
+ function findPathTendril(pathEnv, platform) {
5566
+ const dirs = pathEnv.split(path22.delimiter).filter((d) => d !== "" && !/node_modules[\\/]\.bin/.test(d) && !/[\\/]_npx[\\/]/.test(d));
5567
+ const names = platform === "win32" ? ["tendril.cmd", "tendril.bat"] : ["tendril"];
5568
+ for (const dir of dirs) {
5569
+ for (const name of names) {
5570
+ const candidate = path22.join(dir, name);
5571
+ if (existsSync17(candidate)) return candidate;
5572
+ }
5573
+ }
5574
+ return null;
5575
+ }
5576
+ function probeVersion(binary) {
5577
+ const windowsShim = /\.(cmd|bat)$/i.test(binary);
5578
+ const res = windowsShim ? spawnSync(`"${binary}" --version`, { shell: true, timeout: 5e3, encoding: "utf8" }) : spawnSync(binary, ["--version"], { timeout: 5e3, encoding: "utf8" });
5579
+ if (res.error !== void 0 || res.status !== 0) return null;
5580
+ const m = /^(\d+\.\d+\.\d+)\s*$/m.exec(res.stdout ?? "");
5581
+ return m === null ? null : m[1];
5582
+ }
5583
+ function versionIsNewer(a, b) {
5584
+ const pa = a.split(".").map(Number);
5585
+ const pb = b.split(".").map(Number);
5586
+ for (let i = 0; i < 3; i++) {
5587
+ if ((pb[i] ?? 0) > (pa[i] ?? 0)) return true;
5588
+ if ((pb[i] ?? 0) < (pa[i] ?? 0)) return false;
5589
+ }
5590
+ return false;
5591
+ }
5592
+ async function latestVersionInfo() {
5593
+ try {
5594
+ const ctl = new AbortController();
5595
+ const timer = setTimeout(() => ctl.abort(), 2500);
5596
+ const res = await fetch("https://registry.npmjs.org/@tendrilapp%2fcli", { signal: ctl.signal });
5597
+ clearTimeout(timer);
5598
+ if (!res.ok) return null;
5599
+ const doc = await res.json();
5600
+ const latest = doc["dist-tags"]?.latest;
5601
+ if (latest === void 0) return null;
5602
+ const stamp = doc.time?.[latest];
5603
+ return { latest, ...stamp !== void 0 ? { publishedAt: stamp.slice(0, 10) } : {} };
5604
+ } catch {
5605
+ return null;
5606
+ }
5607
+ }
5608
+ async function runDoctor(flags) {
5609
+ if (flags.describe) {
5610
+ printDescription(DOCTOR_DESCRIPTION);
5611
+ return;
5612
+ }
5613
+ const [report, latest] = await Promise.all([runDoctorChecks({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} }), latestVersionInfo()]);
5614
+ emitData(flags, { version: cliVersion(), ...latest !== null ? { latest: latest.latest, latestPublishedAt: latest.publishedAt ?? null, updateAvailable: versionIsNewer(cliVersion(), latest.latest) } : {}, ...report }, () => {
5615
+ const version = cliVersion();
5616
+ if (latest === null) {
5617
+ process.stdout.write(`tendril ${version} (latest: unreachable)
5618
+ `);
5619
+ } else if (!versionIsNewer(version, latest.latest)) {
5620
+ process.stdout.write(`tendril ${version} (${latest.latest === version ? "latest" : `ahead of registry ${latest.latest}`}${latest.publishedAt !== void 0 ? `, published ${latest.publishedAt}` : ""})
5621
+ `);
5622
+ } else {
5623
+ process.stdout.write(`tendril ${version} \u2014 UPDATE AVAILABLE: ${latest.latest}${latest.publishedAt !== void 0 ? ` (published ${latest.publishedAt})` : ""}
5624
+ `);
5625
+ process.stdout.write(` \u2192 npm install -g @tendrilapp/cli@latest (plugin MCP users update automatically at next session; a stale npx cache clears with npm cache clean --force)
5626
+ `);
5627
+ }
5628
+ for (const check of report.checks) {
5629
+ process.stdout.write(`${check.ok ? "\u2713" : "\u2717"} ${check.name}: ${check.detail}
5630
+ `);
5631
+ if (check.remediation) process.stdout.write(` \u2192 ${check.remediation}
5632
+ `);
5633
+ }
5634
+ process.stdout.write(report.ok ? "\nready to generate\n" : "\nnot ready \u2014 fix the items above\n");
5635
+ });
5636
+ if (!report.ok) process.exit(1);
5637
+ }
5638
+ var DEFAULT_MCP_URL, DOCTOR_DESCRIPTION;
5639
+ var init_doctor = __esm({
5640
+ "packages/cli/src/commands/doctor.ts"() {
5641
+ "use strict";
5642
+ init_src4();
5643
+ init_src();
5644
+ init_describe();
5645
+ init_env();
5646
+ init_environment();
5647
+ init_output();
5648
+ init_entitlement();
5649
+ DEFAULT_MCP_URL = "http://127.0.0.1:3845/mcp";
5650
+ DOCTOR_DESCRIPTION = {
5651
+ name: "doctor",
5652
+ summary: "Check whether this machine can run tendril generate end to end.",
5653
+ args: [],
5654
+ flags: [
5655
+ { flag: "--mcp-url <url>", description: "Figma MCP endpoint", default: DEFAULT_MCP_URL },
5656
+ { flag: "--json", description: "Machine-readable output" }
5657
+ ],
5658
+ output: {
5659
+ ok: "boolean \u2014 Figma desktop MCP reachable AND a scoring browser found (fonts resolve lazily per design system; keys are informational)",
5660
+ checks: "[{ name, ok, detail, remediation? }]"
5661
+ },
5662
+ exitCodes: { 0: "healthy", 1: "one or more required checks failed" },
5663
+ examples: ["tendril doctor", "tendril doctor --json"]
5664
+ };
5665
+ }
5666
+ });
5667
+
5247
5668
  // packages/llm/src/model-config.ts
5248
5669
  import { z as z6 } from "zod";
5249
5670
  function resolveModel(config, requestedId) {
@@ -6545,7 +6966,8 @@ __export(record_exports, {
6545
6966
  runRecordPlan: () => runRecordPlan,
6546
6967
  runRecordStatus: () => runRecordStatus
6547
6968
  });
6548
- import { existsSync as existsSync19, readFileSync as readFileSync16, readdirSync as readdirSync5 } from "node:fs";
6969
+ import { existsSync as existsSync19, mkdtempSync as mkdtempSync2, readFileSync as readFileSync16, readdirSync as readdirSync5 } from "node:fs";
6970
+ import os5 from "node:os";
6549
6971
  import path25 from "node:path";
6550
6972
  import { writeFileSync as writeFileSync9 } from "node:fs";
6551
6973
  function symbolsFromMetadataEnvelope(file, sourceFrame) {
@@ -6592,19 +7014,51 @@ function runRecordPlan(opts) {
6592
7014
  }
6593
7015
  defaults[spec.slice(0, eq)] = spec.slice(eq + 1);
6594
7016
  }
6595
- let symbols = [];
6596
- let metadataTruncated = false;
6597
- for (const spec of opts.metadataFiles) {
7017
+ if (opts.metadataRawFile !== void 0 && opts.metadataRawPartsFile !== void 0) {
7018
+ fail(opts, ExitCode.InputValidation, {
7019
+ error: "pass at most one of --metadata-raw-file and --metadata-raw-parts-file",
7020
+ code: "bad-envelope",
7021
+ remediation: "One get_metadata response: single block \u2192 --metadata-raw-file; multi-block \u2192 --metadata-raw-parts-file (a JSON array of the blocks)."
7022
+ });
7023
+ }
7024
+ const metadataEntries = (opts.metadataFiles ?? []).map((spec) => {
6598
7025
  const [file, frame] = spec.split("@");
7026
+ return frame === void 0 ? { file } : { file, frame };
7027
+ });
7028
+ const rawFile = opts.metadataRawPartsFile ?? opts.metadataRawFile;
7029
+ if (rawFile !== void 0) {
6599
7030
  try {
6600
- const parsed = symbolsFromMetadataEnvelope(path25.resolve(file), frame);
6601
- symbols.push(...parsed.symbols);
6602
- if (parsed.truncated) metadataTruncated = true;
7031
+ const envelope = rawEnvelopeFromFile(rawFile, opts.metadataRawPartsFile !== void 0);
7032
+ const tmp = path25.join(mkdtempSync2(path25.join(os5.tmpdir(), "tendril-plan-meta-")), "get_metadata.json");
7033
+ writeFileSync9(tmp, JSON.stringify(envelope));
7034
+ metadataEntries.push({ file: tmp });
6603
7035
  } catch (err) {
6604
7036
  fail(opts, ExitCode.InputValidation, {
6605
- error: `could not read metadata envelope ${file}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
7037
+ error: `could not read raw metadata ${rawFile}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
6606
7038
  code: "bad-envelope",
6607
- remediation: 'Save the get_metadata TOOL RESPONSE verbatim as JSON \u2014 shape {"content":[{"type":"text","text":"<frame \u2026>\u2026"}]} \u2014 and pass that file. Raw copied text is not an envelope.'
7039
+ remediation: "The raw file holds the get_metadata response text exactly as received (or, for --metadata-raw-parts-file, a JSON array of its blocks)."
7040
+ });
7041
+ }
7042
+ }
7043
+ if (metadataEntries.length === 0) {
7044
+ fail(opts, ExitCode.InputValidation, {
7045
+ error: "no metadata provided",
7046
+ code: "bad-envelope",
7047
+ remediation: "Pass --metadata <envelope.json> (repeatable), or the response text via --metadata-raw-file / --metadata-raw-parts-file."
7048
+ });
7049
+ }
7050
+ let symbols = [];
7051
+ let metadataTruncated = false;
7052
+ for (const { file, frame } of metadataEntries) {
7053
+ try {
7054
+ const parsed = symbolsFromMetadataEnvelope(path25.resolve(file), frame);
7055
+ symbols.push(...parsed.symbols);
7056
+ if (parsed.truncated) metadataTruncated = true;
7057
+ } catch (err) {
7058
+ fail(opts, ExitCode.InputValidation, {
7059
+ error: `could not read metadata envelope ${file}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
7060
+ code: "bad-envelope",
7061
+ remediation: 'Save the get_metadata TOOL RESPONSE verbatim as JSON \u2014 shape {"content":[{"type":"text","text":"<frame \u2026>\u2026"}]} \u2014 and pass that file. Raw copied text is not an envelope.'
6608
7062
  });
6609
7063
  }
6610
7064
  }
@@ -6628,8 +7082,7 @@ function runRecordPlan(opts) {
6628
7082
  });
6629
7083
  }
6630
7084
  if (symbols.length === 0) {
6631
- const leads = opts.metadataFiles.flatMap((spec) => {
6632
- const [file] = spec.split("@");
7085
+ const leads = metadataEntries.flatMap(({ file }) => {
6633
7086
  try {
6634
7087
  const env = JSON.parse(readFileSync16(path25.resolve(file), "utf8"));
6635
7088
  return instanceLeads(env.content.map((c) => c.text ?? "").join("\n"));
@@ -8052,6 +8505,7 @@ OVERLAYS GO IN THE TOP LAYER, NOT ON A Z-INDEX. The prelude requires isolation:
8052
8505
 
8053
8506
  PAINT & PLATFORM TRAPS (each measured on a paid run; every one passed typecheck, token lint, and all behaviour checks and was caught only by pixels or by use):
8054
8507
  - A composite custom property resolves on the element that DECLARES it. A composite token (e.g. --ring: 0 0 0 var(--w) var(--c)) may only reference variables declared at or above its own declaration site \u2014 declared higher while its parts are set on the component, it is invalid-at-computed-value-time and the property silently computes to nothing (measured: a hover ring vanished; three configs at ~0.78).
8508
+ - An <svg> inside an absolutely-positioned glyph layer defaults to display: inline \u2014 it seats on a text baseline, its painted content lands BELOW the box the layout allocated, and an overflow: hidden ancestor clips the displaced ink away entirely. Large glyphs survive; small marks (a 2px dot, a 1.5px stem) vanish while every geometry probe reports the boxes correct (measured: 12/16 configs shipped icons missing their inner marks, certified at ink 0.9995). Set display: block on every glyph svg.
8055
8509
  - Chrome pixel-snaps an <svg> root's paint offset. A fractional inset on an svg element floors to a whole pixel and moves a hairline a full pixel; express fractional placement as transform: scale() about an integer-origin frame (measured: 0.943 \u2192 1.000 on six configs).
8056
8510
  - Pre-composite translucent values over the ACTUAL parent surface, read off the reference pixels \u2014 a control sitting on an elevated surface composites against that surface, not against the page.
8057
8511
  - A native <dialog> carries user-agent padding: 1em. Override every side, or the root grows past its recorded box and every band inside shifts.
@@ -8321,8 +8775,8 @@ var init_adapter = __esm({
8321
8775
  });
8322
8776
 
8323
8777
  // packages/generate/src/bundle-emit.ts
8324
- import { createHash as createHash3 } from "node:crypto";
8325
- import { existsSync as existsSync23, readFileSync as readFileSync20, readdirSync as readdirSync7, writeFileSync as writeFileSync11 } from "node:fs";
8778
+ import { createHash as createHash4 } from "node:crypto";
8779
+ import { copyFileSync, existsSync as existsSync23, mkdirSync as mkdirSync7, readFileSync as readFileSync20, readdirSync as readdirSync7, rmSync as rmSync3, writeFileSync as writeFileSync11 } from "node:fs";
8326
8780
  import path29 from "node:path";
8327
8781
  function pinFromConfigs(configs) {
8328
8782
  const domains = /* @__PURE__ */ new Map();
@@ -8371,6 +8825,37 @@ function cssFontFamilies(css) {
8371
8825
  }
8372
8826
  return [...out];
8373
8827
  }
8828
+ function fontsCssFor(faces, bundleDir, cacheDir = DEFAULT_FONT_CACHE) {
8829
+ const header = [
8830
+ "/* tendril fonts.css \u2014 the exact faces this bundle was scored with (sha-pinned in",
8831
+ " component.json requiredFonts). Load it alongside styles.css so the component",
8832
+ " 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. */"
8834
+ ];
8835
+ const lines = [];
8836
+ for (const face of faces) {
8837
+ const src = path29.join(cacheDir, path29.basename(face.file));
8838
+ const target = `./fonts/${path29.basename(face.file)}`;
8839
+ const format = FONT_FORMATS[path29.extname(face.file).toLowerCase()] ?? "truetype";
8840
+ const family = face.family.replace(/['\\]/g, "").replace(/\*\//g, "");
8841
+ const decl = `@font-face { font-family: '${family}'; font-weight: ${face.weight}; font-style: normal; src: url(${target}) format('${format}'); }`;
8842
+ if (face.source.startsWith("local:")) {
8843
+ lines.push(
8844
+ `/* '${family}' ${face.weight} is a user-licensed face (tendril fonts add; sha256 ${face.sha256.slice(0, 16)}\u2026).`,
8845
+ ` Licensed bytes are never copied into bundles \u2014 place your copy at ${target} and uncomment: */`,
8846
+ `/* ${decl} */`
8847
+ );
8848
+ } else if (existsSync23(src) && createHash4("sha256").update(readFileSync20(src)).digest("hex") === face.sha256) {
8849
+ mkdirSync7(path29.join(bundleDir, "fonts"), { recursive: true });
8850
+ copyFileSync(src, path29.join(bundleDir, "fonts", path29.basename(face.file)));
8851
+ lines.push(decl);
8852
+ } else {
8853
+ lines.push(`/* '${family}' ${face.weight} (sha256 ${face.sha256.slice(0, 16)}\u2026) could not be shipped: cache bytes missing or hash mismatch \u2014 re-run \`tendril fonts resolve\` and re-emit. */`);
8854
+ }
8855
+ }
8856
+ return lines.length === 0 ? null : `${[...header, ...lines].join("\n")}
8857
+ `;
8858
+ }
8374
8859
  function countLatticeSymbols(setDir) {
8375
8860
  const manifestFile = path29.join(setDir, "recording-set.json");
8376
8861
  if (existsSync23(manifestFile)) {
@@ -8411,7 +8896,7 @@ function recordingSetHash(setDir, configs) {
8411
8896
  relPaths,
8412
8897
  (p) => new Uint8Array(readFileSync20(path29.join(setDir, p))),
8413
8898
  (chunks) => {
8414
- const h = createHash3("sha256");
8899
+ const h = createHash4("sha256");
8415
8900
  for (const c of chunks) h.update(c);
8416
8901
  return h.digest("hex");
8417
8902
  }
@@ -8421,7 +8906,11 @@ function statusOf(s) {
8421
8906
  return tierOf(s, BARS.cert);
8422
8907
  }
8423
8908
  function emitBundleV1(opts) {
8424
- const statuses = opts.scores.map((s) => ({ rep: s.rep, similarity: s.similarity, inkRecall: s.inkRecall, ...s.exact !== void 0 ? { exact: s.exact } : {}, status: statusOf(s) }));
8909
+ const substituted = (opts.substitutedFamilies ?? []).length > 0;
8910
+ const statuses = opts.scores.map((s) => {
8911
+ const tier = statusOf(s);
8912
+ return { rep: s.rep, similarity: s.similarity, inkRecall: s.inkRecall, ...s.exact !== void 0 ? { exact: s.exact } : {}, status: substituted && tier === "certified" ? "pass" : tier };
8913
+ });
8425
8914
  const certified = statuses.filter((s) => s.status === "certified").length;
8426
8915
  const pass = statuses.filter((s) => s.status !== "fail").length;
8427
8916
  const lattice = countLatticeSymbols(opts.task.set);
@@ -8494,17 +8983,26 @@ function emitBundleV1(opts) {
8494
8983
  ${stripped}`);
8495
8984
  written.push(stylesPath);
8496
8985
  }
8986
+ const fontsCssPath = path29.join(opts.bundleDir, "fonts.css");
8987
+ rmSync3(path29.join(opts.bundleDir, "fonts"), { recursive: true, force: true });
8988
+ rmSync3(fontsCssPath, { force: true });
8989
+ const fontsCss = fontsCssFor(requiredFontsManifest(families, opts.fontCacheDir), opts.bundleDir, opts.fontCacheDir);
8990
+ if (fontsCss !== null) {
8991
+ writeFileSync11(fontsCssPath, fontsCss);
8992
+ written.push(fontsCssPath);
8993
+ }
8497
8994
  const unrecorded = lattice === null ? null : Math.max(0, lattice - statuses.length);
8498
8995
  const statusLine = `bundle: ${pass}/${statuses.length} recorded configs \u2265 pass bar (${certified} certified), ` + (interaction.length === 0 ? "interaction behaviors NONE VERIFIED" : `interaction behaviors ${interaction.filter((b) => b.pass).length}/${interaction.length}`) + (prelude.length === 0 ? "" : `, page hygiene ${prelude.filter((b) => b.pass).length}/${prelude.length}`) + (unrecorded === null ? "" : `; ${unrecorded} lattice configs UNVERIFIED`) + ` \u2014 claims in component.json, recompute with \`tendril verify\``;
8499
8996
  return { manifest, statusLine, written };
8500
8997
  }
8501
- var BARS;
8998
+ var FONT_FORMATS, BARS;
8502
8999
  var init_bundle_emit = __esm({
8503
9000
  "packages/generate/src/bundle-emit.ts"() {
8504
9001
  "use strict";
8505
9002
  init_src6();
8506
9003
  init_src();
8507
9004
  init_src4();
9005
+ FONT_FORMATS = { ".woff2": "woff2", ".woff": "woff", ".ttf": "truetype", ".otf": "opentype" };
8508
9006
  BARS = {
8509
9007
  pass: { sim: 0.95, ink: 0.95 },
8510
9008
  cert: { sim: 0.97, ink: 0.95 }
@@ -8683,6 +9181,12 @@ function taskFontFamilies(setDir) {
8683
9181
  return null;
8684
9182
  }
8685
9183
  }
9184
+ function unprovisionedFamilies(setDir, cacheDir) {
9185
+ const declared = taskFontFamilies(setDir);
9186
+ if (declared === null) return [];
9187
+ const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
9188
+ return declared.filter((f) => !provided.has(f.toLowerCase()));
9189
+ }
8686
9190
  var init_font_guidance = __esm({
8687
9191
  "packages/cli/src/font-guidance.ts"() {
8688
9192
  "use strict";
@@ -8847,6 +9351,12 @@ async function runVerify(opts) {
8847
9351
  remediation: fontsUnprovenRemediation(task.set)
8848
9352
  });
8849
9353
  }
9354
+ const substitutedFamilies = unprovisionedFamilies(task.set);
9355
+ if (substitutedFamilies.length > 0) {
9356
+ 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`);
9357
+ } else if (opts.bar === "cert" && taskFontFamilies(task.set) === null) {
9358
+ 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
+ }
8850
9360
  const missing = task.configs.filter(
8851
9361
  (c) => !existsSync25(path31.join(task.set, c.rep, "get_screenshot.json")) || !existsSync25(path31.join(task.set, c.rep, "get_metadata.json"))
8852
9362
  );
@@ -8860,7 +9370,7 @@ async function runVerify(opts) {
8860
9370
  const bar = BARS2[opts.bar];
8861
9371
  const evidenceDir = path31.join(opts.bundleDir, "verify-evidence");
8862
9372
  const scores = await scoreBundleForTask(task, opts.bundleDir, bar, { evidenceDir, onProgress: emitProgress });
8863
- const quality = await checkBundleQuality(opts.bundleDir, task.entry);
9373
+ const quality = await checkBundleQuality(opts.bundleDir, task.entry, { dir: task.set, reps: task.configs.map((c) => c.rep) });
8864
9374
  const bundleCss = ["tokens.css", "styles.css"].map((f) => path31.join(opts.bundleDir, f)).filter((f) => existsSync25(f)).map((f) => readFileSync22(f, "utf8")).join("\n");
8865
9375
  const occlusion = await checkSiblingOcclusion(task, opts.bundleDir, bundleCss);
8866
9376
  const parity = await checkHoverParity(task, opts.bundleDir);
@@ -8880,9 +9390,19 @@ async function runVerify(opts) {
8880
9390
  const statuses = [
8881
9391
  ...scores.map((s) => {
8882
9392
  const { exact: _exact, ...reported } = s;
8883
- const base = { ...reported, status: tierOf(s, BARS2.cert) };
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 };
8884
9404
  const reasons = demotions.get(s.rep);
8885
- return reasons === void 0 ? base : { ...base, status: "fail", demotedBy: reasons };
9405
+ return reasons === void 0 ? base : { ...base, status: "fail", demotedBy: [...certDemote.length > 0 ? certDemote : [], ...reasons] };
8886
9406
  }),
8887
9407
  // ADR-010 §2 anti-gaming: recorded configs the adapter does not map
8888
9408
  // are FAILs, never silently absent.
@@ -9042,9 +9562,27 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
9042
9562
  }
9043
9563
  process.stdout.write(`environment: chrome=${report.environment.chromeVersion ?? report.environment.chrome} fonts=${report.environment.fontsManifestSha256 ?? "UNRESOLVED"}
9044
9564
  `);
9565
+ 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
+ if (shippedFonts.length > 0 && substitutedFamilies.length === 0) {
9567
+ 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)
9568
+ `);
9569
+ }
9045
9570
  process.stdout.write(`evidence: ${evidenceDir} (render/ref/diff per config)
9046
9571
  `);
9047
9572
  });
9573
+ if (opts.bar === "cert" && substitutedFamilies.length > 0) {
9574
+ const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
9575
+ 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) })}
9577
+ `);
9578
+ } else {
9579
+ process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
9580
+ \u2192 ${fontsUnprovenRemediation(task.set)}
9581
+ `);
9582
+ }
9583
+ process.exitCode = ExitCode.FontsUnproven;
9584
+ return;
9585
+ }
9048
9586
  if (!ok) {
9049
9587
  warn(
9050
9588
  opts,
@@ -9078,18 +9616,18 @@ __export(engine_exports, {
9078
9616
  runEngineBrief: () => runEngineBrief,
9079
9617
  runEngineScore: () => runEngineScore
9080
9618
  });
9081
- import { existsSync as existsSync26, mkdirSync as mkdirSync7, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
9619
+ import { existsSync as existsSync26, mkdirSync as mkdirSync8, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
9082
9620
  import path32 from "node:path";
9083
9621
  function resolveEngineTask(opts, callerCwd) {
9084
9622
  const asPath = path32.resolve(callerCwd, opts.taskOrSet);
9085
9623
  const isSet = existsSync26(path32.join(asPath, "recording-set.json"));
9086
9624
  const registry = TASKS[opts.taskOrSet];
9087
- if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet };
9625
+ if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, disclosures: [] };
9088
9626
  if (isSet) {
9089
9627
  try {
9090
9628
  const authored = authorTaskFromSet(asPath);
9091
9629
  for (const d of authored.disclosures) warn(opts, d);
9092
- return { task: authored.task, name: path32.basename(asPath), apiPin: { props: authored.api.apiPin.props, forcedStates: authored.api.apiPin.forcedStates } };
9630
+ return { task: authored.task, name: path32.basename(asPath), disclosures: authored.disclosures, apiPin: { props: authored.api.apiPin.props, forcedStates: authored.api.apiPin.forcedStates } };
9093
9631
  } catch (err) {
9094
9632
  fail(opts, ExitCode.InputValidation, {
9095
9633
  error: `cannot author a task from ${asPath}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
@@ -9107,9 +9645,13 @@ function resolveEngineTask(opts, callerCwd) {
9107
9645
  function runEngineBrief(opts) {
9108
9646
  requireEntitlement(opts);
9109
9647
  const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
9110
- const { task, name } = resolveEngineTask(opts, callerCwd);
9648
+ const { task, name, disclosures } = resolveEngineTask(opts, callerCwd);
9111
9649
  const bar = BARS3[opts.bar];
9112
- const brief = buildBrief(task.systemApi, bar, { colorScheme: recordingIsDark(task) ? "dark" : "light" });
9650
+ const disclosureBlock = disclosures.length > 0 ? `
9651
+
9652
+ === RECORDED-SET DISCLOSURES (facts about this task's coverage \u2014 read them) ===
9653
+ ${disclosures.map((d) => `- ${d}`).join("\n")}` : "";
9654
+ const brief = buildBrief(task.systemApi, bar, { colorScheme: recordingIsDark(task) ? "dark" : "light" }) + disclosureBlock;
9113
9655
  const segments = buildSegments(task, "files");
9114
9656
  let notRecorded;
9115
9657
  const manifestPath2 = path32.join(task.set, "recording-set.json");
@@ -9123,7 +9665,7 @@ DO NOT INVENT PIXELS FOR THESE POSES. Implement them ONLY as the composition of
9123
9665
  ${notRecorded}` : "";
9124
9666
  let fontProvisioning;
9125
9667
  if (existsSync26(manifestPath2)) {
9126
- const provided = resolvedFontFamilies().map((f) => f.family);
9668
+ const provided = verifiedFontFamilies().map((f) => f.family);
9127
9669
  const unprovided = recordedFontFamilies(task.set).filter((r) => !provided.some((p) => p.toLowerCase() === r.toLowerCase()));
9128
9670
  if (unprovided.length > 0) {
9129
9671
  const names = unprovided.map((f) => `'${f}'`).join(", ");
@@ -9133,10 +9675,10 @@ ${notRecorded}` : "";
9133
9675
  prompt: `This design's text uses ${names}, which is not in the local font kit. How should it be handled?`,
9134
9676
  options: [
9135
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.`,
9136
- "Continue with a substitute face: generation proceeds in a provided family, the report discloses the substitution, and small text differences are expected and not fixable from CSS."
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.'
9137
9679
  ]
9138
9680
  },
9139
- nonInteractive: "Continue with the substitute and state the substitution in your report."
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."
9140
9682
  };
9141
9683
  }
9142
9684
  }
@@ -9145,7 +9687,7 @@ ${notRecorded}` : "";
9145
9687
  === TASK PAYLOAD (recorded truth, verbatim) ===
9146
9688
  ${segments}`;
9147
9689
  const payloadFile = path32.resolve(callerCwd, opts.out ?? `tendril-out/${name}-brief.md`);
9148
- mkdirSync7(path32.dirname(payloadFile), { recursive: true });
9690
+ mkdirSync8(path32.dirname(payloadFile), { recursive: true });
9149
9691
  writeFileSync12(payloadFile, payload);
9150
9692
  emitData(
9151
9693
  opts,
@@ -9206,6 +9748,10 @@ async function runEngineScore(opts) {
9206
9748
  remediation: fontsUnprovenRemediation(task.set)
9207
9749
  });
9208
9750
  }
9751
+ const substitutedFamilies = unprovisionedFamilies(task.set);
9752
+ if (substitutedFamilies.length > 0) {
9753
+ 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
+ }
9209
9755
  if (opts.rebind !== true && existsSync26(path32.join(candidateDir, "component.json"))) {
9210
9756
  const prior = (() => {
9211
9757
  try {
@@ -9238,7 +9784,7 @@ async function runEngineScore(opts) {
9238
9784
  const parityCoverage = parity.length > 0 ? `${parity.filter((p) => p.pass).length}/${parity.length} hover-forced configs` : "not applicable (no hover-forced configs in this set)";
9239
9785
  const obj = objective(scores, behaviors);
9240
9786
  const total = scores.length + behaviors.length;
9241
- const quality = await checkBundleQuality(candidateDir, task.entry);
9787
+ const quality = await checkBundleQuality(candidateDir, task.entry, { dir: task.set, reps: task.configs.map((c) => c.rep) });
9242
9788
  const qualityFeedback = quality.findings.length === 0 && !quality.tokensAbsent ? "" : `
9243
9789
 
9244
9790
  QUALITY (does not affect the bar \u2014 fix alongside the failing configs):
@@ -9249,9 +9795,14 @@ ${[
9249
9795
  const allPass = obj[0] === total && total > 0;
9250
9796
  const certBar = BARS3["cert"];
9251
9797
  const parityDemoted = new Set(parity.filter((p) => !p.pass).map((p) => p.id.replace(/^parity:/, "")));
9252
- const certifiedReps = scores.filter((sc) => tierOf(sc, certBar) === "certified" && !parityDemoted.has(sc.rep)).map((sc) => sc.rep);
9798
+ 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);
9253
9799
  const certifiedSet = new Set(certifiedReps);
9254
- const certificationFeedback = `
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.`));
9801
+ const absentBlock = absentFindings.length > 0 ? `
9802
+
9803
+ MISSING FEATURES (absent-ink clusters \u2014 recorded ink your render leaves nowhere near covered; fix these first, the global numbers cannot see them):
9804
+ ${absentFindings.join("\n")}` : "";
9805
+ const certificationFeedback = `${absentBlock}
9255
9806
 
9256
9807
  CERTIFICATION: ${certifiedReps.length}/${scores.length} configs at the certification bar (sim \u2265${certBar.sim} AND ink \u2265${certBar.ink}, exact values, after parity demotion \u2014 composition checks at verify can demote further).${certifiedReps.length < scores.length ? ` Below cert: ${scores.filter((sc) => !certifiedSet.has(sc.rep)).map((sc) => sc.rep).join(", ")}.` : ""}
9257
9808
  METRIC DEADBAND (read before iterating on near-misses): the scored similarity/ink deliberately tolerate \xB11px edge shift and antialiased-edge differences \u2014 cross-rasterizer noise absorption. A change entirely inside that band moves these numbers by EXACTLY ZERO (working as designed, not a stuck scorer). The per-config \`exact\` fields in the JSON are tolerance-free and move first: compare exact across rounds to confirm a small fix landed, and stop iterating when only exact moves \u2014 the bar reads the tolerant numbers.`;
@@ -9265,7 +9816,8 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
9265
9816
  model: `${opts.model} (self-reported${opts.host !== void 0 ? ` via ${opts.host}` : ""})`,
9266
9817
  scores,
9267
9818
  behaviors,
9268
- environment: environmentStamp(taskFontFamilies(task.set))
9819
+ environment: environmentStamp(taskFontFamilies(task.set)),
9820
+ substitutedFamilies
9269
9821
  });
9270
9822
  emitData(
9271
9823
  opts,
@@ -9286,7 +9838,7 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
9286
9838
  bundleManifest: emitted.written[0],
9287
9839
  note: "styles.css was stamped with the bundle provenance comment on line 1 \u2014 preserve it in any post-score edit",
9288
9840
  allPass,
9289
- certification: { certified: certifiedReps.length, total: scores.length, bar: certBar, note: "tierOf on exact values + parity demotion; verify's composition checks can demote further" }
9841
+ 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" }
9290
9842
  },
9291
9843
  () => {
9292
9844
  for (const s of scores) process.stdout.write(`${s.pass ? certifiedSet.has(s.rep) ? "CERT" : "PASS" : "FAIL"} ${s.rep}: sim=${s.similarity} ink=${s.inkRecall}${s.error !== void 0 ? ` [${s.error}]` : ""}
@@ -9303,6 +9855,19 @@ ${obj[0]}/${total} \xB7 certified ${certifiedReps.length}/${total} \xB7 floor=${
9303
9855
  }
9304
9856
  }
9305
9857
  );
9858
+ if (opts.bar === "cert" && substitutedFamilies.length > 0) {
9859
+ const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
9860
+ if (opts.json) {
9861
+ 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) })}
9862
+ `);
9863
+ } else {
9864
+ process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
9865
+ \u2192 ${fontsUnprovenRemediation(task.set)}
9866
+ `);
9867
+ }
9868
+ process.exitCode = ExitCode.FontsUnproven;
9869
+ return;
9870
+ }
9306
9871
  if (!allPass) process.exitCode = ExitCode.VerificationFailed;
9307
9872
  }
9308
9873
  var BARS3;
@@ -9502,6 +10067,399 @@ var init_codeconnect = __esm({
9502
10067
  }
9503
10068
  });
9504
10069
 
10070
+ // packages/mcp/src/server.ts
10071
+ import { createHash as createHash5 } from "node:crypto";
10072
+ import { existsSync as existsSync28, mkdtempSync as mkdtempSync3, readFileSync as readFileSync25, readdirSync as readdirSync8, writeFileSync as writeFileSync14 } from "node:fs";
10073
+ import os6 from "node:os";
10074
+ import path34 from "node:path";
10075
+ import { fileURLToPath as fileURLToPath5 } from "node:url";
10076
+ import { z as z12 } from "zod";
10077
+ function sourceHash() {
10078
+ const dir = path34.dirname(fileURLToPath5(import.meta.url));
10079
+ const h = createHash5("sha256");
10080
+ for (const f of readdirSync8(dir).filter((n) => n.endsWith(".ts")).sort()) {
10081
+ h.update(f);
10082
+ h.update(readFileSync25(path34.join(dir, f)));
10083
+ }
10084
+ return h.digest("hex").slice(0, 16);
10085
+ }
10086
+ var REPO_ROOT3, CLI_BIN, BUNDLED_CLI, CLI_SPAWN, str, optStr, TOOLS, BOOT_HASH;
10087
+ var init_server = __esm({
10088
+ "packages/mcp/src/server.ts"() {
10089
+ "use strict";
10090
+ REPO_ROOT3 = path34.resolve(path34.dirname(fileURLToPath5(import.meta.url)), "..", "..", "..");
10091
+ CLI_BIN = path34.join(REPO_ROOT3, "packages", "cli", "src", "bin.ts");
10092
+ BUNDLED_CLI = path34.join(path34.dirname(fileURLToPath5(import.meta.url)), "tendril.js");
10093
+ CLI_SPAWN = existsSync28(BUNDLED_CLI) ? { cmd: process.execPath, prefix: [BUNDLED_CLI] } : { cmd: "npx", prefix: ["tsx", CLI_BIN] };
10094
+ str = (d) => z12.string().describe(d);
10095
+ optStr = (d) => z12.string().optional().describe(d);
10096
+ TOOLS = [
10097
+ {
10098
+ name: "tendril_record_plan",
10099
+ description: "ENTRY POINT for implementing/building a React component from a Figma design or figma.com URL \u2014 start the Tendril pipeline here (after loading the tendril skill, if installed). Plans a recording session: computes the rep queue (anchor + one-factor + conflict crosses) from the verbatim get_metadata response (pass its blocks via metadataParts \u2014 no file to write) and persists the set manifest. Resumes if the set already exists. The output may carry USER QUESTIONS \u2014 defaultsToConfirm (which pose is the component's default) or a multiple-component-sets error (which set to record): render them to a present user and apply the answers via `defaults` / `componentSet`; non-interactive runs follow each question's stated fallback. Recording cost is stated in figmaCallEstimate, never asked about \u2014 proceed with what the user provided.",
10100
+ schema: z12.object({
10101
+ setDir: str("recording set directory to create/resume"),
10102
+ component: str("component/system name"),
10103
+ // Parts array FIRST, same reason as ingest_rep: real responses
10104
+ // are usually multi-block, and the file param made plan the ONE
10105
+ // remaining hand-built-envelope entry point (run 10: the agent
10106
+ // wrote the file twice — once as text, once as JSON envelope).
10107
+ metadataParts: z12.array(z12.string()).optional().describe("the frame-level get_metadata response blocks, every block in order, each verbatim \u2014 never hand-join or save them yourself; this is the NORMAL param"),
10108
+ metadata: optStr("ONLY when get_metadata genuinely returned one single block: its text verbatim (otherwise use metadataParts)"),
10109
+ metadataFiles: z12.array(z12.string()).optional().describe('saved verbatim get_metadata envelope file paths \u2014 JSON shape {"content":[{"type":"text","text":"<frame \u2026>"}]}; optionally <file>@<frameId>. Prefer metadataParts: no file to write'),
10110
+ defaults: z12.array(z12.string()).optional().describe(`axis defaults as "Axis=Value" (from the user's defaultsToConfirm answers; may re-plan a set with nothing recorded yet)`),
10111
+ componentSet: optStr("record only this component set (the user's pick from a multiple-component-sets error)")
10112
+ }),
10113
+ // Texts ride temp files, never argv: Windows caps a command line at
10114
+ // ~32 KB and metadata envelopes can exceed it.
10115
+ argv: (i) => {
10116
+ const argvOut = ["record", "plan", "--set", i["setDir"], "--component", i["component"]];
10117
+ if (i["metadata"] !== void 0 && i["metadataParts"] !== void 0) throw new Error("pass at most one of `metadata` and `metadataParts`");
10118
+ const files = i["metadataFiles"];
10119
+ if (files !== void 0 && files.length > 0) argvOut.push("--metadata", ...files);
10120
+ const single = i["metadata"];
10121
+ const parts = i["metadataParts"];
10122
+ if (single !== void 0 || parts !== void 0) {
10123
+ const tmp = path34.join(mkdtempSync3(path34.join(os6.tmpdir(), "tendril-envelope-")), "response.txt");
10124
+ if (single !== void 0) {
10125
+ writeFileSync14(tmp, single);
10126
+ argvOut.push("--metadata-raw-file", tmp);
10127
+ } else {
10128
+ if (parts.length === 0) throw new Error("`metadataParts` must be a non-empty array of block texts");
10129
+ writeFileSync14(tmp, JSON.stringify(parts));
10130
+ argvOut.push("--metadata-raw-parts-file", tmp);
10131
+ }
10132
+ }
10133
+ if (argvOut.length === 6) throw new Error("nothing to plan from \u2014 pass metadataParts (normal), metadata (single-block), or metadataFiles");
10134
+ argvOut.push(...i["defaults"] !== void 0 ? ["--default", ...i["defaults"]] : [], ...i["componentSet"] !== void 0 ? ["--component-set", i["componentSet"]] : []);
10135
+ return argvOut;
10136
+ }
10137
+ },
10138
+ {
10139
+ name: "tendril_doctor",
10140
+ annotations: { readOnlyHint: true },
10141
+ description: "Machine readiness + version status in one shot: installed vs latest version (with publish date and update remediation), browser identity, font-cache state, Figma desktop MCP reachability. Use to self-diagnose before recording/scoring, or whenever versions are in question. Exit 1 = something not ready; the report says exactly what and how to fix it.",
10142
+ schema: z12.object({}),
10143
+ argv: () => ["doctor"]
10144
+ },
10145
+ {
10146
+ name: "tendril_record_next",
10147
+ annotations: { readOnlyHint: true },
10148
+ description: "Get the next pending recording instruction (which Figma MCP tool to call for which node, and how to save it). RARELY NEEDED: every ingest/fetch response already carries `next` \u2014 use this only to resume an interrupted session. The full queue is known from plan, so independent reps may be recorded in any order (and in parallel).",
10149
+ schema: z12.object({ setDir: str("recording set directory") }),
10150
+ argv: (i) => ["record", "next", "--set", i["setDir"]]
10151
+ },
10152
+ {
10153
+ name: "tendril_record_fetch",
10154
+ description: "Download a Figma asset URL (from get_screenshot's image_url) straight to disk and ingest it as the rep's envelope \u2014 the fallback when only the screenshot piece needs (re-)recording; for a rep's standard three recordings PREFER tendril_record_ingest_rep. Never download the image yourself: the bytes must not pass through your context.",
10155
+ schema: z12.object({
10156
+ setDir: str("recording set directory"),
10157
+ rep: str("planned rep slug"),
10158
+ tool: z12.enum(["get_screenshot"]).describe("get_screenshot"),
10159
+ url: str("image_url from the Figma response, verbatim")
10160
+ }),
10161
+ argv: (i) => ["record", "fetch", "--set", i["setDir"], "--rep", i["rep"], "--tool", i["tool"], "--url", i["url"]]
10162
+ },
10163
+ {
10164
+ name: "tendril_record_ingest_rep",
10165
+ description: "Ingest a rep's ENTIRE recording in ONE call \u2014 the get_metadata response, the get_design_context response, and the get_screenshot image_url together. Make the three Figma calls first, in protocol order (get_metadata, then get_design_context with excludeScreenshot=true, then get_screenshot), then pass all three here VERBATIM. PREFER THIS over three separate ingest/fetch calls: one approvable operation per rep instead of three. Pieces land independently: on a partial failure the error names exactly which piece(s) to re-record \u2014 the rest are already on disk. The response carries `next` and, for design context, `assets` (auto-fetched server-side; only listed failures need record_asset).",
10166
+ schema: z12.object({
10167
+ setDir: str("recording set directory"),
10168
+ rep: str("planned rep slug"),
10169
+ // Parts arrays FIRST: in the field, EVERY real Figma response is
10170
+ // multi-block (run 6: 49/49 reps — metadata 2 blocks, design
10171
+ // context 5-6), so the arrays are the norm and the single-string
10172
+ // params the rare case, not the reverse.
10173
+ metadataParts: z12.array(z12.string()).optional().describe("get_metadata response blocks, every block in order, each verbatim \u2014 never hand-join blocks yourself. Multi-block responses are common (run 6: 49/49 reps); a single block wrapped in a one-element array is equally fine."),
10174
+ contextParts: z12.array(z12.string()).optional().describe("get_design_context response blocks, every block in order, each verbatim \u2014 the NORMAL param (real responses arrive as 5-6 blocks)"),
10175
+ screenshotUrl: optStr("image_url from the get_screenshot response, verbatim \u2014 the CLI downloads it; the bytes never pass through your context"),
10176
+ metadata: optStr("ONLY when get_metadata genuinely returned one single block: its text verbatim (otherwise use metadataParts)"),
10177
+ context: optStr("ONLY when get_design_context genuinely returned one single block: its text verbatim (otherwise use contextParts)")
10178
+ }),
10179
+ // Texts ride temp files, never argv: Windows caps a command line at
10180
+ // ~32 KB and design-context envelopes routinely exceed it.
10181
+ argv: (i) => {
10182
+ const argvOut = ["record", "ingest-rep", "--set", i["setDir"], "--rep", i["rep"]];
10183
+ const bridge = (label, single, parts) => {
10184
+ if (single !== void 0 && parts !== void 0) throw new Error(`pass at most one of \`${label}\` and \`${label}Parts\``);
10185
+ if (single === void 0 && parts === void 0) return;
10186
+ const tmp = path34.join(mkdtempSync3(path34.join(os6.tmpdir(), "tendril-envelope-")), "response.txt");
10187
+ if (single !== void 0) {
10188
+ writeFileSync14(tmp, single);
10189
+ argvOut.push(`--${label}-file`, tmp);
10190
+ } else {
10191
+ const blocks = parts;
10192
+ if (blocks.length === 0) throw new Error(`\`${label}Parts\` must be a non-empty array of block texts`);
10193
+ writeFileSync14(tmp, JSON.stringify(blocks));
10194
+ argvOut.push(`--${label}-parts-file`, tmp);
10195
+ }
10196
+ };
10197
+ bridge("metadata", i["metadata"], i["metadataParts"]);
10198
+ bridge("context", i["context"], i["contextParts"]);
10199
+ if (i["screenshotUrl"] !== void 0) argvOut.push("--screenshot-url", i["screenshotUrl"]);
10200
+ if (argvOut.length === 6) throw new Error("nothing to ingest \u2014 pass at least one of metadata/metadataParts, context/contextParts, screenshotUrl");
10201
+ return argvOut;
10202
+ }
10203
+ },
10204
+ {
10205
+ name: "tendril_record_ingest",
10206
+ description: "Single-piece ingest of a VERBATIM Figma tool-response \u2014 the fallback path (re-recording one failed piece, set-level get_variable_defs, get_metadata_interior for mains); for a rep's standard three recordings PREFER tendril_record_ingest_rep, which takes them all in one call. Pass `text` (single block) or `texts` (response split into multiple output blocks \u2014 each block verbatim, in order; NEVER hand-join them): the CLI constructs the envelope from the same bytes. The response includes `next` and, for get_design_context, `assets` (auto-fetched; only listed failures need manual handling).",
10207
+ schema: z12.object({
10208
+ setDir: str("recording set directory"),
10209
+ rep: str("planned rep slug, or __set__ for the set-level get_variable_defs"),
10210
+ // Enumerated, not a free string: this value reaches a file path.
10211
+ // As a bare string it was an arbitrary relative-path overwrite
10212
+ // (--tool "../../outside/victim" replaced a file outside the set,
10213
+ // exit 0). The sink in session.ts now contains the path too — this
10214
+ // is the second layer, and it makes the tool self-documenting.
10215
+ tool: z12.enum(["get_design_context", "get_metadata", "get_screenshot", "get_variable_defs", "get_metadata_interior"]),
10216
+ text: optStr("the tool response text VERBATIM \u2014 exactly as returned, unmodified (single-block responses; text tools only \u2014 screenshots go through record_fetch)"),
10217
+ texts: z12.array(z12.string()).optional().describe("when the response arrived as MULTIPLE output blocks: every block, in order, each verbatim \u2014 never hand-join blocks yourself"),
10218
+ file: optStr("path to a saved envelope JSON (alternative to text/texts)")
10219
+ }),
10220
+ // The text rides a temp file, never argv: Windows caps a command
10221
+ // line at ~32 KB and design-context envelopes routinely exceed it.
10222
+ argv: (i) => {
10223
+ const base = ["record", "ingest", "--set", i["setDir"], "--rep", i["rep"], "--tool", i["tool"]];
10224
+ const text = i["text"];
10225
+ const texts = i["texts"];
10226
+ const file = i["file"];
10227
+ if ([text, texts, file].filter((x) => x !== void 0).length !== 1) throw new Error("pass exactly one of `text` (single-block response), `texts` (multi-block response), or `file` (a saved envelope)");
10228
+ if (file !== void 0) return [...base, "--file", file];
10229
+ const tmp = path34.join(mkdtempSync3(path34.join(os6.tmpdir(), "tendril-envelope-")), "response.txt");
10230
+ if (text !== void 0) {
10231
+ writeFileSync14(tmp, text);
10232
+ return [...base, "--file", tmp, "--raw"];
10233
+ }
10234
+ writeFileSync14(tmp, JSON.stringify(texts));
10235
+ return [...base, "--file", tmp, "--raw-parts"];
10236
+ }
10237
+ },
10238
+ {
10239
+ name: "tendril_record_asset",
10240
+ description: "FALLBACK ONLY \u2014 ingest auto-fetches design-context assets; use this just for assets listed in an ingest response's `assets.failed`. Batch mode: pass `dir` to ingest every asset-*.<ext> in a directory in ONE call. SVGs with active content are rejected; sizes are capped.",
10241
+ schema: z12.object({
10242
+ setDir: str("recording set directory"),
10243
+ rep: str("planned rep slug"),
10244
+ name: optStr("asset-<id>.<ext> (single-asset mode)"),
10245
+ file: optStr("downloaded asset file path (single-asset mode)"),
10246
+ dir: optStr("batch mode: directory holding downloaded asset-*.<ext> files")
10247
+ }),
10248
+ argv: (i) => [
10249
+ "record",
10250
+ "asset",
10251
+ "--set",
10252
+ i["setDir"],
10253
+ "--rep",
10254
+ i["rep"],
10255
+ ...i["dir"] !== void 0 ? ["--dir", i["dir"]] : ["--name", i["name"], "--file", i["file"]]
10256
+ ]
10257
+ },
10258
+ {
10259
+ name: "tendril_record_status",
10260
+ annotations: { readOnlyHint: true },
10261
+ description: "Recording-set completeness: per-rep recorded/missing tools.",
10262
+ schema: z12.object({ setDir: str("recording set directory") }),
10263
+ argv: (i) => ["record", "status", "--set", i["setDir"]]
10264
+ },
10265
+ {
10266
+ name: "tendril_engine_brief",
10267
+ description: "AGENT-HARNESS engine, step 1: emits the task payload file (system brief + every recorded config's emission, box, assets, tokens) and the protocol. YOU (the calling agent) implement the bundle; the CLI is the only judge. Read the payload file completely before proposing.",
10268
+ schema: z12.object({
10269
+ taskOrSet: str("a recording-set directory (from tendril_record), or a reference task name \u2014 an unknown name returns the valid list in the error"),
10270
+ // Required by design, not convenience: the model choice must be
10271
+ // settled BEFORE generation starts. A smoke run picked its own
10272
+ // model silently because the ask lived in instruction text, which
10273
+ // evaporates in non-interactive sessions; a required parameter
10274
+ // cannot evaporate. Ask the user when one is present; otherwise
10275
+ // choose, declare, and state the reason in your report.
10276
+ model: str("the model that will WRITE the implementation \u2014 ask the user when interactive; declare your reasoned choice when not"),
10277
+ bar: optStr("pass (default) or cert"),
10278
+ out: optStr("payload file path override")
10279
+ }),
10280
+ argv: (i) => [
10281
+ "engine",
10282
+ "brief",
10283
+ i["taskOrSet"],
10284
+ "--model",
10285
+ i["model"],
10286
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
10287
+ ...i["out"] !== void 0 ? ["--out", i["out"]] : []
10288
+ ]
10289
+ },
10290
+ {
10291
+ name: "tendril_engine_score",
10292
+ description: "AGENT-HARNESS engine, step 2 (the oracle): scores a candidate bundle directory against recorded truth \u2014 per-config pixels, behaviors, hover parity \u2014 and returns feedback plus evidence artifacts. Iterate until allPass or two non-improving rounds. Only THIS tool's output counts as a score; never claim numbers yourself.",
10293
+ schema: z12.object({
10294
+ taskOrSet: str("reference task name or recording-set directory"),
10295
+ candidateDir: str("directory containing the proposed bundle files"),
10296
+ bar: optStr("pass (default) or cert"),
10297
+ host: optStr("your host identity (e.g. claude-code, cursor, codex) \u2014 recorded as self-reported provenance"),
10298
+ model: str("the model that proposed the candidate \u2014 REQUIRED; recorded as self-reported provenance, and the CLI refuses to score without it"),
10299
+ rebind: z12.boolean().optional().describe("explicitly re-bind an already-bound bundle to a DIFFERENT recording set \u2014 scoring refuses this otherwise, because rebinding silently rewrites the bundle's verification identity; only pass after telling the user")
10300
+ }),
10301
+ argv: (i) => [
10302
+ "engine",
10303
+ "score",
10304
+ i["taskOrSet"],
10305
+ i["candidateDir"],
10306
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
10307
+ ...i["host"] !== void 0 ? ["--host", i["host"]] : [],
10308
+ "--model",
10309
+ i["model"],
10310
+ ...i["rebind"] === true ? ["--rebind"] : []
10311
+ ]
10312
+ },
10313
+ {
10314
+ name: "tendril_codeconnect",
10315
+ description: "Emit a Figma Code Connect template (.figma.ts) for a certified bundle \u2014 every Figma variant value mapped to its verified prop fragment from recorded truth, stamped with the bundle's trust statement. EXTRA VALUE step after verify passes: offer it to the user. Publishing is the USER'S action (their Figma token, Organization/Enterprise plan) \u2014 via npx @figma/code-connect connect publish, or the Figma MCP's own add_code_connect_map/send_code_connect_mappings tools if available in this session.",
10316
+ schema: z12.object({
10317
+ bundleDir: str("bundle directory (carries component.json)"),
10318
+ figmaUrl: str("figma.com /design/ URL of the COMPONENT SET, with node-id (ask the user to Copy link to selection if you don't have it)"),
10319
+ set: optStr("recording set override (default: the bundle's provenance path)"),
10320
+ out: optStr("output file path (default: <bundle>/<Component>.figma.ts)")
10321
+ }),
10322
+ argv: (i) => [
10323
+ "codeconnect",
10324
+ i["bundleDir"],
10325
+ "--figma-url",
10326
+ i["figmaUrl"],
10327
+ ...i["set"] !== void 0 ? ["--set", i["set"]] : [],
10328
+ ...i["out"] !== void 0 ? ["--out", i["out"]] : []
10329
+ ]
10330
+ },
10331
+ {
10332
+ 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.",
10334
+ schema: z12.object({
10335
+ bundleDir: str("bundle directory to verify"),
10336
+ bar: optStr("pass (default) or cert"),
10337
+ set: optStr("recording-set directory override")
10338
+ }),
10339
+ argv: (i) => [
10340
+ "verify",
10341
+ i["bundleDir"],
10342
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
10343
+ ...i["set"] !== void 0 ? ["--set", i["set"]] : []
10344
+ ]
10345
+ },
10346
+ {
10347
+ name: "tendril_generate_curated",
10348
+ description: "CURATED engine (explicit alternative path): generation by an allowlisted API model over the user's OpenRouter-compatible key, with cost consent, hard spend caps, and resume. Use ONLY when the user asks for API-model generation instead of implementing it yourself.",
10349
+ schema: z12.object({
10350
+ input: str("reference task name or recording-set directory"),
10351
+ model: optStr("OpenRouter model id (default: allowlist pointer)"),
10352
+ bar: optStr("pass (default) or cert"),
10353
+ cap: optStr("spend cap in USD (default 1.50)"),
10354
+ yes: z12.boolean().optional().describe("accept the cost consent (the user must have approved the spend)")
10355
+ }),
10356
+ argv: (i) => [
10357
+ "generate",
10358
+ i["input"],
10359
+ ...i["model"] !== void 0 ? ["--model", i["model"]] : [],
10360
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
10361
+ ...i["cap"] !== void 0 ? ["--cap", i["cap"]] : [],
10362
+ ...i["yes"] === true ? ["--yes"] : []
10363
+ ]
10364
+ }
10365
+ ];
10366
+ BOOT_HASH = (() => {
10367
+ try {
10368
+ return sourceHash();
10369
+ } catch {
10370
+ return "unknown";
10371
+ }
10372
+ })();
10373
+ }
10374
+ });
10375
+
10376
+ // packages/cli/src/commands/permissions.ts
10377
+ var permissions_exports = {};
10378
+ __export(permissions_exports, {
10379
+ PERMISSIONS_DESCRIPTION: () => PERMISSIONS_DESCRIPTION,
10380
+ buildPermissions: () => buildPermissions,
10381
+ runPermissions: () => runPermissions
10382
+ });
10383
+ async function buildPermissions(options) {
10384
+ const LEGAL_TOOL_NAME = /^[A-Za-z0-9_-]+$/;
10385
+ const pipeline = new Set(FIGMA_TOOL_FALLBACK);
10386
+ let figmaTools = FIGMA_TOOL_FALLBACK;
10387
+ try {
10388
+ const client = new McpHttpClient({ url: options.mcpUrl ?? DEFAULT_MCP_URL, ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {} });
10389
+ await client.initialize();
10390
+ const live = (await client.listTools()).map((t) => t.name).filter((n) => LEGAL_TOOL_NAME.test(n) && pipeline.has(n));
10391
+ if (live.length > 0) figmaTools = live;
10392
+ } catch {
10393
+ }
10394
+ return {
10395
+ host: "claude",
10396
+ serverEntries: [TENDRIL_PLUGIN_PREFIX, FIGMA_PLUGIN_PREFIX],
10397
+ toolEntries: [
10398
+ ...TOOLS.map((t) => t.name).filter((n) => LEGAL_TOOL_NAME.test(n)).map((n) => `${TENDRIL_PLUGIN_PREFIX}__${n}`),
10399
+ ...figmaTools.map((name) => `${FIGMA_PLUGIN_PREFIX}__${name}`)
10400
+ ],
10401
+ serverEntriesCaution: "The server-wide entries allow EVERY tool those servers serve \u2014 for tendril that includes tendril_generate_curated (spends your OpenRouter budget when configured), and for Figma every tool the desktop server exposes, beyond the read tools this pipeline uses. The per-tool list is the recommended default.",
10402
+ directConfigNote: `Installed via claude mcp add instead of the plugin? Replace the prefixes: ${TENDRIL_PLUGIN_PREFIX}__<tool> becomes mcp__<your-server-name>__<tool> (same for Figma).`
10403
+ };
10404
+ }
10405
+ async function runPermissions(flags) {
10406
+ if (flags.describe) {
10407
+ printDescription(PERMISSIONS_DESCRIPTION);
10408
+ return;
10409
+ }
10410
+ const result = await buildPermissions({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} });
10411
+ emitData(flags, result, () => {
10412
+ const quoted = (xs) => xs.map((x) => ` ${JSON.stringify(x)}`).join(",\n");
10413
+ process.stdout.write(
10414
+ `Claude Code allowlist for the Tendril pipeline.
10415
+ Paste into .claude/settings.json under permissions.allow:
10416
+
10417
+ ${quoted(result.toolEntries)}
10418
+
10419
+ Shorter but broader \u2014 one entry per server:
10420
+
10421
+ ${quoted(result.serverEntries)}
10422
+
10423
+ \u26A0 ${result.serverEntriesCaution}
10424
+
10425
+ ${result.directConfigNote}
10426
+ `
10427
+ );
10428
+ });
10429
+ }
10430
+ var FIGMA_TOOL_FALLBACK, PERMISSIONS_DESCRIPTION, TENDRIL_PLUGIN_PREFIX, FIGMA_PLUGIN_PREFIX;
10431
+ var init_permissions = __esm({
10432
+ "packages/cli/src/commands/permissions.ts"() {
10433
+ "use strict";
10434
+ init_src();
10435
+ init_server();
10436
+ init_describe();
10437
+ init_output();
10438
+ init_doctor();
10439
+ FIGMA_TOOL_FALLBACK = ["get_metadata", "get_design_context", "get_screenshot", "get_variable_defs", "get_motion_context", "get_figjam"];
10440
+ PERMISSIONS_DESCRIPTION = {
10441
+ name: "permissions",
10442
+ summary: "Print a paste-ready Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools).",
10443
+ args: [],
10444
+ flags: [
10445
+ { flag: "--claude", description: "Claude Code settings.json format (the default and currently only format)" },
10446
+ { flag: "--mcp-url <url>", description: "Figma MCP endpoint to list live tool names from", default: DEFAULT_MCP_URL },
10447
+ { flag: "--json", description: "Machine-readable output" }
10448
+ ],
10449
+ output: {
10450
+ host: '"claude"',
10451
+ serverEntries: "string[] \u2014 one entry per MCP server (allows every tool it serves)",
10452
+ toolEntries: "string[] \u2014 per-tool entries for selective allowlists",
10453
+ directConfigNote: "string \u2014 prefix rewrite for non-plugin (claude mcp add) installs"
10454
+ },
10455
+ exitCodes: { 0: "always (informational)" },
10456
+ examples: ["tendril permissions --claude", "tendril permissions --claude --json"]
10457
+ };
10458
+ TENDRIL_PLUGIN_PREFIX = "mcp__plugin_tendril_tendril";
10459
+ FIGMA_PLUGIN_PREFIX = "mcp__plugin_figma_figma";
10460
+ }
10461
+ });
10462
+
9505
10463
  // packages/cli/src/commands/generate-route.ts
9506
10464
  var generate_route_exports = {};
9507
10465
  __export(generate_route_exports, {
@@ -9526,17 +10484,17 @@ __export(generate_recorded_exports, {
9526
10484
  runGenerateRecorded: () => runGenerateRecorded
9527
10485
  });
9528
10486
  import { confirm as confirm3, isCancel as isCancel3 } from "@clack/prompts";
9529
- import { existsSync as existsSync28, readFileSync as readFileSync25 } from "node:fs";
9530
- import path34 from "node:path";
10487
+ import { existsSync as existsSync29, readFileSync as readFileSync26 } from "node:fs";
10488
+ import path35 from "node:path";
9531
10489
  async function runGenerateRecorded(opts) {
9532
10490
  const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
9533
- const outDirAbs = path34.resolve(callerCwd, opts.out);
9534
- const recordedAsPath = path34.resolve(callerCwd, opts.recorded);
10491
+ const outDirAbs = path35.resolve(callerCwd, opts.out);
10492
+ const recordedAsPath = path35.resolve(callerCwd, opts.recorded);
9535
10493
  let task;
9536
10494
  let taskName;
9537
10495
  let authoredApi;
9538
10496
  let composition;
9539
- const isSet = existsSync28(path34.join(recordedAsPath, "recording-set.json"));
10497
+ const isSet = existsSync29(path35.join(recordedAsPath, "recording-set.json"));
9540
10498
  const registry = TASKS[opts.recorded];
9541
10499
  if (registry !== void 0 && !isSet) {
9542
10500
  task = registry;
@@ -9545,7 +10503,7 @@ async function runGenerateRecorded(opts) {
9545
10503
  try {
9546
10504
  const authored = authorTaskFromSet(recordedAsPath);
9547
10505
  task = authored.task;
9548
- taskName = path34.basename(recordedAsPath);
10506
+ taskName = path35.basename(recordedAsPath);
9549
10507
  authoredApi = authored.api;
9550
10508
  const roles = RolesSchema.safeParse(loadManifest(recordedAsPath).roles);
9551
10509
  if (roles.success) composition = roles.data;
@@ -9571,8 +10529,12 @@ async function runGenerateRecorded(opts) {
9571
10529
  remediation: fontsUnprovenRemediation(task.set)
9572
10530
  });
9573
10531
  }
10532
+ const substitutedFamilies = unprovisionedFamilies(task.set);
10533
+ if (substitutedFamilies.length > 0) {
10534
+ 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
+ }
9574
10536
  const missing = task.configs.filter(
9575
- (c) => !existsSync28(path34.join(task.set, c.rep, "get_screenshot.json")) || !existsSync28(path34.join(task.set, c.rep, "get_metadata.json")) || !existsSync28(path34.join(task.set, c.rep, "get_design_context.json"))
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"))
9576
10538
  );
9577
10539
  if (missing.length > 0) {
9578
10540
  fail(opts, ExitCode.RecordingIncomplete, {
@@ -9642,8 +10604,8 @@ async function runGenerateRecorded(opts) {
9642
10604
  ` : `${line}
9643
10605
  `);
9644
10606
  if (opts.dryRun) {
9645
- emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite: path34.join(outDirAbs, taskName) }, () => {
9646
- process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path34.join(outDirAbs, taskName)})
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)})
9647
10609
  `);
9648
10610
  });
9649
10611
  return;
@@ -9666,10 +10628,10 @@ async function runGenerateRecorded(opts) {
9666
10628
  });
9667
10629
  }
9668
10630
  }
9669
- const bundleDir = path34.join(outDirAbs, taskName);
9670
- if (existsSync28(path34.join(bundleDir, "component.json"))) {
10631
+ const bundleDir = path35.join(outDirAbs, taskName);
10632
+ if (existsSync29(path35.join(bundleDir, "component.json"))) {
9671
10633
  try {
9672
- const prior = readBundleManifest(readFileSync25(path34.join(bundleDir, "component.json"), "utf8")).manifest;
10634
+ const prior = readBundleManifest(readFileSync26(path35.join(bundleDir, "component.json"), "utf8")).manifest;
9673
10635
  if (prior !== void 0 && prior.provenance.recordingSet.hash !== recordingSetHash(task.set, task.configs)) {
9674
10636
  fail(opts, ExitCode.InputValidation, {
9675
10637
  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`,
@@ -9701,7 +10663,7 @@ async function runGenerateRecorded(opts) {
9701
10663
  });
9702
10664
  const statuses = result.finalScores.map((s) => ({
9703
10665
  ...s,
9704
- status: s.similarity >= BARS4.cert.sim && s.inkRecall >= BARS4.cert.ink ? "certified" : s.pass ? "pass" : "fail"
10666
+ status: s.similarity >= BARS4.cert.sim && s.inkRecall >= BARS4.cert.ink && substitutedFamilies.length === 0 ? "certified" : s.pass ? "pass" : "fail"
9705
10667
  }));
9706
10668
  const behaviorFailures = result.finalBehaviors.filter((b) => !b.pass);
9707
10669
  const pixelFailures = statuses.filter((s) => s.status === "fail");
@@ -9720,7 +10682,8 @@ async function runGenerateRecorded(opts) {
9720
10682
  scores: result.finalScores,
9721
10683
  behaviors: result.finalBehaviors,
9722
10684
  environment: environmentStamp(taskFontFamilies(task.set)),
9723
- spentUsd: result.spentUsd
10685
+ spentUsd: result.spentUsd,
10686
+ substitutedFamilies
9724
10687
  });
9725
10688
  trustStatement = emitted.manifest.trustStatement;
9726
10689
  process.stderr.write(opts.json ? `${JSON.stringify({ status: emitted.statusLine })}
@@ -9767,6 +10730,19 @@ ${certified}/${statuses.length} certified \xB7 ${statuses.length - pixelFailures
9767
10730
  `);
9768
10731
  }
9769
10732
  );
10733
+ if (opts.bar === "cert" && substitutedFamilies.length > 0) {
10734
+ const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
10735
+ 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) })}
10737
+ `);
10738
+ } else {
10739
+ process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
10740
+ \u2192 ${fontsUnprovenRemediation(task.set)}
10741
+ `);
10742
+ }
10743
+ process.exitCode = ExitCode.FontsUnproven;
10744
+ return;
10745
+ }
9770
10746
  if (!ok) {
9771
10747
  warn(opts, `${pixelFailures.length} config(s) and ${behaviorFailures.length} behavior(s) below the ${opts.bar} bar \u2014 bundle written, verdict honest (Q4)`);
9772
10748
  process.exitCode = ExitCode.VerificationFailed;
@@ -9800,195 +10776,17 @@ import { CommanderError } from "commander";
9800
10776
  // packages/cli/src/program.ts
9801
10777
  init_src3();
9802
10778
  init_environment();
10779
+ init_doctor();
9803
10780
  import { Command } from "commander";
9804
10781
 
9805
- // packages/cli/src/commands/doctor.ts
9806
- init_src4();
9807
- init_src();
9808
- import { existsSync as existsSync17, readFileSync as readFileSync14, readdirSync as readdirSync3 } from "node:fs";
9809
- import os4 from "node:os";
9810
- import path22 from "node:path";
9811
-
9812
- // packages/cli/src/describe.ts
9813
- var COMMON_EXIT_CODES = {
9814
- 0: "success",
9815
- 1: "general error",
9816
- 2: "authentication error",
9817
- 3: "input validation error",
9818
- 4: "confirmation required (re-run with --yes or answer the prompt)"
9819
- };
9820
- function printDescription(description) {
9821
- process.stdout.write(`${JSON.stringify(description, null, 2)}
9822
- `);
9823
- }
9824
-
9825
- // packages/cli/src/commands/doctor.ts
9826
- init_env();
9827
- init_environment();
9828
- init_output();
9829
- init_entitlement();
9830
- var DEFAULT_MCP_URL = "http://127.0.0.1:3845/mcp";
9831
- var DOCTOR_DESCRIPTION = {
9832
- name: "doctor",
9833
- summary: "Check whether this machine can run tendril generate end to end.",
9834
- args: [],
9835
- flags: [
9836
- { flag: "--mcp-url <url>", description: "Figma MCP endpoint", default: DEFAULT_MCP_URL },
9837
- { flag: "--json", description: "Machine-readable output" }
9838
- ],
9839
- output: {
9840
- ok: "boolean \u2014 Figma desktop MCP reachable AND a scoring browser found (fonts resolve lazily per design system; keys are informational)",
9841
- checks: "[{ name, ok, detail, remediation? }]"
9842
- },
9843
- exitCodes: { 0: "healthy", 1: "one or more required checks failed" },
9844
- examples: ["tendril doctor", "tendril doctor --json"]
9845
- };
9846
- async function runDoctorChecks(options) {
9847
- const checks = [];
9848
- const mcpUrl = options.mcpUrl ?? DEFAULT_MCP_URL;
9849
- const client = new McpHttpClient({
9850
- url: mcpUrl,
9851
- ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
9852
- });
9853
- try {
9854
- const info = await client.initialize();
9855
- const tools = await client.listTools();
9856
- checks.push({
9857
- name: "figma-desktop-mcp",
9858
- ok: true,
9859
- detail: `${info.name} ${info.version} (protocol ${info.protocolVersion}), ${tools.length} tools: ${tools.map((t) => t.name).join(", ")}`
9860
- });
9861
- } catch (err) {
9862
- checks.push({
9863
- name: "figma-desktop-mcp",
9864
- ok: false,
9865
- detail: err instanceof Error ? err.message : String(err),
9866
- remediation: err instanceof McpUnavailableError ? "Open the Figma desktop app, sign in, then Figma menu \u2192 Preferences \u2192 Enable local MCP server (Dev or Full seat on a paid plan)." : "The server answered but the exchange failed \u2014 check the signed-in account's seat, then retry."
9867
- });
9868
- }
9869
- const openrouterKey = resolveCredential("OPENROUTER_API_KEY");
9870
- checks.push(
9871
- openrouterKey ? { name: "openrouter-key", ok: true, detail: "OPENROUTER_API_KEY configured" } : {
9872
- name: "openrouter-key",
9873
- ok: false,
9874
- detail: "OPENROUTER_API_KEY not set \u2014 optional; only the curated API-model engine needs it",
9875
- remediation: "Run `tendril init` to store your OpenRouter key (BYOK) if you plan to use the curated engine."
9876
- }
9877
- );
9878
- try {
9879
- const chrome = resolveChrome();
9880
- checks.push({ name: "browser", ok: true, detail: `${chromeVersion() ?? "version unknown"} at ${chrome}` });
9881
- } catch (err) {
9882
- checks.push({
9883
- name: "browser",
9884
- ok: false,
9885
- detail: err instanceof Error ? err.message : String(err),
9886
- remediation: "Install Google Chrome (or Chromium), or set CHROME_PATH to a Chromium-family browser binary."
9887
- });
9888
- }
9889
- const fontManifest = path22.join(fontCacheDir(), "manifest.json");
9890
- checks.push(
9891
- existsSync17(fontManifest) ? { name: "font-cache", ok: true, detail: `resolved font cache at ${fontCacheDir()} (${JSON.parse(readFileSync14(fontManifest, "utf8")).length} faces)` } : {
9892
- name: "font-cache",
9893
- ok: true,
9894
- detail: `font cache empty at ${fontCacheDir()} \u2014 normal on a fresh machine; faces resolve per design system at first use`,
9895
- remediation: "Nothing to do now: `tendril fonts resolve --set <recording-dir>` fetches exactly what a recording declares, and generate/verify name that command when they need it."
9896
- }
9897
- );
9898
- const pluginRoot = path22.join(os4.homedir(), ".claude", "plugins", "cache", "tendrilapp", "tendril");
9899
- if (existsSync17(pluginRoot)) {
9900
- try {
9901
- const versions = readdirSync3(pluginRoot).filter((v) => /^\d+\.\d+\.\d+$/.test(v));
9902
- const newest = versions.sort((a, b) => versionIsNewer(a, b) ? 1 : -1)[0];
9903
- if (newest !== void 0) {
9904
- const skewed = versionIsNewer(newest, cliVersion());
9905
- checks.push(
9906
- skewed ? {
9907
- name: "plugin-skew",
9908
- ok: false,
9909
- detail: `Claude Code plugin cache holds v${newest} while this CLI is ${cliVersion()} \u2014 the plugin's skill/agents are STALE and will silently shadow current doctrine`,
9910
- remediation: "Update the plugin \u2014 REFRESH THE MARKETPLACE FIRST (its local clone goes stale and a reinstall faithfully reinstalls the old version): terminal Claude Code \u2014 `claude plugin marketplace update tendrilapp` then `claude plugin update tendril`; VS Code extension \u2014 /plugins panel \u2192 Marketplaces tab \u2192 refresh tendrilapp, THEN Plugins tab \u2192 uninstall + reinstall tendril, reopen the chat panel. If the refresh doesn't take, remove the tendrilapp marketplace entirely and re-add TendrilApp/claude-plugin (a fresh clone cannot be stale)."
9911
- } : { name: "plugin-skew", ok: true, detail: `Claude Code plugin v${newest} matches this CLI` }
9912
- );
9913
- }
9914
- } catch {
9915
- }
9916
- }
9917
- const ent = checkEntitlement();
9918
- checks.push(
9919
- ent.ok ? ent.mode === "pre-launch" ? { name: "entitlement", ok: true, detail: "pre-launch build \u2014 entitlement gates present but unarmed (no public key shipped); record/generate run freely" } : { name: "entitlement", ok: true, detail: `active plan "${ent.claims.plan}" until ${new Date(ent.claims.exp).toISOString().slice(0, 10)}${ent.stale ? " (stale \u2014 renews at next opportunity)" : ""}` } : { name: "entitlement", ok: false, detail: ent.error, remediation: ent.remediation }
9920
- );
9921
- const figmaToken = resolveCredential("FIGMA_TOKEN");
9922
- checks.push({
9923
- name: "figma-pat",
9924
- ok: true,
9925
- detail: figmaToken ? "FIGMA_TOKEN configured (REST fallback available)" : "FIGMA_TOKEN not set \u2014 optional; only needed for the REST fallback transport"
9926
- });
9927
- return { ok: checks.filter((c) => c.name !== "figma-pat" && c.name !== "openrouter-key").every((c) => c.ok), checks };
9928
- }
9929
- function versionIsNewer(a, b) {
9930
- const pa = a.split(".").map(Number);
9931
- const pb = b.split(".").map(Number);
9932
- for (let i = 0; i < 3; i++) {
9933
- if ((pb[i] ?? 0) > (pa[i] ?? 0)) return true;
9934
- if ((pb[i] ?? 0) < (pa[i] ?? 0)) return false;
9935
- }
9936
- return false;
9937
- }
9938
- async function latestVersionInfo() {
9939
- try {
9940
- const ctl = new AbortController();
9941
- const timer = setTimeout(() => ctl.abort(), 2500);
9942
- const res = await fetch("https://registry.npmjs.org/@tendrilapp%2fcli", { signal: ctl.signal });
9943
- clearTimeout(timer);
9944
- if (!res.ok) return null;
9945
- const doc = await res.json();
9946
- const latest = doc["dist-tags"]?.latest;
9947
- if (latest === void 0) return null;
9948
- const stamp = doc.time?.[latest];
9949
- return { latest, ...stamp !== void 0 ? { publishedAt: stamp.slice(0, 10) } : {} };
9950
- } catch {
9951
- return null;
9952
- }
9953
- }
9954
- async function runDoctor(flags) {
9955
- if (flags.describe) {
9956
- printDescription(DOCTOR_DESCRIPTION);
9957
- return;
9958
- }
9959
- const [report, latest] = await Promise.all([runDoctorChecks({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} }), latestVersionInfo()]);
9960
- emitData(flags, { version: cliVersion(), ...latest !== null ? { latest: latest.latest, latestPublishedAt: latest.publishedAt ?? null, updateAvailable: versionIsNewer(cliVersion(), latest.latest) } : {}, ...report }, () => {
9961
- const version = cliVersion();
9962
- if (latest === null) {
9963
- process.stdout.write(`tendril ${version} (latest: unreachable)
9964
- `);
9965
- } else if (!versionIsNewer(version, latest.latest)) {
9966
- process.stdout.write(`tendril ${version} (${latest.latest === version ? "latest" : `ahead of registry ${latest.latest}`}${latest.publishedAt !== void 0 ? `, published ${latest.publishedAt}` : ""})
9967
- `);
9968
- } else {
9969
- process.stdout.write(`tendril ${version} \u2014 UPDATE AVAILABLE: ${latest.latest}${latest.publishedAt !== void 0 ? ` (published ${latest.publishedAt})` : ""}
9970
- `);
9971
- process.stdout.write(` \u2192 npm install -g @tendrilapp/cli@latest (plugin MCP users update automatically at next session; a stale npx cache clears with npm cache clean --force)
9972
- `);
9973
- }
9974
- for (const check of report.checks) {
9975
- process.stdout.write(`${check.ok ? "\u2713" : "\u2717"} ${check.name}: ${check.detail}
9976
- `);
9977
- if (check.remediation) process.stdout.write(` \u2192 ${check.remediation}
9978
- `);
9979
- }
9980
- process.stdout.write(report.ok ? "\nready to generate\n" : "\nnot ready \u2014 fix the items above\n");
9981
- });
9982
- if (!report.ok) process.exit(1);
9983
- }
9984
-
9985
10782
  // packages/cli/src/commands/init.ts
9986
10783
  init_src3();
10784
+ init_describe();
10785
+ init_env();
10786
+ init_output();
9987
10787
  import { intro, isCancel, outro, password } from "@clack/prompts";
9988
10788
  import fs from "node:fs";
9989
10789
  import path23 from "node:path";
9990
- init_env();
9991
- init_output();
9992
10790
  var INIT_DESCRIPTION = {
9993
10791
  name: "init",
9994
10792
  summary: "Configure Figma and OpenRouter credentials in .env (idempotent).",
@@ -10096,11 +10894,12 @@ init_src3();
10096
10894
  init_src();
10097
10895
  init_src5();
10098
10896
  init_src2();
10099
- import { confirm as confirm2, isCancel as isCancel2 } from "@clack/prompts";
10100
- import { readFileSync as readFileSync15, readdirSync as readdirSync4, existsSync as existsSync18 } from "node:fs";
10897
+ init_describe();
10101
10898
  init_env();
10102
10899
  init_output();
10103
10900
  init_entitlement();
10901
+ import { confirm as confirm2, isCancel as isCancel2 } from "@clack/prompts";
10902
+ import { readFileSync as readFileSync15, readdirSync as readdirSync4, existsSync as existsSync18 } from "node:fs";
10104
10903
 
10105
10904
  // packages/cli/src/pipeline.ts
10106
10905
  init_src2();
@@ -10847,7 +11646,7 @@ function buildProgram() {
10847
11646
  await runActivate2({ ...flags, serviceUrl: local["serviceUrl"] });
10848
11647
  });
10849
11648
  const record = program.command("record").description("Agent-driven recording protocol: plan the queue, get instructions, ingest verbatim envelopes, resume from disk.");
10850
- record.command("plan").requiredOption("--set <dir>", "recording set directory").requiredOption("--component <name>", "component/system name").requiredOption("--metadata <file...>", "verbatim get_metadata envelope(s), optionally <file>@<frameId>").option("--default <axis=value...>", "explicit axis default, e.g. --default State=Rest (repeatable; persisted to the manifest; may re-plan an unrecorded set)").option("--component-set <name>", "when the metadata holds several component sets, record only this one (name = the set label in the plan error)").option("--sample", "cost sampling: anchor + one-factor + conflict crosses only (the FULL variant matrix is the default; sampling is blind to multi-axis interactions)").action(async (_o, cmd) => {
11649
+ record.command("plan").requiredOption("--set <dir>", "recording set directory").requiredOption("--component <name>", "component/system name").option("--metadata <file...>", "verbatim get_metadata envelope(s), optionally <file>@<frameId>").option("--metadata-raw-file <file>", "the get_metadata response TEXT exactly as received (single block); the CLI builds the envelope itself").option("--metadata-raw-parts-file <file>", "a JSON array of the get_metadata response blocks, each verbatim, in order (multi-block responses)").option("--default <axis=value...>", "explicit axis default, e.g. --default State=Rest (repeatable; persisted to the manifest; may re-plan an unrecorded set)").option("--component-set <name>", "when the metadata holds several component sets, record only this one (name = the set label in the plan error)").option("--sample", "cost sampling: anchor + one-factor + conflict crosses only (the FULL variant matrix is the default; sampling is blind to multi-axis interactions)").action(async (_o, cmd) => {
10851
11650
  const flags = globalFlags(cmd.parent.parent);
10852
11651
  const local = cmd.opts();
10853
11652
  const { runRecordPlan: runRecordPlan2 } = await Promise.resolve().then(() => (init_record(), record_exports));
@@ -10855,8 +11654,10 @@ function buildProgram() {
10855
11654
  ...flags,
10856
11655
  setDir: local["set"],
10857
11656
  component: local["component"],
10858
- metadataFiles: local["metadata"],
10859
11657
  sample: local["sample"],
11658
+ ...local["metadata"] !== void 0 ? { metadataFiles: local["metadata"] } : {},
11659
+ ...local["metadataRawFile"] !== void 0 ? { metadataRawFile: local["metadataRawFile"] } : {},
11660
+ ...local["metadataRawPartsFile"] !== void 0 ? { metadataRawPartsFile: local["metadataRawPartsFile"] } : {},
10860
11661
  ...local["default"] !== void 0 ? { defaultSpecs: local["default"] } : {},
10861
11662
  ...local["componentSet"] !== void 0 ? { componentSet: local["componentSet"] } : {}
10862
11663
  });
@@ -10988,6 +11789,12 @@ function buildProgram() {
10988
11789
  ...local["out"] !== void 0 ? { out: local["out"] } : {}
10989
11790
  });
10990
11791
  });
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) => {
11793
+ const flags = globalFlags(cmd.parent);
11794
+ const local = cmd.opts();
11795
+ const { runPermissions: runPermissions2 } = await Promise.resolve().then(() => (init_permissions(), permissions_exports));
11796
+ await runPermissions2({ ...flags, ...local["mcpUrl"] !== void 0 ? { mcpUrl: local["mcpUrl"] } : {} });
11797
+ });
10991
11798
  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) => {
10992
11799
  const flags = globalFlags(cmd);
10993
11800
  const local = cmd.opts();