@tendrilapp/cli 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tendril.js CHANGED
@@ -762,11 +762,13 @@ function resolveAxisDefaultWithRule(domain, recorded, override) {
762
762
  if (off !== void 0) return { value: off, rule: "boolean-off" };
763
763
  }
764
764
  const avoid = (v) => {
765
- const k = kebab(v);
766
- return INTERACTION_STATES.has(k) || BOOLEAN_STATES.has(k) || ENGAGED_STATES.has(k);
765
+ const tokens = kebab(v).split("-");
766
+ return tokens.some((t) => INTERACTION_STATES.has(t) || BOOLEAN_STATES.has(t) || ENGAGED_STATES.has(t));
767
767
  };
768
768
  const anyAvoided = recorded.some((v) => avoid(v)) && recorded.some((v) => !avoid(v));
769
- const candidates = recorded.some((v) => !avoid(v)) ? recorded.filter((v) => !avoid(v)) : recorded;
769
+ let candidates = recorded.some((v) => !avoid(v)) ? recorded.filter((v) => !avoid(v)) : recorded;
770
+ const dotted = candidates.filter((v) => v.split(/\s*-\s*|\s+/).some((part) => part.startsWith(".")));
771
+ if (dotted.length > 0) candidates = dotted;
770
772
  const counts = /* @__PURE__ */ new Map();
771
773
  for (const v of candidates) counts.set(v, (counts.get(v) ?? 0) + 1);
772
774
  let best;
@@ -789,7 +791,7 @@ var init_axis_defaults = __esm({
789
791
  "use strict";
790
792
  INTERACTION_STATES = /* @__PURE__ */ new Set(["hover", "focus", "focus-visible", "active", "pressed"]);
791
793
  BOOLEAN_STATES = /* @__PURE__ */ new Set(["disabled", "loading"]);
792
- ENGAGED_STATES = /* @__PURE__ */ new Set(["selected", "checked", "indeterminate", "open", "expanded"]);
794
+ ENGAGED_STATES = /* @__PURE__ */ new Set(["selected", "checked", "indeterminate", "mixed", "open", "expanded"]);
793
795
  INTERACTION_EVIDENCE_VALUES = /* @__PURE__ */ new Set([
794
796
  ...INTERACTION_STATES,
795
797
  ...BOOLEAN_STATES,
@@ -1214,8 +1216,8 @@ var init_src = __esm({
1214
1216
  function variableNameToPath(name) {
1215
1217
  return name.split("/").map(canonicalCssIdentPart).filter((part) => part.length > 0);
1216
1218
  }
1217
- function tokenPathToCssVar(path35) {
1218
- return `--${path35.join("-")}`;
1219
+ function tokenPathToCssVar(path38) {
1220
+ return `--${path38.join("-")}`;
1219
1221
  }
1220
1222
  function toDtcgToken(variable, defaultMode) {
1221
1223
  const modes = Object.keys(variable.valuesByMode);
@@ -1259,11 +1261,11 @@ function toDtcgToken(variable, defaultMode) {
1259
1261
  }
1260
1262
  function mapVariablesToDtcg(variables, defaultMode = "light") {
1261
1263
  const entries = variables.map((variable) => {
1262
- const path35 = variableNameToPath(variable.name);
1263
- if (path35.length === 0) {
1264
+ const path38 = variableNameToPath(variable.name);
1265
+ if (path38.length === 0) {
1264
1266
  throw new DtcgMappingError("empty-name", `Figma variable ${variable.id} has an empty name`);
1265
1267
  }
1266
- return { variable, path: path35 };
1268
+ return { variable, path: path38 };
1267
1269
  });
1268
1270
  const groupPrefixes = /* @__PURE__ */ new Set();
1269
1271
  for (const e of entries) {
@@ -1284,21 +1286,21 @@ function mapVariablesToDtcg(variables, defaultMode = "light") {
1284
1286
  }
1285
1287
  const tokens = {};
1286
1288
  const flat = [];
1287
- for (const { variable, path: path35 } of entries) {
1289
+ for (const { variable, path: path38 } of entries) {
1288
1290
  const token = toDtcgToken(variable, defaultMode);
1289
1291
  let group = tokens;
1290
- for (const segment of path35.slice(0, -1)) {
1292
+ for (const segment of path38.slice(0, -1)) {
1291
1293
  const existing = group[segment];
1292
1294
  group = existing ?? (group[segment] = {});
1293
1295
  }
1294
- const leaf = path35[path35.length - 1];
1296
+ const leaf = path38[path38.length - 1];
1295
1297
  if (group[leaf] !== void 0) {
1296
- throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path35.join(".")}" (variable ${variable.id})`);
1298
+ throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path38.join(".")}" (variable ${variable.id})`);
1297
1299
  }
1298
1300
  group[leaf] = token;
1299
1301
  flat.push({
1300
- path: path35.join("."),
1301
- cssVar: tokenPathToCssVar(path35),
1302
+ path: path38.join("."),
1303
+ cssVar: tokenPathToCssVar(path38),
1302
1304
  type: token.$type,
1303
1305
  value: token.$value
1304
1306
  });
@@ -1487,9 +1489,9 @@ function boundId(value) {
1487
1489
  return isObject(value) && typeof value["boundVariableId"] === "string" ? value["boundVariableId"] : void 0;
1488
1490
  }
1489
1491
  function resolveBinding(ctx, id) {
1490
- const path35 = ctx.pathById.get(id);
1491
- if (path35 === void 0) ctx.unresolved.add(id);
1492
- return path35;
1492
+ const path38 = ctx.pathById.get(id);
1493
+ if (path38 === void 0) ctx.unresolved.add(id);
1494
+ return path38;
1493
1495
  }
1494
1496
  function parseVariantProps(name) {
1495
1497
  if (!name.includes("=")) return void 0;
@@ -1524,8 +1526,8 @@ function walk(ctx, raw) {
1524
1526
  if (!isObject(paint) || paint["visible"] === false) continue;
1525
1527
  const id = boundId(paint);
1526
1528
  if (id !== void 0) {
1527
- const path35 = resolveBinding(ctx, id);
1528
- if (path35 !== void 0) tokens.add(path35);
1529
+ const path38 = resolveBinding(ctx, id);
1530
+ if (path38 !== void 0) tokens.add(path38);
1529
1531
  } else if (typeof paint["color"] === "string") {
1530
1532
  ctx.hardcoded.push({ node: name, property, value: paint["color"] });
1531
1533
  }
@@ -1533,8 +1535,8 @@ function walk(ctx, raw) {
1533
1535
  }
1534
1536
  const radiusId = boundId(raw["cornerRadius"]);
1535
1537
  if (radiusId !== void 0) {
1536
- const path35 = resolveBinding(ctx, radiusId);
1537
- if (path35 !== void 0) tokens.add(path35);
1538
+ const path38 = resolveBinding(ctx, radiusId);
1539
+ if (path38 !== void 0) tokens.add(path38);
1538
1540
  } else if (typeof raw["cornerRadius"] === "number" && raw["cornerRadius"] !== 0) {
1539
1541
  ctx.hardcoded.push({ node: name, property: "border-radius", value: `${raw["cornerRadius"]}px` });
1540
1542
  }
@@ -1544,10 +1546,10 @@ function walk(ctx, raw) {
1544
1546
  layout = { mode: layoutMode === "HORIZONTAL" ? "flex-row" : "flex-column" };
1545
1547
  const gapId = boundId(raw["itemSpacing"]);
1546
1548
  if (gapId !== void 0) {
1547
- const path35 = resolveBinding(ctx, gapId);
1548
- if (path35 !== void 0) {
1549
- layout.gap = path35;
1550
- tokens.add(path35);
1549
+ const path38 = resolveBinding(ctx, gapId);
1550
+ if (path38 !== void 0) {
1551
+ layout.gap = path38;
1552
+ tokens.add(path38);
1551
1553
  }
1552
1554
  } else if (typeof raw["itemSpacing"] === "number" && raw["itemSpacing"] !== 0) {
1553
1555
  ctx.hardcoded.push({ node: name, property: "gap", value: `${raw["itemSpacing"]}px` });
@@ -1556,10 +1558,10 @@ function walk(ctx, raw) {
1556
1558
  for (const field of PADDING_FIELDS) {
1557
1559
  const id = boundId(raw[field]);
1558
1560
  if (id !== void 0) {
1559
- const path35 = resolveBinding(ctx, id);
1560
- if (path35 !== void 0) {
1561
- paddingPaths.push(path35);
1562
- tokens.add(path35);
1561
+ const path38 = resolveBinding(ctx, id);
1562
+ if (path38 !== void 0) {
1563
+ paddingPaths.push(path38);
1564
+ tokens.add(path38);
1563
1565
  }
1564
1566
  } else if (typeof raw[field] === "number" && raw[field] !== 0) {
1565
1567
  ctx.hardcoded.push({ node: name, property: field, value: `${raw[field]}px` });
@@ -1968,13 +1970,13 @@ var init_tsc_check = __esm({
1968
1970
  // packages/verify/src/token-lint.ts
1969
1971
  import strictValue from "stylelint-declaration-strict-value";
1970
1972
  import stylelint from "stylelint";
1971
- async function runTokenLint(css, fileLabel = "generated.css", definedVars2) {
1973
+ async function runTokenLint(css, fileLabel = "generated.css", definedVars2, recordedMapEmpty = false) {
1972
1974
  const result = await stylelint.lint({
1973
1975
  code: css,
1974
1976
  codeFilename: fileLabel,
1975
1977
  config: STYLELINT_CONFIG
1976
1978
  });
1977
- const mapKnownEmpty = definedVars2 !== void 0 && definedVars2.size === 0;
1979
+ const mapKnownEmpty = recordedMapEmpty || definedVars2 !== void 0 && definedVars2.size === 0;
1978
1980
  const violations = result.results.flatMap(
1979
1981
  (r) => r.warnings.filter((w) => !(mapKnownEmpty && w.rule === "scale-unlimited/declaration-strict-value")).map((w) => ({
1980
1982
  file: fileLabel,
@@ -3409,7 +3411,29 @@ function resolvedFontFamilies(cacheDir = DEFAULT_FONT_CACHE) {
3409
3411
  function requiredFontsManifest(families, cacheDir = DEFAULT_FONT_CACHE) {
3410
3412
  const mPath = path9.join(cacheDir, "manifest.json");
3411
3413
  const manifest = existsSync5(mPath) ? JSON.parse(readFileSync3(mPath, "utf8")) : [];
3412
- return manifest.filter((f) => families.includes(f.family));
3414
+ const wanted = new Set(families.map((f) => f.toLowerCase()));
3415
+ return manifest.filter((f) => wanted.has(f.family.toLowerCase()));
3416
+ }
3417
+ function verifiedFontFamilies(cacheDir = DEFAULT_FONT_CACHE) {
3418
+ const mPath = path9.join(cacheDir, "manifest.json");
3419
+ if (!existsSync5(mPath)) return [];
3420
+ let entries;
3421
+ try {
3422
+ entries = JSON.parse(readFileSync3(mPath, "utf8"));
3423
+ } catch {
3424
+ return [];
3425
+ }
3426
+ const byFamily = /* @__PURE__ */ new Map();
3427
+ for (const e of entries) {
3428
+ if (typeof e.family !== "string" || typeof e.weight !== "number" || typeof e.file !== "string") continue;
3429
+ const file = path9.isAbsolute(e.file) && existsSync5(e.file) ? e.file : path9.resolve(cacheDir, path9.basename(e.file));
3430
+ if (!existsSync5(file)) continue;
3431
+ if (createHash("sha256").update(readFileSync3(file)).digest("hex") !== e.sha256) continue;
3432
+ const set = byFamily.get(e.family) ?? /* @__PURE__ */ new Set();
3433
+ set.add(e.weight);
3434
+ byFamily.set(e.family, set);
3435
+ }
3436
+ return [...byFamily.entries()].map(([family, w]) => ({ family, weights: [...w].sort((a, b) => a - b) }));
3413
3437
  }
3414
3438
  var DEFAULT_FONT_CACHE, UA;
3415
3439
  var init_font_resolve = __esm({
@@ -3421,11 +3445,21 @@ var init_font_resolve = __esm({
3421
3445
  });
3422
3446
 
3423
3447
  // packages/verify/src/font-faces.ts
3448
+ import { createHash as createHash2 } from "node:crypto";
3424
3449
  import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
3425
3450
  import path10 from "node:path";
3426
3451
  function fontFaceCss(manifestPath2 = path10.join(fontCacheDir(), "manifest.json")) {
3427
3452
  if (!existsSync6(manifestPath2)) return "";
3428
- const manifest = JSON.parse(readFileSync4(manifestPath2, "utf8"));
3453
+ const claimed = JSON.parse(readFileSync4(manifestPath2, "utf8"));
3454
+ const resolveEntry = (f) => {
3455
+ if (path10.isAbsolute(f) && existsSync6(f)) return f;
3456
+ return path10.resolve(path10.dirname(manifestPath2), path10.basename(f));
3457
+ };
3458
+ const manifest = claimed.filter((f) => {
3459
+ const file = resolveEntry(f.file);
3460
+ if (!existsSync6(file)) return false;
3461
+ return createHash2("sha256").update(readFileSync4(file)).digest("hex") === f.sha256;
3462
+ });
3429
3463
  const resolveFile = (f) => {
3430
3464
  if (path10.isAbsolute(f) && existsSync6(f)) return f;
3431
3465
  return path10.resolve(path10.dirname(manifestPath2), path10.basename(f));
@@ -4196,7 +4230,28 @@ function definedVars(tokensCss) {
4196
4230
  for (const m of tokensCss.matchAll(/(--[\w-]+)\s*:/g)) names.add(m[1]);
4197
4231
  return names;
4198
4232
  }
4199
- async function checkBundleQuality(bundleDir, entry) {
4233
+ function recordedTokenMapEmpty(setDir, reps) {
4234
+ const readMap = (file) => {
4235
+ if (!existsSync9(file)) return void 0;
4236
+ try {
4237
+ const parsed = JSON.parse(envelopeFirstTextPart(JSON.parse(readFileSync7(file, "utf8"))) || "{}");
4238
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
4239
+ } catch {
4240
+ return {};
4241
+ }
4242
+ };
4243
+ const setLevel = readMap(path14.join(setDir, "get_variable_defs.json"));
4244
+ if (setLevel !== void 0) return Object.keys(setLevel).length === 0;
4245
+ let recorded = false;
4246
+ for (const rep of reps) {
4247
+ const m = readMap(path14.join(setDir, rep, "get_variable_defs.json"));
4248
+ if (m === void 0) continue;
4249
+ recorded = true;
4250
+ if (Object.keys(m).length > 0) return false;
4251
+ }
4252
+ return recorded;
4253
+ }
4254
+ async function checkBundleQuality(bundleDir, entry, set) {
4200
4255
  const findings = [];
4201
4256
  const entryPath = path14.join(bundleDir, entry);
4202
4257
  const cssPath = path14.join(bundleDir, "styles.css");
@@ -4216,7 +4271,8 @@ async function checkBundleQuality(bundleDir, entry) {
4216
4271
  }
4217
4272
  }
4218
4273
  if (css !== "") {
4219
- for (const v of (await runTokenLint(css, "styles.css", definedVars(tokensCss))).violations) {
4274
+ const recordedEmpty = set !== void 0 && recordedTokenMapEmpty(set.dir, set.reps);
4275
+ for (const v of (await runTokenLint(css, "styles.css", definedVars(tokensCss), recordedEmpty)).violations) {
4220
4276
  findings.push({ kind: "token-lint", file: "styles.css", line: v.line, message: v.message });
4221
4277
  }
4222
4278
  }
@@ -4225,6 +4281,7 @@ async function checkBundleQuality(bundleDir, entry) {
4225
4281
  var init_bundle_quality = __esm({
4226
4282
  "packages/verify/src/bundle-quality.ts"() {
4227
4283
  "use strict";
4284
+ init_src();
4228
4285
  init_runtime();
4229
4286
  init_tsc_check();
4230
4287
  init_token_lint();
@@ -4323,14 +4380,17 @@ function chooseSeat(cropAt, ref, padW, padH, backdrop, origin, extents) {
4323
4380
  }
4324
4381
  return { comparison, seat, scored: cropAt(origin - seat.x, origin - seat.y) };
4325
4382
  }
4326
- function deepestNodeNameAt(set, rep, px, py) {
4327
- let root;
4383
+ function metadataRoot(set, rep) {
4328
4384
  try {
4329
4385
  const text = JSON.parse(readFileSync8(path15.join(set, rep, "get_metadata.json"), "utf8")).content.map((c) => c.text ?? "").join("\n");
4330
- root = parseMetadataStructure(text);
4386
+ return parseMetadataStructure(text);
4331
4387
  } catch {
4332
4388
  return void 0;
4333
4389
  }
4390
+ }
4391
+ function deepestNodeNameAt(set, rep, px, py) {
4392
+ const root = metadataRoot(set, rep);
4393
+ if (root === void 0) return void 0;
4334
4394
  let best;
4335
4395
  const walk2 = (node, ox, oy, isRoot) => {
4336
4396
  if (node.hidden === true) return;
@@ -4346,7 +4406,35 @@ function deepestNodeNameAt(set, rep, px, py) {
4346
4406
  for (const child of node.children) walk2(child, nx, ny, false);
4347
4407
  };
4348
4408
  walk2(root, 0, 0, true);
4349
- return best?.name;
4409
+ if (best !== void 0) return best.name;
4410
+ if (root.name !== "" && px >= 0 && py >= 0 && px < (root.width ?? 0) && py < (root.height ?? 0)) {
4411
+ return `${root.name} (${root.id}) (edge region, no named child at this point)`;
4412
+ }
4413
+ return void 0;
4414
+ }
4415
+ function smallSemanticNodes(set, rep, maxArea = 1024) {
4416
+ const root = metadataRoot(set, rep);
4417
+ if (root === void 0) return [];
4418
+ const found = [];
4419
+ const walk2 = (node, ox, oy, isRoot) => {
4420
+ if (node.hidden === true) return;
4421
+ const nx = isRoot ? 0 : ox + (node.x ?? 0);
4422
+ const ny = isRoot ? 0 : oy + (node.y ?? 0);
4423
+ const w = node.width ?? 0;
4424
+ const h = node.height ?? 0;
4425
+ if (!isRoot && node.name !== "" && w >= 2 && h >= 2 && w * h <= maxArea) {
4426
+ found.push({ name: node.name, id: node.id, x: nx, y: ny, w, h });
4427
+ }
4428
+ for (const child of node.children) walk2(child, nx, ny, false);
4429
+ };
4430
+ walk2(root, 0, 0, true);
4431
+ const seen = /* @__PURE__ */ new Set();
4432
+ return found.sort((a, b) => a.w * a.h - b.w * b.h).filter((n) => {
4433
+ const key = `${n.x}:${n.y}:${n.w}:${n.h}`;
4434
+ if (seen.has(key)) return false;
4435
+ seen.add(key);
4436
+ return true;
4437
+ });
4350
4438
  }
4351
4439
  function repMeta(set, rep) {
4352
4440
  const text = JSON.parse(readFileSync8(path15.join(set, rep, "get_metadata.json"), "utf8")).content.map((c) => c.text ?? "").join("\n");
@@ -5174,7 +5262,7 @@ var init_src4 = __esm({
5174
5262
  // packages/cli/src/environment.ts
5175
5263
  import { existsSync as existsSync14, readFileSync as readFileSync11 } from "node:fs";
5176
5264
  import path19 from "node:path";
5177
- import { createHash as createHash2 } from "node:crypto";
5265
+ import { createHash as createHash3 } from "node:crypto";
5178
5266
  import { fileURLToPath as fileURLToPath4 } from "node:url";
5179
5267
  function cliVersion() {
5180
5268
  try {
@@ -5191,7 +5279,7 @@ function environmentStamp(taskFamilies) {
5191
5279
  const entries = JSON.parse(readFileSync11(manifestPath2, "utf8"));
5192
5280
  const scoped = taskFamilies === void 0 || taskFamilies === null ? entries : entries.filter((f) => taskFamilies.some((fam) => fam.toLowerCase() === f.family.toLowerCase()));
5193
5281
  const faces = scoped.map((f) => `${f.family}:${f.weight}:${f.sha256}`).sort();
5194
- fontsHash = faces.length === 0 ? null : createHash2("sha256").update(faces.join("\n")).digest("hex").slice(0, 16);
5282
+ fontsHash = faces.length === 0 ? null : createHash3("sha256").update(faces.join("\n")).digest("hex").slice(0, 16);
5195
5283
  } catch {
5196
5284
  fontsHash = null;
5197
5285
  }
@@ -5210,6 +5298,25 @@ var init_environment = __esm({
5210
5298
  }
5211
5299
  });
5212
5300
 
5301
+ // packages/cli/src/describe.ts
5302
+ function printDescription(description) {
5303
+ process.stdout.write(`${JSON.stringify(description, null, 2)}
5304
+ `);
5305
+ }
5306
+ var COMMON_EXIT_CODES;
5307
+ var init_describe = __esm({
5308
+ "packages/cli/src/describe.ts"() {
5309
+ "use strict";
5310
+ COMMON_EXIT_CODES = {
5311
+ 0: "success",
5312
+ 1: "general error",
5313
+ 2: "authentication error",
5314
+ 3: "input validation error",
5315
+ 4: "confirmation required (re-run with --yes or answer the prompt)"
5316
+ };
5317
+ }
5318
+ });
5319
+
5213
5320
  // packages/cli/src/env.ts
5214
5321
  import { existsSync as existsSync15, readFileSync as readFileSync12 } from "node:fs";
5215
5322
  import path20 from "node:path";
@@ -5386,6 +5493,211 @@ var init_entitlement = __esm({
5386
5493
  }
5387
5494
  });
5388
5495
 
5496
+ // packages/cli/src/commands/doctor.ts
5497
+ import { spawnSync } from "node:child_process";
5498
+ import { existsSync as existsSync17, readFileSync as readFileSync14, readdirSync as readdirSync3 } from "node:fs";
5499
+ import os4 from "node:os";
5500
+ import path22 from "node:path";
5501
+ async function runDoctorChecks(options) {
5502
+ const checks = [];
5503
+ const mcpUrl = options.mcpUrl ?? DEFAULT_MCP_URL;
5504
+ const client = new McpHttpClient({
5505
+ url: mcpUrl,
5506
+ ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
5507
+ });
5508
+ try {
5509
+ const info = await client.initialize();
5510
+ const tools = await client.listTools();
5511
+ checks.push({
5512
+ name: "figma-desktop-mcp",
5513
+ ok: true,
5514
+ detail: `${info.name} ${info.version} (protocol ${info.protocolVersion}), ${tools.length} tools: ${tools.map((t) => t.name).join(", ")}`
5515
+ });
5516
+ } catch (err) {
5517
+ checks.push({
5518
+ name: "figma-desktop-mcp",
5519
+ ok: false,
5520
+ detail: err instanceof Error ? err.message : String(err),
5521
+ 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."
5522
+ });
5523
+ }
5524
+ const openrouterKey = resolveCredential("OPENROUTER_API_KEY");
5525
+ checks.push(
5526
+ openrouterKey ? { name: "openrouter-key", ok: true, detail: "OPENROUTER_API_KEY configured" } : {
5527
+ name: "openrouter-key",
5528
+ ok: false,
5529
+ detail: "OPENROUTER_API_KEY not set \u2014 optional; only the curated API-model engine needs it",
5530
+ remediation: "Run `tendril init` to store your OpenRouter key (BYOK) if you plan to use the curated engine."
5531
+ }
5532
+ );
5533
+ try {
5534
+ const chrome = resolveChrome();
5535
+ checks.push({ name: "browser", ok: true, detail: `${chromeVersion() ?? "version unknown"} at ${chrome}` });
5536
+ } catch (err) {
5537
+ checks.push({
5538
+ name: "browser",
5539
+ ok: false,
5540
+ detail: err instanceof Error ? err.message : String(err),
5541
+ remediation: "Install Google Chrome (or Chromium), or set CHROME_PATH to a Chromium-family browser binary."
5542
+ });
5543
+ }
5544
+ const fontManifest = path22.join(fontCacheDir(), "manifest.json");
5545
+ checks.push(
5546
+ existsSync17(fontManifest) ? { name: "font-cache", ok: true, detail: `resolved font cache at ${fontCacheDir()} (${JSON.parse(readFileSync14(fontManifest, "utf8")).length} faces)` } : {
5547
+ name: "font-cache",
5548
+ ok: true,
5549
+ detail: `font cache empty at ${fontCacheDir()} \u2014 normal on a fresh machine; faces resolve per design system at first use`,
5550
+ 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."
5551
+ }
5552
+ );
5553
+ const pluginRoot = path22.join(os4.homedir(), ".claude", "plugins", "cache", "tendrilapp", "tendril");
5554
+ if (existsSync17(pluginRoot)) {
5555
+ try {
5556
+ const versions = readdirSync3(pluginRoot).filter((v) => /^\d+\.\d+\.\d+$/.test(v));
5557
+ const newest = versions.sort((a, b) => versionIsNewer(a, b) ? 1 : -1)[0];
5558
+ if (newest !== void 0) {
5559
+ const skewed = versionIsNewer(newest, cliVersion());
5560
+ checks.push(
5561
+ skewed ? {
5562
+ name: "plugin-skew",
5563
+ ok: false,
5564
+ 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`,
5565
+ 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)."
5566
+ } : { name: "plugin-skew", ok: true, detail: `Claude Code plugin v${newest} matches this CLI` }
5567
+ );
5568
+ }
5569
+ } catch {
5570
+ }
5571
+ }
5572
+ const pathBinary = cliVersion() === "0.0.0" ? null : findPathTendril(process.env["PATH"] ?? "", process.platform);
5573
+ if (pathBinary !== null) {
5574
+ const reported = probeVersion(pathBinary);
5575
+ if (reported !== null) {
5576
+ checks.push(
5577
+ reported === cliVersion() ? { name: "path-skew", ok: true, detail: `tendril on PATH (${pathBinary}) is this CLI (${reported})` } : {
5578
+ name: "path-skew",
5579
+ ok: false,
5580
+ detail: `tendril on PATH (${pathBinary}) reports ${reported} while this CLI is ${cliVersion()} \u2014 a stale global install silently shadows the current release in terminals`,
5581
+ 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."
5582
+ }
5583
+ );
5584
+ }
5585
+ }
5586
+ const ent = checkEntitlement();
5587
+ checks.push(
5588
+ 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 }
5589
+ );
5590
+ const figmaToken = resolveCredential("FIGMA_TOKEN");
5591
+ checks.push({
5592
+ name: "figma-pat",
5593
+ ok: true,
5594
+ detail: figmaToken ? "FIGMA_TOKEN configured (REST fallback available)" : "FIGMA_TOKEN not set \u2014 optional; only needed for the REST fallback transport"
5595
+ });
5596
+ return { ok: checks.filter((c) => c.name !== "figma-pat" && c.name !== "openrouter-key" && c.name !== "path-skew").every((c) => c.ok), checks };
5597
+ }
5598
+ function findPathTendril(pathEnv, platform) {
5599
+ const dirs = pathEnv.split(path22.delimiter).filter((d) => d !== "" && !/node_modules[\\/]\.bin/.test(d) && !/[\\/]_npx[\\/]/.test(d));
5600
+ const names = platform === "win32" ? ["tendril.cmd", "tendril.bat"] : ["tendril"];
5601
+ for (const dir of dirs) {
5602
+ for (const name of names) {
5603
+ const candidate = path22.join(dir, name);
5604
+ if (existsSync17(candidate)) return candidate;
5605
+ }
5606
+ }
5607
+ return null;
5608
+ }
5609
+ function probeVersion(binary) {
5610
+ const windowsShim = /\.(cmd|bat)$/i.test(binary);
5611
+ const res = windowsShim ? spawnSync(`"${binary}" --version`, { shell: true, timeout: 5e3, encoding: "utf8" }) : spawnSync(binary, ["--version"], { timeout: 5e3, encoding: "utf8" });
5612
+ if (res.error !== void 0 || res.status !== 0) return null;
5613
+ const m = /^(\d+\.\d+\.\d+)\s*$/m.exec(res.stdout ?? "");
5614
+ return m === null ? null : m[1];
5615
+ }
5616
+ function versionIsNewer(a, b) {
5617
+ const pa = a.split(".").map(Number);
5618
+ const pb = b.split(".").map(Number);
5619
+ for (let i = 0; i < 3; i++) {
5620
+ if ((pb[i] ?? 0) > (pa[i] ?? 0)) return true;
5621
+ if ((pb[i] ?? 0) < (pa[i] ?? 0)) return false;
5622
+ }
5623
+ return false;
5624
+ }
5625
+ async function latestVersionInfo() {
5626
+ try {
5627
+ const ctl = new AbortController();
5628
+ const timer = setTimeout(() => ctl.abort(), 2500);
5629
+ const res = await fetch("https://registry.npmjs.org/@tendrilapp%2fcli", { signal: ctl.signal });
5630
+ clearTimeout(timer);
5631
+ if (!res.ok) return null;
5632
+ const doc = await res.json();
5633
+ const latest = doc["dist-tags"]?.latest;
5634
+ if (latest === void 0) return null;
5635
+ const stamp = doc.time?.[latest];
5636
+ return { latest, ...stamp !== void 0 ? { publishedAt: stamp.slice(0, 10) } : {} };
5637
+ } catch {
5638
+ return null;
5639
+ }
5640
+ }
5641
+ async function runDoctor(flags) {
5642
+ if (flags.describe) {
5643
+ printDescription(DOCTOR_DESCRIPTION);
5644
+ return;
5645
+ }
5646
+ const [report, latest] = await Promise.all([runDoctorChecks({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} }), latestVersionInfo()]);
5647
+ emitData(flags, { version: cliVersion(), ...latest !== null ? { latest: latest.latest, latestPublishedAt: latest.publishedAt ?? null, updateAvailable: versionIsNewer(cliVersion(), latest.latest) } : {}, ...report }, () => {
5648
+ const version = cliVersion();
5649
+ if (latest === null) {
5650
+ process.stdout.write(`tendril ${version} (latest: unreachable)
5651
+ `);
5652
+ } else if (!versionIsNewer(version, latest.latest)) {
5653
+ process.stdout.write(`tendril ${version} (${latest.latest === version ? "latest" : `ahead of registry ${latest.latest}`}${latest.publishedAt !== void 0 ? `, published ${latest.publishedAt}` : ""})
5654
+ `);
5655
+ } else {
5656
+ process.stdout.write(`tendril ${version} \u2014 UPDATE AVAILABLE: ${latest.latest}${latest.publishedAt !== void 0 ? ` (published ${latest.publishedAt})` : ""}
5657
+ `);
5658
+ 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)
5659
+ `);
5660
+ }
5661
+ for (const check of report.checks) {
5662
+ process.stdout.write(`${check.ok ? "\u2713" : "\u2717"} ${check.name}: ${check.detail}
5663
+ `);
5664
+ if (check.remediation) process.stdout.write(` \u2192 ${check.remediation}
5665
+ `);
5666
+ }
5667
+ process.stdout.write(report.ok ? "\nready to generate\n" : "\nnot ready \u2014 fix the items above\n");
5668
+ });
5669
+ if (!report.ok) process.exit(1);
5670
+ }
5671
+ var DEFAULT_MCP_URL, DOCTOR_DESCRIPTION;
5672
+ var init_doctor = __esm({
5673
+ "packages/cli/src/commands/doctor.ts"() {
5674
+ "use strict";
5675
+ init_src4();
5676
+ init_src();
5677
+ init_describe();
5678
+ init_env();
5679
+ init_environment();
5680
+ init_output();
5681
+ init_entitlement();
5682
+ DEFAULT_MCP_URL = "http://127.0.0.1:3845/mcp";
5683
+ DOCTOR_DESCRIPTION = {
5684
+ name: "doctor",
5685
+ summary: "Check whether this machine can run tendril generate end to end.",
5686
+ args: [],
5687
+ flags: [
5688
+ { flag: "--mcp-url <url>", description: "Figma MCP endpoint", default: DEFAULT_MCP_URL },
5689
+ { flag: "--json", description: "Machine-readable output" }
5690
+ ],
5691
+ output: {
5692
+ ok: "boolean \u2014 Figma desktop MCP reachable AND a scoring browser found (fonts resolve lazily per design system; keys are informational)",
5693
+ checks: "[{ name, ok, detail, remediation? }]"
5694
+ },
5695
+ exitCodes: { 0: "healthy", 1: "one or more required checks failed" },
5696
+ examples: ["tendril doctor", "tendril doctor --json"]
5697
+ };
5698
+ }
5699
+ });
5700
+
5389
5701
  // packages/llm/src/model-config.ts
5390
5702
  import { z as z6 } from "zod";
5391
5703
  function resolveModel(config, requestedId) {
@@ -6531,7 +6843,13 @@ var init_bundle = __esm({
6531
6843
  // insufficient identity (two builds, one path, incomparable
6532
6844
  // numbers); optional so pre-epoch bundles keep validating.
6533
6845
  chromeVersion: z10.string().nullable().optional(),
6534
- fontsManifestSha256: z10.string().nullable()
6846
+ fontsManifestSha256: z10.string().nullable(),
6847
+ // Run 11: a bundle scored under a substitute face carried CSS
6848
+ // compensation (font-variation-settings tuned to the substitute)
6849
+ // with nothing marking it conditional — the provenance now names
6850
+ // the families that were substituted at scoring time. Optional so
6851
+ // pre-0.1.19 bundles keep validating; absent means none.
6852
+ substitutedFamilies: z10.array(z10.string()).optional()
6535
6853
  }),
6536
6854
  /** Coverage denominator (Q2 honesty): recorded vs full-lattice size
6537
6855
  * when the lattice is known (null when the kit exposes no lattice). */
@@ -6687,7 +7005,8 @@ __export(record_exports, {
6687
7005
  runRecordPlan: () => runRecordPlan,
6688
7006
  runRecordStatus: () => runRecordStatus
6689
7007
  });
6690
- import { existsSync as existsSync19, readFileSync as readFileSync16, readdirSync as readdirSync5 } from "node:fs";
7008
+ import { existsSync as existsSync19, mkdtempSync as mkdtempSync2, readFileSync as readFileSync16, readdirSync as readdirSync5 } from "node:fs";
7009
+ import os5 from "node:os";
6691
7010
  import path25 from "node:path";
6692
7011
  import { writeFileSync as writeFileSync9 } from "node:fs";
6693
7012
  function symbolsFromMetadataEnvelope(file, sourceFrame) {
@@ -6734,10 +7053,42 @@ function runRecordPlan(opts) {
6734
7053
  }
6735
7054
  defaults[spec.slice(0, eq)] = spec.slice(eq + 1);
6736
7055
  }
7056
+ if (opts.metadataRawFile !== void 0 && opts.metadataRawPartsFile !== void 0) {
7057
+ fail(opts, ExitCode.InputValidation, {
7058
+ error: "pass at most one of --metadata-raw-file and --metadata-raw-parts-file",
7059
+ code: "bad-envelope",
7060
+ remediation: "One get_metadata response: single block \u2192 --metadata-raw-file; multi-block \u2192 --metadata-raw-parts-file (a JSON array of the blocks)."
7061
+ });
7062
+ }
7063
+ const metadataEntries = (opts.metadataFiles ?? []).map((spec) => {
7064
+ const [file, frame] = spec.split("@");
7065
+ return frame === void 0 ? { file } : { file, frame };
7066
+ });
7067
+ const rawFile = opts.metadataRawPartsFile ?? opts.metadataRawFile;
7068
+ if (rawFile !== void 0) {
7069
+ try {
7070
+ const envelope = rawEnvelopeFromFile(rawFile, opts.metadataRawPartsFile !== void 0);
7071
+ const tmp = path25.join(mkdtempSync2(path25.join(os5.tmpdir(), "tendril-plan-meta-")), "get_metadata.json");
7072
+ writeFileSync9(tmp, JSON.stringify(envelope));
7073
+ metadataEntries.push({ file: tmp });
7074
+ } catch (err) {
7075
+ fail(opts, ExitCode.InputValidation, {
7076
+ error: `could not read raw metadata ${rawFile}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
7077
+ code: "bad-envelope",
7078
+ 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)."
7079
+ });
7080
+ }
7081
+ }
7082
+ if (metadataEntries.length === 0) {
7083
+ fail(opts, ExitCode.InputValidation, {
7084
+ error: "no metadata provided",
7085
+ code: "bad-envelope",
7086
+ remediation: "Pass --metadata <envelope.json> (repeatable), or the response text via --metadata-raw-file / --metadata-raw-parts-file."
7087
+ });
7088
+ }
6737
7089
  let symbols = [];
6738
7090
  let metadataTruncated = false;
6739
- for (const spec of opts.metadataFiles) {
6740
- const [file, frame] = spec.split("@");
7091
+ for (const { file, frame } of metadataEntries) {
6741
7092
  try {
6742
7093
  const parsed = symbolsFromMetadataEnvelope(path25.resolve(file), frame);
6743
7094
  symbols.push(...parsed.symbols);
@@ -6770,8 +7121,7 @@ function runRecordPlan(opts) {
6770
7121
  });
6771
7122
  }
6772
7123
  if (symbols.length === 0) {
6773
- const leads = opts.metadataFiles.flatMap((spec) => {
6774
- const [file] = spec.split("@");
7124
+ const leads = metadataEntries.flatMap(({ file }) => {
6775
7125
  try {
6776
7126
  const env = JSON.parse(readFileSync16(path25.resolve(file), "utf8"));
6777
7127
  return instanceLeads(env.content.map((c) => c.text ?? "").join("\n"));
@@ -7435,6 +7785,9 @@ function buildFeedback(scores, behaviors, bar, mode = "fenced") {
7435
7785
  const reg = s.region !== void 0 ? ` worst-region=[x ${s.region.x0}-${s.region.x1}, y ${s.region.y0}-${s.region.y1}, density ${s.region.density}]` : "";
7436
7786
  return base + err + reg;
7437
7787
  });
7788
+ const absentLines = scores.flatMap(
7789
+ (s) => (s.absentInk ?? []).map((c) => `MISSING ${s.rep}: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with NO render ink within reach at [x ${c.x}-${c.x + c.w}, y ${c.y}-${c.y + c.h}]`)
7790
+ );
7438
7791
  return `Scores (bar: sim \u2265${bar.sim} AND ink \u2265${bar.ink} per config):
7439
7792
  ${lines.join("\n")}${behLines.length > 0 ? `
7440
7793
 
@@ -7442,9 +7795,12 @@ BEHAVIORAL invariants (machine-verified, GATING \u2014 fix these with real handl
7442
7795
  ${behLines.join("\n")}` : ""}${preludeLines.length > 0 ? `
7443
7796
 
7444
7797
  PRELUDE parity (computed-style checks, GATING \u2014 fix the named CSS property on the named element):
7445
- ${preludeLines.join("\n")}` : ""}
7798
+ ${preludeLines.join("\n")}` : ""}${absentLines.length > 0 ? `
7446
7799
 
7447
- Fix the FAIL configs (region coordinates are in the recorded screenshot's frame; ink<1 means recorded foreground pixels your render does not cover). Do not regress PASS configs. ${mode === "files" ? "Update the files in your candidate directory and re-run the score." : "Reply with the complete corrected files in the same FILE format."}`;
7800
+ MISSING FEATURES (absent-ink clusters \u2014 recorded marks your render leaves out or paints invisibly; GATING at the cert bar. Fix the named node's ink \u2014 a config carrying one cannot certify):
7801
+ ${absentLines.join("\n")}` : ""}
7802
+
7803
+ Fix the FAIL configs (region coordinates are in the recorded screenshot's frame; ink<1 means recorded foreground pixels your render does not cover). Reading the pair: ink=1 with LOW sim means nothing is missing \u2014 shapes or positions are wrong, do not chase missing ink; LOW ink with high sim means recorded marks are absent or painted invisibly. Do not regress PASS configs. ${mode === "files" ? "Update the files in your candidate directory and re-run the score." : "Reply with the complete corrected files in the same FILE format."}`;
7448
7804
  }
7449
7805
  function archivePriorRun(outDir) {
7450
7806
  if (!existsSync20(path26.join(outDir, "run-log.json")) && !existsSync20(path26.join(outDir, "loop-state.json"))) return void 0;
@@ -7514,14 +7870,14 @@ async function runEngineLoop(opts) {
7514
7870
  progress(
7515
7871
  `iter ${iter}: pass=${obj[0]}/${total} floor=${obj[1].toFixed(3)} mean=${obj[2].toFixed(3)} ${accepted ? "ACCEPT" : "reject"} $${usd.toFixed(3)} (total $${spentUsd.toFixed(3)})${modelMs !== void 0 ? ` model=${Math.round(modelMs / 1e3)}s` : ""} score=${Math.round(scoreMs / 1e3)}s`
7516
7872
  );
7517
- return { allPass: obj[0] === total && total > 0 };
7873
+ return { allPass: obj[0] === total && total > 0 && (opts.absentInkGates !== true || scores.every((s) => (s.absentInk?.length ?? 0) === 0)) };
7518
7874
  };
7519
7875
  if (opts.seed !== void 0 && !resuming) {
7520
7876
  const { allPass } = await scoreCandidate(opts.seed, 0, 0);
7521
7877
  if (allPass) stopReason = "all-pass";
7522
7878
  }
7523
7879
  const lastLog = log[log.length - 1];
7524
- if (resuming && lastLog !== void 0 && lastLog.parseError === void 0 && lastLog.scores.length > 0 && lastLog.objective[0] === lastLog.scores.length + (lastLog.behaviors?.length ?? 0)) {
7880
+ if (resuming && lastLog !== void 0 && lastLog.parseError === void 0 && lastLog.scores.length > 0 && lastLog.objective[0] === lastLog.scores.length + (lastLog.behaviors?.length ?? 0) && (opts.absentInkGates !== true || lastLog.scores.every((s) => (s.absentInk?.length ?? 0) === 0))) {
7525
7881
  stopReason = "all-pass";
7526
7882
  }
7527
7883
  const state = () => ({
@@ -7946,6 +8302,23 @@ function recordedFontNeeds(setDir) {
7946
8302
  return { family, weights: weights.size === 0 ? [400] : [...weights].sort((a, b) => a - b) };
7947
8303
  });
7948
8304
  }
8305
+ function symbolFontGlyphCount(setDir, reps) {
8306
+ const PUA = /[\uE000-\uF8FF\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/u;
8307
+ const glyphs = /* @__PURE__ */ new Set();
8308
+ for (const rep of reps) {
8309
+ const file = path27.join(setDir, rep, "get_metadata.json");
8310
+ if (!existsSync21(file)) continue;
8311
+ try {
8312
+ const text = JSON.parse(readFileSync18(file, "utf8")).content.map((c) => c.text ?? "").join("\n");
8313
+ for (const m of text.matchAll(/<text\s[^>]*name="([^"]*)"/g)) {
8314
+ const name = decodeXmlEntities(m[1]).replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)));
8315
+ if (PUA.test(name)) glyphs.add(name);
8316
+ }
8317
+ } catch {
8318
+ }
8319
+ }
8320
+ return glyphs.size;
8321
+ }
7949
8322
  function recordedFontFamilies(setDir) {
7950
8323
  return recordedFontNeeds(setDir).map((n) => n.family);
7951
8324
  }
@@ -8142,6 +8515,12 @@ function authorTaskFromSet(setDir, opts = {}) {
8142
8515
  return void 0;
8143
8516
  }).filter((x) => x !== void 0);
8144
8517
  const { behaviors, prelude, disclosures } = authorBehaviors(api, { ...sentinels.length > 0 ? { sentinels } : {} });
8518
+ const puaGlyphs = symbolFontGlyphCount(setDir, manifest.reps.map((r) => r.slug));
8519
+ if (puaGlyphs > 0) {
8520
+ disclosures.push(
8521
+ `SYMBOL-FONT ICONS: ${puaGlyphs} distinct recorded icon glyph(s) come from a symbol font (private-use codepoints) \u2014 the recording carries NO vector geometry for them. Reconstruct each mark against the reference pixels with extreme care, compare magnified crops after every score (run 11 shipped a checkmark where the reference was a dot, and a diamond where it was two chevrons \u2014 both scored above bar), and NEVER give two configs sharing the same recorded glyph different shapes`
8522
+ );
8523
+ }
8145
8524
  if (dismissName !== void 0) {
8146
8525
  disclosures.push(`dismiss affordance detected from the recording (${dismissName}) \u2014 onDismiss authored; dismiss-notifies-and-commits enforces the notification contract`);
8147
8526
  }
@@ -8464,8 +8843,8 @@ var init_adapter = __esm({
8464
8843
  });
8465
8844
 
8466
8845
  // packages/generate/src/bundle-emit.ts
8467
- import { createHash as createHash3 } from "node:crypto";
8468
- import { existsSync as existsSync23, readFileSync as readFileSync20, readdirSync as readdirSync7, writeFileSync as writeFileSync11 } from "node:fs";
8846
+ import { createHash as createHash4 } from "node:crypto";
8847
+ import { copyFileSync, existsSync as existsSync23, mkdirSync as mkdirSync7, readFileSync as readFileSync20, readdirSync as readdirSync7, rmSync as rmSync3, writeFileSync as writeFileSync11 } from "node:fs";
8469
8848
  import path29 from "node:path";
8470
8849
  function pinFromConfigs(configs) {
8471
8850
  const domains = /* @__PURE__ */ new Map();
@@ -8514,8 +8893,44 @@ function cssFontFamilies(css) {
8514
8893
  }
8515
8894
  return [...out];
8516
8895
  }
8517
- function countLatticeSymbols(setDir) {
8518
- const manifestFile = path29.join(setDir, "recording-set.json");
8896
+ function fontsCssFor(faces, bundleDir, cacheDir = DEFAULT_FONT_CACHE, substitutedFamilies = []) {
8897
+ const header = [
8898
+ "/* tendril fonts.css \u2014 the exact faces this bundle was scored with (sha-pinned in",
8899
+ " component.json requiredFonts). Load it alongside styles.css so the component",
8900
+ " renders in the recorded typeface \u2014 without it the browser substitutes, which is",
8901
+ " exactly the drift verification exists to rule out. Not read by scoring. */",
8902
+ ...substitutedFamilies.length > 0 ? [
8903
+ `/* GENERATED UNDER FONT SUBSTITUTION: ${substitutedFamilies.map((f) => `'${f}'`).join(", ")} was/were NOT provisioned \u2014`,
8904
+ " scores measured a substitute face and no config certified. This file covers only",
8905
+ " cache-provided faces; resolve the real families and re-score for the full set. */"
8906
+ ] : []
8907
+ ];
8908
+ const lines = [];
8909
+ for (const face of faces) {
8910
+ const src = path29.join(cacheDir, path29.basename(face.file));
8911
+ const target = `./fonts/${path29.basename(face.file)}`;
8912
+ const format = FONT_FORMATS[path29.extname(face.file).toLowerCase()] ?? "truetype";
8913
+ const family = face.family.replace(/['\\]/g, "").replace(/\*\//g, "");
8914
+ const decl = `@font-face { font-family: '${family}'; font-weight: ${face.weight}; font-style: normal; src: url(${target}) format('${format}'); }`;
8915
+ if (face.source.startsWith("local:")) {
8916
+ lines.push(
8917
+ `/* '${family}' ${face.weight} is a user-licensed face (tendril fonts add; sha256 ${face.sha256.slice(0, 16)}\u2026).`,
8918
+ ` Licensed bytes are never copied into bundles \u2014 place your copy at ${target} and uncomment: */`,
8919
+ `/* ${decl} */`
8920
+ );
8921
+ } else if (existsSync23(src) && createHash4("sha256").update(readFileSync20(src)).digest("hex") === face.sha256) {
8922
+ mkdirSync7(path29.join(bundleDir, "fonts"), { recursive: true });
8923
+ copyFileSync(src, path29.join(bundleDir, "fonts", path29.basename(face.file)));
8924
+ lines.push(decl);
8925
+ } else {
8926
+ 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. */`);
8927
+ }
8928
+ }
8929
+ return lines.length === 0 ? null : `${[...header, ...lines].join("\n")}
8930
+ `;
8931
+ }
8932
+ function countLatticeSymbols(setDir) {
8933
+ const manifestFile = path29.join(setDir, "recording-set.json");
8519
8934
  if (existsSync23(manifestFile)) {
8520
8935
  try {
8521
8936
  const lattice = JSON.parse(readFileSync20(manifestFile, "utf8")).latticeNames;
@@ -8554,17 +8969,22 @@ function recordingSetHash(setDir, configs) {
8554
8969
  relPaths,
8555
8970
  (p) => new Uint8Array(readFileSync20(path29.join(setDir, p))),
8556
8971
  (chunks) => {
8557
- const h = createHash3("sha256");
8972
+ const h = createHash4("sha256");
8558
8973
  for (const c of chunks) h.update(c);
8559
8974
  return h.digest("hex");
8560
8975
  }
8561
8976
  );
8562
8977
  }
8563
8978
  function statusOf(s) {
8564
- return tierOf(s, BARS.cert);
8979
+ const tier = tierOf(s, BARS.cert);
8980
+ return tier === "certified" && (s.absentInk?.length ?? 0) > 0 ? "pass" : tier;
8565
8981
  }
8566
8982
  function emitBundleV1(opts) {
8567
- 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) }));
8983
+ const substituted = (opts.substitutedFamilies ?? []).length > 0;
8984
+ const statuses = opts.scores.map((s) => {
8985
+ const tier = statusOf(s);
8986
+ return { rep: s.rep, similarity: s.similarity, inkRecall: s.inkRecall, ...s.exact !== void 0 ? { exact: s.exact } : {}, status: substituted && tier === "certified" ? "pass" : tier };
8987
+ });
8568
8988
  const certified = statuses.filter((s) => s.status === "certified").length;
8569
8989
  const pass = statuses.filter((s) => s.status !== "fail").length;
8570
8990
  const lattice = countLatticeSymbols(opts.task.set);
@@ -8607,7 +9027,7 @@ function emitBundleV1(opts) {
8607
9027
  ...opts.licenseNote !== void 0 ? { licenseNote: opts.licenseNote } : {},
8608
9028
  hash: recordingSetHash(opts.task.set, opts.task.configs)
8609
9029
  },
8610
- environment: opts.environment,
9030
+ environment: { ...opts.environment, ...(opts.substitutedFamilies ?? []).length > 0 ? { substitutedFamilies: opts.substitutedFamilies } : {} },
8611
9031
  coverage: { recordedConfigs: statuses.length, latticeConfigs: lattice },
8612
9032
  generatedAt: opts.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
8613
9033
  ...opts.spentUsd !== void 0 ? { spentUsd: opts.spentUsd } : {}
@@ -8637,17 +9057,26 @@ function emitBundleV1(opts) {
8637
9057
  ${stripped}`);
8638
9058
  written.push(stylesPath);
8639
9059
  }
9060
+ const fontsCssPath = path29.join(opts.bundleDir, "fonts.css");
9061
+ rmSync3(path29.join(opts.bundleDir, "fonts"), { recursive: true, force: true });
9062
+ rmSync3(fontsCssPath, { force: true });
9063
+ const fontsCss = fontsCssFor(requiredFontsManifest(families, opts.fontCacheDir), opts.bundleDir, opts.fontCacheDir, opts.substitutedFamilies ?? []);
9064
+ if (fontsCss !== null) {
9065
+ writeFileSync11(fontsCssPath, fontsCss);
9066
+ written.push(fontsCssPath);
9067
+ }
8640
9068
  const unrecorded = lattice === null ? null : Math.max(0, lattice - statuses.length);
8641
9069
  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\``;
8642
9070
  return { manifest, statusLine, written };
8643
9071
  }
8644
- var BARS;
9072
+ var FONT_FORMATS, BARS;
8645
9073
  var init_bundle_emit = __esm({
8646
9074
  "packages/generate/src/bundle-emit.ts"() {
8647
9075
  "use strict";
8648
9076
  init_src6();
8649
9077
  init_src();
8650
9078
  init_src4();
9079
+ FONT_FORMATS = { ".woff2": "woff2", ".woff": "woff", ".ttf": "truetype", ".otf": "opentype" };
8651
9080
  BARS = {
8652
9081
  pass: { sim: 0.95, ink: 0.95 },
8653
9082
  cert: { sim: 0.97, ink: 0.95 }
@@ -8826,6 +9255,28 @@ function taskFontFamilies(setDir) {
8826
9255
  return null;
8827
9256
  }
8828
9257
  }
9258
+ function unprovisionedFamilies(setDir, cacheDir) {
9259
+ const declared = taskFontFamilies(setDir);
9260
+ if (declared === null) return [];
9261
+ const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
9262
+ return declared.filter((f) => !provided.has(f.toLowerCase()));
9263
+ }
9264
+ function missingWeights(setDir, cacheDir) {
9265
+ let needs;
9266
+ try {
9267
+ needs = recordedFontNeeds(setDir);
9268
+ } catch {
9269
+ return [];
9270
+ }
9271
+ const provided = new Map(verifiedFontFamilies(cacheDir).map((f) => [f.family.toLowerCase(), f.weights]));
9272
+ const gaps = [];
9273
+ for (const n of needs) {
9274
+ const have = provided.get(n.family.toLowerCase());
9275
+ if (have === void 0) continue;
9276
+ if (n.weights.some((w) => !have.includes(w))) gaps.push({ family: n.family, declared: n.weights, provided: have });
9277
+ }
9278
+ return gaps;
9279
+ }
8829
9280
  var init_font_guidance = __esm({
8830
9281
  "packages/cli/src/font-guidance.ts"() {
8831
9282
  "use strict";
@@ -8837,6 +9288,7 @@ var init_font_guidance = __esm({
8837
9288
  // packages/cli/src/commands/verify.ts
8838
9289
  var verify_exports = {};
8839
9290
  __export(verify_exports, {
9291
+ foldConfigStatus: () => foldConfigStatus,
8840
9292
  interactionCoverage: () => interactionCoverage,
8841
9293
  runVerify: () => runVerify
8842
9294
  });
@@ -8851,6 +9303,26 @@ function interactionCoverage(behaviors) {
8851
9303
  operability: interaction.length > 0 && interaction.every((b) => b.pass) ? "verified" : "unverified"
8852
9304
  };
8853
9305
  }
9306
+ function foldConfigStatus(s, failDemotions, substitutedFamilies) {
9307
+ const { exact: _exact, ...reported } = s;
9308
+ let status = tierOf(s, BARS2.cert);
9309
+ const certDemote = [];
9310
+ let absentInkDemoted = false;
9311
+ if (status === "certified" && s.absentInk !== void 0 && s.absentInk.length > 0) {
9312
+ status = "pass";
9313
+ absentInkDemoted = true;
9314
+ certDemote.push(...s.absentInk.map((c) => `absent ink: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with no render ink within reach (missing or invisible feature)`));
9315
+ }
9316
+ if (status === "certified" && substitutedFamilies.length > 0) {
9317
+ status = "pass";
9318
+ certDemote.push(`substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 certification never measures a substitute face`);
9319
+ }
9320
+ const base = certDemote.length > 0 ? { ...reported, status, demotedBy: certDemote } : { ...reported, status };
9321
+ return {
9322
+ row: failDemotions === void 0 ? base : { ...base, status: "fail", demotedBy: [...certDemote, ...failDemotions] },
9323
+ absentInkDemoted
9324
+ };
9325
+ }
8854
9326
  function taskFromManifest(opts, manifest, setDir) {
8855
9327
  const recordedSlugs = loadManifest(setDir).reps.map((r) => r.slug);
8856
9328
  const adapterSlugs = Object.keys(manifest.propAdapter);
@@ -8990,6 +9462,15 @@ async function runVerify(opts) {
8990
9462
  remediation: fontsUnprovenRemediation(task.set)
8991
9463
  });
8992
9464
  }
9465
+ const substitutedFamilies = unprovisionedFamilies(task.set);
9466
+ if (substitutedFamilies.length > 0) {
9467
+ 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`);
9468
+ } else if (opts.bar === "cert" && taskFontFamilies(task.set) === null) {
9469
+ warn(opts, "family coverage could not be established from this recording (no declared families) \u2014 the certification gate covers only cache non-emptiness here");
9470
+ }
9471
+ for (const g of missingWeights(task.set)) {
9472
+ warn(opts, `font weights (advisory): the recording implies weights ${g.declared.join(", ")} for "${g.family}" \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (certification stays family-gated; implied weights can leak across families)`);
9473
+ }
8993
9474
  const missing = task.configs.filter(
8994
9475
  (c) => !existsSync25(path31.join(task.set, c.rep, "get_screenshot.json")) || !existsSync25(path31.join(task.set, c.rep, "get_metadata.json"))
8995
9476
  );
@@ -9003,7 +9484,7 @@ async function runVerify(opts) {
9003
9484
  const bar = BARS2[opts.bar];
9004
9485
  const evidenceDir = path31.join(opts.bundleDir, "verify-evidence");
9005
9486
  const scores = await scoreBundleForTask(task, opts.bundleDir, bar, { evidenceDir, onProgress: emitProgress });
9006
- const quality = await checkBundleQuality(opts.bundleDir, task.entry);
9487
+ const quality = await checkBundleQuality(opts.bundleDir, task.entry, { dir: task.set, reps: task.configs.map((c) => c.rep) });
9007
9488
  const bundleCss = ["tokens.css", "styles.css"].map((f) => path31.join(opts.bundleDir, f)).filter((f) => existsSync25(f)).map((f) => readFileSync22(f, "utf8")).join("\n");
9008
9489
  const occlusion = await checkSiblingOcclusion(task, opts.bundleDir, bundleCss);
9009
9490
  const parity = await checkHoverParity(task, opts.bundleDir);
@@ -9020,19 +9501,10 @@ async function runVerify(opts) {
9020
9501
  if (crops !== void 0) {
9021
9502
  for (const c of crops) if (!c.pass) demote(c.id.split(":")[1] ?? "", "composition crop failed (A1.4)");
9022
9503
  }
9504
+ const folded = scores.map((s) => foldConfigStatus(s, demotions.get(s.rep), substitutedFamilies));
9505
+ const absentInkDemoted = scores.filter((_, i) => folded[i].absentInkDemoted).map((s) => s.rep);
9023
9506
  const statuses = [
9024
- ...scores.map((s) => {
9025
- const { exact: _exact, ...reported } = s;
9026
- let status = tierOf(s, BARS2.cert);
9027
- const certDemote = [];
9028
- if (status === "certified" && s.absentInk !== void 0 && s.absentInk.length > 0) {
9029
- status = "pass";
9030
- 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)`));
9031
- }
9032
- const base = certDemote.length > 0 ? { ...reported, status, demotedBy: certDemote } : { ...reported, status };
9033
- const reasons = demotions.get(s.rep);
9034
- return reasons === void 0 ? base : { ...base, status: "fail", demotedBy: [...certDemote.length > 0 ? certDemote : [], ...reasons] };
9035
- }),
9507
+ ...folded.map((f) => f.row),
9036
9508
  // ADR-010 §2 anti-gaming: recorded configs the adapter does not map
9037
9509
  // are FAILs, never silently absent.
9038
9510
  ...unmapped.map((rep) => ({ rep, similarity: 0, inkRecall: 0, pass: false, status: "fail", error: "not mapped by the bundle's prop adapter" }))
@@ -9045,7 +9517,9 @@ async function runVerify(opts) {
9045
9517
  const occlusionFailures = occlusion.filter((o) => !o.pass);
9046
9518
  const coverage = interactionCoverage(behaviors);
9047
9519
  const evidenceUnverified = interactionEvidence.length > 0 && coverage.interactionChecks === 0;
9048
- const ok = pixelFailures.length === 0 && behaviorFailures.length === 0 && structuralFailures.length === 0 && cropFailures.length === 0 && occlusionFailures.length === 0 && !evidenceUnverified;
9520
+ const okExceptDemotion = pixelFailures.length === 0 && behaviorFailures.length === 0 && structuralFailures.length === 0 && cropFailures.length === 0 && occlusionFailures.length === 0 && !evidenceUnverified;
9521
+ const certBlockedByAbsentInk = opts.bar === "cert" ? absentInkDemoted : [];
9522
+ const ok = okExceptDemotion && certBlockedByAbsentInk.length === 0;
9049
9523
  const report = {
9050
9524
  bundle: opts.bundleDir,
9051
9525
  ...opts.task !== void 0 ? { task: opts.task } : {},
@@ -9102,7 +9576,11 @@ async function runVerify(opts) {
9102
9576
  crops: crops ?? { unavailable: regionsOut !== void 0 && "unavailable" in regionsOut ? regionsOut.unavailable : "no role manifest" }
9103
9577
  }
9104
9578
  } : {},
9105
- verdict: ok ? "verified" : "verification-failed"
9579
+ verdict: ok ? "verified" : "verification-failed",
9580
+ // Machine-readable cause (adversarial review): a cert-bar failure
9581
+ // with zero pixel/behavior/composition failures was only
9582
+ // explainable from stderr prose.
9583
+ ...certBlockedByAbsentInk.length > 0 ? { certBlockedByAbsentInk } : {}
9106
9584
  };
9107
9585
  emitData(opts, report, () => {
9108
9586
  for (const s of statuses) {
@@ -9162,7 +9640,7 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
9162
9640
  }
9163
9641
  if (evidenceUnverified) {
9164
9642
  process.stdout.write(
9165
- `FAIL interaction-evidence \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and ZERO interaction behaviors were verified. The authoring vocabulary did not map them; this is an instrument failure, not a verified component.
9643
+ `FAIL interaction-evidence \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and ZERO interaction behaviors were verified. The authoring vocabulary did not map them; this is an instrument failure, not a verified component. Mappable today: ${[...INTERACTION_STATES, ...BOOLEAN_STATES].join("/")} \u2014 on an axis named State (checked/selected state axes are recorded as evidence but not yet authorable \u2014 widening is on the roadmap; this is not fixable from component code).
9166
9644
  `
9167
9645
  );
9168
9646
  } else if (ic.interactionChecks === 0) {
@@ -9191,13 +9669,39 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
9191
9669
  }
9192
9670
  process.stdout.write(`environment: chrome=${report.environment.chromeVersion ?? report.environment.chrome} fonts=${report.environment.fontsManifestSha256 ?? "UNRESOLVED"}
9193
9671
  `);
9672
+ 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")));
9673
+ if (shippedFonts.length > 0 && substitutedFamilies.length === 0) {
9674
+ 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)
9675
+ `);
9676
+ }
9194
9677
  process.stdout.write(`evidence: ${evidenceDir} (render/ref/diff per config)
9195
9678
  `);
9196
9679
  });
9197
- if (!ok) {
9680
+ if (opts.bar === "cert" && substitutedFamilies.length > 0) {
9681
+ const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
9682
+ const alsoAbsent = absentInkDemoted.length > 0 ? ` \u2014 and ${absentInkDemoted.length} config(s) also carry absent-ink demotions (named in the report); certification requires fixing those too` : "";
9683
+ if (opts.json) {
9684
+ process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
9685
+ `);
9686
+ } else {
9687
+ process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}
9688
+ \u2192 ${fontsUnprovenRemediation(task.set)}
9689
+ `);
9690
+ }
9691
+ process.exitCode = ExitCode.FontsUnproven;
9692
+ return;
9693
+ }
9694
+ if (certBlockedByAbsentInk.length > 0) {
9695
+ warn(
9696
+ opts,
9697
+ `${certBlockedByAbsentInk.length} config(s) demoted by absent-ink clusters (${certBlockedByAbsentInk.join(", ")}) \u2014 certification never ships a missing or invisible feature; each cluster is named in the report above. Fix the component and re-verify, or verify at --bar pass for the disclosed result.`
9698
+ );
9699
+ process.exitCode = ExitCode.VerificationFailed;
9700
+ }
9701
+ if (!okExceptDemotion) {
9198
9702
  warn(
9199
9703
  opts,
9200
- `${pixelFailures.length} config(s), ${behaviorFailures.length} behavior(s), ${structuralFailures.length + cropFailures.length + occlusionFailures.length} composition check(s) below the ${opts.bar} bar \u2014 bundle written, verdict honest (Q4)`
9704
+ `${pixelFailures.length} config(s), ${behaviorFailures.length} behavior(s), ${structuralFailures.length + cropFailures.length + occlusionFailures.length} composition check(s) below the ${opts.bar} bar${evidenceUnverified ? "; PLUS the interaction-evidence instrument gate failed (operability could not be verified \u2014 see FAIL interaction-evidence above)" : ""} \u2014 bundle written, verdict honest (Q4)`
9201
9705
  );
9202
9706
  process.exitCode = ExitCode.VerificationFailed;
9203
9707
  }
@@ -9227,18 +9731,18 @@ __export(engine_exports, {
9227
9731
  runEngineBrief: () => runEngineBrief,
9228
9732
  runEngineScore: () => runEngineScore
9229
9733
  });
9230
- import { existsSync as existsSync26, mkdirSync as mkdirSync7, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
9734
+ import { appendFileSync, existsSync as existsSync26, mkdirSync as mkdirSync8, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
9231
9735
  import path32 from "node:path";
9232
9736
  function resolveEngineTask(opts, callerCwd) {
9233
9737
  const asPath = path32.resolve(callerCwd, opts.taskOrSet);
9234
9738
  const isSet = existsSync26(path32.join(asPath, "recording-set.json"));
9235
9739
  const registry = TASKS[opts.taskOrSet];
9236
- if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, disclosures: [] };
9740
+ if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, disclosures: [], interactionEvidence: [] };
9237
9741
  if (isSet) {
9238
9742
  try {
9239
9743
  const authored = authorTaskFromSet(asPath);
9240
9744
  for (const d of authored.disclosures) warn(opts, d);
9241
- return { task: authored.task, name: path32.basename(asPath), disclosures: authored.disclosures, apiPin: { props: authored.api.apiPin.props, forcedStates: authored.api.apiPin.forcedStates } };
9745
+ return { task: authored.task, name: path32.basename(asPath), disclosures: authored.disclosures, interactionEvidence: authored.api.interactionEvidence, apiPin: { props: authored.api.apiPin.props, forcedStates: authored.api.apiPin.forcedStates } };
9242
9746
  } catch (err) {
9243
9747
  fail(opts, ExitCode.InputValidation, {
9244
9748
  error: `cannot author a task from ${asPath}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
@@ -9276,20 +9780,22 @@ DO NOT INVENT PIXELS FOR THESE POSES. Implement them ONLY as the composition of
9276
9780
  ${notRecorded}` : "";
9277
9781
  let fontProvisioning;
9278
9782
  if (existsSync26(manifestPath2)) {
9279
- const provided = resolvedFontFamilies().map((f) => f.family);
9783
+ const provided = verifiedFontFamilies().map((f) => f.family);
9280
9784
  const unprovided = recordedFontFamilies(task.set).filter((r) => !provided.some((p) => p.toLowerCase() === r.toLowerCase()));
9281
9785
  if (unprovided.length > 0) {
9282
9786
  const names = unprovided.map((f) => `'${f}'`).join(", ");
9787
+ const PROPRIETARY = /^(sf pro|sf compact|sf mono|new york|pingfang|segoe ui|helvetica neue|proxima nova|avenir)/i;
9788
+ const allProprietary = unprovided.every((f) => PROPRIETARY.test(f.trim()));
9283
9789
  fontProvisioning = {
9284
9790
  unprovided,
9285
9791
  question: {
9286
9792
  prompt: `This design's text uses ${names}, which is not in the local font kit. How should it be handled?`,
9287
9793
  options: [
9288
- `(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.`,
9289
- "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."
9794
+ allProprietary ? `(Recommended) ${names} ${unprovided.length === 1 ? "is a proprietary face" : "are proprietary faces"} \u2014 Google Fonts cannot serve ${unprovided.length === 1 ? "it" : "them"}, so skip \`fonts resolve\`: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold a licence for (the file never leaves this machine), then re-run this brief \u2014 exact text fidelity.` : `(Recommended) Run \`tendril fonts resolve --set ${task.set}\` \u2014 open-source families are fetched automatically. Any face that fails there is one you license privately: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold (the file never leaves this machine). Then re-run this brief \u2014 exact text fidelity.`,
9795
+ 'Continue with a substitute face: generation proceeds in a provided family, no config can score "certified" under it (cert-bar runs exit fonts-unproven after their report), the substitution is disclosed, and small text differences are expected and not fixable from CSS.'
9290
9796
  ]
9291
9797
  },
9292
- nonInteractive: "Continue with the substitute and state the substitution in your report."
9798
+ 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."
9293
9799
  };
9294
9800
  }
9295
9801
  }
@@ -9298,7 +9804,7 @@ ${notRecorded}` : "";
9298
9804
  === TASK PAYLOAD (recorded truth, verbatim) ===
9299
9805
  ${segments}`;
9300
9806
  const payloadFile = path32.resolve(callerCwd, opts.out ?? `tendril-out/${name}-brief.md`);
9301
- mkdirSync7(path32.dirname(payloadFile), { recursive: true });
9807
+ mkdirSync8(path32.dirname(payloadFile), { recursive: true });
9302
9808
  writeFileSync12(payloadFile, payload);
9303
9809
  emitData(
9304
9810
  opts,
@@ -9344,7 +9850,7 @@ async function runEngineScore(opts) {
9344
9850
  requireEntitlement(opts);
9345
9851
  const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
9346
9852
  const candidateDir = path32.resolve(callerCwd, opts.candidateDir);
9347
- const { task, name, apiPin } = resolveEngineTask(opts, callerCwd);
9853
+ const { task, name, apiPin, interactionEvidence } = resolveEngineTask(opts, callerCwd);
9348
9854
  if (!existsSync26(candidateDir)) {
9349
9855
  fail(opts, ExitCode.InputValidation, {
9350
9856
  error: `candidate directory not found: ${candidateDir}`,
@@ -9359,6 +9865,13 @@ async function runEngineScore(opts) {
9359
9865
  remediation: fontsUnprovenRemediation(task.set)
9360
9866
  });
9361
9867
  }
9868
+ const substitutedFamilies = unprovisionedFamilies(task.set);
9869
+ if (substitutedFamilies.length > 0) {
9870
+ warn(opts, `substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 scores measure a substitute face and no config can be certified under one (the stamp covers only the provisioned subset); resolve the families to certify`);
9871
+ }
9872
+ for (const g of missingWeights(task.set)) {
9873
+ warn(opts, `font weights (advisory): the recording implies weights ${g.declared.join(", ")} for "${g.family}" \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (certification stays family-gated; implied weights can leak across families)`);
9874
+ }
9362
9875
  if (opts.rebind !== true && existsSync26(path32.join(candidateDir, "component.json"))) {
9363
9876
  const prior = (() => {
9364
9877
  try {
@@ -9391,7 +9904,7 @@ async function runEngineScore(opts) {
9391
9904
  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)";
9392
9905
  const obj = objective(scores, behaviors);
9393
9906
  const total = scores.length + behaviors.length;
9394
- const quality = await checkBundleQuality(candidateDir, task.entry);
9907
+ const quality = await checkBundleQuality(candidateDir, task.entry, { dir: task.set, reps: task.configs.map((c) => c.rep) });
9395
9908
  const qualityFeedback = quality.findings.length === 0 && !quality.tokensAbsent ? "" : `
9396
9909
 
9397
9910
  QUALITY (does not affect the bar \u2014 fix alongside the failing configs):
@@ -9399,12 +9912,13 @@ ${[
9399
9912
  ...quality.findings.map((f) => `- ${f.kind} ${f.file}${f.line === void 0 ? "" : `:${f.line}`} \u2014 ${f.message}`),
9400
9913
  ...quality.tokensAbsent ? ["- no design tokens: no tokens.css and no var(--\u2026) reference; every value is hardcoded"] : []
9401
9914
  ].join("\n")}`;
9402
- const allPass = obj[0] === total && total > 0;
9915
+ const absentAtCertBar = opts.bar === "cert" ? scores.filter((sc) => (sc.absentInk?.length ?? 0) > 0).map((sc) => sc.rep) : [];
9916
+ const allPass = obj[0] === total && total > 0 && absentAtCertBar.length === 0;
9403
9917
  const certBar = BARS3["cert"];
9404
9918
  const parityDemoted = new Set(parity.filter((p) => !p.pass).map((p) => p.id.replace(/^parity:/, "")));
9405
- const certifiedReps = scores.filter((sc) => tierOf(sc, certBar) === "certified" && !parityDemoted.has(sc.rep) && (sc.absentInk === void 0 || sc.absentInk.length === 0)).map((sc) => sc.rep);
9919
+ const certifiedReps = substitutedFamilies.length > 0 ? [] : scores.filter((sc) => tierOf(sc, certBar) === "certified" && !parityDemoted.has(sc.rep) && (sc.absentInk === void 0 || sc.absentInk.length === 0)).map((sc) => sc.rep);
9406
9920
  const certifiedSet = new Set(certifiedReps);
9407
- const absentFindings = scores.flatMap((sc) => (sc.absentInk ?? []).map((c) => `- ${sc.rep}: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with NO render ink within reach at [x ${c.x}-${c.x + c.w}, y ${c.y}-${c.y + c.h}] (missing or invisible feature; magnified crops in the evidence dir; coordinates are reference-space \u2014 subtract the config's seat for box-space). A certified-tier config with such a finding is demoted to pass.`));
9921
+ const absentFindings = scores.flatMap((sc) => (sc.absentInk ?? []).map((c) => `- ${sc.rep}: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with NO render ink within reach at [x ${c.x}-${c.x + c.w}, y ${c.y}-${c.y + c.h}] (missing or invisible feature; magnified crops in the evidence dir; coordinates are reference-space \u2014 subtract the config's seat for box-space). A certified-tier config with such a finding is demoted to pass, and at --bar cert it FAILS the run (exit 5).`));
9408
9922
  const absentBlock = absentFindings.length > 0 ? `
9409
9923
 
9410
9924
  MISSING FEATURES (absent-ink clusters \u2014 recorded ink your render leaves nowhere near covered; fix these first, the global numbers cannot see them):
@@ -9423,8 +9937,14 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
9423
9937
  model: `${opts.model} (self-reported${opts.host !== void 0 ? ` via ${opts.host}` : ""})`,
9424
9938
  scores,
9425
9939
  behaviors,
9426
- environment: environmentStamp(taskFontFamilies(task.set))
9940
+ environment: environmentStamp(taskFontFamilies(task.set)),
9941
+ substitutedFamilies
9427
9942
  });
9943
+ appendFileSync(
9944
+ path32.join(candidateDir, "score-history.jsonl"),
9945
+ `${JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), bar: opts.bar, pass: obj[0], total, certified: certifiedReps.length, floor: obj[1], mean: obj[2] })}
9946
+ `
9947
+ );
9428
9948
  emitData(
9429
9949
  opts,
9430
9950
  {
@@ -9444,6 +9964,10 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
9444
9964
  bundleManifest: emitted.written[0],
9445
9965
  note: "styles.css was stamped with the bundle provenance comment on line 1 \u2014 preserve it in any post-score edit",
9446
9966
  allPass,
9967
+ // Run 11: generators read allPass:true and reported success on
9968
+ // bundles verify then FAILED on the interaction-evidence gate —
9969
+ // the oracle must say what verify will say, including this.
9970
+ ...interactionEvidence.length > 0 && interactionCoverage(behaviors).interactionChecks === 0 ? { interactionEvidenceUnverified: true, verifyWillFail: "interaction-evidence \u2014 the recording proves interactive poses none of the authored behaviors cover; not fixable from component code; REPORT it, do not iterate on it" } : {},
9447
9971
  certification: { certified: certifiedReps.length, total: scores.length, bar: certBar, note: "tierOf on exact values + parity and absent-ink demotion; verify's composition checks can demote further" }
9448
9972
  },
9449
9973
  () => {
@@ -9455,12 +9979,31 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
9455
9979
  ${obj[0]}/${total} \xB7 certified ${certifiedReps.length}/${total} \xB7 floor=${obj[1].toFixed(3)} \xB7 mean=${obj[2].toFixed(3)} \xB7 evidence: ${evidenceDir}
9456
9980
  `);
9457
9981
  const ic = interactionCoverage(behaviors);
9458
- if (ic.interactionChecks === 0) {
9982
+ if (interactionEvidence.length > 0 && ic.interactionChecks === 0) {
9983
+ process.stdout.write(`INSTRUMENT GAP \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and zero interaction behaviors were authored; verify WILL fail this bundle (interaction-evidence). Not fixable from component code \u2014 report it, do not iterate on it.
9984
+ `);
9985
+ } else if (ic.interactionChecks === 0) {
9459
9986
  process.stdout.write(`UNVERIFIED operability \u2014 0 interaction checks; the behaviour passes above are page-level style hygiene
9460
9987
  `);
9461
9988
  }
9462
9989
  }
9463
9990
  );
9991
+ if (absentAtCertBar.length > 0) {
9992
+ warn(opts, `${absentAtCertBar.length} config(s) carry absent-ink clusters (${absentAtCertBar.join(", ")}) \u2014 at --bar cert these fail the run; the MISSING FEATURES block above names each one`);
9993
+ }
9994
+ if (opts.bar === "cert" && substitutedFamilies.length > 0) {
9995
+ const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
9996
+ if (opts.json) {
9997
+ 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) })}
9998
+ `);
9999
+ } else {
10000
+ process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
10001
+ \u2192 ${fontsUnprovenRemediation(task.set)}
10002
+ `);
10003
+ }
10004
+ process.exitCode = ExitCode.FontsUnproven;
10005
+ return;
10006
+ }
9464
10007
  if (!allPass) process.exitCode = ExitCode.VerificationFailed;
9465
10008
  }
9466
10009
  var BARS3;
@@ -9660,6 +10203,586 @@ var init_codeconnect = __esm({
9660
10203
  }
9661
10204
  });
9662
10205
 
10206
+ // packages/mcp/src/server.ts
10207
+ import { createHash as createHash5 } from "node:crypto";
10208
+ import { existsSync as existsSync28, mkdtempSync as mkdtempSync3, readFileSync as readFileSync25, readdirSync as readdirSync8, writeFileSync as writeFileSync14 } from "node:fs";
10209
+ import os6 from "node:os";
10210
+ import path34 from "node:path";
10211
+ import { fileURLToPath as fileURLToPath5 } from "node:url";
10212
+ import { z as z12 } from "zod";
10213
+ function sourceHash() {
10214
+ const dir = path34.dirname(fileURLToPath5(import.meta.url));
10215
+ const h = createHash5("sha256");
10216
+ for (const f of readdirSync8(dir).filter((n) => n.endsWith(".ts")).sort()) {
10217
+ h.update(f);
10218
+ h.update(readFileSync25(path34.join(dir, f)));
10219
+ }
10220
+ return h.digest("hex").slice(0, 16);
10221
+ }
10222
+ var REPO_ROOT3, CLI_BIN, BUNDLED_CLI, CLI_SPAWN, str, optStr, TOOLS, BOOT_HASH;
10223
+ var init_server = __esm({
10224
+ "packages/mcp/src/server.ts"() {
10225
+ "use strict";
10226
+ REPO_ROOT3 = path34.resolve(path34.dirname(fileURLToPath5(import.meta.url)), "..", "..", "..");
10227
+ CLI_BIN = path34.join(REPO_ROOT3, "packages", "cli", "src", "bin.ts");
10228
+ BUNDLED_CLI = path34.join(path34.dirname(fileURLToPath5(import.meta.url)), "tendril.js");
10229
+ CLI_SPAWN = existsSync28(BUNDLED_CLI) ? { cmd: process.execPath, prefix: [BUNDLED_CLI] } : { cmd: "npx", prefix: ["tsx", CLI_BIN] };
10230
+ str = (d) => z12.string().describe(d);
10231
+ optStr = (d) => z12.string().optional().describe(d);
10232
+ TOOLS = [
10233
+ {
10234
+ name: "tendril_record_plan",
10235
+ 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.",
10236
+ schema: z12.object({
10237
+ setDir: str("recording set directory to create/resume"),
10238
+ component: str("component/system name"),
10239
+ // Parts array FIRST, same reason as ingest_rep: real responses
10240
+ // are usually multi-block, and the file param made plan the ONE
10241
+ // remaining hand-built-envelope entry point (run 10: the agent
10242
+ // wrote the file twice — once as text, once as JSON envelope).
10243
+ 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"),
10244
+ metadata: optStr("ONLY when get_metadata genuinely returned one single block: its text verbatim (otherwise use metadataParts)"),
10245
+ 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'),
10246
+ 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)`),
10247
+ componentSet: optStr("record only this component set (the user's pick from a multiple-component-sets error)")
10248
+ }),
10249
+ // Texts ride temp files, never argv: Windows caps a command line at
10250
+ // ~32 KB and metadata envelopes can exceed it.
10251
+ argv: (i) => {
10252
+ const argvOut = ["record", "plan", "--set", i["setDir"], "--component", i["component"]];
10253
+ if (i["metadata"] !== void 0 && i["metadataParts"] !== void 0) throw new Error("pass at most one of `metadata` and `metadataParts`");
10254
+ const files = i["metadataFiles"];
10255
+ if (files !== void 0 && files.length > 0) argvOut.push("--metadata", ...files);
10256
+ const single = i["metadata"];
10257
+ const parts = i["metadataParts"];
10258
+ if (single !== void 0 || parts !== void 0) {
10259
+ const tmp = path34.join(mkdtempSync3(path34.join(os6.tmpdir(), "tendril-envelope-")), "response.txt");
10260
+ if (single !== void 0) {
10261
+ writeFileSync14(tmp, single);
10262
+ argvOut.push("--metadata-raw-file", tmp);
10263
+ } else {
10264
+ if (parts.length === 0) throw new Error("`metadataParts` must be a non-empty array of block texts");
10265
+ writeFileSync14(tmp, JSON.stringify(parts));
10266
+ argvOut.push("--metadata-raw-parts-file", tmp);
10267
+ }
10268
+ }
10269
+ if (argvOut.length === 6) throw new Error("nothing to plan from \u2014 pass metadataParts (normal), metadata (single-block), or metadataFiles");
10270
+ argvOut.push(...i["defaults"] !== void 0 ? ["--default", ...i["defaults"]] : [], ...i["componentSet"] !== void 0 ? ["--component-set", i["componentSet"]] : []);
10271
+ return argvOut;
10272
+ }
10273
+ },
10274
+ {
10275
+ name: "tendril_permissions",
10276
+ description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing. USE THIS when tendril/Figma tool calls keep hitting permission prompts: offer it to the user once \u2014 ONE approval here replaces a prompt per pipeline call (run 8 measured 16+ prompts for a single component's recording). The user must reload the session for new settings to apply.",
10277
+ schema: z12.object({
10278
+ write: z12.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries")
10279
+ }),
10280
+ argv: (i) => ["permissions", "--claude", ...i["write"] === true ? ["--write"] : []]
10281
+ },
10282
+ {
10283
+ name: "tendril_doctor",
10284
+ annotations: { readOnlyHint: true },
10285
+ 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.",
10286
+ schema: z12.object({}),
10287
+ argv: () => ["doctor"]
10288
+ },
10289
+ {
10290
+ name: "tendril_record_next",
10291
+ annotations: { readOnlyHint: true },
10292
+ 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).",
10293
+ schema: z12.object({ setDir: str("recording set directory") }),
10294
+ argv: (i) => ["record", "next", "--set", i["setDir"]]
10295
+ },
10296
+ {
10297
+ name: "tendril_record_fetch",
10298
+ 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.",
10299
+ schema: z12.object({
10300
+ setDir: str("recording set directory"),
10301
+ rep: str("planned rep slug"),
10302
+ tool: z12.enum(["get_screenshot"]).describe("get_screenshot"),
10303
+ url: str("image_url from the Figma response, verbatim")
10304
+ }),
10305
+ argv: (i) => ["record", "fetch", "--set", i["setDir"], "--rep", i["rep"], "--tool", i["tool"], "--url", i["url"]]
10306
+ },
10307
+ {
10308
+ name: "tendril_record_ingest_rep",
10309
+ 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).",
10310
+ schema: z12.object({
10311
+ setDir: str("recording set directory"),
10312
+ rep: str("planned rep slug"),
10313
+ // Parts arrays FIRST: in the field, EVERY real Figma response is
10314
+ // multi-block (run 6: 49/49 reps — metadata 2 blocks, design
10315
+ // context 5-6), so the arrays are the norm and the single-string
10316
+ // params the rare case, not the reverse.
10317
+ 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."),
10318
+ 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)"),
10319
+ screenshotUrl: optStr("image_url from the get_screenshot response, verbatim \u2014 the CLI downloads it; the bytes never pass through your context"),
10320
+ metadata: optStr("ONLY when get_metadata genuinely returned one single block: its text verbatim (otherwise use metadataParts)"),
10321
+ context: optStr("ONLY when get_design_context genuinely returned one single block: its text verbatim (otherwise use contextParts)")
10322
+ }),
10323
+ // Texts ride temp files, never argv: Windows caps a command line at
10324
+ // ~32 KB and design-context envelopes routinely exceed it.
10325
+ argv: (i) => {
10326
+ const argvOut = ["record", "ingest-rep", "--set", i["setDir"], "--rep", i["rep"]];
10327
+ const bridge = (label, single, parts) => {
10328
+ if (single !== void 0 && parts !== void 0) throw new Error(`pass at most one of \`${label}\` and \`${label}Parts\``);
10329
+ if (single === void 0 && parts === void 0) return;
10330
+ const tmp = path34.join(mkdtempSync3(path34.join(os6.tmpdir(), "tendril-envelope-")), "response.txt");
10331
+ if (single !== void 0) {
10332
+ writeFileSync14(tmp, single);
10333
+ argvOut.push(`--${label}-file`, tmp);
10334
+ } else {
10335
+ const blocks = parts;
10336
+ if (blocks.length === 0) throw new Error(`\`${label}Parts\` must be a non-empty array of block texts`);
10337
+ writeFileSync14(tmp, JSON.stringify(blocks));
10338
+ argvOut.push(`--${label}-parts-file`, tmp);
10339
+ }
10340
+ };
10341
+ bridge("metadata", i["metadata"], i["metadataParts"]);
10342
+ bridge("context", i["context"], i["contextParts"]);
10343
+ if (i["screenshotUrl"] !== void 0) argvOut.push("--screenshot-url", i["screenshotUrl"]);
10344
+ if (argvOut.length === 6) throw new Error("nothing to ingest \u2014 pass at least one of metadata/metadataParts, context/contextParts, screenshotUrl");
10345
+ return argvOut;
10346
+ }
10347
+ },
10348
+ {
10349
+ name: "tendril_record_ingest",
10350
+ 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).",
10351
+ schema: z12.object({
10352
+ setDir: str("recording set directory"),
10353
+ rep: str("planned rep slug, or __set__ for the set-level get_variable_defs"),
10354
+ // Enumerated, not a free string: this value reaches a file path.
10355
+ // As a bare string it was an arbitrary relative-path overwrite
10356
+ // (--tool "../../outside/victim" replaced a file outside the set,
10357
+ // exit 0). The sink in session.ts now contains the path too — this
10358
+ // is the second layer, and it makes the tool self-documenting.
10359
+ tool: z12.enum(["get_design_context", "get_metadata", "get_screenshot", "get_variable_defs", "get_metadata_interior"]),
10360
+ text: optStr("the tool response text VERBATIM \u2014 exactly as returned, unmodified (single-block responses; text tools only \u2014 screenshots go through record_fetch)"),
10361
+ 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"),
10362
+ file: optStr("path to a saved envelope JSON (alternative to text/texts)")
10363
+ }),
10364
+ // The text rides a temp file, never argv: Windows caps a command
10365
+ // line at ~32 KB and design-context envelopes routinely exceed it.
10366
+ argv: (i) => {
10367
+ const base = ["record", "ingest", "--set", i["setDir"], "--rep", i["rep"], "--tool", i["tool"]];
10368
+ const text = i["text"];
10369
+ const texts = i["texts"];
10370
+ const file = i["file"];
10371
+ 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)");
10372
+ if (file !== void 0) return [...base, "--file", file];
10373
+ const tmp = path34.join(mkdtempSync3(path34.join(os6.tmpdir(), "tendril-envelope-")), "response.txt");
10374
+ if (text !== void 0) {
10375
+ writeFileSync14(tmp, text);
10376
+ return [...base, "--file", tmp, "--raw"];
10377
+ }
10378
+ writeFileSync14(tmp, JSON.stringify(texts));
10379
+ return [...base, "--file", tmp, "--raw-parts"];
10380
+ }
10381
+ },
10382
+ {
10383
+ name: "tendril_record_asset",
10384
+ 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.",
10385
+ schema: z12.object({
10386
+ setDir: str("recording set directory"),
10387
+ rep: str("planned rep slug"),
10388
+ name: optStr("asset-<id>.<ext> (single-asset mode)"),
10389
+ file: optStr("downloaded asset file path (single-asset mode)"),
10390
+ dir: optStr("batch mode: directory holding downloaded asset-*.<ext> files")
10391
+ }),
10392
+ argv: (i) => [
10393
+ "record",
10394
+ "asset",
10395
+ "--set",
10396
+ i["setDir"],
10397
+ "--rep",
10398
+ i["rep"],
10399
+ ...i["dir"] !== void 0 ? ["--dir", i["dir"]] : ["--name", i["name"], "--file", i["file"]]
10400
+ ]
10401
+ },
10402
+ {
10403
+ name: "tendril_record_status",
10404
+ annotations: { readOnlyHint: true },
10405
+ description: "Recording-set completeness: per-rep recorded/missing tools.",
10406
+ schema: z12.object({ setDir: str("recording set directory") }),
10407
+ argv: (i) => ["record", "status", "--set", i["setDir"]]
10408
+ },
10409
+ {
10410
+ name: "tendril_engine_brief",
10411
+ 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.",
10412
+ schema: z12.object({
10413
+ 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"),
10414
+ // Required by design, not convenience: the model choice must be
10415
+ // settled BEFORE generation starts. A smoke run picked its own
10416
+ // model silently because the ask lived in instruction text, which
10417
+ // evaporates in non-interactive sessions; a required parameter
10418
+ // cannot evaporate. Ask the user when one is present; otherwise
10419
+ // choose, declare, and state the reason in your report.
10420
+ model: str("the model that will WRITE the implementation \u2014 ask the user when interactive; declare your reasoned choice when not"),
10421
+ bar: optStr("pass (default) or cert"),
10422
+ out: optStr("payload file path override")
10423
+ }),
10424
+ argv: (i) => [
10425
+ "engine",
10426
+ "brief",
10427
+ i["taskOrSet"],
10428
+ "--model",
10429
+ i["model"],
10430
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
10431
+ ...i["out"] !== void 0 ? ["--out", i["out"]] : []
10432
+ ]
10433
+ },
10434
+ {
10435
+ name: "tendril_engine_score",
10436
+ 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.",
10437
+ schema: z12.object({
10438
+ taskOrSet: str("reference task name or recording-set directory"),
10439
+ candidateDir: str("directory containing the proposed bundle files"),
10440
+ bar: optStr("pass (default) or cert"),
10441
+ host: optStr("your host identity (e.g. claude-code, cursor, codex) \u2014 recorded as self-reported provenance"),
10442
+ model: str("the model that proposed the candidate \u2014 REQUIRED; recorded as self-reported provenance, and the CLI refuses to score without it"),
10443
+ 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")
10444
+ }),
10445
+ argv: (i) => [
10446
+ "engine",
10447
+ "score",
10448
+ i["taskOrSet"],
10449
+ i["candidateDir"],
10450
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
10451
+ ...i["host"] !== void 0 ? ["--host", i["host"]] : [],
10452
+ "--model",
10453
+ i["model"],
10454
+ ...i["rebind"] === true ? ["--rebind"] : []
10455
+ ]
10456
+ },
10457
+ {
10458
+ name: "tendril_codeconnect",
10459
+ 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.",
10460
+ schema: z12.object({
10461
+ bundleDir: str("bundle directory (carries component.json)"),
10462
+ 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)"),
10463
+ set: optStr("recording set override (default: the bundle's provenance path)"),
10464
+ out: optStr("output file path (default: <bundle>/<Component>.figma.ts)")
10465
+ }),
10466
+ argv: (i) => [
10467
+ "codeconnect",
10468
+ i["bundleDir"],
10469
+ "--figma-url",
10470
+ i["figmaUrl"],
10471
+ ...i["set"] !== void 0 ? ["--set", i["set"]] : [],
10472
+ ...i["out"] !== void 0 ? ["--out", i["out"]] : []
10473
+ ]
10474
+ },
10475
+ {
10476
+ name: "tendril_verify",
10477
+ description: "Verify/check that a component matches its Figma design \u2014 use when the user asks whether an implementation is faithful to the design, or to re-certify an existing Tendril bundle. Recomputes full verification (per-config status, behaviors, composition, evidence artifacts). Free, account-less, network-less \u2014 the trust anchor. Exit 5 means below the target bar with an honest report \u2014 sub-bar scores, or at bar cert a config demoted by absent-ink clusters.",
10478
+ schema: z12.object({
10479
+ bundleDir: str("bundle directory to verify"),
10480
+ bar: optStr("pass (default) or cert"),
10481
+ set: optStr("recording-set directory override")
10482
+ }),
10483
+ argv: (i) => [
10484
+ "verify",
10485
+ i["bundleDir"],
10486
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
10487
+ ...i["set"] !== void 0 ? ["--set", i["set"]] : []
10488
+ ]
10489
+ },
10490
+ {
10491
+ name: "tendril_generate_curated",
10492
+ 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.",
10493
+ schema: z12.object({
10494
+ input: str("reference task name or recording-set directory"),
10495
+ model: optStr("OpenRouter model id (default: allowlist pointer)"),
10496
+ bar: optStr("pass (default) or cert"),
10497
+ cap: optStr("spend cap in USD (default 1.50)"),
10498
+ yes: z12.boolean().optional().describe("accept the cost consent (the user must have approved the spend)")
10499
+ }),
10500
+ argv: (i) => [
10501
+ "generate",
10502
+ i["input"],
10503
+ ...i["model"] !== void 0 ? ["--model", i["model"]] : [],
10504
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
10505
+ ...i["cap"] !== void 0 ? ["--cap", i["cap"]] : [],
10506
+ ...i["yes"] === true ? ["--yes"] : []
10507
+ ]
10508
+ }
10509
+ ];
10510
+ BOOT_HASH = (() => {
10511
+ try {
10512
+ return sourceHash();
10513
+ } catch {
10514
+ return "unknown";
10515
+ }
10516
+ })();
10517
+ }
10518
+ });
10519
+
10520
+ // packages/cli/src/commands/permissions.ts
10521
+ var permissions_exports = {};
10522
+ __export(permissions_exports, {
10523
+ PERMISSIONS_DESCRIPTION: () => PERMISSIONS_DESCRIPTION,
10524
+ buildPermissions: () => buildPermissions,
10525
+ mergeAllowlist: () => mergeAllowlist,
10526
+ runPermissions: () => runPermissions
10527
+ });
10528
+ import { existsSync as existsSync29, mkdirSync as mkdirSync9, readFileSync as readFileSync26, writeFileSync as writeFileSync15 } from "node:fs";
10529
+ import os7 from "node:os";
10530
+ import path35 from "node:path";
10531
+ function mergeAllowlist(file, entries) {
10532
+ let settings = {};
10533
+ if (existsSync29(file) && readFileSync26(file, "utf8").trim() !== "") {
10534
+ settings = JSON.parse(readFileSync26(file, "utf8"));
10535
+ if (settings === null || typeof settings !== "object" || Array.isArray(settings)) throw new Error("settings root is not an object");
10536
+ }
10537
+ const permissions = settings["permissions"] ??= {};
10538
+ if (permissions === null || typeof permissions !== "object" || Array.isArray(permissions)) throw new Error("permissions is not an object");
10539
+ const allow = permissions["allow"] ??= [];
10540
+ if (!Array.isArray(allow)) throw new Error("permissions.allow is not an array");
10541
+ const present = new Set(allow.filter((x) => typeof x === "string"));
10542
+ const added = entries.filter((e) => !present.has(e));
10543
+ const alreadyPresent = entries.filter((e) => present.has(e));
10544
+ if (added.length > 0) {
10545
+ allow.push(...added);
10546
+ mkdirSync9(path35.dirname(file), { recursive: true });
10547
+ writeFileSync15(file, `${JSON.stringify(settings, null, 2)}
10548
+ `);
10549
+ }
10550
+ return { added, alreadyPresent };
10551
+ }
10552
+ async function buildPermissions(options) {
10553
+ const LEGAL_TOOL_NAME = /^[A-Za-z0-9_-]+$/;
10554
+ const pipeline = new Set(FIGMA_TOOL_FALLBACK);
10555
+ let figmaTools = FIGMA_TOOL_FALLBACK;
10556
+ try {
10557
+ const client = new McpHttpClient({ url: options.mcpUrl ?? DEFAULT_MCP_URL, ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {} });
10558
+ await client.initialize();
10559
+ const live = (await client.listTools()).map((t) => t.name).filter((n) => LEGAL_TOOL_NAME.test(n) && pipeline.has(n));
10560
+ if (live.length > 0) figmaTools = live;
10561
+ } catch {
10562
+ }
10563
+ return {
10564
+ host: "claude",
10565
+ serverEntries: [TENDRIL_PLUGIN_PREFIX, FIGMA_PLUGIN_PREFIX],
10566
+ toolEntries: [
10567
+ ...TOOLS.map((t) => t.name).filter((n) => LEGAL_TOOL_NAME.test(n)).map((n) => `${TENDRIL_PLUGIN_PREFIX}__${n}`),
10568
+ ...figmaTools.map((name) => `${FIGMA_PLUGIN_PREFIX}__${name}`)
10569
+ ],
10570
+ 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.",
10571
+ 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).`
10572
+ };
10573
+ }
10574
+ async function runPermissions(flags) {
10575
+ if (flags.describe) {
10576
+ printDescription(PERMISSIONS_DESCRIPTION);
10577
+ return;
10578
+ }
10579
+ const result = await buildPermissions({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} });
10580
+ if (flags.write) {
10581
+ const base = process.env["INIT_CWD"] ?? process.cwd();
10582
+ const file = flags.user ? path35.join(os7.homedir(), ".claude", "settings.json") : path35.join(base, ".claude", "settings.local.json");
10583
+ if (flags.dryRun) {
10584
+ emitData(flags, { file, wouldAdd: result.toolEntries }, () => {
10585
+ process.stdout.write(`dry-run: would merge ${result.toolEntries.length} per-tool entries into ${file}
10586
+ `);
10587
+ });
10588
+ return;
10589
+ }
10590
+ try {
10591
+ const { added, alreadyPresent } = mergeAllowlist(file, result.toolEntries);
10592
+ emitData(flags, { ...result, written: { file, added, alreadyPresent } }, () => {
10593
+ process.stdout.write(
10594
+ added.length === 0 ? `already installed: all ${alreadyPresent.length} Tendril pipeline entries present in ${file}
10595
+ ` : `installed: ${added.length} allowlist entr${added.length === 1 ? "y" : "ies"} added to ${file}${alreadyPresent.length > 0 ? ` (${alreadyPresent.length} already present)` : ""}
10596
+ restart or reopen the Claude Code session to pick up settings changes
10597
+ `
10598
+ );
10599
+ });
10600
+ } catch (err) {
10601
+ fail(flags, ExitCode.InputValidation, {
10602
+ error: `cannot rewrite ${file}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
10603
+ code: "settings-unwritable",
10604
+ remediation: "The file is not plain JSON this command can faithfully re-emit (comments or an unexpected shape). Add the entries by hand: run without --write for the paste-ready block."
10605
+ });
10606
+ }
10607
+ return;
10608
+ }
10609
+ emitData(flags, result, () => {
10610
+ const quoted = (xs) => xs.map((x) => ` ${JSON.stringify(x)}`).join(",\n");
10611
+ process.stdout.write(
10612
+ `Claude Code allowlist for the Tendril pipeline.
10613
+ One command installs it (project-local, idempotent):
10614
+
10615
+ tendril permissions --claude --write
10616
+
10617
+ Or paste into .claude/settings.json under permissions.allow:
10618
+
10619
+ ${quoted(result.toolEntries)}
10620
+
10621
+ Shorter but broader \u2014 one entry per server:
10622
+
10623
+ ${quoted(result.serverEntries)}
10624
+
10625
+ \u26A0 ${result.serverEntriesCaution}
10626
+
10627
+ ${result.directConfigNote}
10628
+ `
10629
+ );
10630
+ });
10631
+ }
10632
+ var FIGMA_TOOL_FALLBACK, PERMISSIONS_DESCRIPTION, TENDRIL_PLUGIN_PREFIX, FIGMA_PLUGIN_PREFIX;
10633
+ var init_permissions = __esm({
10634
+ "packages/cli/src/commands/permissions.ts"() {
10635
+ "use strict";
10636
+ init_src3();
10637
+ init_src();
10638
+ init_server();
10639
+ init_describe();
10640
+ init_output();
10641
+ init_doctor();
10642
+ FIGMA_TOOL_FALLBACK = ["get_metadata", "get_design_context", "get_screenshot", "get_variable_defs", "get_motion_context", "get_figjam"];
10643
+ PERMISSIONS_DESCRIPTION = {
10644
+ name: "permissions",
10645
+ summary: "Install (or print) the Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools).",
10646
+ args: [],
10647
+ flags: [
10648
+ { flag: "--claude", description: "Claude Code settings format (the default and currently only format)" },
10649
+ { flag: "--write", description: "Merge the per-tool entries into .claude/settings.local.json in the current project (idempotent; creates the file; never touches other keys)" },
10650
+ { flag: "--user", description: "With --write: target ~/.claude/settings.json (every project) instead of the project-local file" },
10651
+ { flag: "--mcp-url <url>", description: "Figma MCP endpoint to list live tool names from", default: DEFAULT_MCP_URL },
10652
+ { flag: "--json", description: "Machine-readable output" }
10653
+ ],
10654
+ output: {
10655
+ host: '"claude"',
10656
+ serverEntries: "string[] \u2014 one entry per MCP server (allows every tool it serves)",
10657
+ toolEntries: "string[] \u2014 per-tool entries for selective allowlists",
10658
+ directConfigNote: "string \u2014 prefix rewrite for non-plugin (claude mcp add) installs",
10659
+ written: "with --write: { file, added, alreadyPresent }"
10660
+ },
10661
+ exitCodes: { 0: "printed or written", 3: "with --write: the target file exists but is not JSON this command can safely rewrite" },
10662
+ examples: ["tendril permissions --claude --write", "tendril permissions --claude", "tendril permissions --claude --json"]
10663
+ };
10664
+ TENDRIL_PLUGIN_PREFIX = "mcp__plugin_tendril_tendril";
10665
+ FIGMA_PLUGIN_PREFIX = "mcp__plugin_figma_figma";
10666
+ }
10667
+ });
10668
+
10669
+ // packages/cli/src/commands/inspect.ts
10670
+ var inspect_exports = {};
10671
+ __export(inspect_exports, {
10672
+ INSPECT_DESCRIPTION: () => INSPECT_DESCRIPTION,
10673
+ runInspect: () => runInspect
10674
+ });
10675
+ import { existsSync as existsSync30, readFileSync as readFileSync27, writeFileSync as writeFileSync16 } from "node:fs";
10676
+ import path36 from "node:path";
10677
+ async function runInspect(opts) {
10678
+ if (opts.describe) {
10679
+ printDescription(INSPECT_DESCRIPTION);
10680
+ return;
10681
+ }
10682
+ const bundleDir = path36.resolve(opts.bundleDir);
10683
+ const evidenceDir = path36.join(bundleDir, "verify-evidence");
10684
+ const manifestPath2 = path36.join(bundleDir, "component.json");
10685
+ if (!existsSync30(evidenceDir) || !existsSync30(manifestPath2)) {
10686
+ fail(opts, ExitCode.InputValidation, {
10687
+ error: `nothing to inspect in ${bundleDir} \u2014 ${existsSync30(manifestPath2) ? "no verify-evidence directory" : "no component.json"}`,
10688
+ code: "no-evidence",
10689
+ remediation: "Run `tendril verify <bundleDir>` first \u2014 inspect reads the ref/render evidence that run writes."
10690
+ });
10691
+ }
10692
+ const { manifest } = readBundleManifest(readFileSync27(manifestPath2, "utf8"));
10693
+ if (manifest === void 0) {
10694
+ fail(opts, ExitCode.InputValidation, {
10695
+ error: "component.json did not parse as a bundle manifest",
10696
+ code: "no-mount-contract",
10697
+ remediation: "Re-emit the bundle (score/verify rewrite component.json), then re-run inspect."
10698
+ });
10699
+ }
10700
+ const setDir = path36.resolve(opts.set ?? manifest.provenance.recordingSet.path);
10701
+ const reps = Object.keys(manifest.propAdapter).filter((rep) => existsSync30(path36.join(evidenceDir, `${rep}-ref.png`)) && existsSync30(path36.join(evidenceDir, `${rep}-render.png`)));
10702
+ if (reps.length === 0) {
10703
+ fail(opts, ExitCode.InputValidation, {
10704
+ error: "verify-evidence holds no ref/render pairs for this bundle's configs",
10705
+ code: "no-evidence",
10706
+ remediation: "Run `tendril verify <bundleDir>` first \u2014 inspect reads the ref/render evidence that run writes."
10707
+ });
10708
+ }
10709
+ let crops = 0;
10710
+ const sections = [];
10711
+ for (const rep of reps) {
10712
+ const ref = new Uint8Array(readFileSync27(path36.join(evidenceDir, `${rep}-ref.png`)));
10713
+ const render = new Uint8Array(readFileSync27(path36.join(evidenceDir, `${rep}-render.png`)));
10714
+ const nodes = smallSemanticNodes(setDir, rep, opts.maxArea ?? 1024).slice(0, 12);
10715
+ const cells = [];
10716
+ for (const [i, n] of nodes.entries()) {
10717
+ const rect = { x: n.x, y: n.y, w: n.w, h: n.h };
10718
+ try {
10719
+ writeFileSync16(path36.join(evidenceDir, `${rep}-inspect-${i}-ref.png`), zoomCrop(ref, rect));
10720
+ writeFileSync16(path36.join(evidenceDir, `${rep}-inspect-${i}-render.png`), zoomCrop(render, rect));
10721
+ } catch {
10722
+ continue;
10723
+ }
10724
+ crops += 1;
10725
+ cells.push(
10726
+ `<figure><figcaption>${esc(n.name)} <code>${esc(n.id)}</code> \xB7 ${n.w}\xD7${n.h}</figcaption><div class="pair"><span><em>recorded</em><img src="./${rep}-inspect-${i}-ref.png" alt="recorded ${esc(n.name)}"></span><span><em>rendered</em><img src="./${rep}-inspect-${i}-render.png" alt="rendered ${esc(n.name)}"></span></div></figure>`
10727
+ );
10728
+ }
10729
+ sections.push(
10730
+ `<section><h2>${esc(rep)}</h2><div class="full"><span><em>recorded</em><img src="./${rep}-ref.png"></span><span><em>rendered</em><img src="./${rep}-render.png"></span><span><em>diff</em><img src="./${rep}-diff.png"></span></div>` + (cells.length > 0 ? `<div class="grid">${cells.join("")}</div>` : `<p class="none">no small recorded nodes in this config's sweep</p>`) + `</section>`
10731
+ );
10732
+ }
10733
+ const sheet = path36.join(evidenceDir, "inspect.html");
10734
+ writeFileSync16(
10735
+ sheet,
10736
+ `<!doctype html><meta charset="utf-8"><title>${esc(manifest.name)} \u2014 tendril inspect</title><style>
10737
+ body{font:14px/1.45 system-ui,sans-serif;margin:24px;background:#fff;color:#111}
10738
+ h1{font-size:20px} h2{font-size:16px;border-top:1px solid #ddd;padding-top:16px}
10739
+ .pair,.full{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-start}
10740
+ .full img{max-width:400px;border:1px solid #ccc} .pair img{border:1px solid #ccc;image-rendering:pixelated}
10741
+ figure{margin:0 0 16px} figcaption{margin-bottom:4px} em{display:block;color:#666;font-style:normal;font-size:12px}
10742
+ .grid{display:flex;flex-wrap:wrap;gap:20px;margin-top:12px} .none{color:#666}
10743
+ </style><h1>${esc(manifest.name)} \u2014 detail sheet (small recorded nodes, recorded vs rendered)</h1>
10744
+ <p>Every crop is a recorded node small enough that global metrics weight it as a rounding error.
10745
+ Scan the pairs: anything present on the left and missing/invisible on the right is a defect,
10746
+ whatever the scores said. Verdicts come from <code>tendril verify</code> \u2014 this sheet only shows.</p>
10747
+ ${sections.join("\n")}
10748
+ `
10749
+ );
10750
+ emitData(opts, { sheet, configs: reps.length, crops }, () => {
10751
+ process.stdout.write(`inspect sheet: ${sheet}
10752
+ ${reps.length} config(s), ${crops} detail crop pair(s) \u2014 open the sheet and scan recorded vs rendered
10753
+ `);
10754
+ });
10755
+ }
10756
+ var INSPECT_DESCRIPTION, esc;
10757
+ var init_inspect = __esm({
10758
+ "packages/cli/src/commands/inspect.ts"() {
10759
+ "use strict";
10760
+ init_src3();
10761
+ init_src6();
10762
+ init_src4();
10763
+ init_describe();
10764
+ init_output();
10765
+ INSPECT_DESCRIPTION = {
10766
+ name: "inspect",
10767
+ summary: "Build an eye-verifiable detail sheet from verify evidence: magnified ref-vs-render crops of every small recorded node (icons, controls, marks).",
10768
+ args: [{ name: "bundleDir", required: true, description: "bundle directory (must carry component.json and a verify-evidence dir from a prior `tendril verify`)" }],
10769
+ flags: [
10770
+ { flag: "--set <dir>", description: "recording set override (default: the bundle's provenance path)" },
10771
+ { flag: "--max-area <px2>", description: "node area ceiling for the detail sweep", default: "1024" },
10772
+ { flag: "--json", description: "Machine-readable output" }
10773
+ ],
10774
+ output: {
10775
+ sheet: "string \u2014 path to the generated inspect.html",
10776
+ configs: "number \u2014 configs with evidence found",
10777
+ crops: "number \u2014 detail crop pairs written"
10778
+ },
10779
+ exitCodes: { 0: "sheet written", 3: "no verify evidence to inspect (run `tendril verify` first)" },
10780
+ examples: ["tendril inspect ./src/components/Banner", "tendril inspect ./bundle --set ./tendril/recordings/banner"]
10781
+ };
10782
+ esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
10783
+ }
10784
+ });
10785
+
9663
10786
  // packages/cli/src/commands/generate-route.ts
9664
10787
  var generate_route_exports = {};
9665
10788
  __export(generate_route_exports, {
@@ -9684,17 +10807,17 @@ __export(generate_recorded_exports, {
9684
10807
  runGenerateRecorded: () => runGenerateRecorded
9685
10808
  });
9686
10809
  import { confirm as confirm3, isCancel as isCancel3 } from "@clack/prompts";
9687
- import { existsSync as existsSync28, readFileSync as readFileSync25 } from "node:fs";
9688
- import path34 from "node:path";
10810
+ import { existsSync as existsSync31, readFileSync as readFileSync28 } from "node:fs";
10811
+ import path37 from "node:path";
9689
10812
  async function runGenerateRecorded(opts) {
9690
10813
  const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
9691
- const outDirAbs = path34.resolve(callerCwd, opts.out);
9692
- const recordedAsPath = path34.resolve(callerCwd, opts.recorded);
10814
+ const outDirAbs = path37.resolve(callerCwd, opts.out);
10815
+ const recordedAsPath = path37.resolve(callerCwd, opts.recorded);
9693
10816
  let task;
9694
10817
  let taskName;
9695
10818
  let authoredApi;
9696
10819
  let composition;
9697
- const isSet = existsSync28(path34.join(recordedAsPath, "recording-set.json"));
10820
+ const isSet = existsSync31(path37.join(recordedAsPath, "recording-set.json"));
9698
10821
  const registry = TASKS[opts.recorded];
9699
10822
  if (registry !== void 0 && !isSet) {
9700
10823
  task = registry;
@@ -9703,7 +10826,7 @@ async function runGenerateRecorded(opts) {
9703
10826
  try {
9704
10827
  const authored = authorTaskFromSet(recordedAsPath);
9705
10828
  task = authored.task;
9706
- taskName = path34.basename(recordedAsPath);
10829
+ taskName = path37.basename(recordedAsPath);
9707
10830
  authoredApi = authored.api;
9708
10831
  const roles = RolesSchema.safeParse(loadManifest(recordedAsPath).roles);
9709
10832
  if (roles.success) composition = roles.data;
@@ -9729,8 +10852,15 @@ async function runGenerateRecorded(opts) {
9729
10852
  remediation: fontsUnprovenRemediation(task.set)
9730
10853
  });
9731
10854
  }
10855
+ const substitutedFamilies = unprovisionedFamilies(task.set);
10856
+ if (substitutedFamilies.length > 0) {
10857
+ warn(opts, `substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 scores measure a substitute face and no config can be certified under one (the stamp covers only the provisioned subset); resolve the families to certify`);
10858
+ }
10859
+ for (const g of missingWeights(task.set)) {
10860
+ warn(opts, `font weights (advisory): the recording implies weights ${g.declared.join(", ")} for "${g.family}" \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (certification stays family-gated; implied weights can leak across families)`);
10861
+ }
9732
10862
  const missing = task.configs.filter(
9733
- (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"))
10863
+ (c) => !existsSync31(path37.join(task.set, c.rep, "get_screenshot.json")) || !existsSync31(path37.join(task.set, c.rep, "get_metadata.json")) || !existsSync31(path37.join(task.set, c.rep, "get_design_context.json"))
9734
10864
  );
9735
10865
  if (missing.length > 0) {
9736
10866
  fail(opts, ExitCode.RecordingIncomplete, {
@@ -9800,8 +10930,8 @@ async function runGenerateRecorded(opts) {
9800
10930
  ` : `${line}
9801
10931
  `);
9802
10932
  if (opts.dryRun) {
9803
- emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite: path34.join(outDirAbs, taskName) }, () => {
9804
- process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path34.join(outDirAbs, taskName)})
10933
+ emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite: path37.join(outDirAbs, taskName) }, () => {
10934
+ process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path37.join(outDirAbs, taskName)})
9805
10935
  `);
9806
10936
  });
9807
10937
  return;
@@ -9824,10 +10954,10 @@ async function runGenerateRecorded(opts) {
9824
10954
  });
9825
10955
  }
9826
10956
  }
9827
- const bundleDir = path34.join(outDirAbs, taskName);
9828
- if (existsSync28(path34.join(bundleDir, "component.json"))) {
10957
+ const bundleDir = path37.join(outDirAbs, taskName);
10958
+ if (existsSync31(path37.join(bundleDir, "component.json"))) {
9829
10959
  try {
9830
- const prior = readBundleManifest(readFileSync25(path34.join(bundleDir, "component.json"), "utf8")).manifest;
10960
+ const prior = readBundleManifest(readFileSync28(path37.join(bundleDir, "component.json"), "utf8")).manifest;
9831
10961
  if (prior !== void 0 && prior.provenance.recordingSet.hash !== recordingSetHash(task.set, task.configs)) {
9832
10962
  fail(opts, ExitCode.InputValidation, {
9833
10963
  error: `${bundleDir} already holds a bundle bound to recording set "${prior.provenance.recordingSet.path}" \u2014 generating here against a different set would silently rewrite its verification identity`,
@@ -9840,6 +10970,7 @@ async function runGenerateRecorded(opts) {
9840
10970
  }
9841
10971
  }
9842
10972
  const result = await runEngineLoop({
10973
+ absentInkGates: opts.bar === "cert",
9843
10974
  engine,
9844
10975
  segments,
9845
10976
  brief,
@@ -9859,12 +10990,13 @@ async function runGenerateRecorded(opts) {
9859
10990
  });
9860
10991
  const statuses = result.finalScores.map((s) => ({
9861
10992
  ...s,
9862
- status: s.similarity >= BARS4.cert.sim && s.inkRecall >= BARS4.cert.ink ? "certified" : s.pass ? "pass" : "fail"
10993
+ status: (s.exact?.similarity ?? s.similarity) >= BARS4.cert.sim && (s.exact?.inkRecall ?? s.inkRecall) >= BARS4.cert.ink && substitutedFamilies.length === 0 && (s.absentInk?.length ?? 0) === 0 ? "certified" : s.pass ? "pass" : "fail"
9863
10994
  }));
9864
10995
  const behaviorFailures = result.finalBehaviors.filter((b) => !b.pass);
9865
10996
  const pixelFailures = statuses.filter((s) => s.status === "fail");
9866
10997
  const certified = statuses.filter((s) => s.status === "certified").length;
9867
- const ok = result.best !== void 0 && pixelFailures.length === 0 && behaviorFailures.length === 0;
10998
+ const absentAtCertBar = opts.bar === "cert" ? result.finalScores.filter((s) => (s.absentInk?.length ?? 0) > 0).map((s) => s.rep) : [];
10999
+ const ok = result.best !== void 0 && pixelFailures.length === 0 && behaviorFailures.length === 0 && absentAtCertBar.length === 0;
9868
11000
  let trustStatement;
9869
11001
  if (result.best !== void 0) {
9870
11002
  const emitted = emitBundleV1({
@@ -9878,7 +11010,8 @@ async function runGenerateRecorded(opts) {
9878
11010
  scores: result.finalScores,
9879
11011
  behaviors: result.finalBehaviors,
9880
11012
  environment: environmentStamp(taskFontFamilies(task.set)),
9881
- spentUsd: result.spentUsd
11013
+ spentUsd: result.spentUsd,
11014
+ substitutedFamilies
9882
11015
  });
9883
11016
  trustStatement = emitted.manifest.trustStatement;
9884
11017
  process.stderr.write(opts.json ? `${JSON.stringify({ status: emitted.statusLine })}
@@ -9925,7 +11058,25 @@ ${certified}/${statuses.length} certified \xB7 ${statuses.length - pixelFailures
9925
11058
  `);
9926
11059
  }
9927
11060
  );
9928
- if (!ok) {
11061
+ if (opts.bar === "cert" && substitutedFamilies.length > 0) {
11062
+ const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
11063
+ const alsoAbsent = absentAtCertBar.length > 0 ? ` \u2014 and ${absentAtCertBar.length} config(s) also carry absent-ink clusters; certification requires fixing those too` : "";
11064
+ if (opts.json) {
11065
+ process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
11066
+ `);
11067
+ } else {
11068
+ process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}
11069
+ \u2192 ${fontsUnprovenRemediation(task.set)}
11070
+ `);
11071
+ }
11072
+ process.exitCode = ExitCode.FontsUnproven;
11073
+ return;
11074
+ }
11075
+ if (absentAtCertBar.length > 0) {
11076
+ warn(opts, `${absentAtCertBar.length} config(s) carry absent-ink clusters (${absentAtCertBar.join(", ")}) \u2014 certification never ships a missing or invisible feature (1B); fix the component or run at --bar pass for the disclosed result`);
11077
+ process.exitCode = ExitCode.VerificationFailed;
11078
+ }
11079
+ if (!ok && (pixelFailures.length > 0 || behaviorFailures.length > 0 || result.best === void 0)) {
9929
11080
  warn(opts, `${pixelFailures.length} config(s) and ${behaviorFailures.length} behavior(s) below the ${opts.bar} bar \u2014 bundle written, verdict honest (Q4)`);
9930
11081
  process.exitCode = ExitCode.VerificationFailed;
9931
11082
  }
@@ -9958,195 +11109,17 @@ import { CommanderError } from "commander";
9958
11109
  // packages/cli/src/program.ts
9959
11110
  init_src3();
9960
11111
  init_environment();
11112
+ init_doctor();
9961
11113
  import { Command } from "commander";
9962
11114
 
9963
- // packages/cli/src/commands/doctor.ts
9964
- init_src4();
9965
- init_src();
9966
- import { existsSync as existsSync17, readFileSync as readFileSync14, readdirSync as readdirSync3 } from "node:fs";
9967
- import os4 from "node:os";
9968
- import path22 from "node:path";
9969
-
9970
- // packages/cli/src/describe.ts
9971
- var COMMON_EXIT_CODES = {
9972
- 0: "success",
9973
- 1: "general error",
9974
- 2: "authentication error",
9975
- 3: "input validation error",
9976
- 4: "confirmation required (re-run with --yes or answer the prompt)"
9977
- };
9978
- function printDescription(description) {
9979
- process.stdout.write(`${JSON.stringify(description, null, 2)}
9980
- `);
9981
- }
9982
-
9983
- // packages/cli/src/commands/doctor.ts
9984
- init_env();
9985
- init_environment();
9986
- init_output();
9987
- init_entitlement();
9988
- var DEFAULT_MCP_URL = "http://127.0.0.1:3845/mcp";
9989
- var DOCTOR_DESCRIPTION = {
9990
- name: "doctor",
9991
- summary: "Check whether this machine can run tendril generate end to end.",
9992
- args: [],
9993
- flags: [
9994
- { flag: "--mcp-url <url>", description: "Figma MCP endpoint", default: DEFAULT_MCP_URL },
9995
- { flag: "--json", description: "Machine-readable output" }
9996
- ],
9997
- output: {
9998
- ok: "boolean \u2014 Figma desktop MCP reachable AND a scoring browser found (fonts resolve lazily per design system; keys are informational)",
9999
- checks: "[{ name, ok, detail, remediation? }]"
10000
- },
10001
- exitCodes: { 0: "healthy", 1: "one or more required checks failed" },
10002
- examples: ["tendril doctor", "tendril doctor --json"]
10003
- };
10004
- async function runDoctorChecks(options) {
10005
- const checks = [];
10006
- const mcpUrl = options.mcpUrl ?? DEFAULT_MCP_URL;
10007
- const client = new McpHttpClient({
10008
- url: mcpUrl,
10009
- ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
10010
- });
10011
- try {
10012
- const info = await client.initialize();
10013
- const tools = await client.listTools();
10014
- checks.push({
10015
- name: "figma-desktop-mcp",
10016
- ok: true,
10017
- detail: `${info.name} ${info.version} (protocol ${info.protocolVersion}), ${tools.length} tools: ${tools.map((t) => t.name).join(", ")}`
10018
- });
10019
- } catch (err) {
10020
- checks.push({
10021
- name: "figma-desktop-mcp",
10022
- ok: false,
10023
- detail: err instanceof Error ? err.message : String(err),
10024
- 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."
10025
- });
10026
- }
10027
- const openrouterKey = resolveCredential("OPENROUTER_API_KEY");
10028
- checks.push(
10029
- openrouterKey ? { name: "openrouter-key", ok: true, detail: "OPENROUTER_API_KEY configured" } : {
10030
- name: "openrouter-key",
10031
- ok: false,
10032
- detail: "OPENROUTER_API_KEY not set \u2014 optional; only the curated API-model engine needs it",
10033
- remediation: "Run `tendril init` to store your OpenRouter key (BYOK) if you plan to use the curated engine."
10034
- }
10035
- );
10036
- try {
10037
- const chrome = resolveChrome();
10038
- checks.push({ name: "browser", ok: true, detail: `${chromeVersion() ?? "version unknown"} at ${chrome}` });
10039
- } catch (err) {
10040
- checks.push({
10041
- name: "browser",
10042
- ok: false,
10043
- detail: err instanceof Error ? err.message : String(err),
10044
- remediation: "Install Google Chrome (or Chromium), or set CHROME_PATH to a Chromium-family browser binary."
10045
- });
10046
- }
10047
- const fontManifest = path22.join(fontCacheDir(), "manifest.json");
10048
- checks.push(
10049
- existsSync17(fontManifest) ? { name: "font-cache", ok: true, detail: `resolved font cache at ${fontCacheDir()} (${JSON.parse(readFileSync14(fontManifest, "utf8")).length} faces)` } : {
10050
- name: "font-cache",
10051
- ok: true,
10052
- detail: `font cache empty at ${fontCacheDir()} \u2014 normal on a fresh machine; faces resolve per design system at first use`,
10053
- 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."
10054
- }
10055
- );
10056
- const pluginRoot = path22.join(os4.homedir(), ".claude", "plugins", "cache", "tendrilapp", "tendril");
10057
- if (existsSync17(pluginRoot)) {
10058
- try {
10059
- const versions = readdirSync3(pluginRoot).filter((v) => /^\d+\.\d+\.\d+$/.test(v));
10060
- const newest = versions.sort((a, b) => versionIsNewer(a, b) ? 1 : -1)[0];
10061
- if (newest !== void 0) {
10062
- const skewed = versionIsNewer(newest, cliVersion());
10063
- checks.push(
10064
- skewed ? {
10065
- name: "plugin-skew",
10066
- ok: false,
10067
- 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`,
10068
- 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)."
10069
- } : { name: "plugin-skew", ok: true, detail: `Claude Code plugin v${newest} matches this CLI` }
10070
- );
10071
- }
10072
- } catch {
10073
- }
10074
- }
10075
- const ent = checkEntitlement();
10076
- checks.push(
10077
- 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 }
10078
- );
10079
- const figmaToken = resolveCredential("FIGMA_TOKEN");
10080
- checks.push({
10081
- name: "figma-pat",
10082
- ok: true,
10083
- detail: figmaToken ? "FIGMA_TOKEN configured (REST fallback available)" : "FIGMA_TOKEN not set \u2014 optional; only needed for the REST fallback transport"
10084
- });
10085
- return { ok: checks.filter((c) => c.name !== "figma-pat" && c.name !== "openrouter-key").every((c) => c.ok), checks };
10086
- }
10087
- function versionIsNewer(a, b) {
10088
- const pa = a.split(".").map(Number);
10089
- const pb = b.split(".").map(Number);
10090
- for (let i = 0; i < 3; i++) {
10091
- if ((pb[i] ?? 0) > (pa[i] ?? 0)) return true;
10092
- if ((pb[i] ?? 0) < (pa[i] ?? 0)) return false;
10093
- }
10094
- return false;
10095
- }
10096
- async function latestVersionInfo() {
10097
- try {
10098
- const ctl = new AbortController();
10099
- const timer = setTimeout(() => ctl.abort(), 2500);
10100
- const res = await fetch("https://registry.npmjs.org/@tendrilapp%2fcli", { signal: ctl.signal });
10101
- clearTimeout(timer);
10102
- if (!res.ok) return null;
10103
- const doc = await res.json();
10104
- const latest = doc["dist-tags"]?.latest;
10105
- if (latest === void 0) return null;
10106
- const stamp = doc.time?.[latest];
10107
- return { latest, ...stamp !== void 0 ? { publishedAt: stamp.slice(0, 10) } : {} };
10108
- } catch {
10109
- return null;
10110
- }
10111
- }
10112
- async function runDoctor(flags) {
10113
- if (flags.describe) {
10114
- printDescription(DOCTOR_DESCRIPTION);
10115
- return;
10116
- }
10117
- const [report, latest] = await Promise.all([runDoctorChecks({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} }), latestVersionInfo()]);
10118
- emitData(flags, { version: cliVersion(), ...latest !== null ? { latest: latest.latest, latestPublishedAt: latest.publishedAt ?? null, updateAvailable: versionIsNewer(cliVersion(), latest.latest) } : {}, ...report }, () => {
10119
- const version = cliVersion();
10120
- if (latest === null) {
10121
- process.stdout.write(`tendril ${version} (latest: unreachable)
10122
- `);
10123
- } else if (!versionIsNewer(version, latest.latest)) {
10124
- process.stdout.write(`tendril ${version} (${latest.latest === version ? "latest" : `ahead of registry ${latest.latest}`}${latest.publishedAt !== void 0 ? `, published ${latest.publishedAt}` : ""})
10125
- `);
10126
- } else {
10127
- process.stdout.write(`tendril ${version} \u2014 UPDATE AVAILABLE: ${latest.latest}${latest.publishedAt !== void 0 ? ` (published ${latest.publishedAt})` : ""}
10128
- `);
10129
- 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)
10130
- `);
10131
- }
10132
- for (const check of report.checks) {
10133
- process.stdout.write(`${check.ok ? "\u2713" : "\u2717"} ${check.name}: ${check.detail}
10134
- `);
10135
- if (check.remediation) process.stdout.write(` \u2192 ${check.remediation}
10136
- `);
10137
- }
10138
- process.stdout.write(report.ok ? "\nready to generate\n" : "\nnot ready \u2014 fix the items above\n");
10139
- });
10140
- if (!report.ok) process.exit(1);
10141
- }
10142
-
10143
11115
  // packages/cli/src/commands/init.ts
10144
11116
  init_src3();
11117
+ init_describe();
11118
+ init_env();
11119
+ init_output();
10145
11120
  import { intro, isCancel, outro, password } from "@clack/prompts";
10146
11121
  import fs from "node:fs";
10147
11122
  import path23 from "node:path";
10148
- init_env();
10149
- init_output();
10150
11123
  var INIT_DESCRIPTION = {
10151
11124
  name: "init",
10152
11125
  summary: "Configure Figma and OpenRouter credentials in .env (idempotent).",
@@ -10254,11 +11227,12 @@ init_src3();
10254
11227
  init_src();
10255
11228
  init_src5();
10256
11229
  init_src2();
10257
- import { confirm as confirm2, isCancel as isCancel2 } from "@clack/prompts";
10258
- import { readFileSync as readFileSync15, readdirSync as readdirSync4, existsSync as existsSync18 } from "node:fs";
11230
+ init_describe();
10259
11231
  init_env();
10260
11232
  init_output();
10261
11233
  init_entitlement();
11234
+ import { confirm as confirm2, isCancel as isCancel2 } from "@clack/prompts";
11235
+ import { readFileSync as readFileSync15, readdirSync as readdirSync4, existsSync as existsSync18 } from "node:fs";
10262
11236
 
10263
11237
  // packages/cli/src/pipeline.ts
10264
11238
  init_src2();
@@ -11005,7 +11979,7 @@ function buildProgram() {
11005
11979
  await runActivate2({ ...flags, serviceUrl: local["serviceUrl"] });
11006
11980
  });
11007
11981
  const record = program.command("record").description("Agent-driven recording protocol: plan the queue, get instructions, ingest verbatim envelopes, resume from disk.");
11008
- 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) => {
11982
+ 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) => {
11009
11983
  const flags = globalFlags(cmd.parent.parent);
11010
11984
  const local = cmd.opts();
11011
11985
  const { runRecordPlan: runRecordPlan2 } = await Promise.resolve().then(() => (init_record(), record_exports));
@@ -11013,8 +11987,10 @@ function buildProgram() {
11013
11987
  ...flags,
11014
11988
  setDir: local["set"],
11015
11989
  component: local["component"],
11016
- metadataFiles: local["metadata"],
11017
11990
  sample: local["sample"],
11991
+ ...local["metadata"] !== void 0 ? { metadataFiles: local["metadata"] } : {},
11992
+ ...local["metadataRawFile"] !== void 0 ? { metadataRawFile: local["metadataRawFile"] } : {},
11993
+ ...local["metadataRawPartsFile"] !== void 0 ? { metadataRawPartsFile: local["metadataRawPartsFile"] } : {},
11018
11994
  ...local["default"] !== void 0 ? { defaultSpecs: local["default"] } : {},
11019
11995
  ...local["componentSet"] !== void 0 ? { componentSet: local["componentSet"] } : {}
11020
11996
  });
@@ -11146,6 +12122,28 @@ function buildProgram() {
11146
12122
  ...local["out"] !== void 0 ? { out: local["out"] } : {}
11147
12123
  });
11148
12124
  });
12125
+ program.command("permissions").description("Install (or print) the Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools) \u2014 no more transcribing tool names from prompts.").option("--claude", "Claude Code settings format (the default and currently only format)").option("--write", "merge the per-tool entries into .claude/settings.local.json in the current project (idempotent; never touches other keys)").option("--user", "with --write: target ~/.claude/settings.json (every project) instead").option("--mcp-url <url>", "Figma MCP endpoint to list live tool names from").action(async (_o, cmd) => {
12126
+ const flags = globalFlags(cmd.parent);
12127
+ const local = cmd.opts();
12128
+ const { runPermissions: runPermissions2 } = await Promise.resolve().then(() => (init_permissions(), permissions_exports));
12129
+ await runPermissions2({
12130
+ ...flags,
12131
+ ...local["write"] !== void 0 ? { write: local["write"] } : {},
12132
+ ...local["user"] !== void 0 ? { user: local["user"] } : {},
12133
+ ...local["mcpUrl"] !== void 0 ? { mcpUrl: local["mcpUrl"] } : {}
12134
+ });
12135
+ });
12136
+ program.command("inspect").description("Build an eye-verifiable detail sheet from verify evidence: magnified recorded-vs-rendered crops of every small recorded node (icons, controls, marks) plus full-frame triples, in one static HTML page.").argument("<bundleDir>", "bundle directory with component.json and verify-evidence").option("--set <dir>", "recording set override (default: the bundle's provenance path)").option("--max-area <px2>", "node area ceiling for the detail sweep", "1024").action(async (bundleDir, _o, cmd) => {
12137
+ const flags = globalFlags(cmd.parent);
12138
+ const local = cmd.opts();
12139
+ const { runInspect: runInspect2 } = await Promise.resolve().then(() => (init_inspect(), inspect_exports));
12140
+ await runInspect2({
12141
+ ...flags,
12142
+ bundleDir,
12143
+ ...local["set"] !== void 0 ? { set: local["set"] } : {},
12144
+ ...local["maxArea"] !== void 0 ? { maxArea: Number(local["maxArea"]) } : {}
12145
+ });
12146
+ });
11149
12147
  program.command("verify").description("Recompute verification for a generated bundle against its recording set (per-config status, behaviors, honest exit codes).").argument("<bundleDir>", "bundle directory to verify").option("--task <name>", "legacy task adapter for bundles WITHOUT component.json (bundle v1 carries its own prop manifest)").option("--set <dir>", "recording set directory (overrides the bundle's provenance path)").option("--bar <bar>", "target bar: pass (0.95) or cert (0.97)", "pass").action(async (bundleDir, _opts, cmd) => {
11150
12148
  const flags = globalFlags(cmd);
11151
12149
  const local = cmd.opts();