@tendrilapp/cli 0.1.25 → 0.1.27

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
@@ -1028,6 +1028,14 @@ function planSet(setDir, component, symbols, opts = {}) {
1028
1028
  const { manifest: existing, raw } = readManifestFile(setDir);
1029
1029
  const wantsNewDefaults = opts.defaults !== void 0 && JSON.stringify(opts.defaults) !== JSON.stringify(existing.defaults ?? {});
1030
1030
  const anythingRecorded = existing.reps.some((r) => RECORD_TOOLS.some((t) => existsSync(path.join(setDir, r.slug, `${t}.json`))));
1031
+ const scopeUpgraded = opts.variantScope === "component-set" && existing.variantScope !== "component-set";
1032
+ if (opts.variantScope === "component-set") {
1033
+ raw["variantScope"] = "component-set";
1034
+ const variants = symbols.map((s) => s.name).filter((n) => n.includes("="));
1035
+ if (variants.length > 0 && (scopeUpgraded || variants.length > (existing.latticeNames ?? []).length)) {
1036
+ raw["latticeNames"] = variants;
1037
+ }
1038
+ }
1031
1039
  if (!wantsNewDefaults || anythingRecorded) {
1032
1040
  if (opts.sample !== true) {
1033
1041
  const known = new Set(existing.reps.map((r) => r.nodeId));
@@ -1049,6 +1057,10 @@ function planSet(setDir, component, symbols, opts = {}) {
1049
1057
  return { manifest: manifest2, plan: { reps: [], notRecorded: [] }, resumed: true, toppedUp: appended.map((a) => ({ slug: a.slug, nodeId: a.nodeId })) };
1050
1058
  }
1051
1059
  }
1060
+ if (scopeUpgraded) {
1061
+ const manifest2 = writeManifest(setDir, raw);
1062
+ return { manifest: manifest2, plan: { reps: [], notRecorded: [] }, resumed: true };
1063
+ }
1052
1064
  return { manifest: existing, plan: { reps: [], notRecorded: [] }, resumed: true };
1053
1065
  }
1054
1066
  }
@@ -1058,6 +1070,7 @@ function planSet(setDir, component, symbols, opts = {}) {
1058
1070
  version: 1,
1059
1071
  component,
1060
1072
  ...opts.sourceFrames !== void 0 ? { sourceFrames: opts.sourceFrames } : {},
1073
+ ...opts.variantScope !== void 0 ? { variantScope: opts.variantScope } : {},
1061
1074
  ...opts.defaults !== void 0 && Object.keys(opts.defaults).length > 0 ? { defaults: opts.defaults } : {},
1062
1075
  ...(() => {
1063
1076
  const variants = symbols.map((s) => s.name).filter((n) => n.includes("="));
@@ -1184,6 +1197,31 @@ var init_session = __esm({
1184
1197
  * domains: the API must cover the lattice even where only a subset
1185
1198
  * is recorded. */
1186
1199
  latticeNames: z4.array(z4.string()).optional(),
1200
+ /**
1201
+ * WHETHER `latticeNames` IS A DENOMINATOR AT ALL.
1202
+ *
1203
+ * The lattice is derived from the metadata handed to `record plan`,
1204
+ * so when that metadata is a bare list of variant nodes the count is
1205
+ * the SELECTION, not the component set. Field case (run 17): four
1206
+ * node ids that were 2 of 5 columns of a 5×2 set. Planning them would
1207
+ * have recorded a set silently missing three variants — and the
1208
+ * plan's own cross-check would have passed, because it validates
1209
+ * against the same metadata it was handed. Downstream that reads as
1210
+ * `latticeConfigs: 4 / unrecordedConfigs: 0`: an affirmative COMPLETE
1211
+ * claim over 40% of a missing axis, on the trust anchor's own
1212
+ * coverage disclosure.
1213
+ *
1214
+ * The signal is whether the metadata carried ENCLOSING CONTEXT: a
1215
+ * per-variant `get_metadata` returns the `<symbol>` as the root with
1216
+ * no ancestor, while a page- or set-level call nests the variants
1217
+ * under the named component set. "component-set" therefore means the
1218
+ * planner saw the set's own children; "selection" means it saw what
1219
+ * it was given and cannot know what else exists.
1220
+ *
1221
+ * Absent on sets planned before this field — treated as unknown, not
1222
+ * as complete, because that is what it was.
1223
+ */
1224
+ variantScope: z4.enum(["component-set", "selection"]).optional(),
1187
1225
  /** Which planning mode produced this queue. Absent = planned before
1188
1226
  * the full-matrix default (i.e. sampled) — resume uses this to
1189
1227
  * top-up rather than silently perpetuating a sampled queue. */
@@ -1894,6 +1932,10 @@ function tendrilInvocation() {
1894
1932
  function tendrilCommand(args) {
1895
1933
  return `${tendrilInvocation()} ${args}`;
1896
1934
  }
1935
+ function quoteArg(value) {
1936
+ if (/["`$]/.test(value)) throw new Error(`refusing to emit a shell argument containing a double quote, backtick or $: ${value}`);
1937
+ return /[\s'*?[\]()&;|<>#~]/.test(value) ? `"${value}"` : value;
1938
+ }
1897
1939
  var NPX_INVOCATION, cached;
1898
1940
  var init_invocation = __esm({
1899
1941
  "packages/cli/src/invocation.ts"() {
@@ -3450,6 +3492,112 @@ var init_paths = __esm({
3450
3492
  }
3451
3493
  });
3452
3494
 
3495
+ // packages/verify/src/font-collection.ts
3496
+ function isCollection(bytes) {
3497
+ return bytes.length >= 12 && new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0) === TTC_TAG;
3498
+ }
3499
+ function nameTableStrings(view, bytes, nameOffset, nameLength) {
3500
+ const out = /* @__PURE__ */ new Map();
3501
+ if (nameOffset + 6 > bytes.length) return out;
3502
+ const count = view.getUint16(nameOffset + 2);
3503
+ const stringOffset = nameOffset + view.getUint16(nameOffset + 4);
3504
+ for (let i = 0; i < count; i++) {
3505
+ const rec = nameOffset + 6 + i * 12;
3506
+ if (rec + 12 > bytes.length) break;
3507
+ const platformId = view.getUint16(rec);
3508
+ const encodingId = view.getUint16(rec + 2);
3509
+ const nameId = view.getUint16(rec + 6);
3510
+ const length = view.getUint16(rec + 8);
3511
+ const offset = stringOffset + view.getUint16(rec + 10);
3512
+ if (offset + length > bytes.length || offset + length > nameOffset + nameLength) continue;
3513
+ const slice = bytes.subarray(offset, offset + length);
3514
+ const isUtf16 = platformId === 3 || platformId === 0 || encodingId === 1;
3515
+ let value = "";
3516
+ if (isUtf16) {
3517
+ for (let j = 0; j + 1 < slice.length; j += 2) value += String.fromCharCode(slice[j] << 8 | slice[j + 1]);
3518
+ } else {
3519
+ for (const b of slice) value += String.fromCharCode(b);
3520
+ }
3521
+ if (value !== "" && !out.has(nameId)) out.set(nameId, value);
3522
+ }
3523
+ return out;
3524
+ }
3525
+ function listCollectionFaces(bytes) {
3526
+ if (!isCollection(bytes)) return [];
3527
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3528
+ const numFonts = view.getUint32(8);
3529
+ const faces = [];
3530
+ for (let i = 0; i < numFonts; i++) {
3531
+ const dirOffset = view.getUint32(12 + i * 4);
3532
+ if (dirOffset + SFNT_HEADER_SIZE > bytes.length) continue;
3533
+ const numTables = view.getUint16(dirOffset + 4);
3534
+ let names = /* @__PURE__ */ new Map();
3535
+ for (let t = 0; t < numTables; t++) {
3536
+ const rec = dirOffset + SFNT_HEADER_SIZE + t * TABLE_RECORD_SIZE;
3537
+ if (rec + TABLE_RECORD_SIZE > bytes.length) break;
3538
+ const tag = String.fromCharCode(bytes[rec], bytes[rec + 1], bytes[rec + 2], bytes[rec + 3]);
3539
+ if (tag !== "name") continue;
3540
+ names = nameTableStrings(view, bytes, view.getUint32(rec + 8), view.getUint32(rec + 12));
3541
+ break;
3542
+ }
3543
+ const family = names.get(1);
3544
+ const subfamily = names.get(2);
3545
+ faces.push({ index: i, ...family !== void 0 ? { family } : {}, ...subfamily !== void 0 ? { subfamily } : {} });
3546
+ }
3547
+ return faces;
3548
+ }
3549
+ function extractCollectionFace(bytes, index) {
3550
+ if (!isCollection(bytes)) throw new Error("not a TrueType Collection");
3551
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3552
+ const numFonts = view.getUint32(8);
3553
+ if (index < 0 || index >= numFonts) throw new Error(`face index ${index} out of range \u2014 the collection holds ${numFonts}`);
3554
+ const dirOffset = view.getUint32(12 + index * 4);
3555
+ const numTables = view.getUint16(dirOffset + 4);
3556
+ const records = [];
3557
+ for (let t = 0; t < numTables; t++) {
3558
+ const rec = dirOffset + SFNT_HEADER_SIZE + t * TABLE_RECORD_SIZE;
3559
+ const checksum = view.getUint32(rec + 4);
3560
+ const offset = view.getUint32(rec + 8);
3561
+ const length = view.getUint32(rec + 12);
3562
+ if (offset + length > bytes.length) throw new Error(`collection table ${t} runs past the end of the file`);
3563
+ records.push({ tag: bytes.subarray(rec, rec + 4), checksum, data: bytes.subarray(offset, offset + length) });
3564
+ }
3565
+ const aligned = (n) => n + 3 & ~3;
3566
+ const bodySize = records.reduce((sum, r) => sum + aligned(r.data.length), 0);
3567
+ const out = new Uint8Array(SFNT_HEADER_SIZE + records.length * TABLE_RECORD_SIZE + bodySize);
3568
+ const outView = new DataView(out.buffer);
3569
+ outView.setUint32(0, view.getUint32(dirOffset));
3570
+ outView.setUint16(4, records.length);
3571
+ const pow2 = Math.floor(Math.log2(Math.max(1, records.length)));
3572
+ outView.setUint16(6, 16 * 2 ** pow2);
3573
+ outView.setUint16(8, pow2);
3574
+ outView.setUint16(10, records.length * 16 - 16 * 2 ** pow2);
3575
+ let cursor = SFNT_HEADER_SIZE + records.length * TABLE_RECORD_SIZE;
3576
+ records.forEach((r, i) => {
3577
+ const rec = SFNT_HEADER_SIZE + i * TABLE_RECORD_SIZE;
3578
+ out.set(r.tag, rec);
3579
+ outView.setUint32(rec + 4, r.checksum);
3580
+ outView.setUint32(rec + 8, cursor);
3581
+ outView.setUint32(rec + 12, r.data.length);
3582
+ out.set(r.data, cursor);
3583
+ cursor += aligned(r.data.length);
3584
+ });
3585
+ return out;
3586
+ }
3587
+ function facesForFamily(bytes, family) {
3588
+ const want = family.toLowerCase().replace(/\s+/g, "");
3589
+ return listCollectionFaces(bytes).filter((f) => (f.family ?? "").toLowerCase().replace(/\s+/g, "") === want);
3590
+ }
3591
+ var TTC_TAG, TABLE_RECORD_SIZE, SFNT_HEADER_SIZE;
3592
+ var init_font_collection = __esm({
3593
+ "packages/verify/src/font-collection.ts"() {
3594
+ "use strict";
3595
+ TTC_TAG = 1953784678;
3596
+ TABLE_RECORD_SIZE = 16;
3597
+ SFNT_HEADER_SIZE = 12;
3598
+ }
3599
+ });
3600
+
3453
3601
  // packages/verify/src/font-resolve.ts
3454
3602
  import { createHash } from "node:crypto";
3455
3603
  import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
@@ -3538,18 +3686,33 @@ async function resolveFonts(family, weights, cacheDir = DEFAULT_FONT_CACHE) {
3538
3686
  `);
3539
3687
  return { resolved, failures };
3540
3688
  }
3541
- function addLocalFont(family, weight, filePath, cacheDir = DEFAULT_FONT_CACHE) {
3689
+ function addLocalFont(family, weight, filePath, cacheDir = DEFAULT_FONT_CACHE, faceIndex) {
3542
3690
  const src = path10.resolve(filePath);
3543
3691
  if (!existsSync6(src)) throw new Error(`font file not found: ${src}`);
3544
3692
  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`);
3693
+ if (![".woff2", ".woff", ".ttf", ".otf", ".ttc"].includes(ext)) {
3694
+ throw new Error(`unsupported font file "${ext}" \u2014 use .woff2 (preferred), .woff, .ttf, .otf or .ttc`);
3547
3695
  }
3548
- const bytes = new Uint8Array(readFileSync3(src));
3696
+ let bytes = new Uint8Array(readFileSync3(src));
3549
3697
  if (bytes.length === 0) throw new Error(`font file is empty: ${src}`);
3698
+ let storedExt = ext;
3699
+ if (isCollection(bytes)) {
3700
+ const candidates = facesForFamily(bytes, family);
3701
+ const chosen = faceIndex ?? (candidates.length === 1 ? candidates[0].index : void 0);
3702
+ if (chosen === void 0) {
3703
+ const all = listCollectionFaces(bytes);
3704
+ const shown = (candidates.length > 0 ? candidates : all).map((f) => ` --face ${f.index} ${f.family ?? "(unnamed)"}${f.subfamily !== void 0 ? ` ${f.subfamily}` : ""}`).join("\n");
3705
+ throw new Error(
3706
+ `${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>:
3707
+ ${shown}`
3708
+ );
3709
+ }
3710
+ bytes = new Uint8Array(extractCollectionFace(bytes, chosen));
3711
+ storedExt = ".ttf";
3712
+ }
3550
3713
  mkdirSync2(cacheDir, { recursive: true });
3551
3714
  const sha256 = createHash("sha256").update(bytes).digest("hex");
3552
- const file = path10.join(cacheDir, `${family.toLowerCase().replace(/\s+/g, "-")}-${weight}${ext}`);
3715
+ const file = path10.join(cacheDir, `${family.toLowerCase().replace(/\s+/g, "-")}-${weight}${storedExt}`);
3553
3716
  writeFileSync3(file, bytes);
3554
3717
  const face = { family, weight, source: `local:${path10.basename(src)}`, sha256, file, license: "unknown" };
3555
3718
  const mPath = path10.join(cacheDir, "manifest.json");
@@ -3639,6 +3802,7 @@ var DEFAULT_FONT_CACHE, UA, FONT_LICENSES, GOOGLE_LICENSE_IDS, GOOGLE_FONT_FILE_
3639
3802
  var init_font_resolve = __esm({
3640
3803
  "packages/verify/src/font-resolve.ts"() {
3641
3804
  "use strict";
3805
+ init_font_collection();
3642
3806
  DEFAULT_FONT_CACHE = fontCacheDir();
3643
3807
  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
3808
  FONT_LICENSES = ["OFL-1.1", "Apache-2.0", "UFL-1.0", "proprietary", "unknown"];
@@ -4157,15 +4321,44 @@ async function runSteps(page, spec, renderPose) {
4157
4321
  const verdict = await page.evaluate(
4158
4322
  `(() => {
4159
4323
  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) {
4324
+ // Attributes a user actually perceives, whether by eye or
4325
+ // through assistive tech. Deliberately NOT every attribute:
4326
+ // a data-* or a name= is a place to park a string, which is
4327
+ // the dodge this check exists to catch.
4328
+ const PERCEIVED = ['placeholder', 'alt', 'aria-label', 'title', 'value'];
4329
+ // 'value' counts ONLY where the platform RENDERS it as the
4330
+ // control's text: text-entry inputs, textarea, and the
4331
+ // input-typed buttons whose value IS their label. Nearly
4332
+ // every element carries a value PROPERTY \u2014 a visible
4333
+ // checkbox or <button> with value={sentinel} would satisfy
4334
+ // an unrestricted check while no user ever perceives the
4335
+ // string, which reopens the exact dodge this check exists
4336
+ // to close (post-release review, 2026-08-14). password is
4337
+ // excluded because its value renders as dots, not as the
4338
+ // string. Fail-closed allowlist, not a blocklist.
4339
+ const VALUE_RENDERS = ['text', 'search', 'email', 'url', 'tel', 'number', 'submit', 'reset', 'button'];
4340
+ const valueRenders = (el) =>
4341
+ el instanceof HTMLTextAreaElement || (el instanceof HTMLInputElement && VALUE_RENDERS.includes(el.type));
4342
+ const visible = (el) => {
4164
4343
  const r = el.getBoundingClientRect();
4165
4344
  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)';
4345
+ return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' && Number(cs.opacity) > 0;
4346
+ };
4347
+ const els = [...document.querySelectorAll('#root *')];
4348
+ const textHolders = els.filter((el) => [...el.childNodes].some((n) => n.nodeType === 3 && (n.textContent ?? '').includes(needle)));
4349
+ for (const el of textHolders) if (visible(el)) return true;
4350
+ const attrHolders = els.filter((el) =>
4351
+ PERCEIVED.some((a) => {
4352
+ // The LIVE value for form controls: React's defaultValue
4353
+ // sets the property, and the attribute may never appear.
4354
+ if (a === 'value' && !valueRenders(el)) return false;
4355
+ const v = a === 'value' ? el.value : el.getAttribute(a);
4356
+ return typeof v === 'string' && v.includes(needle);
4357
+ }),
4358
+ );
4359
+ for (const el of attrHolders) if (visible(el)) return true;
4360
+ 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';
4361
+ return 'present but not visibly rendered (hidden/zero-size/transparent node)';
4169
4362
  })()`
4170
4363
  );
4171
4364
  if (verdict !== true) return { id: spec.id, pass: false, detail: `sentinel "${step.assertTextVisible}": ${String(verdict)}` };
@@ -4430,7 +4623,7 @@ function definedVars(tokensCss) {
4430
4623
  for (const m of tokensCss.matchAll(/(--[\w-]+)\s*:/g)) names.add(m[1]);
4431
4624
  return names;
4432
4625
  }
4433
- function recordedTokenMapEmpty(setDir, reps) {
4626
+ function recordedTokenMapState(setDir, reps) {
4434
4627
  const readMap = (file) => {
4435
4628
  if (!existsSync10(file)) return void 0;
4436
4629
  try {
@@ -4441,15 +4634,15 @@ function recordedTokenMapEmpty(setDir, reps) {
4441
4634
  }
4442
4635
  };
4443
4636
  const setLevel = readMap(path15.join(setDir, "get_variable_defs.json"));
4444
- if (setLevel !== void 0) return Object.keys(setLevel).length === 0;
4637
+ if (setLevel !== void 0) return Object.keys(setLevel).length === 0 ? "empty" : "populated";
4445
4638
  let recorded = false;
4446
4639
  for (const rep of reps) {
4447
4640
  const m = readMap(path15.join(setDir, rep, "get_variable_defs.json"));
4448
4641
  if (m === void 0) continue;
4449
4642
  recorded = true;
4450
- if (Object.keys(m).length > 0) return false;
4643
+ if (Object.keys(m).length > 0) return "populated";
4451
4644
  }
4452
- return recorded;
4645
+ return recorded ? "empty" : "never-recorded";
4453
4646
  }
4454
4647
  function scannable(css) {
4455
4648
  const blank = (m) => m.replace(/[^\n]/g, " ");
@@ -4536,10 +4729,17 @@ async function checkBundleQuality(bundleDir, entry, set, fontManifest) {
4536
4729
  }
4537
4730
  }
4538
4731
  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) {
4732
+ const mapState = set === void 0 ? void 0 : recordedTokenMapState(set.dir, set.reps);
4733
+ for (const v of (await runTokenLint(css, "styles.css", definedVars(tokensCss), mapState !== void 0 && mapState !== "populated")).violations) {
4541
4734
  findings.push({ kind: "token-lint", file: "styles.css", line: v.line, message: v.message });
4542
4735
  }
4736
+ if (mapState === "never-recorded") {
4737
+ findings.push({
4738
+ kind: "token-lint",
4739
+ file: "styles.css",
4740
+ 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."
4741
+ });
4742
+ }
4543
4743
  }
4544
4744
  return { findings, tokensAbsent: tokensCss === void 0 && !/var\(\s*--/.test(css) };
4545
4745
  }
@@ -5519,6 +5719,7 @@ var init_src4 = __esm({
5519
5719
  init_tasks();
5520
5720
  init_prelude();
5521
5721
  init_mount_limits();
5722
+ init_font_collection();
5522
5723
  init_font_faces();
5523
5724
  init_font_resolve();
5524
5725
  init_paths();
@@ -5770,6 +5971,15 @@ import { spawnSync } from "node:child_process";
5770
5971
  import { existsSync as existsSync18, readFileSync as readFileSync14, readdirSync as readdirSync3 } from "node:fs";
5771
5972
  import os4 from "node:os";
5772
5973
  import path23 from "node:path";
5974
+ function withDeadline(work, ms) {
5975
+ return Promise.race([
5976
+ work,
5977
+ new Promise((_resolve, reject) => {
5978
+ const timer = setTimeout(() => reject(new McpProbeTimeout(ms)), ms);
5979
+ timer.unref?.();
5980
+ })
5981
+ ]);
5982
+ }
5773
5983
  async function runDoctorChecks(options) {
5774
5984
  const checks = [];
5775
5985
  const mcpUrl = options.mcpUrl ?? DEFAULT_MCP_URL;
@@ -5778,19 +5988,26 @@ async function runDoctorChecks(options) {
5778
5988
  ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
5779
5989
  });
5780
5990
  try {
5781
- const info = await client.initialize();
5782
- const tools = await client.listTools();
5991
+ const info = await withDeadline(client.initialize(), MCP_PROBE_DEADLINE_MS);
5992
+ const tools = await withDeadline(client.listTools(), MCP_PROBE_DEADLINE_MS);
5783
5993
  checks.push({
5784
5994
  name: "figma-desktop-mcp",
5785
5995
  ok: true,
5786
- detail: `${info.name} ${info.version} (protocol ${info.protocolVersion}), ${tools.length} tools: ${tools.map((t) => t.name).join(", ")}`
5996
+ // WHOSE capability this tested. Run 16 read a green tick here as
5997
+ // permission to start recording, and could not: the CLI reaches
5998
+ // this server with its own localhost client, while the AGENT held
5999
+ // no Figma MCP tools at all, and `record` is agent-driven with no
6000
+ // CLI path. The operator wrote a 100-line MCP client in the first
6001
+ // ten minutes of a paid run. A check consumed by one party about
6002
+ // another party's capability has to say so out loud.
6003
+ 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
6004
  });
5788
6005
  } catch (err) {
5789
6006
  checks.push({
5790
6007
  name: "figma-desktop-mcp",
5791
6008
  ok: false,
5792
6009
  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."
6010
+ 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
6011
  });
5795
6012
  }
5796
6013
  const openrouterKey = resolveCredential("OPENROUTER_API_KEY");
@@ -5929,7 +6146,7 @@ async function runDoctor(flags) {
5929
6146
  });
5930
6147
  if (!report.ok) process.exit(1);
5931
6148
  }
5932
- var DEFAULT_MCP_URL, DOCTOR_DESCRIPTION;
6149
+ var DEFAULT_MCP_URL, DOCTOR_DESCRIPTION, MCP_PROBE_DEADLINE_MS, McpProbeTimeout;
5933
6150
  var init_doctor = __esm({
5934
6151
  "packages/cli/src/commands/doctor.ts"() {
5935
6152
  "use strict";
@@ -5957,6 +6174,15 @@ var init_doctor = __esm({
5957
6174
  exitCodes: { 0: "healthy", 1: "one or more required checks failed" },
5958
6175
  examples: ["tendril doctor", "tendril doctor --json"]
5959
6176
  };
6177
+ MCP_PROBE_DEADLINE_MS = 1e4;
6178
+ McpProbeTimeout = class extends Error {
6179
+ constructor(ms) {
6180
+ super(
6181
+ `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).`
6182
+ );
6183
+ this.name = "McpProbeTimeout";
6184
+ }
6185
+ };
5960
6186
  }
5961
6187
  });
5962
6188
 
@@ -7457,6 +7683,7 @@ function runRecordPlan(opts) {
7457
7683
  }
7458
7684
  }
7459
7685
  const setNames = [...new Set(symbols.map((s) => s.setName).filter((n) => n !== void 0))];
7686
+ const variantScope = setNames.length > 0 ? "component-set" : "selection";
7460
7687
  if (opts.componentSet !== void 0) {
7461
7688
  const filtered = symbols.filter((s) => s.setName === opts.componentSet);
7462
7689
  if (filtered.length === 0) {
@@ -7514,7 +7741,11 @@ function runRecordPlan(opts) {
7514
7741
  if (metadataTruncated) {
7515
7742
  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
7743
  }
7517
- const { manifest, plan, resumed, toppedUp } = planSet(opts.setDir, opts.component, symbols, { ...Object.keys(defaults).length > 0 ? { defaults } : {}, ...opts.sample === true ? { sample: true } : {} });
7744
+ const { manifest, plan, resumed, toppedUp } = planSet(opts.setDir, opts.component, symbols, {
7745
+ ...Object.keys(defaults).length > 0 ? { defaults } : {},
7746
+ ...opts.sample === true ? { sample: true } : {},
7747
+ variantScope
7748
+ });
7518
7749
  const defaultReports = reportAxisDefaults(symbols, Object.keys(defaults).length > 0 ? defaults : void 0);
7519
7750
  const toConfirm = resumed ? [] : defaultReports.filter((r) => (r.rule === "non-interaction" || r.rule === "frequency") && r.domain.length > 1);
7520
7751
  const interactionStatesToConfirm = recordsInteractionState(defaultReports) ? void 0 : interactionDisclosure(manifest.component, defaultReports);
@@ -7532,7 +7763,7 @@ function runRecordPlan(opts) {
7532
7763
  callsWorstCase: posesRemaining * FIGMA_CALLS_PER_REP_WORST,
7533
7764
  posesToRecord: posesRemaining,
7534
7765
  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.",
7766
+ 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
7767
  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
7768
  verdictTemplate: `${callSentence}. Your <seat> seat on <plan> allows <allowance>/day. This set needs about <days> day(s) at that rate.`,
7538
7769
  options: [
@@ -7548,7 +7779,7 @@ function runRecordPlan(opts) {
7548
7779
  // a CLI flag and the MCP surface has no parameter for it.
7549
7780
  decidedBy: "HUMAN ONLY \u2014 offer it, never choose it, and you cannot run it",
7550
7781
  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`]
7782
+ userRuns: [`rm ${quoteArg(path26.join(opts.setDir, "recording-set.json"))}`, `${tendrilCommand(`record plan --set ${quoteArg(opts.setDir)} --component ${quoteArg(manifest.component)}`)} --sample`]
7552
7783
  },
7553
7784
  {
7554
7785
  id: "larger-allowance",
@@ -7569,6 +7800,13 @@ function runRecordPlan(opts) {
7569
7800
  // the one check that catches parse/transfer losses this pipeline
7570
7801
  // cannot detect from the envelope alone.
7571
7802
  variantsFound: symbols.length,
7803
+ // WHAT variantsFound IS A COUNT OF. The comment above tells the
7804
+ // reader to hold it against Figma's own variant count — a check
7805
+ // run 17 proved an agent cannot perform, because it cannot see
7806
+ // the user's screen. So the pipeline must say when the number is
7807
+ // merely the selection it was handed.
7808
+ variantScope,
7809
+ ...variantScope === "selection" ? { variantCoverageUnknown: SELECTION_SCOPE_DISCLOSURE } : {},
7572
7810
  planMode: manifest.planMode ?? "sample",
7573
7811
  ...metadataTruncated ? { metadataTruncated: true } : {},
7574
7812
  ...toppedUp !== void 0 ? { toppedUp } : {},
@@ -7673,7 +7911,19 @@ function nextPayload(setDir) {
7673
7911
  const frameNode = Object.keys(manifest.sourceFrames ?? {})[0] ?? manifest.reps[0]?.nodeId ?? "";
7674
7912
  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
7913
  }
7676
- return instruction === null ? { complete: true, progress } : { ...instruction, note: `${instruction.note}. ${ENVELOPE_HELP}`, progress };
7914
+ return instruction === null ? { complete: true, progress } : {
7915
+ ...instruction,
7916
+ note: `${instruction.note}. ${ENVELOPE_HELP}`,
7917
+ // RESUME POINTER, NOT A WORK ASSIGNMENT. This is the globally
7918
+ // first unrecorded rep, so under parallel recorders it hands the
7919
+ // same rep to everyone: run 18 measured 8 of 10 ingests pointing
7920
+ // a recorder at `anchor`, which was another agent's job. Correct
7921
+ // for picking a set back up, wrong as a loop driver, and it was
7922
+ // labelled as neither.
7923
+ scope: "resume-pointer",
7924
+ 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.",
7925
+ progress
7926
+ };
7677
7927
  }
7678
7928
  function runRecordNext(opts) {
7679
7929
  const payload = nextPayload(opts.setDir);
@@ -8081,7 +8331,7 @@ function runRecordFinish(opts) {
8081
8331
  `);
8082
8332
  }
8083
8333
  }
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;
8334
+ 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
8335
  var init_record = __esm({
8086
8336
  "packages/cli/src/commands/record.ts"() {
8087
8337
  "use strict";
@@ -8094,6 +8344,7 @@ var init_record = __esm({
8094
8344
  FIGMA_CALLS_PER_REP = 3;
8095
8345
  FIGMA_CALLS_PER_REP_WORST = 4;
8096
8346
  MULTI_DAY_CALL_THRESHOLD = 600;
8347
+ 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
8348
  LIMIT_HINT = {
8098
8349
  status: "UNVERIFIED \u2014 recorded 2026-08-14 from Figma's published documentation and one field report; Figma changes these at will.",
8099
8350
  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 +8943,13 @@ function authorComponentApi(opts) {
8692
8943
  const provided = opts.fonts ?? [];
8693
8944
  const recorded = opts.recordedFonts ?? [];
8694
8945
  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` : ""}. ` : "");
8946
+ const faceGaps = (opts.providedFaces ?? []).map((f) => {
8947
+ const need = (opts.recordedFaces ?? []).find((r) => r.family.toLowerCase() === f.family.toLowerCase());
8948
+ const missing = (need?.weights ?? []).filter((w) => !f.weights.includes(w));
8949
+ return { family: f.family, has: f.weights, missing };
8950
+ }).filter((f) => f.missing.length > 0);
8951
+ 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. ` : "") : "";
8952
+ 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
8953
  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
8954
  const systemApi = `Prescribed API (the harness mounts exactly this; deviation scores 0):
8698
8955
  export function ${componentIdent}(props: {
@@ -8992,8 +9249,8 @@ function recordedTextSlots(setDir, repSlugs) {
8992
9249
  }
8993
9250
  function authorTaskFromSet(setDir, opts = {}) {
8994
9251
  if (opts.fonts === void 0) {
8995
- const resolved = resolvedFontFamilies().map((f) => f.family);
8996
- if (resolved.length > 0) opts = { ...opts, fonts: resolved };
9252
+ const resolvedFaces = resolvedFontFamilies();
9253
+ if (resolvedFaces.length > 0) opts = { ...opts, fonts: resolvedFaces.map((f) => f.family), providedFaces: resolvedFaces.map((f) => ({ family: f.family, weights: [...f.weights] })) };
8997
9254
  }
8998
9255
  const manifest = loadManifest(setDir);
8999
9256
  const poses = [];
@@ -9025,6 +9282,15 @@ function authorTaskFromSet(setDir, opts = {}) {
9025
9282
  poses,
9026
9283
  ...latticeNames !== void 0 ? { latticeNames } : {},
9027
9284
  ...opts.fonts !== void 0 ? { fonts: opts.fonts } : {},
9285
+ ...opts.providedFaces !== void 0 ? { providedFaces: opts.providedFaces } : {},
9286
+ ...(() => {
9287
+ try {
9288
+ const needs = recordedFontNeeds(setDir);
9289
+ return needs.length > 0 ? { recordedFaces: needs } : {};
9290
+ } catch {
9291
+ return {};
9292
+ }
9293
+ })(),
9028
9294
  ...recordedFonts.length > 0 ? { recordedFonts } : {},
9029
9295
  ...Object.keys(defaults).length > 0 ? { defaults } : {},
9030
9296
  ...textSlots.length > 0 ? { textSlots } : {},
@@ -9522,7 +9788,9 @@ function countLatticeSymbols(setDir) {
9522
9788
  const manifestFile = path30.join(setDir, "recording-set.json");
9523
9789
  if (existsSync24(manifestFile)) {
9524
9790
  try {
9525
- const lattice = JSON.parse(readFileSync20(manifestFile, "utf8")).latticeNames;
9791
+ const stored = JSON.parse(readFileSync20(manifestFile, "utf8"));
9792
+ if (stored.variantScope !== "component-set") return null;
9793
+ const lattice = stored.latticeNames;
9526
9794
  if (lattice !== void 0 && lattice.length > 0) return lattice.length;
9527
9795
  } catch {
9528
9796
  }
@@ -10091,10 +10359,76 @@ var init_src7 = __esm({
10091
10359
  }
10092
10360
  });
10093
10361
 
10362
+ // packages/cli/src/font-guidance.ts
10363
+ import path31 from "node:path";
10364
+ function fontsUnprovenRemediation(setDir) {
10365
+ const set = setDir === void 0 ? void 0 : path31.resolve(setDir);
10366
+ if (set !== void 0) {
10367
+ try {
10368
+ const needs = recordedFontNeeds(set);
10369
+ if (needs.length > 0) {
10370
+ const families = needs.map((n) => `"${n.family}"`).join(", ");
10371
+ return `Run \`${tendrilCommand(`fonts resolve --set ${set}`)}\` \u2014 the recording declares ${families}; faces land in ${DEFAULT_FONT_CACHE}.`;
10372
+ }
10373
+ } catch {
10374
+ }
10375
+ }
10376
+ const target = set ?? "<recording-dir>";
10377
+ 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}.`;
10378
+ }
10379
+ function taskFontFamilies(setDir) {
10380
+ try {
10381
+ const families = recordedFontFamilies(setDir);
10382
+ return families.length > 0 ? families : null;
10383
+ } catch {
10384
+ return null;
10385
+ }
10386
+ }
10387
+ function unprovisionedFamilies(setDir, cacheDir) {
10388
+ const declared = taskFontFamilies(setDir);
10389
+ if (declared === null) return [];
10390
+ const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
10391
+ return declared.filter((f) => !provided.has(f.toLowerCase()));
10392
+ }
10393
+ function unprovisionedFaces(setDir, cacheDir) {
10394
+ return [
10395
+ ...unprovisionedFamilies(setDir, cacheDir),
10396
+ ...missingWeights(setDir, cacheDir).map((g) => {
10397
+ const missing = g.declared.filter((w) => !g.provided.includes(w));
10398
+ return `${g.family} (${missing.length === 1 ? "weight" : "weights"} ${missing.join(", ")})`;
10399
+ })
10400
+ ];
10401
+ }
10402
+ function missingWeights(setDir, cacheDir) {
10403
+ let needs;
10404
+ try {
10405
+ needs = recordedFontNeeds(setDir, { pairedOnly: true });
10406
+ } catch {
10407
+ return [];
10408
+ }
10409
+ const provided = new Map(verifiedFontFamilies(cacheDir).map((f) => [f.family.toLowerCase(), f.weights]));
10410
+ const gaps = [];
10411
+ for (const n of needs) {
10412
+ const have = provided.get(n.family.toLowerCase());
10413
+ if (have === void 0) continue;
10414
+ if (n.weights.some((w) => !have.includes(w))) gaps.push({ family: n.family, declared: n.weights, provided: have });
10415
+ }
10416
+ return gaps;
10417
+ }
10418
+ var init_font_guidance = __esm({
10419
+ "packages/cli/src/font-guidance.ts"() {
10420
+ "use strict";
10421
+ init_src4();
10422
+ init_src7();
10423
+ init_invocation();
10424
+ }
10425
+ });
10426
+
10094
10427
  // packages/cli/src/commands/fonts.ts
10095
10428
  var fonts_exports = {};
10096
10429
  __export(fonts_exports, {
10097
10430
  DEFAULT_FONT_CACHE: () => DEFAULT_FONT_CACHE,
10431
+ familyMismatch: () => familyMismatch,
10098
10432
  runFontsAdd: () => runFontsAdd,
10099
10433
  runFontsRequired: () => runFontsRequired,
10100
10434
  runFontsResolve: () => runFontsResolve,
@@ -10102,7 +10436,7 @@ __export(fonts_exports, {
10102
10436
  runFontsStatus: () => runFontsStatus
10103
10437
  });
10104
10438
  import { existsSync as existsSync25, readFileSync as readFileSync21 } from "node:fs";
10105
- import path31 from "node:path";
10439
+ import path32 from "node:path";
10106
10440
  async function runFontsResolve(opts) {
10107
10441
  const result = await resolveFonts(opts.family, opts.weights, opts.cacheDir);
10108
10442
  emitData(opts, result, () => {
@@ -10117,7 +10451,7 @@ async function runFontsResolve(opts) {
10117
10451
  }
10118
10452
  }
10119
10453
  async function runFontsResolveSet(opts) {
10120
- const setDir = path31.resolve(process.env["INIT_CWD"] ?? process.cwd(), opts.set);
10454
+ const setDir = path32.resolve(process.env["INIT_CWD"] ?? process.cwd(), opts.set);
10121
10455
  let needs = [];
10122
10456
  try {
10123
10457
  needs = recordedFontNeeds(setDir);
@@ -10190,7 +10524,7 @@ async function runFontsResolveSet(opts) {
10190
10524
  }
10191
10525
  }
10192
10526
  function runFontsStatus(opts) {
10193
- const manifestPath2 = path31.join(opts.cacheDir, "manifest.json");
10527
+ const manifestPath2 = path32.join(opts.cacheDir, "manifest.json");
10194
10528
  if (!existsSync25(manifestPath2)) {
10195
10529
  fail(opts, ExitCode.FontsUnproven, {
10196
10530
  error: `no font cache at ${opts.cacheDir}`,
@@ -10199,7 +10533,7 @@ function runFontsStatus(opts) {
10199
10533
  });
10200
10534
  }
10201
10535
  const faces = JSON.parse(readFileSync21(manifestPath2, "utf8"));
10202
- const lockVerdicts = opts.lock !== void 0 ? checkFontLock(path31.resolve(opts.lock), opts.cacheDir) : null;
10536
+ const lockVerdicts = opts.lock !== void 0 ? checkFontLock(path32.resolve(opts.lock), opts.cacheDir) : null;
10203
10537
  emitData(opts, { faces, lockVerdicts }, () => {
10204
10538
  for (const f of faces) process.stdout.write(`cached ${f.family} ${f.weight} (${f.sha256.slice(0, 12)}\u2026)
10205
10539
  `);
@@ -10222,10 +10556,45 @@ function runFontsRequired(opts) {
10222
10556
  });
10223
10557
  if (missing.length > 0) process.exit(ExitCode.FontsUnproven);
10224
10558
  }
10559
+ function editDistance(a, b) {
10560
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
10561
+ const row = new Array(b.length + 1).fill(0);
10562
+ for (let i = 1; i <= a.length; i++) {
10563
+ row[0] = i;
10564
+ for (let j = 1; j <= b.length; j++) {
10565
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
10566
+ }
10567
+ for (let j = 0; j <= b.length; j++) prev[j] = row[j];
10568
+ }
10569
+ return prev[b.length];
10570
+ }
10571
+ function familyMismatch(family, declared) {
10572
+ if (declared.length === 0) return void 0;
10573
+ if (declared.some((d) => d.toLowerCase() === family.toLowerCase())) return void 0;
10574
+ const ranked = declared.map((d) => ({ d, score: editDistance(family.toLowerCase(), d.toLowerCase()) })).sort((a, b) => a.score - b.score);
10575
+ const best = ranked[0];
10576
+ return best.score <= Math.max(3, Math.floor(family.length / 3)) ? { nearest: best.d } : {};
10577
+ }
10225
10578
  function runFontsAdd(opts) {
10579
+ if (opts.set !== void 0) {
10580
+ const declared = taskFontFamilies(path32.resolve(opts.set)) ?? [];
10581
+ const mismatch = familyMismatch(opts.family, declared);
10582
+ if (mismatch !== void 0) {
10583
+ fail(opts, ExitCode.InputValidation, {
10584
+ 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.`,
10585
+ code: "font-family-not-declared",
10586
+ 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.`
10587
+ });
10588
+ }
10589
+ } else {
10590
+ warn(
10591
+ opts,
10592
+ `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`
10593
+ );
10594
+ }
10226
10595
  let face;
10227
10596
  try {
10228
- face = addLocalFont(opts.family, opts.weight, opts.file, opts.cacheDir);
10597
+ face = addLocalFont(opts.family, opts.weight, opts.file, opts.cacheDir, opts.face);
10229
10598
  } catch (err) {
10230
10599
  fail(opts, ExitCode.InputValidation, {
10231
10600
  error: err instanceof Error ? err.message : String(err),
@@ -10248,71 +10617,7 @@ var init_fonts = __esm({
10248
10617
  init_src4();
10249
10618
  init_src7();
10250
10619
  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();
10620
+ init_font_guidance();
10316
10621
  init_invocation();
10317
10622
  }
10318
10623
  });
@@ -10328,6 +10633,7 @@ __export(verify_exports, {
10328
10633
  failureTally: () => failureTally,
10329
10634
  foldConfigStatus: () => foldConfigStatus,
10330
10635
  interactionCoverage: () => interactionCoverage,
10636
+ latticeCoverage: () => latticeCoverage,
10331
10637
  occlusionReport: () => occlusionReport,
10332
10638
  operabilityLine: () => operabilityLine,
10333
10639
  operabilityReport: () => operabilityReport,
@@ -10425,6 +10731,21 @@ function checkSummarySegments(input) {
10425
10731
  const operability = "short" in input.operability ? `operability UNVERIFIED (${input.operability.short})` : `operability ${input.operability.passed}/${input.operability.checks} interaction`;
10426
10732
  return ` \xB7 ${operability} \xB7 ${composition} \xB7 ${occlusion}`;
10427
10733
  }
10734
+ function latticeCoverage(setManifest, scoredConfigs) {
10735
+ const lattice = setManifest.latticeNames?.length;
10736
+ const established = setManifest.variantScope === "component-set";
10737
+ const notRecorded = setManifest.notRecorded !== void 0 && setManifest.notRecorded !== "" ? { notRecorded: setManifest.notRecorded } : {};
10738
+ if (lattice === void 0) return { latticeConfigs: null, ...notRecorded };
10739
+ if (established) return { latticeConfigs: lattice, unrecordedConfigs: Math.max(0, lattice - scoredConfigs), ...notRecorded };
10740
+ return {
10741
+ latticeConfigs: null,
10742
+ // The count is still worth reporting — it is just not a
10743
+ // denominator, and the name has to stop implying that it is.
10744
+ variantsPlanned: lattice,
10745
+ 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",
10746
+ ...notRecorded
10747
+ };
10748
+ }
10428
10749
  function occlusionReport(occlusion) {
10429
10750
  if (occlusion.length === 0) {
10430
10751
  return {
@@ -10685,12 +11006,7 @@ async function runVerify(opts) {
10685
11006
  // itself — not only in a long-gone plan output.
10686
11007
  ...(() => {
10687
11008
  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
- };
11009
+ return latticeCoverage(loadManifest(task.set), statuses.length);
10694
11010
  } catch {
10695
11011
  return { latticeConfigs: null };
10696
11012
  }
@@ -10991,6 +11307,7 @@ ${notRecorded}` : "";
10991
11307
  === TASK PAYLOAD (recorded truth, verbatim) ===
10992
11308
  ${segments}`;
10993
11309
  const payloadFile = path34.resolve(callerCwd, opts.out ?? `tendril-out/${name}-brief.md`);
11310
+ const candidateDirSuggestion = path34.resolve(callerCwd, `tendril-out/${name}-candidate`);
10994
11311
  mkdirSync8(path34.dirname(payloadFile), { recursive: true });
10995
11312
  writeFileSync12(payloadFile, payload);
10996
11313
  emitData(
@@ -11019,8 +11336,16 @@ ${segments}`;
11019
11336
  protocol: [
11020
11337
  "Settle the proposer model (see modelSelection \u2014 ask the user when one is present), then:",
11021
11338
  `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.`,
11339
+ `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.`,
11340
+ // Both paths quoted, and the candidate directory NAMED rather
11341
+ // than left as `<candidateDir>`: run 18 worked in a folder
11342
+ // called "Test 2", where the unquoted form split into two
11343
+ // arguments, and a placeholder is the other half of the same
11344
+ // defect — a command the reader has to finish is one they can
11345
+ // finish wrongly.
11346
+ `Run \`${tendrilCommand(
11347
+ `engine score ${quoteArg(ref)} ${quoteArg(candidateDirSuggestion)} --bar ${opts.bar} --model ${opts.model ?? "<declared-model>"} --json`
11348
+ )}\` \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
11349
  "Apply the returned feedback and re-score. Stop when all checks pass or two consecutive scores fail to improve.",
11025
11350
  "Never edit recording sets; never claim scores yourself \u2014 only the score command's output counts."
11026
11351
  ]
@@ -11144,7 +11469,21 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
11144
11469
  {
11145
11470
  task: name,
11146
11471
  bar: opts.bar,
11147
- objective: { passCount: obj[0], total, floor: obj[1], mean: obj[2] },
11472
+ // `total` is pixel configs PLUS behaviour checks, and nothing said
11473
+ // so: run 18's operator relayed "6/16" for two rounds without
11474
+ // knowing it meant ZERO configs passing — materially worse than it
11475
+ // sounds, and the number a human decides whether to keep paying on.
11476
+ objective: {
11477
+ passCount: obj[0],
11478
+ total,
11479
+ pixelConfigs: scores.length,
11480
+ pixelConfigsPassing: scores.filter((s) => s.pass).length,
11481
+ behaviorChecks: behaviors.length,
11482
+ behaviorChecksPassing: behaviors.filter((b) => b.pass).length,
11483
+ 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`,
11484
+ floor: obj[1],
11485
+ mean: obj[2]
11486
+ },
11148
11487
  scores,
11149
11488
  behaviors,
11150
11489
  feedback,
@@ -13278,11 +13617,19 @@ function buildProgram() {
13278
13617
  await runFontsResolve2({ ...flags, family, weights: local["weights"].map(Number), cacheDir });
13279
13618
  }
13280
13619
  });
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) => {
13620
+ 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
13621
  const flags = globalFlags(cmd.parent.parent);
13283
13622
  const local = cmd.opts();
13284
13623
  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 });
13624
+ runFontsAdd2({
13625
+ ...flags,
13626
+ family,
13627
+ weight: Number(weight),
13628
+ file,
13629
+ cacheDir: local["cache"] ?? DEFAULT_FONT_CACHE,
13630
+ ...local["set"] !== void 0 ? { set: local["set"] } : {},
13631
+ ...local["face"] !== void 0 ? { face: Number(local["face"]) } : {}
13632
+ });
13286
13633
  });
13287
13634
  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
13635
  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.27",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",