@tendrilapp/cli 0.1.25 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/SKILL.md CHANGED
@@ -137,11 +137,15 @@ Batch runs (several components in one session):
137
137
  name what kind of control it is.
138
138
  FEASIBILITY, before the first recording call: the plan output
139
139
  states the call arithmetic for this queue (poses × calls per pose).
140
- Turn it into a verdict with one free check — call the Figma
141
- `whoami` tool, which names the seat and plan and is exempt from
142
- Figma's tool-call limits, then divide the queue's call count by the
143
- daily allowance that seat carries. Never quote an allowance from
144
- memory; use the one whoami reports. If the set fits inside one
140
+ Turn it into a verdict with one free check — IF your Figma server
141
+ exposes a `whoami` tool, call it: it names the seat and plan and is
142
+ exempt from Figma's tool-call limits. Then divide the queue's call
143
+ count by the daily allowance that seat carries. Never quote an
144
+ allowance from memory; use the one whoami reports. Not every
145
+ transport has it — the remote Figma MCP server does, the local Dev
146
+ Mode server does not — so if yours does not, say the allowance is
147
+ UNKNOWN and show the plan's own hint as the unverified figure it is
148
+ marked as, rather than presenting a guess as a checked number. If the set fits inside one
145
149
  day, record and say nothing about cost — a set that comfortably
146
150
  fits is not worth the user's attention. If it does NOT fit, stop
147
151
  and tell the user in plain numbers before recording anything:
package/dist/tendril.js CHANGED
@@ -1058,6 +1058,7 @@ function planSet(setDir, component, symbols, opts = {}) {
1058
1058
  version: 1,
1059
1059
  component,
1060
1060
  ...opts.sourceFrames !== void 0 ? { sourceFrames: opts.sourceFrames } : {},
1061
+ ...opts.variantScope !== void 0 ? { variantScope: opts.variantScope } : {},
1061
1062
  ...opts.defaults !== void 0 && Object.keys(opts.defaults).length > 0 ? { defaults: opts.defaults } : {},
1062
1063
  ...(() => {
1063
1064
  const variants = symbols.map((s) => s.name).filter((n) => n.includes("="));
@@ -1184,6 +1185,31 @@ var init_session = __esm({
1184
1185
  * domains: the API must cover the lattice even where only a subset
1185
1186
  * is recorded. */
1186
1187
  latticeNames: z4.array(z4.string()).optional(),
1188
+ /**
1189
+ * WHETHER `latticeNames` IS A DENOMINATOR AT ALL.
1190
+ *
1191
+ * The lattice is derived from the metadata handed to `record plan`,
1192
+ * so when that metadata is a bare list of variant nodes the count is
1193
+ * the SELECTION, not the component set. Field case (run 17): four
1194
+ * node ids that were 2 of 5 columns of a 5×2 set. Planning them would
1195
+ * have recorded a set silently missing three variants — and the
1196
+ * plan's own cross-check would have passed, because it validates
1197
+ * against the same metadata it was handed. Downstream that reads as
1198
+ * `latticeConfigs: 4 / unrecordedConfigs: 0`: an affirmative COMPLETE
1199
+ * claim over 40% of a missing axis, on the trust anchor's own
1200
+ * coverage disclosure.
1201
+ *
1202
+ * The signal is whether the metadata carried ENCLOSING CONTEXT: a
1203
+ * per-variant `get_metadata` returns the `<symbol>` as the root with
1204
+ * no ancestor, while a page- or set-level call nests the variants
1205
+ * under the named component set. "component-set" therefore means the
1206
+ * planner saw the set's own children; "selection" means it saw what
1207
+ * it was given and cannot know what else exists.
1208
+ *
1209
+ * Absent on sets planned before this field — treated as unknown, not
1210
+ * as complete, because that is what it was.
1211
+ */
1212
+ variantScope: z4.enum(["component-set", "selection"]).optional(),
1187
1213
  /** Which planning mode produced this queue. Absent = planned before
1188
1214
  * the full-matrix default (i.e. sampled) — resume uses this to
1189
1215
  * top-up rather than silently perpetuating a sampled queue. */
@@ -1894,6 +1920,10 @@ function tendrilInvocation() {
1894
1920
  function tendrilCommand(args) {
1895
1921
  return `${tendrilInvocation()} ${args}`;
1896
1922
  }
1923
+ function quoteArg(value) {
1924
+ if (/["`$\\]/.test(value)) throw new Error(`refusing to emit a shell argument containing quote/backslash/backtick/$: ${value}`);
1925
+ return /[\s'*?[\]()&;|<>#~]/.test(value) ? `"${value}"` : value;
1926
+ }
1897
1927
  var NPX_INVOCATION, cached;
1898
1928
  var init_invocation = __esm({
1899
1929
  "packages/cli/src/invocation.ts"() {
@@ -3450,6 +3480,112 @@ var init_paths = __esm({
3450
3480
  }
3451
3481
  });
3452
3482
 
3483
+ // packages/verify/src/font-collection.ts
3484
+ function isCollection(bytes) {
3485
+ return bytes.length >= 12 && new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0) === TTC_TAG;
3486
+ }
3487
+ function nameTableStrings(view, bytes, nameOffset, nameLength) {
3488
+ const out = /* @__PURE__ */ new Map();
3489
+ if (nameOffset + 6 > bytes.length) return out;
3490
+ const count = view.getUint16(nameOffset + 2);
3491
+ const stringOffset = nameOffset + view.getUint16(nameOffset + 4);
3492
+ for (let i = 0; i < count; i++) {
3493
+ const rec = nameOffset + 6 + i * 12;
3494
+ if (rec + 12 > bytes.length) break;
3495
+ const platformId = view.getUint16(rec);
3496
+ const encodingId = view.getUint16(rec + 2);
3497
+ const nameId = view.getUint16(rec + 6);
3498
+ const length = view.getUint16(rec + 8);
3499
+ const offset = stringOffset + view.getUint16(rec + 10);
3500
+ if (offset + length > bytes.length || offset + length > nameOffset + nameLength) continue;
3501
+ const slice = bytes.subarray(offset, offset + length);
3502
+ const isUtf16 = platformId === 3 || platformId === 0 || encodingId === 1;
3503
+ let value = "";
3504
+ if (isUtf16) {
3505
+ for (let j = 0; j + 1 < slice.length; j += 2) value += String.fromCharCode(slice[j] << 8 | slice[j + 1]);
3506
+ } else {
3507
+ for (const b of slice) value += String.fromCharCode(b);
3508
+ }
3509
+ if (value !== "" && !out.has(nameId)) out.set(nameId, value);
3510
+ }
3511
+ return out;
3512
+ }
3513
+ function listCollectionFaces(bytes) {
3514
+ if (!isCollection(bytes)) return [];
3515
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3516
+ const numFonts = view.getUint32(8);
3517
+ const faces = [];
3518
+ for (let i = 0; i < numFonts; i++) {
3519
+ const dirOffset = view.getUint32(12 + i * 4);
3520
+ if (dirOffset + SFNT_HEADER_SIZE > bytes.length) continue;
3521
+ const numTables = view.getUint16(dirOffset + 4);
3522
+ let names = /* @__PURE__ */ new Map();
3523
+ for (let t = 0; t < numTables; t++) {
3524
+ const rec = dirOffset + SFNT_HEADER_SIZE + t * TABLE_RECORD_SIZE;
3525
+ if (rec + TABLE_RECORD_SIZE > bytes.length) break;
3526
+ const tag = String.fromCharCode(bytes[rec], bytes[rec + 1], bytes[rec + 2], bytes[rec + 3]);
3527
+ if (tag !== "name") continue;
3528
+ names = nameTableStrings(view, bytes, view.getUint32(rec + 8), view.getUint32(rec + 12));
3529
+ break;
3530
+ }
3531
+ const family = names.get(1);
3532
+ const subfamily = names.get(2);
3533
+ faces.push({ index: i, ...family !== void 0 ? { family } : {}, ...subfamily !== void 0 ? { subfamily } : {} });
3534
+ }
3535
+ return faces;
3536
+ }
3537
+ function extractCollectionFace(bytes, index) {
3538
+ if (!isCollection(bytes)) throw new Error("not a TrueType Collection");
3539
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3540
+ const numFonts = view.getUint32(8);
3541
+ if (index < 0 || index >= numFonts) throw new Error(`face index ${index} out of range \u2014 the collection holds ${numFonts}`);
3542
+ const dirOffset = view.getUint32(12 + index * 4);
3543
+ const numTables = view.getUint16(dirOffset + 4);
3544
+ const records = [];
3545
+ for (let t = 0; t < numTables; t++) {
3546
+ const rec = dirOffset + SFNT_HEADER_SIZE + t * TABLE_RECORD_SIZE;
3547
+ const checksum = view.getUint32(rec + 4);
3548
+ const offset = view.getUint32(rec + 8);
3549
+ const length = view.getUint32(rec + 12);
3550
+ if (offset + length > bytes.length) throw new Error(`collection table ${t} runs past the end of the file`);
3551
+ records.push({ tag: bytes.subarray(rec, rec + 4), checksum, data: bytes.subarray(offset, offset + length) });
3552
+ }
3553
+ const aligned = (n) => n + 3 & ~3;
3554
+ const bodySize = records.reduce((sum, r) => sum + aligned(r.data.length), 0);
3555
+ const out = new Uint8Array(SFNT_HEADER_SIZE + records.length * TABLE_RECORD_SIZE + bodySize);
3556
+ const outView = new DataView(out.buffer);
3557
+ outView.setUint32(0, view.getUint32(dirOffset));
3558
+ outView.setUint16(4, records.length);
3559
+ const pow2 = Math.floor(Math.log2(Math.max(1, records.length)));
3560
+ outView.setUint16(6, 16 * 2 ** pow2);
3561
+ outView.setUint16(8, pow2);
3562
+ outView.setUint16(10, records.length * 16 - 16 * 2 ** pow2);
3563
+ let cursor = SFNT_HEADER_SIZE + records.length * TABLE_RECORD_SIZE;
3564
+ records.forEach((r, i) => {
3565
+ const rec = SFNT_HEADER_SIZE + i * TABLE_RECORD_SIZE;
3566
+ out.set(r.tag, rec);
3567
+ outView.setUint32(rec + 4, r.checksum);
3568
+ outView.setUint32(rec + 8, cursor);
3569
+ outView.setUint32(rec + 12, r.data.length);
3570
+ out.set(r.data, cursor);
3571
+ cursor += aligned(r.data.length);
3572
+ });
3573
+ return out;
3574
+ }
3575
+ function facesForFamily(bytes, family) {
3576
+ const want = family.toLowerCase().replace(/\s+/g, "");
3577
+ return listCollectionFaces(bytes).filter((f) => (f.family ?? "").toLowerCase().replace(/\s+/g, "") === want);
3578
+ }
3579
+ var TTC_TAG, TABLE_RECORD_SIZE, SFNT_HEADER_SIZE;
3580
+ var init_font_collection = __esm({
3581
+ "packages/verify/src/font-collection.ts"() {
3582
+ "use strict";
3583
+ TTC_TAG = 1953784678;
3584
+ TABLE_RECORD_SIZE = 16;
3585
+ SFNT_HEADER_SIZE = 12;
3586
+ }
3587
+ });
3588
+
3453
3589
  // packages/verify/src/font-resolve.ts
3454
3590
  import { createHash } from "node:crypto";
3455
3591
  import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
@@ -3538,18 +3674,33 @@ async function resolveFonts(family, weights, cacheDir = DEFAULT_FONT_CACHE) {
3538
3674
  `);
3539
3675
  return { resolved, failures };
3540
3676
  }
3541
- function addLocalFont(family, weight, filePath, cacheDir = DEFAULT_FONT_CACHE) {
3677
+ function addLocalFont(family, weight, filePath, cacheDir = DEFAULT_FONT_CACHE, faceIndex) {
3542
3678
  const src = path10.resolve(filePath);
3543
3679
  if (!existsSync6(src)) throw new Error(`font file not found: ${src}`);
3544
3680
  const ext = path10.extname(src).toLowerCase();
3545
- if (![".woff2", ".woff", ".ttf", ".otf"].includes(ext)) {
3546
- throw new Error(`unsupported font file "${ext}" \u2014 use .woff2 (preferred), .woff, .ttf or .otf`);
3681
+ if (![".woff2", ".woff", ".ttf", ".otf", ".ttc"].includes(ext)) {
3682
+ throw new Error(`unsupported font file "${ext}" \u2014 use .woff2 (preferred), .woff, .ttf, .otf or .ttc`);
3547
3683
  }
3548
- const bytes = new Uint8Array(readFileSync3(src));
3684
+ let bytes = new Uint8Array(readFileSync3(src));
3549
3685
  if (bytes.length === 0) throw new Error(`font file is empty: ${src}`);
3686
+ let storedExt = ext;
3687
+ if (isCollection(bytes)) {
3688
+ const candidates = facesForFamily(bytes, family);
3689
+ const chosen = faceIndex ?? (candidates.length === 1 ? candidates[0].index : void 0);
3690
+ if (chosen === void 0) {
3691
+ const all = listCollectionFaces(bytes);
3692
+ const shown = (candidates.length > 0 ? candidates : all).map((f) => ` --face ${f.index} ${f.family ?? "(unnamed)"}${f.subfamily !== void 0 ? ` ${f.subfamily}` : ""}`).join("\n");
3693
+ throw new Error(
3694
+ `${path10.basename(src)} is a collection of ${all.length} faces and ${candidates.length === 0 ? `none is named "${family}"` : `${candidates.length} match "${family}"`} \u2014 name the one you mean with --face <index>:
3695
+ ${shown}`
3696
+ );
3697
+ }
3698
+ bytes = new Uint8Array(extractCollectionFace(bytes, chosen));
3699
+ storedExt = ".ttf";
3700
+ }
3550
3701
  mkdirSync2(cacheDir, { recursive: true });
3551
3702
  const sha256 = createHash("sha256").update(bytes).digest("hex");
3552
- const file = path10.join(cacheDir, `${family.toLowerCase().replace(/\s+/g, "-")}-${weight}${ext}`);
3703
+ const file = path10.join(cacheDir, `${family.toLowerCase().replace(/\s+/g, "-")}-${weight}${storedExt}`);
3553
3704
  writeFileSync3(file, bytes);
3554
3705
  const face = { family, weight, source: `local:${path10.basename(src)}`, sha256, file, license: "unknown" };
3555
3706
  const mPath = path10.join(cacheDir, "manifest.json");
@@ -3639,6 +3790,7 @@ var DEFAULT_FONT_CACHE, UA, FONT_LICENSES, GOOGLE_LICENSE_IDS, GOOGLE_FONT_FILE_
3639
3790
  var init_font_resolve = __esm({
3640
3791
  "packages/verify/src/font-resolve.ts"() {
3641
3792
  "use strict";
3793
+ init_font_collection();
3642
3794
  DEFAULT_FONT_CACHE = fontCacheDir();
3643
3795
  UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36";
3644
3796
  FONT_LICENSES = ["OFL-1.1", "Apache-2.0", "UFL-1.0", "proprietary", "unknown"];
@@ -4157,15 +4309,30 @@ async function runSteps(page, spec, renderPose) {
4157
4309
  const verdict = await page.evaluate(
4158
4310
  `(() => {
4159
4311
  const needle = ${JSON.stringify(step.assertTextVisible)};
4160
- const els = [...document.querySelectorAll('#root *')];
4161
- const holders = els.filter((el) => [...el.childNodes].some((n) => n.nodeType === 3 && (n.textContent ?? '').includes(needle)));
4162
- if (holders.length === 0) return 'text not in the DOM at all';
4163
- for (const el of holders) {
4312
+ // Attributes a user actually perceives, whether by eye or
4313
+ // through assistive tech. Deliberately NOT every attribute:
4314
+ // a data-* or a name= is a place to park a string, which is
4315
+ // the dodge this check exists to catch.
4316
+ const PERCEIVED = ['placeholder', 'alt', 'aria-label', 'title', 'value'];
4317
+ const visible = (el) => {
4164
4318
  const r = el.getBoundingClientRect();
4165
4319
  const cs = getComputedStyle(el);
4166
- if (r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' && Number(cs.opacity) > 0) return true;
4167
- }
4168
- return 'text present but not visibly rendered (hidden/zero-size/transparent node)';
4320
+ return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' && Number(cs.opacity) > 0;
4321
+ };
4322
+ const els = [...document.querySelectorAll('#root *')];
4323
+ const textHolders = els.filter((el) => [...el.childNodes].some((n) => n.nodeType === 3 && (n.textContent ?? '').includes(needle)));
4324
+ for (const el of textHolders) if (visible(el)) return true;
4325
+ const attrHolders = els.filter((el) =>
4326
+ PERCEIVED.some((a) => {
4327
+ // The LIVE value for form controls: React's defaultValue
4328
+ // sets the property, and the attribute may never appear.
4329
+ const v = a === 'value' && 'value' in el ? el.value : el.getAttribute(a);
4330
+ return typeof v === 'string' && v.includes(needle);
4331
+ }),
4332
+ );
4333
+ for (const el of attrHolders) if (visible(el)) return true;
4334
+ if (textHolders.length === 0 && attrHolders.length === 0) return 'text not in the DOM at all, and not in a placeholder/alt/aria-label/title/value either';
4335
+ return 'present but not visibly rendered (hidden/zero-size/transparent node)';
4169
4336
  })()`
4170
4337
  );
4171
4338
  if (verdict !== true) return { id: spec.id, pass: false, detail: `sentinel "${step.assertTextVisible}": ${String(verdict)}` };
@@ -4430,7 +4597,7 @@ function definedVars(tokensCss) {
4430
4597
  for (const m of tokensCss.matchAll(/(--[\w-]+)\s*:/g)) names.add(m[1]);
4431
4598
  return names;
4432
4599
  }
4433
- function recordedTokenMapEmpty(setDir, reps) {
4600
+ function recordedTokenMapState(setDir, reps) {
4434
4601
  const readMap = (file) => {
4435
4602
  if (!existsSync10(file)) return void 0;
4436
4603
  try {
@@ -4441,15 +4608,15 @@ function recordedTokenMapEmpty(setDir, reps) {
4441
4608
  }
4442
4609
  };
4443
4610
  const setLevel = readMap(path15.join(setDir, "get_variable_defs.json"));
4444
- if (setLevel !== void 0) return Object.keys(setLevel).length === 0;
4611
+ if (setLevel !== void 0) return Object.keys(setLevel).length === 0 ? "empty" : "populated";
4445
4612
  let recorded = false;
4446
4613
  for (const rep of reps) {
4447
4614
  const m = readMap(path15.join(setDir, rep, "get_variable_defs.json"));
4448
4615
  if (m === void 0) continue;
4449
4616
  recorded = true;
4450
- if (Object.keys(m).length > 0) return false;
4617
+ if (Object.keys(m).length > 0) return "populated";
4451
4618
  }
4452
- return recorded;
4619
+ return recorded ? "empty" : "never-recorded";
4453
4620
  }
4454
4621
  function scannable(css) {
4455
4622
  const blank = (m) => m.replace(/[^\n]/g, " ");
@@ -4536,10 +4703,17 @@ async function checkBundleQuality(bundleDir, entry, set, fontManifest) {
4536
4703
  }
4537
4704
  }
4538
4705
  if (css !== "") {
4539
- const recordedEmpty = set !== void 0 && recordedTokenMapEmpty(set.dir, set.reps);
4540
- for (const v of (await runTokenLint(css, "styles.css", definedVars(tokensCss), recordedEmpty)).violations) {
4706
+ const mapState = set === void 0 ? void 0 : recordedTokenMapState(set.dir, set.reps);
4707
+ for (const v of (await runTokenLint(css, "styles.css", definedVars(tokensCss), mapState !== void 0 && mapState !== "populated")).violations) {
4541
4708
  findings.push({ kind: "token-lint", file: "styles.css", line: v.line, message: v.message });
4542
4709
  }
4710
+ if (mapState === "never-recorded") {
4711
+ findings.push({
4712
+ kind: "token-lint",
4713
+ file: "styles.css",
4714
+ message: "the recording set has NO design-token map (no get_variable_defs envelope at set or rep level), so whether these literals have kit tokens is UNKNOWN, not answered \u2014 the brief's empty token table means 'never recorded', not 'this kit has none'. Record it with `record next` on the set (it asks for get_variable_defs once, at set level) and re-run to get real token guidance."
4715
+ });
4716
+ }
4543
4717
  }
4544
4718
  return { findings, tokensAbsent: tokensCss === void 0 && !/var\(\s*--/.test(css) };
4545
4719
  }
@@ -5519,6 +5693,7 @@ var init_src4 = __esm({
5519
5693
  init_tasks();
5520
5694
  init_prelude();
5521
5695
  init_mount_limits();
5696
+ init_font_collection();
5522
5697
  init_font_faces();
5523
5698
  init_font_resolve();
5524
5699
  init_paths();
@@ -5770,6 +5945,15 @@ import { spawnSync } from "node:child_process";
5770
5945
  import { existsSync as existsSync18, readFileSync as readFileSync14, readdirSync as readdirSync3 } from "node:fs";
5771
5946
  import os4 from "node:os";
5772
5947
  import path23 from "node:path";
5948
+ function withDeadline(work, ms) {
5949
+ return Promise.race([
5950
+ work,
5951
+ new Promise((_resolve, reject) => {
5952
+ const timer = setTimeout(() => reject(new McpProbeTimeout(ms)), ms);
5953
+ timer.unref?.();
5954
+ })
5955
+ ]);
5956
+ }
5773
5957
  async function runDoctorChecks(options) {
5774
5958
  const checks = [];
5775
5959
  const mcpUrl = options.mcpUrl ?? DEFAULT_MCP_URL;
@@ -5778,19 +5962,26 @@ async function runDoctorChecks(options) {
5778
5962
  ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
5779
5963
  });
5780
5964
  try {
5781
- const info = await client.initialize();
5782
- const tools = await client.listTools();
5965
+ const info = await withDeadline(client.initialize(), MCP_PROBE_DEADLINE_MS);
5966
+ const tools = await withDeadline(client.listTools(), MCP_PROBE_DEADLINE_MS);
5783
5967
  checks.push({
5784
5968
  name: "figma-desktop-mcp",
5785
5969
  ok: true,
5786
- detail: `${info.name} ${info.version} (protocol ${info.protocolVersion}), ${tools.length} tools: ${tools.map((t) => t.name).join(", ")}`
5970
+ // WHOSE capability this tested. Run 16 read a green tick here as
5971
+ // permission to start recording, and could not: the CLI reaches
5972
+ // this server with its own localhost client, while the AGENT held
5973
+ // no Figma MCP tools at all, and `record` is agent-driven with no
5974
+ // CLI path. The operator wrote a 100-line MCP client in the first
5975
+ // ten minutes of a paid run. A check consumed by one party about
5976
+ // another party's capability has to say so out loud.
5977
+ detail: `${info.name} ${info.version} (protocol ${info.protocolVersion}), ${tools.length} tools: ${tools.map((t) => t.name).join(", ")} \u2014 this is THIS CLI's own connection. It does NOT test whether your agent session holds Figma MCP tools, and recording needs the AGENT to make those calls: confirm with one real get_metadata call before planning.`
5787
5978
  });
5788
5979
  } catch (err) {
5789
5980
  checks.push({
5790
5981
  name: "figma-desktop-mcp",
5791
5982
  ok: false,
5792
5983
  detail: err instanceof Error ? err.message : String(err),
5793
- 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."
5984
+ 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)." : err instanceof McpProbeTimeout ? "Quit and reopen the Figma desktop app, then re-run doctor. If it answers again, nothing else is wrong \u2014 the server was reachable but stalled." : "The server answered but the exchange failed \u2014 check the signed-in account's seat, then retry."
5794
5985
  });
5795
5986
  }
5796
5987
  const openrouterKey = resolveCredential("OPENROUTER_API_KEY");
@@ -5929,7 +6120,7 @@ async function runDoctor(flags) {
5929
6120
  });
5930
6121
  if (!report.ok) process.exit(1);
5931
6122
  }
5932
- var DEFAULT_MCP_URL, DOCTOR_DESCRIPTION;
6123
+ var DEFAULT_MCP_URL, DOCTOR_DESCRIPTION, MCP_PROBE_DEADLINE_MS, McpProbeTimeout;
5933
6124
  var init_doctor = __esm({
5934
6125
  "packages/cli/src/commands/doctor.ts"() {
5935
6126
  "use strict";
@@ -5957,6 +6148,15 @@ var init_doctor = __esm({
5957
6148
  exitCodes: { 0: "healthy", 1: "one or more required checks failed" },
5958
6149
  examples: ["tendril doctor", "tendril doctor --json"]
5959
6150
  };
6151
+ MCP_PROBE_DEADLINE_MS = 1e4;
6152
+ McpProbeTimeout = class extends Error {
6153
+ constructor(ms) {
6154
+ super(
6155
+ `the Figma MCP server accepted the connection but did not answer within ${ms / 1e3}s. It is reachable and not responding \u2014 usually a Figma desktop app that needs restarting (this state has been seen after an in-place app update).`
6156
+ );
6157
+ this.name = "McpProbeTimeout";
6158
+ }
6159
+ };
5960
6160
  }
5961
6161
  });
5962
6162
 
@@ -7457,6 +7657,7 @@ function runRecordPlan(opts) {
7457
7657
  }
7458
7658
  }
7459
7659
  const setNames = [...new Set(symbols.map((s) => s.setName).filter((n) => n !== void 0))];
7660
+ const variantScope = setNames.length > 0 ? "component-set" : "selection";
7460
7661
  if (opts.componentSet !== void 0) {
7461
7662
  const filtered = symbols.filter((s) => s.setName === opts.componentSet);
7462
7663
  if (filtered.length === 0) {
@@ -7514,7 +7715,11 @@ function runRecordPlan(opts) {
7514
7715
  if (metadataTruncated) {
7515
7716
  warn(opts, "get_metadata response appears TRUNCATED (unclosed structure) \u2014 the variant list below may be incomplete. Cross-check variantsFound against the variant count Figma shows for this component set; re-fetch the metadata if lower.");
7516
7717
  }
7517
- const { manifest, plan, resumed, toppedUp } = planSet(opts.setDir, opts.component, symbols, { ...Object.keys(defaults).length > 0 ? { defaults } : {}, ...opts.sample === true ? { sample: true } : {} });
7718
+ const { manifest, plan, resumed, toppedUp } = planSet(opts.setDir, opts.component, symbols, {
7719
+ ...Object.keys(defaults).length > 0 ? { defaults } : {},
7720
+ ...opts.sample === true ? { sample: true } : {},
7721
+ variantScope
7722
+ });
7518
7723
  const defaultReports = reportAxisDefaults(symbols, Object.keys(defaults).length > 0 ? defaults : void 0);
7519
7724
  const toConfirm = resumed ? [] : defaultReports.filter((r) => (r.rule === "non-interaction" || r.rule === "frequency") && r.domain.length > 1);
7520
7725
  const interactionStatesToConfirm = recordsInteractionState(defaultReports) ? void 0 : interactionDisclosure(manifest.component, defaultReports);
@@ -7532,7 +7737,7 @@ function runRecordPlan(opts) {
7532
7737
  callsWorstCase: posesRemaining * FIGMA_CALLS_PER_REP_WORST,
7533
7738
  posesToRecord: posesRemaining,
7534
7739
  callsPerPose: FIGMA_CALLS_PER_REP,
7535
- instruction: "BEFORE the first recording call, call the Figma MCP `whoami` tool \u2014 it names the seat and plan and is EXEMPT from Figma's tool-call limits, so this check costs nothing and spends no quota. Read the daily tool-call allowance for that seat/plan and divide: callsNeeded \xF7 allowance = days this set needs. Tendril owns the multiplication above; the allowance is Figma's and is theirs to change, so take it from whoami and never from memory.",
7740
+ instruction: "BEFORE the first recording call, IF your Figma server exposes a `whoami` tool, call it \u2014 it names the seat and plan and is EXEMPT from Figma's tool-call limits, so the check costs nothing and spends no quota. Read the daily tool-call allowance for that seat/plan and divide: callsNeeded \xF7 allowance = days this set needs. Tendril owns the multiplication above; the allowance is Figma's and theirs to change, so take it from whoami and never from memory. NOT EVERY TRANSPORT HAS IT: the remote Figma MCP server does; the local Dev Mode server exposes six tools and `whoami` is not among them (measured 2026-08-14). If yours does not, say so plainly, treat the allowance as UNKNOWN, and use limitHintIfWhoamiIsSilent below \u2014 which is unverified and dated, and must be shown as such. Do not report a guessed allowance as a checked one.",
7536
7741
  surface: multiDay ? "This set exceeds every daily allowance we have heard of, so it CANNOT finish in one day. State the verdict to the user in plain numbers BEFORE recording anything and let them choose from `options` \u2014 do not start on the assumption it will fit." : "Say nothing about cost if the division fits inside one day's allowance \u2014 a set that comfortably fits is not worth the user's attention. Surface the verdict and `options` only when it does not fit.",
7537
7742
  verdictTemplate: `${callSentence}. Your <seat> seat on <plan> allows <allowance>/day. This set needs about <days> day(s) at that rate.`,
7538
7743
  options: [
@@ -7548,7 +7753,7 @@ function runRecordPlan(opts) {
7548
7753
  // a CLI flag and the MCP surface has no parameter for it.
7549
7754
  decidedBy: "HUMAN ONLY \u2014 offer it, never choose it, and you cannot run it",
7550
7755
  text: "Record a reduced pose set (anchor + one-factor sweeps + conflict crosses) instead of the full variant matrix. This buys calls with coverage \u2014 sampling is blind to multi-axis interactions and the report discloses the poses it skipped \u2014 so only the user may accept that trade. There is no tool parameter for it; the user runs it themselves in their own terminal, before any pose is recorded.",
7551
- userRuns: [`rm ${path26.join(opts.setDir, "recording-set.json")}`, `${tendrilCommand(`record plan --set ${opts.setDir} --component ${manifest.component}`)} --sample`]
7756
+ userRuns: [`rm ${quoteArg(path26.join(opts.setDir, "recording-set.json"))}`, `${tendrilCommand(`record plan --set ${quoteArg(opts.setDir)} --component ${quoteArg(manifest.component)}`)} --sample`]
7552
7757
  },
7553
7758
  {
7554
7759
  id: "larger-allowance",
@@ -7569,6 +7774,13 @@ function runRecordPlan(opts) {
7569
7774
  // the one check that catches parse/transfer losses this pipeline
7570
7775
  // cannot detect from the envelope alone.
7571
7776
  variantsFound: symbols.length,
7777
+ // WHAT variantsFound IS A COUNT OF. The comment above tells the
7778
+ // reader to hold it against Figma's own variant count — a check
7779
+ // run 17 proved an agent cannot perform, because it cannot see
7780
+ // the user's screen. So the pipeline must say when the number is
7781
+ // merely the selection it was handed.
7782
+ variantScope,
7783
+ ...variantScope === "selection" ? { variantCoverageUnknown: SELECTION_SCOPE_DISCLOSURE } : {},
7572
7784
  planMode: manifest.planMode ?? "sample",
7573
7785
  ...metadataTruncated ? { metadataTruncated: true } : {},
7574
7786
  ...toppedUp !== void 0 ? { toppedUp } : {},
@@ -7673,7 +7885,19 @@ function nextPayload(setDir) {
7673
7885
  const frameNode = Object.keys(manifest.sourceFrames ?? {})[0] ?? manifest.reps[0]?.nodeId ?? "";
7674
7886
  return { slug: "__set__", nodeId: frameNode, tool: "get_variable_defs", note: `SET-LEVEL: call get_variable_defs on the component frame and ingest with --rep __set__. ${ENVELOPE_HELP}`, progress };
7675
7887
  }
7676
- return instruction === null ? { complete: true, progress } : { ...instruction, note: `${instruction.note}. ${ENVELOPE_HELP}`, progress };
7888
+ return instruction === null ? { complete: true, progress } : {
7889
+ ...instruction,
7890
+ note: `${instruction.note}. ${ENVELOPE_HELP}`,
7891
+ // RESUME POINTER, NOT A WORK ASSIGNMENT. This is the globally
7892
+ // first unrecorded rep, so under parallel recorders it hands the
7893
+ // same rep to everyone: run 18 measured 8 of 10 ingests pointing
7894
+ // a recorder at `anchor`, which was another agent's job. Correct
7895
+ // for picking a set back up, wrong as a loop driver, and it was
7896
+ // labelled as neither.
7897
+ scope: "resume-pointer",
7898
+ parallelWarning: "This is the first unrecorded rep in the WHOLE set, not your next one. If several recorders are working this set at once, ignore it and follow your own assigned rep list \u2014 otherwise every recorder converges on the same rep.",
7899
+ progress
7900
+ };
7677
7901
  }
7678
7902
  function runRecordNext(opts) {
7679
7903
  const payload = nextPayload(opts.setDir);
@@ -8081,7 +8305,7 @@ function runRecordFinish(opts) {
8081
8305
  `);
8082
8306
  }
8083
8307
  }
8084
- var FIGMA_CALLS_PER_REP, FIGMA_CALLS_PER_REP_WORST, MULTI_DAY_CALL_THRESHOLD, LIMIT_HINT, stateTokens, MAX_SPOKEN_VALUES, andList, ENVELOPE_HELP, isAutoFetchAssetUrl, describeRoleLoss;
8308
+ var FIGMA_CALLS_PER_REP, FIGMA_CALLS_PER_REP_WORST, MULTI_DAY_CALL_THRESHOLD, SELECTION_SCOPE_DISCLOSURE, LIMIT_HINT, stateTokens, MAX_SPOKEN_VALUES, andList, ENVELOPE_HELP, isAutoFetchAssetUrl, describeRoleLoss;
8085
8309
  var init_record = __esm({
8086
8310
  "packages/cli/src/commands/record.ts"() {
8087
8311
  "use strict";
@@ -8094,6 +8318,7 @@ var init_record = __esm({
8094
8318
  FIGMA_CALLS_PER_REP = 3;
8095
8319
  FIGMA_CALLS_PER_REP_WORST = 4;
8096
8320
  MULTI_DAY_CALL_THRESHOLD = 600;
8321
+ SELECTION_SCOPE_DISCLOSURE = "variantsFound counts the nodes you passed, NOT the component set: this metadata carried no enclosing set, so nothing here can tell whether the set holds more variants. Do NOT report the lattice as complete on this basis. To establish the real denominator, call get_metadata WITHOUT a nodeId (a page-level listing), find the component set these nodes sit under, and re-run plan against it \u2014 re-planning is free until the first envelope is ingested. If you proceed as-is, say plainly that coverage is unestablished.";
8097
8322
  LIMIT_HINT = {
8098
8323
  status: "UNVERIFIED \u2014 recorded 2026-08-14 from Figma's published documentation and one field report; Figma changes these at will.",
8099
8324
  use: "Use ONLY if whoami states no allowance, and say it is unverified when you show it. The allowance whoami reports always wins.",
@@ -8692,7 +8917,13 @@ function authorComponentApi(opts) {
8692
8917
  const provided = opts.fonts ?? [];
8693
8918
  const recorded = opts.recordedFonts ?? [];
8694
8919
  const unprovided = recorded.filter((r) => !provided.some((p) => p.toLowerCase() === r.toLowerCase()));
8695
- const fontsLine = (provided.length > 0 ? `Fonts provided by the harness: ${provided.map((f) => `'${f}'`).join(", ")}. ` : "") + (recorded.length > 0 ? `The recording's declared famil${recorded.length === 1 ? "y is" : "ies are"} ${recorded.map((f) => `'${f}'`).join(", ")}${unprovided.length > 0 ? ` \u2014 ${unprovided.map((f) => `'${f}'`).join(", ")} ${unprovided.length === 1 ? "is" : "are"} NOT provided: the mount will substitute a provided face, scoring reflects the recorded one, and the residual glyph delta is not closable from CSS \u2014 do not chase it` : ""}. ` : "");
8920
+ const faceGaps = (opts.providedFaces ?? []).map((f) => {
8921
+ const need = (opts.recordedFaces ?? []).find((r) => r.family.toLowerCase() === f.family.toLowerCase());
8922
+ const missing = (need?.weights ?? []).filter((w) => !f.weights.includes(w));
8923
+ return { family: f.family, has: f.weights, missing };
8924
+ }).filter((f) => f.missing.length > 0);
8925
+ const weightsLine = (opts.providedFaces ?? []).length > 0 ? `Weights the harness can actually serve: ${(opts.providedFaces ?? []).map((f) => `'${f.family}' (${[...f.weights].sort((a, b) => a - b).join(", ")})`).join("; ")}. ` + (faceGaps.length > 0 ? `WEIGHT GAP \u2014 ${faceGaps.map((g) => `'${g.family}' is recorded at ${g.missing.join(", ")} which the cache does NOT hold`).join("; ")}. CSS binds the FAMILY before the weight and font-synthesis: none blocks the fall-through, so text at a missing weight renders in a weight this family DOES have, silently and at full score-cost. Do not lead a font stack with a family that cannot serve the weight you are setting; put a family that has it first, or report the gap rather than chasing the difference as geometry. ` : "") : "";
8926
+ const fontsLine = weightsLine + (provided.length > 0 ? `Fonts provided by the harness: ${provided.map((f) => `'${f}'`).join(", ")}. ` : "") + (recorded.length > 0 ? `The recording's declared famil${recorded.length === 1 ? "y is" : "ies are"} ${recorded.map((f) => `'${f}'`).join(", ")}${unprovided.length > 0 ? ` \u2014 ${unprovided.map((f) => `'${f}'`).join(", ")} ${unprovided.length === 1 ? "is" : "are"} NOT provided: the mount will substitute a provided face, scoring reflects the recorded one, and the residual glyph delta is not closable from CSS \u2014 do not chase it` : ""}. ` : "");
8696
8927
  const forcingCanon = forcedStates.length > 0 ? `Interactive states are REAL (:hover, :focus-visible) AND statically forceable via the data-tendril-state attribute (a DOCUMENTED token-list attribute; tokens: ${forcedStates.map((t) => `"${t}"`).join(" | ")}) spread onto the root; forced and real selectors must share ONE declaration block, e.g. :is(:hover, [data-tendril-state~="hover"]) \u2014 match with ~= so compound states ("hover selected") work. ` : "";
8697
8928
  const systemApi = `Prescribed API (the harness mounts exactly this; deviation scores 0):
8698
8929
  export function ${componentIdent}(props: {
@@ -8992,8 +9223,8 @@ function recordedTextSlots(setDir, repSlugs) {
8992
9223
  }
8993
9224
  function authorTaskFromSet(setDir, opts = {}) {
8994
9225
  if (opts.fonts === void 0) {
8995
- const resolved = resolvedFontFamilies().map((f) => f.family);
8996
- if (resolved.length > 0) opts = { ...opts, fonts: resolved };
9226
+ const resolvedFaces = resolvedFontFamilies();
9227
+ if (resolvedFaces.length > 0) opts = { ...opts, fonts: resolvedFaces.map((f) => f.family), providedFaces: resolvedFaces.map((f) => ({ family: f.family, weights: [...f.weights] })) };
8997
9228
  }
8998
9229
  const manifest = loadManifest(setDir);
8999
9230
  const poses = [];
@@ -9025,6 +9256,15 @@ function authorTaskFromSet(setDir, opts = {}) {
9025
9256
  poses,
9026
9257
  ...latticeNames !== void 0 ? { latticeNames } : {},
9027
9258
  ...opts.fonts !== void 0 ? { fonts: opts.fonts } : {},
9259
+ ...opts.providedFaces !== void 0 ? { providedFaces: opts.providedFaces } : {},
9260
+ ...(() => {
9261
+ try {
9262
+ const needs = recordedFontNeeds(setDir);
9263
+ return needs.length > 0 ? { recordedFaces: needs } : {};
9264
+ } catch {
9265
+ return {};
9266
+ }
9267
+ })(),
9028
9268
  ...recordedFonts.length > 0 ? { recordedFonts } : {},
9029
9269
  ...Object.keys(defaults).length > 0 ? { defaults } : {},
9030
9270
  ...textSlots.length > 0 ? { textSlots } : {},
@@ -9522,7 +9762,9 @@ function countLatticeSymbols(setDir) {
9522
9762
  const manifestFile = path30.join(setDir, "recording-set.json");
9523
9763
  if (existsSync24(manifestFile)) {
9524
9764
  try {
9525
- const lattice = JSON.parse(readFileSync20(manifestFile, "utf8")).latticeNames;
9765
+ const stored = JSON.parse(readFileSync20(manifestFile, "utf8"));
9766
+ if (stored.variantScope !== "component-set") return null;
9767
+ const lattice = stored.latticeNames;
9526
9768
  if (lattice !== void 0 && lattice.length > 0) return lattice.length;
9527
9769
  } catch {
9528
9770
  }
@@ -10091,10 +10333,76 @@ var init_src7 = __esm({
10091
10333
  }
10092
10334
  });
10093
10335
 
10336
+ // packages/cli/src/font-guidance.ts
10337
+ import path31 from "node:path";
10338
+ function fontsUnprovenRemediation(setDir) {
10339
+ const set = setDir === void 0 ? void 0 : path31.resolve(setDir);
10340
+ if (set !== void 0) {
10341
+ try {
10342
+ const needs = recordedFontNeeds(set);
10343
+ if (needs.length > 0) {
10344
+ const families = needs.map((n) => `"${n.family}"`).join(", ");
10345
+ return `Run \`${tendrilCommand(`fonts resolve --set ${set}`)}\` \u2014 the recording declares ${families}; faces land in ${DEFAULT_FONT_CACHE}.`;
10346
+ }
10347
+ } catch {
10348
+ }
10349
+ }
10350
+ const target = set ?? "<recording-dir>";
10351
+ return `Run \`${tendrilCommand(`fonts resolve --set ${target}`)}\` (or \`${tendrilCommand('fonts resolve "<Family>" --weights 400 500 600')}\`) to populate the font cache \u2014 the cache read is ${DEFAULT_FONT_CACHE}.`;
10352
+ }
10353
+ function taskFontFamilies(setDir) {
10354
+ try {
10355
+ const families = recordedFontFamilies(setDir);
10356
+ return families.length > 0 ? families : null;
10357
+ } catch {
10358
+ return null;
10359
+ }
10360
+ }
10361
+ function unprovisionedFamilies(setDir, cacheDir) {
10362
+ const declared = taskFontFamilies(setDir);
10363
+ if (declared === null) return [];
10364
+ const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
10365
+ return declared.filter((f) => !provided.has(f.toLowerCase()));
10366
+ }
10367
+ function unprovisionedFaces(setDir, cacheDir) {
10368
+ return [
10369
+ ...unprovisionedFamilies(setDir, cacheDir),
10370
+ ...missingWeights(setDir, cacheDir).map((g) => {
10371
+ const missing = g.declared.filter((w) => !g.provided.includes(w));
10372
+ return `${g.family} (${missing.length === 1 ? "weight" : "weights"} ${missing.join(", ")})`;
10373
+ })
10374
+ ];
10375
+ }
10376
+ function missingWeights(setDir, cacheDir) {
10377
+ let needs;
10378
+ try {
10379
+ needs = recordedFontNeeds(setDir, { pairedOnly: true });
10380
+ } catch {
10381
+ return [];
10382
+ }
10383
+ const provided = new Map(verifiedFontFamilies(cacheDir).map((f) => [f.family.toLowerCase(), f.weights]));
10384
+ const gaps = [];
10385
+ for (const n of needs) {
10386
+ const have = provided.get(n.family.toLowerCase());
10387
+ if (have === void 0) continue;
10388
+ if (n.weights.some((w) => !have.includes(w))) gaps.push({ family: n.family, declared: n.weights, provided: have });
10389
+ }
10390
+ return gaps;
10391
+ }
10392
+ var init_font_guidance = __esm({
10393
+ "packages/cli/src/font-guidance.ts"() {
10394
+ "use strict";
10395
+ init_src4();
10396
+ init_src7();
10397
+ init_invocation();
10398
+ }
10399
+ });
10400
+
10094
10401
  // packages/cli/src/commands/fonts.ts
10095
10402
  var fonts_exports = {};
10096
10403
  __export(fonts_exports, {
10097
10404
  DEFAULT_FONT_CACHE: () => DEFAULT_FONT_CACHE,
10405
+ familyMismatch: () => familyMismatch,
10098
10406
  runFontsAdd: () => runFontsAdd,
10099
10407
  runFontsRequired: () => runFontsRequired,
10100
10408
  runFontsResolve: () => runFontsResolve,
@@ -10102,7 +10410,7 @@ __export(fonts_exports, {
10102
10410
  runFontsStatus: () => runFontsStatus
10103
10411
  });
10104
10412
  import { existsSync as existsSync25, readFileSync as readFileSync21 } from "node:fs";
10105
- import path31 from "node:path";
10413
+ import path32 from "node:path";
10106
10414
  async function runFontsResolve(opts) {
10107
10415
  const result = await resolveFonts(opts.family, opts.weights, opts.cacheDir);
10108
10416
  emitData(opts, result, () => {
@@ -10117,7 +10425,7 @@ async function runFontsResolve(opts) {
10117
10425
  }
10118
10426
  }
10119
10427
  async function runFontsResolveSet(opts) {
10120
- const setDir = path31.resolve(process.env["INIT_CWD"] ?? process.cwd(), opts.set);
10428
+ const setDir = path32.resolve(process.env["INIT_CWD"] ?? process.cwd(), opts.set);
10121
10429
  let needs = [];
10122
10430
  try {
10123
10431
  needs = recordedFontNeeds(setDir);
@@ -10190,7 +10498,7 @@ async function runFontsResolveSet(opts) {
10190
10498
  }
10191
10499
  }
10192
10500
  function runFontsStatus(opts) {
10193
- const manifestPath2 = path31.join(opts.cacheDir, "manifest.json");
10501
+ const manifestPath2 = path32.join(opts.cacheDir, "manifest.json");
10194
10502
  if (!existsSync25(manifestPath2)) {
10195
10503
  fail(opts, ExitCode.FontsUnproven, {
10196
10504
  error: `no font cache at ${opts.cacheDir}`,
@@ -10199,7 +10507,7 @@ function runFontsStatus(opts) {
10199
10507
  });
10200
10508
  }
10201
10509
  const faces = JSON.parse(readFileSync21(manifestPath2, "utf8"));
10202
- const lockVerdicts = opts.lock !== void 0 ? checkFontLock(path31.resolve(opts.lock), opts.cacheDir) : null;
10510
+ const lockVerdicts = opts.lock !== void 0 ? checkFontLock(path32.resolve(opts.lock), opts.cacheDir) : null;
10203
10511
  emitData(opts, { faces, lockVerdicts }, () => {
10204
10512
  for (const f of faces) process.stdout.write(`cached ${f.family} ${f.weight} (${f.sha256.slice(0, 12)}\u2026)
10205
10513
  `);
@@ -10222,10 +10530,45 @@ function runFontsRequired(opts) {
10222
10530
  });
10223
10531
  if (missing.length > 0) process.exit(ExitCode.FontsUnproven);
10224
10532
  }
10533
+ function editDistance(a, b) {
10534
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
10535
+ const row = new Array(b.length + 1).fill(0);
10536
+ for (let i = 1; i <= a.length; i++) {
10537
+ row[0] = i;
10538
+ for (let j = 1; j <= b.length; j++) {
10539
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
10540
+ }
10541
+ for (let j = 0; j <= b.length; j++) prev[j] = row[j];
10542
+ }
10543
+ return prev[b.length];
10544
+ }
10545
+ function familyMismatch(family, declared) {
10546
+ if (declared.length === 0) return void 0;
10547
+ if (declared.some((d) => d.toLowerCase() === family.toLowerCase())) return void 0;
10548
+ const ranked = declared.map((d) => ({ d, score: editDistance(family.toLowerCase(), d.toLowerCase()) })).sort((a, b) => a.score - b.score);
10549
+ const best = ranked[0];
10550
+ return best.score <= Math.max(3, Math.floor(family.length / 3)) ? { nearest: best.d } : {};
10551
+ }
10225
10552
  function runFontsAdd(opts) {
10553
+ if (opts.set !== void 0) {
10554
+ const declared = taskFontFamilies(path32.resolve(opts.set)) ?? [];
10555
+ const mismatch = familyMismatch(opts.family, declared);
10556
+ if (mismatch !== void 0) {
10557
+ fail(opts, ExitCode.InputValidation, {
10558
+ error: `the recording set does not declare a family named "${opts.family}" \u2014 it declares ${declared.map((d) => `"${d}"`).join(", ")}. Adding it under this name would cache a face the mount never matches, and scoring would keep refusing for the family that is still missing.`,
10559
+ code: "font-family-not-declared",
10560
+ remediation: mismatch.nearest !== void 0 ? `Did you mean "${mismatch.nearest}"? ${tendrilCommand(`fonts add "${mismatch.nearest}" ${opts.weight} ${opts.file} --set ${path32.resolve(opts.set)}`)}` : `Use one of the declared families exactly as the recording spells it, or drop --set to add a family this set does not declare.`
10561
+ });
10562
+ }
10563
+ } else {
10564
+ warn(
10565
+ opts,
10566
+ `family "${opts.family}" was NOT checked against a recording \u2014 pass --set <recording-dir> to have the spelling verified, since a face cached under a name the mount does not match leaves scoring refusing for a family that still looks provided`
10567
+ );
10568
+ }
10226
10569
  let face;
10227
10570
  try {
10228
- face = addLocalFont(opts.family, opts.weight, opts.file, opts.cacheDir);
10571
+ face = addLocalFont(opts.family, opts.weight, opts.file, opts.cacheDir, opts.face);
10229
10572
  } catch (err) {
10230
10573
  fail(opts, ExitCode.InputValidation, {
10231
10574
  error: err instanceof Error ? err.message : String(err),
@@ -10248,71 +10591,7 @@ var init_fonts = __esm({
10248
10591
  init_src4();
10249
10592
  init_src7();
10250
10593
  init_output();
10251
- init_invocation();
10252
- }
10253
- });
10254
-
10255
- // packages/cli/src/font-guidance.ts
10256
- import path32 from "node:path";
10257
- function fontsUnprovenRemediation(setDir) {
10258
- const set = setDir === void 0 ? void 0 : path32.resolve(setDir);
10259
- if (set !== void 0) {
10260
- try {
10261
- const needs = recordedFontNeeds(set);
10262
- if (needs.length > 0) {
10263
- const families = needs.map((n) => `"${n.family}"`).join(", ");
10264
- return `Run \`${tendrilCommand(`fonts resolve --set ${set}`)}\` \u2014 the recording declares ${families}; faces land in ${DEFAULT_FONT_CACHE}.`;
10265
- }
10266
- } catch {
10267
- }
10268
- }
10269
- const target = set ?? "<recording-dir>";
10270
- return `Run \`${tendrilCommand(`fonts resolve --set ${target}`)}\` (or \`${tendrilCommand('fonts resolve "<Family>" --weights 400 500 600')}\`) to populate the font cache \u2014 the cache read is ${DEFAULT_FONT_CACHE}.`;
10271
- }
10272
- function taskFontFamilies(setDir) {
10273
- try {
10274
- const families = recordedFontFamilies(setDir);
10275
- return families.length > 0 ? families : null;
10276
- } catch {
10277
- return null;
10278
- }
10279
- }
10280
- function unprovisionedFamilies(setDir, cacheDir) {
10281
- const declared = taskFontFamilies(setDir);
10282
- if (declared === null) return [];
10283
- const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
10284
- return declared.filter((f) => !provided.has(f.toLowerCase()));
10285
- }
10286
- function unprovisionedFaces(setDir, cacheDir) {
10287
- return [
10288
- ...unprovisionedFamilies(setDir, cacheDir),
10289
- ...missingWeights(setDir, cacheDir).map((g) => {
10290
- const missing = g.declared.filter((w) => !g.provided.includes(w));
10291
- return `${g.family} (${missing.length === 1 ? "weight" : "weights"} ${missing.join(", ")})`;
10292
- })
10293
- ];
10294
- }
10295
- function missingWeights(setDir, cacheDir) {
10296
- let needs;
10297
- try {
10298
- needs = recordedFontNeeds(setDir, { pairedOnly: true });
10299
- } catch {
10300
- return [];
10301
- }
10302
- const provided = new Map(verifiedFontFamilies(cacheDir).map((f) => [f.family.toLowerCase(), f.weights]));
10303
- const gaps = [];
10304
- for (const n of needs) {
10305
- const have = provided.get(n.family.toLowerCase());
10306
- if (have === void 0) continue;
10307
- if (n.weights.some((w) => !have.includes(w))) gaps.push({ family: n.family, declared: n.weights, provided: have });
10308
- }
10309
- return gaps;
10310
- }
10311
- var init_font_guidance = __esm({
10312
- "packages/cli/src/font-guidance.ts"() {
10313
- "use strict";
10314
- init_src4();
10315
- init_src7();
10594
+ init_font_guidance();
10316
10595
  init_invocation();
10317
10596
  }
10318
10597
  });
@@ -10328,6 +10607,7 @@ __export(verify_exports, {
10328
10607
  failureTally: () => failureTally,
10329
10608
  foldConfigStatus: () => foldConfigStatus,
10330
10609
  interactionCoverage: () => interactionCoverage,
10610
+ latticeCoverage: () => latticeCoverage,
10331
10611
  occlusionReport: () => occlusionReport,
10332
10612
  operabilityLine: () => operabilityLine,
10333
10613
  operabilityReport: () => operabilityReport,
@@ -10425,6 +10705,21 @@ function checkSummarySegments(input) {
10425
10705
  const operability = "short" in input.operability ? `operability UNVERIFIED (${input.operability.short})` : `operability ${input.operability.passed}/${input.operability.checks} interaction`;
10426
10706
  return ` \xB7 ${operability} \xB7 ${composition} \xB7 ${occlusion}`;
10427
10707
  }
10708
+ function latticeCoverage(setManifest, scoredConfigs) {
10709
+ const lattice = setManifest.latticeNames?.length;
10710
+ const established = setManifest.variantScope === "component-set";
10711
+ const notRecorded = setManifest.notRecorded !== void 0 && setManifest.notRecorded !== "" ? { notRecorded: setManifest.notRecorded } : {};
10712
+ if (lattice === void 0) return { latticeConfigs: null, ...notRecorded };
10713
+ if (established) return { latticeConfigs: lattice, unrecordedConfigs: Math.max(0, lattice - scoredConfigs), ...notRecorded };
10714
+ return {
10715
+ latticeConfigs: null,
10716
+ // The count is still worth reporting — it is just not a
10717
+ // denominator, and the name has to stop implying that it is.
10718
+ variantsPlanned: lattice,
10719
+ latticeUnestablished: setManifest.variantScope === "selection" ? "the recording was planned from a node SELECTION, not from a component set \u2014 the plan counted the nodes it was handed and could not see whether the set holds more variants, so the number of unrecorded poses is UNKNOWN, not zero" : "this set was planned before variant scope was recorded, so whether its lattice came from a component set or from a handed-in selection was never established \u2014 the number of unrecorded poses is unknown, not zero",
10720
+ ...notRecorded
10721
+ };
10722
+ }
10428
10723
  function occlusionReport(occlusion) {
10429
10724
  if (occlusion.length === 0) {
10430
10725
  return {
@@ -10685,12 +10980,7 @@ async function runVerify(opts) {
10685
10980
  // itself — not only in a long-gone plan output.
10686
10981
  ...(() => {
10687
10982
  try {
10688
- const setManifest = loadManifest(task.set);
10689
- const lattice = setManifest.latticeNames?.length;
10690
- return {
10691
- ...lattice !== void 0 ? { latticeConfigs: lattice, unrecordedConfigs: Math.max(0, lattice - statuses.length) } : { latticeConfigs: null },
10692
- ...setManifest.notRecorded !== void 0 && setManifest.notRecorded !== "" ? { notRecorded: setManifest.notRecorded } : {}
10693
- };
10983
+ return latticeCoverage(loadManifest(task.set), statuses.length);
10694
10984
  } catch {
10695
10985
  return { latticeConfigs: null };
10696
10986
  }
@@ -10991,6 +11281,7 @@ ${notRecorded}` : "";
10991
11281
  === TASK PAYLOAD (recorded truth, verbatim) ===
10992
11282
  ${segments}`;
10993
11283
  const payloadFile = path34.resolve(callerCwd, opts.out ?? `tendril-out/${name}-brief.md`);
11284
+ const candidateDirSuggestion = path34.resolve(callerCwd, `tendril-out/${name}-candidate`);
10994
11285
  mkdirSync8(path34.dirname(payloadFile), { recursive: true });
10995
11286
  writeFileSync12(payloadFile, payload);
10996
11287
  emitData(
@@ -11019,8 +11310,16 @@ ${segments}`;
11019
11310
  protocol: [
11020
11311
  "Settle the proposer model (see modelSelection \u2014 ask the user when one is present), then:",
11021
11312
  `Read ${payloadFile} completely \u2014 it is the system brief plus every recorded config's emission, box, and assets.`,
11022
- `Write the complete bundle files (${task.entry}, styles.css, optional tokens.css) into a candidate directory.`,
11023
- `Run \`${tendrilCommand(`engine score ${ref} <candidateDir> --bar ${opts.bar} --model ${opts.model ?? "<declared-model>"} --json`)}\` \u2014 the CLI is the sole judge, and it refuses to score an undeclared model.`,
11313
+ `Write the complete bundle files (${task.entry}, styles.css, optional tokens.css) into ${quoteArg(candidateDirSuggestion)} (create it with a bare \`mkdir -p\`), or into another directory if you were handed one.`,
11314
+ // Both paths quoted, and the candidate directory NAMED rather
11315
+ // than left as `<candidateDir>`: run 18 worked in a folder
11316
+ // called "Test 2", where the unquoted form split into two
11317
+ // arguments, and a placeholder is the other half of the same
11318
+ // defect — a command the reader has to finish is one they can
11319
+ // finish wrongly.
11320
+ `Run \`${tendrilCommand(
11321
+ `engine score ${quoteArg(ref)} ${quoteArg(candidateDirSuggestion)} --bar ${opts.bar} --model ${opts.model ?? "<declared-model>"} --json`
11322
+ )}\` \u2014 the CLI is the sole judge, and it refuses to score an undeclared model. If you wrote the bundle elsewhere, pass that directory instead.`,
11024
11323
  "Apply the returned feedback and re-score. Stop when all checks pass or two consecutive scores fail to improve.",
11025
11324
  "Never edit recording sets; never claim scores yourself \u2014 only the score command's output counts."
11026
11325
  ]
@@ -11144,7 +11443,21 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
11144
11443
  {
11145
11444
  task: name,
11146
11445
  bar: opts.bar,
11147
- objective: { passCount: obj[0], total, floor: obj[1], mean: obj[2] },
11446
+ // `total` is pixel configs PLUS behaviour checks, and nothing said
11447
+ // so: run 18's operator relayed "6/16" for two rounds without
11448
+ // knowing it meant ZERO configs passing — materially worse than it
11449
+ // sounds, and the number a human decides whether to keep paying on.
11450
+ objective: {
11451
+ passCount: obj[0],
11452
+ total,
11453
+ pixelConfigs: scores.length,
11454
+ pixelConfigsPassing: scores.filter((s) => s.pass).length,
11455
+ behaviorChecks: behaviors.length,
11456
+ behaviorChecksPassing: behaviors.filter((b) => b.pass).length,
11457
+ note: `passCount/total counts ${scores.length} pixel config(s) AND ${behaviors.length} behaviour check(s) together \u2014 read pixelConfigsPassing to see how many CONFIGS pass`,
11458
+ floor: obj[1],
11459
+ mean: obj[2]
11460
+ },
11148
11461
  scores,
11149
11462
  behaviors,
11150
11463
  feedback,
@@ -13278,11 +13591,19 @@ function buildProgram() {
13278
13591
  await runFontsResolve2({ ...flags, family, weights: local["weights"].map(Number), cacheDir });
13279
13592
  }
13280
13593
  });
13281
- fonts.command("add").description("Register a font file you already license, so a kit using a proprietary face can be scored.").argument("<family>", 'font family name as the design uses it, e.g. "S\xF6hne"').argument("<weight>", "numeric weight, e.g. 400").argument("<file>", "path to a .woff2/.woff/.ttf/.otf you are licensed to use").option("--cache <dir>", "cache directory (default: the per-user font cache)").action(async (family, weight, file, _o, cmd) => {
13594
+ fonts.command("add").description("Register a font file you already license, so a kit using a proprietary face can be scored.").argument("<family>", 'font family name as the design uses it, e.g. "S\xF6hne"').argument("<weight>", "numeric weight, e.g. 400").argument("<file>", "path to a .woff2/.woff/.ttf/.otf you are licensed to use").option("--cache <dir>", "cache directory (default: the per-user font cache)").option("--set <dir>", "recording set to verify the family spelling against \u2014 a face cached under a name the mount never matches leaves scoring refusing").option("--face <index>", "which face to lift out of a .ttc collection (the error lists them)").action(async (family, weight, file, _o, cmd) => {
13282
13595
  const flags = globalFlags(cmd.parent.parent);
13283
13596
  const local = cmd.opts();
13284
13597
  const { runFontsAdd: runFontsAdd2 } = await Promise.resolve().then(() => (init_fonts(), fonts_exports));
13285
- runFontsAdd2({ ...flags, family, weight: Number(weight), file, cacheDir: local["cache"] ?? DEFAULT_FONT_CACHE });
13598
+ runFontsAdd2({
13599
+ ...flags,
13600
+ family,
13601
+ weight: Number(weight),
13602
+ file,
13603
+ cacheDir: local["cache"] ?? DEFAULT_FONT_CACHE,
13604
+ ...local["set"] !== void 0 ? { set: local["set"] } : {},
13605
+ ...local["face"] !== void 0 ? { face: Number(local["face"]) } : {}
13606
+ });
13286
13607
  });
13287
13608
  fonts.command("status").option("--lock <file>", "hash lock to verify against (e.g. packages/verify/fixtures/fonts.lock.json)").option("--cache <dir>", "cache directory (default: the per-user font cache)").action(async (_o, cmd) => {
13288
13609
  const flags = globalFlags(cmd.parent.parent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",