@tendrilapp/cli 0.1.38 → 0.1.40

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
@@ -198,7 +198,10 @@ Batch runs (several components in one session):
198
198
  real faces is the only path to certification.
199
199
  3. Record each planned rep with FOUR tool calls: make the THREE
200
200
  Figma MCP calls in protocol order — get_metadata, then
201
- get_design_context (excludeScreenshot=true), then get_screenshot
201
+ get_design_context (excludeScreenshot=true), then get_screenshot
202
+ (ALWAYS with contentsOnly: true and maxDimension: 4096 — without
203
+ isolation the export bakes in the editor's component-set chrome,
204
+ which scores as ink no implementation can paint) —
202
205
  and then ONE `tendril_record_ingest_rep` carrying all three:
203
206
  metadata and context response text passed VERBATIM — responses
204
207
  often arrive as MULTIPLE output blocks (block counts vary BY
@@ -112,7 +112,7 @@ var TOOLS = [
112
112
  },
113
113
  {
114
114
  name: "tendril_record_ingest_rep",
115
- description: "Ingest a rep's ENTIRE recording in ONE call \u2014 the get_metadata response, the get_design_context response, and the get_screenshot image_url together. Make the three Figma calls first, in protocol order (get_metadata, then get_design_context with excludeScreenshot=true, then get_screenshot), then pass all three here VERBATIM. PREFER THIS over three separate ingest/fetch calls: one approvable operation per rep instead of three. Pieces land independently: on a partial failure the error names exactly which piece(s) to re-record \u2014 the rest are already on disk. The response carries `next` and, for design context, `assets` (auto-fetched server-side; only listed failures need record_asset).",
115
+ description: "Ingest a rep's ENTIRE recording in ONE call \u2014 the get_metadata response, the get_design_context response, and the get_screenshot image_url together. Make the three Figma calls first, in protocol order (get_metadata, then get_design_context with excludeScreenshot=true, then get_screenshot with contentsOnly=true and maxDimension=4096 \u2014 isolation keeps the editor's component-set chrome out of the padded export, where it would otherwise score as unpaintable reference ink), then pass all three here VERBATIM. PREFER THIS over three separate ingest/fetch calls: one approvable operation per rep instead of three. Pieces land independently: on a partial failure the error names exactly which piece(s) to re-record \u2014 the rest are already on disk. The response carries `next` and, for design context, `assets` (auto-fetched server-side; only listed failures need record_asset).",
116
116
  schema: z.object({
117
117
  setDir: str("recording set directory"),
118
118
  rep: str("planned rep slug"),
package/dist/tendril.js CHANGED
@@ -1203,6 +1203,33 @@ function containedPath(setDir, ...segments) {
1203
1203
  }
1204
1204
  return target;
1205
1205
  }
1206
+ function clampedReference(setDir, slug, payload) {
1207
+ const metaFile = containedPath(setDir, slug, "get_metadata.json");
1208
+ if (!existsSync(metaFile)) return void 0;
1209
+ let box;
1210
+ try {
1211
+ const m = /width="([\d.]+)"\s+height="([\d.]+)"/.exec(envelopeTextContent(JSON.parse(readFileSync(metaFile, "utf8"))));
1212
+ if (m !== null) box = { w: Math.round(Number(m[1])), h: Math.round(Number(m[2])) };
1213
+ } catch {
1214
+ return void 0;
1215
+ }
1216
+ if (box === void 0 || box.w === 0 || box.h === 0) return void 0;
1217
+ const dims = referencePngDims(payload);
1218
+ if (dims === void 0) return void 0;
1219
+ if (dims.w >= box.w - 1 && dims.h >= box.h - 1) return void 0;
1220
+ return `the reference image is ${dims.w}\xD7${dims.h} but the recorded box is ${box.w}\xD7${box.h} \u2014 it was SCALED DOWN, so it is not pixel ground truth. Re-run get_screenshot passing contentsOnly: true and maxDimension: 4096 (the tool defaults to 1024 and clamps anything longer; a flat generous cap is always at least the ${Math.max(box.w, box.h)} this box needs, and contentsOnly keeps the editor's component-set chrome out of the padding)`;
1221
+ }
1222
+ function referencePngDims(payload) {
1223
+ const parts = payload?.content;
1224
+ if (!Array.isArray(parts)) return void 0;
1225
+ for (const p of parts) {
1226
+ if (p?.type !== "image" || typeof p.data !== "string") continue;
1227
+ const buf = Buffer.from(p.data, "base64");
1228
+ if (buf.length < 24) return void 0;
1229
+ return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
1230
+ }
1231
+ return void 0;
1232
+ }
1206
1233
  function ingestEnvelope(setDir, slug, tool, payload) {
1207
1234
  const manifest = loadManifest(setDir);
1208
1235
  if (!manifest.reps.some((r) => r.slug === slug)) throw new Error(`unknown rep "${slug}" \u2014 not in the planned manifest`);
@@ -1218,6 +1245,10 @@ function ingestEnvelope(setDir, slug, tool, payload) {
1218
1245
  if (!parsed.success) {
1219
1246
  throw new Error(`${slug}/${tool} was NOT recorded \u2014 envelope rejected at the boundary: ${parsed.error.issues[0]?.message ?? "invalid"}. ${REINGEST_GUIDANCE}`);
1220
1247
  }
1248
+ const clamped = tool === "get_screenshot" ? clampedReference(setDir, slug, payload) : void 0;
1249
+ if (clamped !== void 0) {
1250
+ throw new Error(`${slug}/${tool} was NOT recorded \u2014 ${clamped}. ${REINGEST_GUIDANCE}`);
1251
+ }
1221
1252
  const file = containedPath(setDir, slug, `${tool}.json`);
1222
1253
  mkdirSync(path.dirname(file), { recursive: true });
1223
1254
  const overwrote = existsSync(file);
@@ -3277,6 +3308,7 @@ function absentInkClusters(render, reference, background = [255, 255, 255]) {
3277
3308
  let x1 = sx;
3278
3309
  let y1 = sy;
3279
3310
  let px = 0;
3311
+ let chromePx = 0;
3280
3312
  const stack = [si];
3281
3313
  seen[si] = 1;
3282
3314
  while (stack.length > 0) {
@@ -3284,6 +3316,8 @@ function absentInkClusters(render, reference, background = [255, 255, 255]) {
3284
3316
  const cx = i % width;
3285
3317
  const cy = (i - cx) / width;
3286
3318
  px += 1;
3319
+ const ci = i * 4;
3320
+ if (canvasB.data[ci] === FIGMA_CHROME[0] && canvasB.data[ci + 1] === FIGMA_CHROME[1] && canvasB.data[ci + 2] === FIGMA_CHROME[2]) chromePx += 1;
3287
3321
  if (cx < x0) x0 = cx;
3288
3322
  if (cy < y0) y0 = cy;
3289
3323
  if (cx > x1) x1 = cx;
@@ -3301,7 +3335,7 @@ function absentInkClusters(render, reference, background = [255, 255, 255]) {
3301
3335
  }
3302
3336
  }
3303
3337
  }
3304
- if (px >= ABSENT_MIN_PX) found.push({ x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1, px, memberX: sx, memberY: sy });
3338
+ if (px >= ABSENT_MIN_PX) found.push({ x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1, px, memberX: sx, memberY: sy, ...chromePx * 2 >= px ? { chrome: true } : {} });
3305
3339
  }
3306
3340
  }
3307
3341
  }
@@ -3514,7 +3548,7 @@ function localizeDifference(render, reference, masks = []) {
3514
3548
  const boxArea = (x1 - x0 + 1) * (y1 - y0 + 1);
3515
3549
  return { x0, y0, x1, y1, density: count / boxArea };
3516
3550
  }
3517
- function diffPng(render, reference, masks = []) {
3551
+ function diffPng(render, reference, masks = [], background = [255, 255, 255]) {
3518
3552
  const a = PNG.sync.read(Buffer.from(render));
3519
3553
  const b = PNG.sync.read(Buffer.from(reference));
3520
3554
  const width = Math.max(a.width, b.width);
@@ -3527,6 +3561,29 @@ function diffPng(render, reference, masks = []) {
3527
3561
  }
3528
3562
  const out = new PNG({ width, height });
3529
3563
  pixelmatch(canvasA.data, canvasB.data, out.data, width, height, { threshold: 0.15 });
3564
+ const inkAt = (data, index) => isInk(data, index * 4, background);
3565
+ for (let y = 0; y < height; y++) {
3566
+ for (let x = 0; x < width; x++) {
3567
+ const i = width * y + x;
3568
+ const o = i * 4;
3569
+ if (!(out.data[o] === 255 && out.data[o + 1] === 0 && out.data[o + 2] === 0)) continue;
3570
+ const refInk = inkAt(canvasB.data, i);
3571
+ const renInk = inkAt(canvasA.data, i);
3572
+ let covered = false;
3573
+ for (let dy = -1; dy <= 1 && !covered; dy++) {
3574
+ for (let dx = -1; dx <= 1 && !covered; dx++) {
3575
+ const nx = x + dx;
3576
+ const ny = y + dy;
3577
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
3578
+ if (inkAt(canvasA.data, width * ny + nx)) covered = true;
3579
+ }
3580
+ }
3581
+ const colour = refInk && !renInk ? covered ? DIFF_LEGEND.shift : DIFF_LEGEND.absent : !refInk && renInk ? DIFF_LEGEND.extra : DIFF_LEGEND.recolor;
3582
+ out.data[o] = colour[0];
3583
+ out.data[o + 1] = colour[1];
3584
+ out.data[o + 2] = colour[2];
3585
+ }
3586
+ }
3530
3587
  return new Uint8Array(PNG.sync.write(out));
3531
3588
  }
3532
3589
  function cropPng(png, x, y, width, height) {
@@ -3550,13 +3607,65 @@ function cropPng(png, x, y, width, height) {
3550
3607
  }
3551
3608
  return new Uint8Array(PNG.sync.write(out));
3552
3609
  }
3553
- var INK_DELTA, ABSENT_RADIUS, ABSENT_MIN_PX;
3610
+ var INK_DELTA, FIGMA_CHROME, ABSENT_RADIUS, ABSENT_MIN_PX, DIFF_LEGEND, DIFF_LEGEND_TEXT;
3554
3611
  var init_image_diff = __esm({
3555
3612
  "packages/verify/src/image-diff.ts"() {
3556
3613
  "use strict";
3557
3614
  INK_DELTA = 30;
3615
+ FIGMA_CHROME = [138, 56, 245];
3558
3616
  ABSENT_RADIUS = 3;
3559
3617
  ABSENT_MIN_PX = 6;
3618
+ DIFF_LEGEND = {
3619
+ /** Reference ink with no render ink within ±1px. THIS is the ink
3620
+ * deficit — the only category that moves inkRecall. Red. */
3621
+ absent: [255, 0, 0],
3622
+ /** Ink in both, different colour. Moves similarity only. Amber. */
3623
+ recolor: [255, 170, 0],
3624
+ /** Render painted ink where the reference records none. Blue. */
3625
+ extra: [0, 120, 255],
3626
+ /** Reference ink covered within the metric's ±1px neighbourhood —
3627
+ * ALREADY FORGIVEN by inkRecall. Dimmed grey, so it reads as tolerated
3628
+ * rather than as a defect to chase (measured at only 3.0% of red, but
3629
+ * chasing it is exactly what the nudge saga was). */
3630
+ shift: [150, 150, 150],
3631
+ /** pixelmatch's OWN anti-aliasing colour, passed through unrepainted.
3632
+ * `includeAA` is false, so these pixels are painted but NOT counted as
3633
+ * mismatches — they are in neither the similarity number nor the ink
3634
+ * deficit. Listed here because the first field run measured 343-380 of
3635
+ * them per config and the reader had a fourth colour with no key
3636
+ * (CompTest run, 2026-08-19). Do not repaint it: passing pixelmatch's
3637
+ * own marking through unchanged is what keeps "not counted" honest. */
3638
+ antialias: [255, 255, 0]
3639
+ };
3640
+ DIFF_LEGEND_TEXT = [
3641
+ "Tendril diff image legend \u2014 what each colour means.",
3642
+ "",
3643
+ "A verdict has TWO gates and this one image serves both, so the colour",
3644
+ "says WHICH number a pixel belongs to. Marked pixels are exactly the",
3645
+ "ones pixelmatch marks; only their colour carries the cause.",
3646
+ "",
3647
+ ` #ff0000 RED reference ink the render never painted, and no render`,
3648
+ ` ink within 1px. THE INK DEFICIT \u2014 the only category`,
3649
+ ` that moves inkRecall.`,
3650
+ ` #ffaa00 AMBER ink in BOTH images, different colour. Moves`,
3651
+ ` similarity. CANNOT move inkRecall.`,
3652
+ ` #0078ff BLUE render ink where the reference records none. Moves`,
3653
+ ` similarity. Cannot move inkRecall.`,
3654
+ ` #969696 GREY reference ink covered within 1px \u2014 a displacement`,
3655
+ ` inkRecall ALREADY FORGIVES. Not a defect to chase.`,
3656
+ ` #ffff00 YELLOW pixelmatch judged this anti-aliasing. Painted but`,
3657
+ ` NOT COUNTED: in neither the similarity number nor`,
3658
+ ` the ink deficit.`,
3659
+ "",
3660
+ "Raw marked-pixel count is a poor guide to cause: a field run measured a",
3661
+ "config where BLUE dominated 1797 to 270 while only the 270 red pixels",
3662
+ "demoted it.",
3663
+ "",
3664
+ "If red clusters sit OUTSIDE the recorded node box, suspect the",
3665
+ "reference rather than the code \u2014 Figma pads exports to contain outer",
3666
+ "effects, and the pad can capture the editor's own component-set chrome.",
3667
+ ""
3668
+ ].join("\n");
3560
3669
  }
3561
3670
  });
3562
3671
 
@@ -6841,7 +6950,10 @@ function repEffectExtents(set, rep) {
6841
6950
  }
6842
6951
  }
6843
6952
  async function scoreBundleForTask(task, bundleDir, bar = BAR, opts = {}) {
6844
- if (opts.evidenceDir !== void 0) mkdirSync3(opts.evidenceDir, { recursive: true });
6953
+ if (opts.evidenceDir !== void 0) {
6954
+ mkdirSync3(opts.evidenceDir, { recursive: true });
6955
+ writeFileSync6(path20.join(opts.evidenceDir, "diff-legend.txt"), DIFF_LEGEND_TEXT);
6956
+ }
6845
6957
  const CONFIGS2 = task.configs;
6846
6958
  const timeoutMs = opts.mountDeadlineMs ?? ENGINE_MOUNT_DEADLINE_MS;
6847
6959
  const entryTsx = path20.join(bundleDir, task.entry);
@@ -6944,13 +7056,14 @@ body{margin:0;background:rgb(${backdrop.join(",")})}
6944
7056
  const pass = r.similarity >= bar.sim && r.inkRecall >= bar.ink && !slab;
6945
7057
  const region = pass ? void 0 : localizeDifference(scored, ref);
6946
7058
  const absent = absentInkClusters(scored, ref, backdrop).map((c) => {
7059
+ if (c.chrome === true) return c;
6947
7060
  const name = deepestNodeNameAt(task.set, cfg.rep, c.memberX - seat.x, c.memberY - seat.y);
6948
7061
  return name === void 0 ? c : { ...c, name };
6949
7062
  });
6950
7063
  if (opts.evidenceDir !== void 0) {
6951
7064
  writeFileSync6(path20.join(opts.evidenceDir, `${cfg.rep}-render.png`), scored);
6952
7065
  writeFileSync6(path20.join(opts.evidenceDir, `${cfg.rep}-ref.png`), ref);
6953
- writeFileSync6(path20.join(opts.evidenceDir, `${cfg.rep}-diff.png`), diffPng(scored, ref));
7066
+ writeFileSync6(path20.join(opts.evidenceDir, `${cfg.rep}-diff.png`), diffPng(scored, ref, [], backdrop));
6954
7067
  for (const [i, c] of absent.slice(0, 3).entries()) {
6955
7068
  writeFileSync6(path20.join(opts.evidenceDir, `${cfg.rep}-absent-${i}-ref.png`), zoomCrop(ref, c));
6956
7069
  writeFileSync6(path20.join(opts.evidenceDir, `${cfg.rep}-absent-${i}-render.png`), zoomCrop(scored, c));
@@ -14303,7 +14416,7 @@ var init_record = __esm({
14303
14416
  const shown = items.length <= MAX_SPOKEN_VALUES ? items : [...items.slice(0, MAX_SPOKEN_VALUES), `${items.length - MAX_SPOKEN_VALUES} more`];
14304
14417
  return shown.length <= 1 ? shown[0] ?? "" : `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]}`;
14305
14418
  };
14306
- ENVELOPE_HELP = `Envelope format: text tools save {"content":[{"type":"text","text":"<VERBATIM response text incl. any Currently-selected-nodes block>"}]}; get_screenshot: do NOT download the image yourself \u2014 pass its image_url to \`${tendrilCommand("record fetch")}\` (MCP: tendril_record_fetch), which pulls the bytes to disk directly. That keeps the pixel ground truth out of your context and costs one approval instead of a shell command per asset. Assets are asset-<first-8-hex-of-figma-uuid>.<ext>; their URLs appear as const declarations inside the design-context text and also expire.`;
14419
+ ENVELOPE_HELP = `Envelope format: text tools save {"content":[{"type":"text","text":"<VERBATIM response text incl. any Currently-selected-nodes block>"}]}; VERBATIM MEANS EVERY PART, NOT JUST THE TEXT ONE: if a response carries additional content blocks \u2014 an image block especially \u2014 keep them in the envelope you save. get_design_context is documented to return a screenshot alongside the code, and whether it actually does is a question our whole recorded corpus cannot answer because every saved envelope holds text only. Saving what arrives settles it at no extra call. get_screenshot: pass contentsOnly: true AND maxDimension: 4096 \u2014 always, on every call, whatever the node's size. contentsOnly renders the node in isolation; without it the export is what the CANVAS looks like, which for a node inside a component set includes the editor's own component-set chrome (#8a38f5 dashes) baked into the padding. That chrome is reference ink no implementation can paint, so it lands as absent-ink clusters and demotes an otherwise perfect component: measured 2026-08-19, a Modal scored 0/3 certified whose every absent cluster was chrome, and the same render against a contentsOnly reference scored 3/3 (ink 0.9949 -> 0.9996). Isolation does NOT drop the outer effects we deliberately capture \u2014 the padded dimensions come back byte-identical (1276x199 and 1836x175, drop shadow intact). maxDimension is a CAP, not a target: a 56x28 node requested at a high cap comes back 56x28, never upscaled (measured across 24 poses, 2026-08-19). The tool DEFAULTS TO 1024 and clamps anything longer, which silently yields a downscaled reference that is not pixel ground truth, and ingest refuses that. Do NOT compute the cap from the node box: Figma PADS the export to contain outer effects, so the reference is routinely larger than the box it came from \u2014 a recorded 1800px-wide modal exported at 1836px, and a cap sized to the box would have been refused. A flat generous cap removes the arithmetic and the whole failure class. Then do NOT download the image yourself \u2014 pass its image_url to \`${tendrilCommand("record fetch")}\` (MCP: tendril_record_fetch), which pulls the bytes to disk directly. That keeps the pixel ground truth out of your context and costs one approval instead of a shell command per asset. Assets are asset-<first-8-hex-of-figma-uuid>.<ext>; their URLs appear as const declarations inside the design-context text and also expire.`;
14307
14420
  isAutoFetchAssetUrl = (url) => isFigmaAssetUrl(url) || isLocalAssetUrl(url);
14308
14421
  describeRoleLoss = (loss) => loss.kind === "main" ? `drops main "${loss.main}"` : `stops "${loss.part}" being a part of main "${loss.main}"`;
14309
14422
  }
@@ -14806,7 +14919,17 @@ function foldConfigStatus(s, failDemotions, substitutedFamilies) {
14806
14919
  if (status === "certified" && s.absentInk !== void 0 && s.absentInk.length > 0) {
14807
14920
  status = "pass";
14808
14921
  absentInkDemoted = true;
14809
- certDemote.push(...s.absentInk.map((c) => `absent ink: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with no render ink within reach (missing or invisible feature)`));
14922
+ certDemote.push(
14923
+ ...s.absentInk.map(
14924
+ (c) => (
14925
+ // A chrome cluster is a defect in the RECORDING, and saying
14926
+ // "missing or invisible feature" points the reader at code that
14927
+ // is not wrong — run 26's operator spent a round hunting a
14928
+ // layout bug that did not exist.
14929
+ c.chrome === true ? `absent ink: ${c.px}px of Figma's component-set CHROME baked into the reference \u2014 this is the editor's own outline, not your component, and no implementation can paint it. RE-RECORD this rep with get_screenshot contentsOnly: true (the outer effect survives isolation; only the chrome goes).` : `absent ink: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with no render ink within reach (missing or invisible feature)`
14930
+ )
14931
+ )
14932
+ );
14810
14933
  }
14811
14934
  if (status === "certified" && substitutedFamilies.length > 0) {
14812
14935
  status = "pass";
@@ -15544,7 +15667,7 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
15544
15667
  if (certBlockedByAbsentInk.length > 0) {
15545
15668
  warn(
15546
15669
  opts,
15547
- `${certBlockedByAbsentInk.length} config(s) demoted by absent-ink clusters (${certBlockedByAbsentInk.join(", ")}) \u2014 certification never ships a missing or invisible feature; each cluster is named in the report above. Fix the component and re-verify, or verify at --bar pass for the disclosed result.`
15670
+ `${certBlockedByAbsentInk.length} config(s) demoted by absent-ink clusters (${certBlockedByAbsentInk.join(", ")}) \u2014 certification never ships a missing or invisible feature; each cluster is named in the report above. Fix the component and re-verify, or verify at --bar pass for the disclosed result. BEFORE changing code, check WHERE the clusters sit: open the -absent-*-ref.png crops in the evidence dir. If the missing ink lies at the extreme EDGES of the reference rather than inside the component, it is probably not yours \u2014 Figma pads an export to contain outer effects (a shadow, a glow), and the pad can capture the editor's own component-set chrome, which is purple #8a38f5 and which no correct implementation can ever paint. Measured 2026-08-19: a shadowed modal was demoted 0/3 with EVERY absent cluster made of chrome, while the same kit's unshadowed button, exported with no pad, certified 24/24 \u2014 and re-recording with contentsOnly: true took the same code to 3/3. That is a recording artifact, not a defect in your component.`
15548
15671
  );
15549
15672
  process.exitCode = ExitCode.VerificationFailed;
15550
15673
  }
@@ -16381,7 +16504,7 @@ var init_server = __esm({
16381
16504
  },
16382
16505
  {
16383
16506
  name: "tendril_record_ingest_rep",
16384
- description: "Ingest a rep's ENTIRE recording in ONE call \u2014 the get_metadata response, the get_design_context response, and the get_screenshot image_url together. Make the three Figma calls first, in protocol order (get_metadata, then get_design_context with excludeScreenshot=true, then get_screenshot), then pass all three here VERBATIM. PREFER THIS over three separate ingest/fetch calls: one approvable operation per rep instead of three. Pieces land independently: on a partial failure the error names exactly which piece(s) to re-record \u2014 the rest are already on disk. The response carries `next` and, for design context, `assets` (auto-fetched server-side; only listed failures need record_asset).",
16507
+ description: "Ingest a rep's ENTIRE recording in ONE call \u2014 the get_metadata response, the get_design_context response, and the get_screenshot image_url together. Make the three Figma calls first, in protocol order (get_metadata, then get_design_context with excludeScreenshot=true, then get_screenshot with contentsOnly=true and maxDimension=4096 \u2014 isolation keeps the editor's component-set chrome out of the padded export, where it would otherwise score as unpaintable reference ink), then pass all three here VERBATIM. PREFER THIS over three separate ingest/fetch calls: one approvable operation per rep instead of three. Pieces land independently: on a partial failure the error names exactly which piece(s) to re-record \u2014 the rest are already on disk. The response carries `next` and, for design context, `assets` (auto-fetched server-side; only listed failures need record_asset).",
16385
16508
  schema: z14.object({
16386
16509
  setDir: str("recording set directory"),
16387
16510
  rep: str("planned rep slug"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",