@tendrilapp/cli 0.1.15 → 0.1.17

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
@@ -164,11 +164,24 @@ Non-negotiables (the CLI enforces these; do not fight them):
164
164
  6. Tell the user roughly what a run costs them: organism-scale
165
165
  components have measured 0.3–0.7M tokens of their plan.
166
166
 
167
- Delegated generation is SLOW BY NATURE — measured runs take 3–16
168
- minutes, and the one fully successful generator run did not write its
169
- first file until 14m24s. Do not treat silence as failure. Tell the
170
- user up front that a generator agent typically runs 5–15 minutes, and
171
- poll the candidate directory rather than guessing.
167
+ Delegated generation is SLOW BY NATURE — measured runs span 3–23
168
+ minutes (run 8: two healthy runs at ~23m each). Do not treat silence
169
+ as failure. Tell the user up front that a generator agent typically
170
+ runs 10–25 minutes, and poll the candidate directory rather than
171
+ guessing.
172
+
173
+ ESCALATION (when the balanced model plateaus below the bar): ask the
174
+ user before spending the premium tier; point the second generator at
175
+ the SAME candidate directory (never a fresh start — the work is
176
+ sunk); hand it a calibration table of failing vs passing configs and
177
+ the exact fields; never pass rebind. TRAP (measured, run 8): the
178
+ nearest PASSING config is not a safe regression tripwire — it may
179
+ carry the same defect and pass by luck (one config passed its bar by
180
+ 0.00089 while carrying the identical corner defect as the three
181
+ failures). An edit whose exact fields do not move across a round did
182
+ NOTHING: revert it before the next round — bundles must never
183
+ accumulate unsupported edits a later reader mistakes for recorded
184
+ truth.
172
185
 
173
186
  Stop a generator agent only on evidence of the real failure mode:
174
187
  reasoning runaway, where a turn ends on max_tokens having emitted a
@@ -368,7 +368,7 @@ var UPDATE_PROMPT = {
368
368
  "1. The MCP server updates ITSELF automatically at session start (npx @latest) \u2014 nothing to do for the pipeline.",
369
369
  "2. If a global CLI is installed: `npm install -g @tendrilapp/cli@latest`.",
370
370
  "3. If tendril_doctor reported the SERVER version stale this session: `npm cache clean --force`, then the user restarts the session so npx re-resolves @latest.",
371
- "4. The optional plugin is a thin shim (triggers, agents, commands) that rarely changes \u2014 usually nothing to do. When a plugin update IS announced (third-party marketplaces do not reliably auto-update): terminal Claude Code \u2014 `claude plugin marketplace update tendrilapp` FIRST (the update command compares against a cached clone and can falsely report already-latest), then `claude plugin update tendril`, then /reload-plugins; VS Code extension \u2014 the /plugins panel has no update button: uninstall tendril, reinstall from the marketplace, reopen the chat panel; Claude Desktop \u2014 no plugin management: quit and relaunch. Every critical surface already updated in step 1 regardless.",
371
+ "4. The optional plugin is a thin shim (triggers, agents, commands) that rarely changes \u2014 usually nothing to do. When a plugin update IS announced (third-party marketplaces do not reliably auto-update): terminal Claude Code \u2014 `claude plugin marketplace update tendrilapp` FIRST (the update command compares against a cached clone and can falsely report already-latest), then `claude plugin update tendril`, then /reload-plugins; VS Code extension \u2014 the /plugins panel has no update button: Marketplaces tab \u2192 REFRESH tendrilapp FIRST (a stale marketplace clone makes reinstall reinstall the old version \u2014 measured), then Plugins tab \u2192 uninstall tendril, reinstall, reopen the chat panel; if the refresh doesn't take, remove and re-add the marketplace. Claude Desktop \u2014 no plugin management: quit and relaunch. Every critical surface already updated in step 1 regardless.",
372
372
  "5. Confirm by running tendril_doctor and reporting its version line. Never claim success without that confirmation."
373
373
  ].join("\n")
374
374
  };
package/dist/tendril.js CHANGED
@@ -1974,8 +1974,9 @@ async function runTokenLint(css, fileLabel = "generated.css", definedVars2) {
1974
1974
  codeFilename: fileLabel,
1975
1975
  config: STYLELINT_CONFIG
1976
1976
  });
1977
+ const mapKnownEmpty = definedVars2 !== void 0 && definedVars2.size === 0;
1977
1978
  const violations = result.results.flatMap(
1978
- (r) => r.warnings.map((w) => ({
1979
+ (r) => r.warnings.filter((w) => !(mapKnownEmpty && w.rule === "scale-unlimited/declaration-strict-value")).map((w) => ({
1979
1980
  file: fileLabel,
1980
1981
  line: w.line,
1981
1982
  property: w.rule,
@@ -2332,6 +2333,23 @@ function detectBackdrop(png, opts = {}) {
2332
2333
  const { modal, consensus } = ringConsensus(png);
2333
2334
  if (consensus < 0.9) return [255, 255, 255];
2334
2335
  if (Math.min(...modal) < 200 && opts.trustDark !== true) return [255, 255, 255];
2336
+ const decoded = PNG.sync.read(Buffer.from(png));
2337
+ if (decoded.width >= 4 && decoded.height >= 4) {
2338
+ const blocks = cornerBlocks(decoded, 2);
2339
+ const first = blocks[0];
2340
+ const cornersAgree = blocks.every((c) => Math.abs(c[0] - first[0]) + Math.abs(c[1] - first[1]) + Math.abs(c[2] - first[2]) <= INK_DELTA);
2341
+ const cornersDisagreeWithRing = Math.abs(first[0] - modal[0]) + Math.abs(first[1] - modal[1]) + Math.abs(first[2] - modal[2]) > INK_DELTA;
2342
+ if (cornersAgree && cornersDisagreeWithRing) {
2343
+ const extremes = cornerBlocks(decoded, 1);
2344
+ const value = [
2345
+ Math.round(extremes.reduce((a, c) => a + c[0], 0) / 4),
2346
+ Math.round(extremes.reduce((a, c) => a + c[1], 0) / 4),
2347
+ Math.round(extremes.reduce((a, c) => a + c[2], 0) / 4)
2348
+ ];
2349
+ if (Math.min(...value) < 200 && opts.trustDark !== true) return [255, 255, 255];
2350
+ return value;
2351
+ }
2352
+ }
2335
2353
  return modal;
2336
2354
  }
2337
2355
  function pngDimensions(png) {
@@ -2375,6 +2393,111 @@ function cornersMatchCanvas(ref, canvas) {
2375
2393
  (c) => Math.abs(c[0] - canvas[0]) + Math.abs(c[1] - canvas[1]) + Math.abs(c[2] - canvas[2]) <= INK_DELTA
2376
2394
  );
2377
2395
  }
2396
+ function absentInkClusters(render, reference, background = [255, 255, 255]) {
2397
+ const a = PNG.sync.read(Buffer.from(render));
2398
+ const b = PNG.sync.read(Buffer.from(reference));
2399
+ const width = Math.max(a.width, b.width);
2400
+ const height = Math.max(a.height, b.height);
2401
+ const canvasA = onCanvas(a, width, height);
2402
+ const canvasB = onCanvas(b, width, height);
2403
+ const ring = ringModal(canvasB);
2404
+ const backgrounds = channelDistance(ring, background) > INK_DELTA ? [background, ring] : [background];
2405
+ const found = [];
2406
+ for (const bg of backgrounds) {
2407
+ const renderInk = new Uint8Array(width * height);
2408
+ for (let y = 0; y < height; y++) {
2409
+ for (let x = 0; x < width; x++) {
2410
+ if (isInk(canvasA.data, (width * y + x) * 4, bg)) renderInk[width * y + x] = 1;
2411
+ }
2412
+ }
2413
+ const candidate = new Uint8Array(width * height);
2414
+ for (let y = 0; y < height; y++) {
2415
+ for (let x = 0; x < width; x++) {
2416
+ if (!isInk(canvasB.data, (width * y + x) * 4, bg)) continue;
2417
+ let covered = false;
2418
+ scan: for (let dy = -ABSENT_RADIUS; dy <= ABSENT_RADIUS; dy++) {
2419
+ for (let dx = -ABSENT_RADIUS; dx <= ABSENT_RADIUS; dx++) {
2420
+ const nx = x + dx;
2421
+ const ny = y + dy;
2422
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
2423
+ if (renderInk[width * ny + nx] === 1) {
2424
+ covered = true;
2425
+ break scan;
2426
+ }
2427
+ }
2428
+ }
2429
+ if (!covered) candidate[width * y + x] = 1;
2430
+ }
2431
+ }
2432
+ const seen = new Uint8Array(width * height);
2433
+ for (let sy = 0; sy < height; sy++) {
2434
+ for (let sx = 0; sx < width; sx++) {
2435
+ const si = width * sy + sx;
2436
+ if (candidate[si] !== 1 || seen[si] === 1) continue;
2437
+ let x0 = sx;
2438
+ let y0 = sy;
2439
+ let x1 = sx;
2440
+ let y1 = sy;
2441
+ let px = 0;
2442
+ const stack = [si];
2443
+ seen[si] = 1;
2444
+ while (stack.length > 0) {
2445
+ const i = stack.pop();
2446
+ const cx = i % width;
2447
+ const cy = (i - cx) / width;
2448
+ px += 1;
2449
+ if (cx < x0) x0 = cx;
2450
+ if (cy < y0) y0 = cy;
2451
+ if (cx > x1) x1 = cx;
2452
+ if (cy > y1) y1 = cy;
2453
+ for (let dy = -1; dy <= 1; dy++) {
2454
+ for (let dx = -1; dx <= 1; dx++) {
2455
+ const nx = cx + dx;
2456
+ const ny = cy + dy;
2457
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
2458
+ const ni = width * ny + nx;
2459
+ if (candidate[ni] === 1 && seen[ni] === 0) {
2460
+ seen[ni] = 1;
2461
+ stack.push(ni);
2462
+ }
2463
+ }
2464
+ }
2465
+ }
2466
+ if (px >= ABSENT_MIN_PX) found.push({ x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1, px, memberX: sx, memberY: sy });
2467
+ }
2468
+ }
2469
+ }
2470
+ const merged = [];
2471
+ for (const c of found.sort((c1, c2) => c2.px - c1.px)) {
2472
+ const overlaps = merged.some((m) => c.x < m.x + m.w && m.x < c.x + c.w && c.y < m.y + m.h && m.y < c.y + c.h);
2473
+ if (!overlaps) merged.push(c);
2474
+ }
2475
+ return merged;
2476
+ }
2477
+ function zoomCrop(png, rect, scale = 8, pad = 6) {
2478
+ const src = PNG.sync.read(Buffer.from(png));
2479
+ const x0 = Math.max(0, rect.x - pad);
2480
+ const y0 = Math.max(0, rect.y - pad);
2481
+ const x1 = Math.min(src.width, rect.x + rect.w + pad);
2482
+ const y1 = Math.min(src.height, rect.y + rect.h + pad);
2483
+ const w = Math.max(1, x1 - x0);
2484
+ const h = Math.max(1, y1 - y0);
2485
+ const out = new PNG({ width: w * scale, height: h * scale });
2486
+ for (let y = 0; y < h * scale; y++) {
2487
+ for (let x = 0; x < w * scale; x++) {
2488
+ const sx = x0 + Math.floor(x / scale);
2489
+ const sy = y0 + Math.floor(y / scale);
2490
+ const si = (src.width * sy + sx) * 4;
2491
+ const di = (out.width * y + x) * 4;
2492
+ const alpha = src.data[si + 3] ?? 0;
2493
+ out.data[di] = Math.round(((src.data[si] ?? 0) * alpha + 255 * (255 - alpha)) / 255);
2494
+ out.data[di + 1] = Math.round(((src.data[si + 1] ?? 0) * alpha + 255 * (255 - alpha)) / 255);
2495
+ out.data[di + 2] = Math.round(((src.data[si + 2] ?? 0) * alpha + 255 * (255 - alpha)) / 255);
2496
+ out.data[di + 3] = 255;
2497
+ }
2498
+ }
2499
+ return Uint8Array.from(PNG.sync.write(out));
2500
+ }
2378
2501
  function measureCanvasCoupling(ref, renderA, renderB, backdrop, box) {
2379
2502
  const rRaw = PNG.sync.read(Buffer.from(ref));
2380
2503
  const r = onCanvas(rRaw, rRaw.width, rRaw.height);
@@ -2589,11 +2712,13 @@ function cropPng(png, x, y, width, height) {
2589
2712
  }
2590
2713
  return new Uint8Array(PNG.sync.write(out));
2591
2714
  }
2592
- var INK_DELTA;
2715
+ var INK_DELTA, ABSENT_RADIUS, ABSENT_MIN_PX;
2593
2716
  var init_image_diff = __esm({
2594
2717
  "packages/verify/src/image-diff.ts"() {
2595
2718
  "use strict";
2596
2719
  INK_DELTA = 30;
2720
+ ABSENT_RADIUS = 3;
2721
+ ABSENT_MIN_PX = 6;
2597
2722
  }
2598
2723
  });
2599
2724
 
@@ -4198,6 +4323,31 @@ function chooseSeat(cropAt, ref, padW, padH, backdrop, origin, extents) {
4198
4323
  }
4199
4324
  return { comparison, seat, scored: cropAt(origin - seat.x, origin - seat.y) };
4200
4325
  }
4326
+ function deepestNodeNameAt(set, rep, px, py) {
4327
+ let root;
4328
+ try {
4329
+ const text = JSON.parse(readFileSync8(path15.join(set, rep, "get_metadata.json"), "utf8")).content.map((c) => c.text ?? "").join("\n");
4330
+ root = parseMetadataStructure(text);
4331
+ } catch {
4332
+ return void 0;
4333
+ }
4334
+ let best;
4335
+ const walk2 = (node, ox, oy, isRoot) => {
4336
+ if (node.hidden === true) return;
4337
+ const nx = isRoot ? 0 : ox + (node.x ?? 0);
4338
+ const ny = isRoot ? 0 : oy + (node.y ?? 0);
4339
+ const w = node.width ?? 0;
4340
+ const h = node.height ?? 0;
4341
+ const contains = px >= nx && py >= ny && px < nx + w && py < ny + h;
4342
+ if (contains && !isRoot && node.name !== "") {
4343
+ const area = w * h;
4344
+ if (best === void 0 || area <= best.area) best = { name: `${node.name} (${node.id})`, area };
4345
+ }
4346
+ for (const child of node.children) walk2(child, nx, ny, false);
4347
+ };
4348
+ walk2(root, 0, 0, true);
4349
+ return best?.name;
4350
+ }
4201
4351
  function repMeta(set, rep) {
4202
4352
  const text = JSON.parse(readFileSync8(path15.join(set, rep, "get_metadata.json"), "utf8")).content.map((c) => c.text ?? "").join("\n");
4203
4353
  const root = parseMetadataStructure(text);
@@ -4318,10 +4468,18 @@ body{margin:0;background:rgb(${backdrop.join(",")})}
4318
4468
  const slab = realPadBoth && coupling.padCorners.masked >= 8 && coupling.padCorners.opaque / coupling.padCorners.masked >= 0.9;
4319
4469
  const pass = r.similarity >= bar.sim && r.inkRecall >= bar.ink && !slab;
4320
4470
  const region = pass ? void 0 : localizeDifference(scored, ref);
4471
+ const absent = absentInkClusters(scored, ref, backdrop).map((c) => {
4472
+ const name = deepestNodeNameAt(task.set, cfg.rep, c.memberX - seat.x, c.memberY - seat.y);
4473
+ return name === void 0 ? c : { ...c, name };
4474
+ });
4321
4475
  if (opts.evidenceDir !== void 0) {
4322
4476
  writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-render.png`), scored);
4323
4477
  writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-ref.png`), ref);
4324
4478
  writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-diff.png`), diffPng(scored, ref));
4479
+ for (const [i, c] of absent.slice(0, 3).entries()) {
4480
+ writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-absent-${i}-ref.png`), zoomCrop(ref, c));
4481
+ writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-absent-${i}-render.png`), zoomCrop(scored, c));
4482
+ }
4325
4483
  }
4326
4484
  return {
4327
4485
  rep: cfg.rep,
@@ -4332,6 +4490,7 @@ body{margin:0;background:rgb(${backdrop.join(",")})}
4332
4490
  ...slab ? { error: `paints the page canvas to the pad corners (${coupling.padCorners.opaque}/${coupling.padCorners.masked} opaque) \u2014 a square slab carrying the recorded canvas past the frame, wrong on any real page` } : {},
4333
4491
  ...region === void 0 ? {} : { region: { x0: region.x0, y0: region.y0, x1: region.x1, y1: region.y1, density: Math.round(region.density * 100) / 100 } },
4334
4492
  ...seat.x === 0 && seat.y === 0 ? {} : { seat },
4493
+ ...absent.length > 0 ? { absentInk: absent } : {},
4335
4494
  ...coupling.padMasked + coupling.inBoxMasked > 0 ? { canvasCoupling: coupling } : {}
4336
4495
  };
4337
4496
  } finally {
@@ -7616,8 +7775,8 @@ function authorComponentApi(opts) {
7616
7775
  configs.push({ rep: p.slug, component: componentIdent, props: {} });
7617
7776
  }
7618
7777
  if (inexpressible.length > 0) throw new PoseCompletenessError(inexpressible);
7619
- if (opts.dismissible === true) {
7620
- props.push({ name: "onDismiss", kind: "callback" });
7778
+ if (opts.dismissible !== void 0) {
7779
+ props.push({ name: "onDismiss", kind: "callback", default: opts.dismissible });
7621
7780
  }
7622
7781
  const entry = `${componentIdent}.tsx`;
7623
7782
  const apiPin = {
@@ -7626,13 +7785,15 @@ function authorComponentApi(opts) {
7626
7785
  name: pr.name,
7627
7786
  type: pr.kind === "boolean" ? "boolean" : pr.kind === "string" ? "string" : pr.kind === "callback" ? "() => void" : pr.values.map((v) => `"${v}"`).join(" | "),
7628
7787
  required: false,
7629
- ...pr.default !== void 0 ? { default: pr.default } : {}
7788
+ // A callback's `default` field carries detection EVIDENCE for the
7789
+ // prop line, not a value — it never enters the pin.
7790
+ ...pr.default !== void 0 && pr.kind !== "callback" ? { default: pr.default } : {}
7630
7791
  })),
7631
7792
  forcedStates,
7632
7793
  poseCompleteness: { recordedPoses: opts.poses.length, expressible: configs.length }
7633
7794
  };
7634
7795
  const propLines = props.map(
7635
- (pr) => pr.kind === "boolean" ? ` ${pr.name}?: boolean;` : pr.kind === "string" ? ` ${pr.name}?: string; // CONTENT \u2014 recorded default ${JSON.stringify(pr.default)}` : pr.kind === "callback" ? ` ${pr.name}?: () => void; // NOTIFICATION \u2014 fires on the recorded affordance; the component owns its state (it hides/updates itself) and the callback informs, never controls` : ` ${pr.name}?: ${pr.values.map((v) => `"${v}"`).join(" | ")}; // default "${pr.default}"`
7796
+ (pr) => pr.kind === "boolean" ? ` ${pr.name}?: boolean;` : pr.kind === "string" ? ` ${pr.name}?: string; // CONTENT \u2014 recorded default ${JSON.stringify(pr.default)}` : pr.kind === "callback" ? ` ${pr.name}?: () => void; // NOTIFICATION \u2014 recorded evidence: ${pr.default ?? "the recorded affordance"}; the component owns its state (it hides/updates itself) and the callback informs, never controls` : ` ${pr.name}?: ${pr.values.map((v) => `"${v}"`).join(" | ")}; // default "${pr.default}"`
7636
7797
  );
7637
7798
  const provided = opts.fonts ?? [];
7638
7799
  const recorded = opts.recordedFonts ?? [];
@@ -7705,7 +7866,7 @@ function authorBehaviors(api, extras = {}) {
7705
7866
  behaviors.push({
7706
7867
  id: `content-prop-renders(${sentinel.prop})`,
7707
7868
  config: sentinel.config,
7708
- props: { [sentinel.prop]: marker },
7869
+ props: { ...sentinel.props ?? {}, [sentinel.prop]: marker },
7709
7870
  steps: [{ assertTextVisible: marker }]
7710
7871
  });
7711
7872
  }
@@ -7823,7 +7984,7 @@ function recordedTextSlots(setDir, repSlugs) {
7823
7984
  if (t === "" || !/[A-Za-z0-9]/.test(t)) continue;
7824
7985
  texts.push(t);
7825
7986
  }
7826
- perRep.push({ slug, texts });
7987
+ perRep.push({ slug, texts, code });
7827
7988
  }
7828
7989
  const axisValuesBySlug = /* @__PURE__ */ new Map();
7829
7990
  for (const slug of repSlugs) {
@@ -7866,7 +8027,9 @@ function recordedTextSlots(setDir, repSlugs) {
7866
8027
  const v = propRep.find((pr) => pr.slug === r.slug)?.props.get(name) ?? def;
7867
8028
  return r.texts.some((t) => t.includes(v)) || r.texts.join(" ").replace(/\s+/g, " ").includes(v.replace(/\s+/g, " ").trim());
7868
8029
  }).map((r) => r.slug);
7869
- return { prop: name, default: def, overrides, varies: Object.keys(overrides).length > 0, visibleIn };
8030
+ const refPattern = new RegExp(`\\{\\s*${name}\\s*\\}`);
8031
+ const referencedIn = perRep.filter((r) => refPattern.test(r.code)).map((r) => r.slug);
8032
+ return { prop: name, default: def, overrides, varies: Object.keys(overrides).length > 0, visibleIn, referencedIn };
7870
8033
  });
7871
8034
  }
7872
8035
  if (perRep.length === 0) return [];
@@ -7913,7 +8076,7 @@ function recordedTextSlots(setDir, repSlugs) {
7913
8076
  usedNames.add(prop);
7914
8077
  const overrides = {};
7915
8078
  for (const [slug, v] of sl.values) if (v !== def) overrides[slug] = v;
7916
- return { prop, default: def, overrides, varies: Object.keys(overrides).length > 0, visibleIn: [...sl.values.keys()] };
8079
+ return { prop, default: def, overrides, varies: Object.keys(overrides).length > 0, visibleIn: [...sl.values.keys()], referencedIn: [] };
7917
8080
  });
7918
8081
  }
7919
8082
  function authorTaskFromSet(setDir, opts = {}) {
@@ -7954,12 +8117,29 @@ function authorTaskFromSet(setDir, opts = {}) {
7954
8117
  ...recordedFonts.length > 0 ? { recordedFonts } : {},
7955
8118
  ...Object.keys(defaults).length > 0 ? { defaults } : {},
7956
8119
  ...textSlots.length > 0 ? { textSlots } : {},
7957
- ...dismissName !== void 0 ? { dismissible: true } : {}
8120
+ ...dismissName !== void 0 ? { dismissible: dismissName } : {}
7958
8121
  });
7959
8122
  const anchorSlug = api.configs.find((c) => Object.keys(c.props).length === 0)?.rep ?? api.configs[0]?.rep;
7960
- const sentinels = textSlots.filter((slot) => !slot.varies && slot.visibleIn.length > 0).map((slot) => {
8123
+ const sentinels = textSlots.filter((slot) => !slot.varies).map((slot) => {
7961
8124
  const authored = api.props.find((pr) => pr.kind === "string" && pr.default === slot.default);
7962
- return authored === void 0 ? void 0 : { prop: authored.name, config: anchorSlug !== void 0 && slot.visibleIn.includes(anchorSlug) ? anchorSlug : slot.visibleIn[0] };
8125
+ if (authored === void 0) return void 0;
8126
+ if (slot.visibleIn.length > 0) {
8127
+ const entry = {
8128
+ prop: authored.name,
8129
+ config: anchorSlug !== void 0 && slot.visibleIn.includes(anchorSlug) ? anchorSlug : slot.visibleIn[0]
8130
+ };
8131
+ return entry;
8132
+ }
8133
+ if (slot.referencedIn.length > 0) {
8134
+ const toggle = api.props.find(
8135
+ (pr) => pr.kind === "boolean" && authored.name.toLowerCase().startsWith(pr.name.toLowerCase()) && authored.name.length > pr.name.length
8136
+ );
8137
+ if (toggle !== void 0 && anchorSlug !== void 0) {
8138
+ const entry = { prop: authored.name, config: anchorSlug, props: { [toggle.name]: true } };
8139
+ return entry;
8140
+ }
8141
+ }
8142
+ return void 0;
7963
8143
  }).filter((x) => x !== void 0);
7964
8144
  const { behaviors, prelude, disclosures } = authorBehaviors(api, { ...sentinels.length > 0 ? { sentinels } : {} });
7965
8145
  if (dismissName !== void 0) {
@@ -7970,10 +8150,15 @@ function authorTaskFromSet(setDir, opts = {}) {
7970
8150
  }
7971
8151
  for (const slot of textSlots) {
7972
8152
  if (slot.varies) continue;
7973
- if (slot.visibleIn.length > 0) {
8153
+ const sentineled = sentinels.some((sn) => api.props.some((pr) => pr.name === sn.prop && pr.default === slot.default));
8154
+ if (sentineled) {
7974
8155
  disclosures.push(
7975
8156
  `constant content prop (recorded ${JSON.stringify(slot.default)}) is sentinel-checked: a behavior mounts it with a sentinel string and asserts it renders \u2014 hardcoding the recorded literal fails that check`
7976
8157
  );
8158
+ } else if (slot.referencedIn.length > 0) {
8159
+ disclosures.push(
8160
+ `content prop (recorded ${JSON.stringify(slot.default)}) is rendered by REFERENCE in the emission but has no visibility evidence and no boolean toggle to reveal it \u2014 sentinel skipped (it could fail honest components whose pose hides the node); wire the prop anyway`
8161
+ );
7977
8162
  } else {
7978
8163
  disclosures.push(
7979
8164
  `content prop from recorded text is UNEXERCISED AND UNRENDERED: NO recorded pose visibly renders ${JSON.stringify(slot.default)} (hidden node) \u2014 prescribed for completeness; wire it behind its visibility toggle; no sentinel can honestly run`
@@ -8009,6 +8194,7 @@ OVERLAYS GO IN THE TOP LAYER, NOT ON A Z-INDEX. The prelude requires isolation:
8009
8194
 
8010
8195
  PAINT & PLATFORM TRAPS (each measured on a paid run; every one passed typecheck, token lint, and all behaviour checks and was caught only by pixels or by use):
8011
8196
  - A composite custom property resolves on the element that DECLARES it. A composite token (e.g. --ring: 0 0 0 var(--w) var(--c)) may only reference variables declared at or above its own declaration site \u2014 declared higher while its parts are set on the component, it is invalid-at-computed-value-time and the property silently computes to nothing (measured: a hover ring vanished; three configs at ~0.78).
8197
+ - An <svg> inside an absolutely-positioned glyph layer defaults to display: inline \u2014 it seats on a text baseline, its painted content lands BELOW the box the layout allocated, and an overflow: hidden ancestor clips the displaced ink away entirely. Large glyphs survive; small marks (a 2px dot, a 1.5px stem) vanish while every geometry probe reports the boxes correct (measured: 12/16 configs shipped icons missing their inner marks, certified at ink 0.9995). Set display: block on every glyph svg.
8012
8198
  - Chrome pixel-snaps an <svg> root's paint offset. A fractional inset on an svg element floors to a whole pixel and moves a hairline a full pixel; express fractional placement as transform: scale() about an integer-origin frame (measured: 0.943 \u2192 1.000 on six configs).
8013
8199
  - Pre-composite translucent values over the ACTUAL parent surface, read off the reference pixels \u2014 a control sitting on an elevated surface composites against that surface, not against the page.
8014
8200
  - A native <dialog> carries user-agent padding: 1em. Override every side, or the root grows past its recorded box and every band inside shifts.
@@ -8378,7 +8564,7 @@ function statusOf(s) {
8378
8564
  return tierOf(s, BARS.cert);
8379
8565
  }
8380
8566
  function emitBundleV1(opts) {
8381
- const statuses = opts.scores.map((s) => ({ rep: s.rep, similarity: s.similarity, inkRecall: s.inkRecall, status: statusOf(s) }));
8567
+ const statuses = opts.scores.map((s) => ({ rep: s.rep, similarity: s.similarity, inkRecall: s.inkRecall, ...s.exact !== void 0 ? { exact: s.exact } : {}, status: statusOf(s) }));
8382
8568
  const certified = statuses.filter((s) => s.status === "certified").length;
8383
8569
  const pass = statuses.filter((s) => s.status !== "fail").length;
8384
8570
  const lattice = countLatticeSymbols(opts.task.set);
@@ -8837,9 +9023,15 @@ async function runVerify(opts) {
8837
9023
  const statuses = [
8838
9024
  ...scores.map((s) => {
8839
9025
  const { exact: _exact, ...reported } = s;
8840
- const base = { ...reported, status: tierOf(s, BARS2.cert) };
9026
+ let status = tierOf(s, BARS2.cert);
9027
+ const certDemote = [];
9028
+ if (status === "certified" && s.absentInk !== void 0 && s.absentInk.length > 0) {
9029
+ status = "pass";
9030
+ certDemote.push(...s.absentInk.map((c) => `absent ink: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with no render ink within reach (missing or invisible feature)`));
9031
+ }
9032
+ const base = certDemote.length > 0 ? { ...reported, status, demotedBy: certDemote } : { ...reported, status };
8841
9033
  const reasons = demotions.get(s.rep);
8842
- return reasons === void 0 ? base : { ...base, status: "fail", demotedBy: reasons };
9034
+ return reasons === void 0 ? base : { ...base, status: "fail", demotedBy: [...certDemote.length > 0 ? certDemote : [], ...reasons] };
8843
9035
  }),
8844
9036
  // ADR-010 §2 anti-gaming: recorded configs the adapter does not map
8845
9037
  // are FAILs, never silently absent.
@@ -9041,12 +9233,12 @@ function resolveEngineTask(opts, callerCwd) {
9041
9233
  const asPath = path32.resolve(callerCwd, opts.taskOrSet);
9042
9234
  const isSet = existsSync26(path32.join(asPath, "recording-set.json"));
9043
9235
  const registry = TASKS[opts.taskOrSet];
9044
- if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet };
9236
+ if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, disclosures: [] };
9045
9237
  if (isSet) {
9046
9238
  try {
9047
9239
  const authored = authorTaskFromSet(asPath);
9048
9240
  for (const d of authored.disclosures) warn(opts, d);
9049
- return { task: authored.task, name: path32.basename(asPath), apiPin: { props: authored.api.apiPin.props, forcedStates: authored.api.apiPin.forcedStates } };
9241
+ return { task: authored.task, name: path32.basename(asPath), disclosures: authored.disclosures, apiPin: { props: authored.api.apiPin.props, forcedStates: authored.api.apiPin.forcedStates } };
9050
9242
  } catch (err) {
9051
9243
  fail(opts, ExitCode.InputValidation, {
9052
9244
  error: `cannot author a task from ${asPath}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
@@ -9064,9 +9256,13 @@ function resolveEngineTask(opts, callerCwd) {
9064
9256
  function runEngineBrief(opts) {
9065
9257
  requireEntitlement(opts);
9066
9258
  const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
9067
- const { task, name } = resolveEngineTask(opts, callerCwd);
9259
+ const { task, name, disclosures } = resolveEngineTask(opts, callerCwd);
9068
9260
  const bar = BARS3[opts.bar];
9069
- const brief = buildBrief(task.systemApi, bar, { colorScheme: recordingIsDark(task) ? "dark" : "light" });
9261
+ const disclosureBlock = disclosures.length > 0 ? `
9262
+
9263
+ === RECORDED-SET DISCLOSURES (facts about this task's coverage \u2014 read them) ===
9264
+ ${disclosures.map((d) => `- ${d}`).join("\n")}` : "";
9265
+ const brief = buildBrief(task.systemApi, bar, { colorScheme: recordingIsDark(task) ? "dark" : "light" }) + disclosureBlock;
9070
9266
  const segments = buildSegments(task, "files");
9071
9267
  let notRecorded;
9072
9268
  const manifestPath2 = path32.join(task.set, "recording-set.json");
@@ -9206,9 +9402,14 @@ ${[
9206
9402
  const allPass = obj[0] === total && total > 0;
9207
9403
  const certBar = BARS3["cert"];
9208
9404
  const parityDemoted = new Set(parity.filter((p) => !p.pass).map((p) => p.id.replace(/^parity:/, "")));
9209
- const certifiedReps = scores.filter((sc) => tierOf(sc, certBar) === "certified" && !parityDemoted.has(sc.rep)).map((sc) => sc.rep);
9405
+ const certifiedReps = scores.filter((sc) => tierOf(sc, certBar) === "certified" && !parityDemoted.has(sc.rep) && (sc.absentInk === void 0 || sc.absentInk.length === 0)).map((sc) => sc.rep);
9210
9406
  const certifiedSet = new Set(certifiedReps);
9211
- const certificationFeedback = `
9407
+ const absentFindings = scores.flatMap((sc) => (sc.absentInk ?? []).map((c) => `- ${sc.rep}: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with NO render ink within reach at [x ${c.x}-${c.x + c.w}, y ${c.y}-${c.y + c.h}] (missing or invisible feature; magnified crops in the evidence dir; coordinates are reference-space \u2014 subtract the config's seat for box-space). A certified-tier config with such a finding is demoted to pass.`));
9408
+ const absentBlock = absentFindings.length > 0 ? `
9409
+
9410
+ MISSING FEATURES (absent-ink clusters \u2014 recorded ink your render leaves nowhere near covered; fix these first, the global numbers cannot see them):
9411
+ ${absentFindings.join("\n")}` : "";
9412
+ const certificationFeedback = `${absentBlock}
9212
9413
 
9213
9414
  CERTIFICATION: ${certifiedReps.length}/${scores.length} configs at the certification bar (sim \u2265${certBar.sim} AND ink \u2265${certBar.ink}, exact values, after parity demotion \u2014 composition checks at verify can demote further).${certifiedReps.length < scores.length ? ` Below cert: ${scores.filter((sc) => !certifiedSet.has(sc.rep)).map((sc) => sc.rep).join(", ")}.` : ""}
9214
9415
  METRIC DEADBAND (read before iterating on near-misses): the scored similarity/ink deliberately tolerate \xB11px edge shift and antialiased-edge differences \u2014 cross-rasterizer noise absorption. A change entirely inside that band moves these numbers by EXACTLY ZERO (working as designed, not a stuck scorer). The per-config \`exact\` fields in the JSON are tolerance-free and move first: compare exact across rounds to confirm a small fix landed, and stop iterating when only exact moves \u2014 the bar reads the tolerant numbers.`;
@@ -9243,7 +9444,7 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
9243
9444
  bundleManifest: emitted.written[0],
9244
9445
  note: "styles.css was stamped with the bundle provenance comment on line 1 \u2014 preserve it in any post-score edit",
9245
9446
  allPass,
9246
- certification: { certified: certifiedReps.length, total: scores.length, bar: certBar, note: "tierOf on exact values + parity demotion; verify's composition checks can demote further" }
9447
+ certification: { certified: certifiedReps.length, total: scores.length, bar: certBar, note: "tierOf on exact values + parity and absent-ink demotion; verify's composition checks can demote further" }
9247
9448
  },
9248
9449
  () => {
9249
9450
  for (const s of scores) process.stdout.write(`${s.pass ? certifiedSet.has(s.rep) ? "CERT" : "PASS" : "FAIL"} ${s.rep}: sim=${s.similarity} ink=${s.inkRecall}${s.error !== void 0 ? ` [${s.error}]` : ""}
@@ -9864,7 +10065,7 @@ async function runDoctorChecks(options) {
9864
10065
  name: "plugin-skew",
9865
10066
  ok: false,
9866
10067
  detail: `Claude Code plugin cache holds v${newest} while this CLI is ${cliVersion()} \u2014 the plugin's skill/agents are STALE and will silently shadow current doctrine`,
9867
- remediation: "Update the plugin: terminal Claude Code \u2014 `claude plugin marketplace update tendrilapp` then `claude plugin update tendril`; VS Code extension \u2014 /plugins panel: uninstall tendril, reinstall, reopen the chat panel."
10068
+ remediation: "Update the plugin \u2014 REFRESH THE MARKETPLACE FIRST (its local clone goes stale and a reinstall faithfully reinstalls the old version): terminal Claude Code \u2014 `claude plugin marketplace update tendrilapp` then `claude plugin update tendril`; VS Code extension \u2014 /plugins panel \u2192 Marketplaces tab \u2192 refresh tendrilapp, THEN Plugins tab \u2192 uninstall + reinstall tendril, reopen the chat panel. If the refresh doesn't take, remove the tendrilapp marketplace entirely and re-add TendrilApp/claude-plugin (a fresh clone cannot be stale)."
9868
10069
  } : { name: "plugin-skew", ok: true, detail: `Claude Code plugin v${newest} matches this CLI` }
9869
10070
  );
9870
10071
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",