@tendrilapp/cli 0.1.19 → 0.1.20

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
@@ -20,12 +20,35 @@ Non-negotiables (the CLI enforces these; do not fight them):
20
20
  - Sub-bar results are honest, not failures to hide: exit 5 ships the
21
21
  bundle with real scores. Report them as they are.
22
22
 
23
- First-run friction: if tendril/Figma tool calls are hitting permission
24
- prompts, tell the user ONE approval can replace them all and offer
25
- `tendril_permissions` with `write: true` it merges the pipeline's
26
- per-tool allowlist into the project's `.claude/settings.local.json`
27
- (idempotent, touches nothing else; the user reloads the session to
28
- apply). Never run it unoffered: it edits the user's settings.
23
+ Permissions, FIRST THING in every pipeline run (the trigger is a FILE
24
+ CHECK, not prompt-watching you cannot observe permission prompts, so
25
+ never condition on them; a measured session full of prompts had a
26
+ prompt-conditioned offer never fire): check whether ANY Claude
27
+ settings file the host merges the project's
28
+ `.claude/settings.local.json` or `.claude/settings.json`, or the
29
+ user's `~/.claude/settings.json` — contains tendril MCP entries
30
+ (plugin installs use `mcp__plugin_tendril_tendril__*`; direct
31
+ `claude mcp add` installs use `mcp__<server-name>__*`). If none does,
32
+ BEFORE the first recording call, tell the user one approval can
33
+ replace every pipeline prompt and offer `tendril_permissions` with
34
+ `write: true` — it merges the per-tool allowlist into the project's
35
+ settings.local.json (idempotent, touches nothing else; note it writes
36
+ PLUGIN-prefixed names — a direct-install user should run
37
+ `tendril permissions --claude` and adapt the prefix instead) and the
38
+ user must reload the session to apply; say that out loud. Offer ONCE
39
+ per session; never run it unoffered (it edits the user's settings),
40
+ and if declined, proceed without mentioning it again.
41
+
42
+ Long generator/score waits: silence is not a health signal. The scorer
43
+ appends a timestamped line to `<candidateDir>/score-history.jsonl`
44
+ after EVERY completed round — the file APPEARS ONLY AFTER THE FIRST
45
+ score, so its absence early in a run is normal, not frozen. Poll it
46
+ (and the candidate dir's mtimes) about every 30 seconds and relay
47
+ progress to the user ("round 3 scored: 15/21 at floor 0.885"). A
48
+ healthy run can take 30+ minutes; never kill on elapsed time alone —
49
+ count staleness from the LAST CHANGE to either signal, not from run
50
+ start, and when past ~20 minutes of true staleness, ask the user
51
+ before killing.
29
52
 
30
53
  Batch runs (several components in one session):
31
54
  - Cap concurrent recorders at FOUR. All recorders share one Figma
@@ -66,7 +66,7 @@ var TOOLS = [
66
66
  },
67
67
  {
68
68
  name: "tendril_permissions",
69
- description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing. USE THIS when tendril/Figma tool calls keep hitting permission prompts: offer it to the user once \u2014 ONE approval here replaces a prompt per pipeline call (run 8 measured 16+ prompts for a single component's recording). The user must reload the session for new settings to apply.",
69
+ description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing. OFFER THIS AT PIPELINE START whenever NO merged Claude settings file (project .claude/settings.local.json or .claude/settings.json, or user ~/.claude/settings.json) contains tendril MCP entries \u2014 plugin installs use mcp__plugin_tendril_tendril__*, direct claude-mcp-add installs use mcp__<server>__* (for those, write:true installs PLUGIN-prefixed names that will not match: list without writing and adapt the prefix instead). A FILE check, never prompt-watching \u2014 agents cannot observe permission prompts. ONE approval here replaces a prompt per pipeline call. Never run it unoffered; the user must reload the session for new settings to apply \u2014 say so.",
70
70
  schema: z.object({
71
71
  write: z.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries")
72
72
  }),
package/dist/tendril.js CHANGED
@@ -8256,7 +8256,7 @@ function dismissEvidence(setDir, repSlugs) {
8256
8256
  }
8257
8257
  return void 0;
8258
8258
  }
8259
- function recordedFontNeeds(setDir) {
8259
+ function recordedFontNeeds(setDir, opts = {}) {
8260
8260
  const byFamily = /* @__PURE__ */ new Map();
8261
8261
  const unpaired = /* @__PURE__ */ new Set();
8262
8262
  const famAdd = (raw, weight) => {
@@ -8298,8 +8298,8 @@ function recordedFontNeeds(setDir) {
8298
8298
  if (existsSync21(defs)) fromDefs(envelopeText(defs));
8299
8299
  }
8300
8300
  return [...byFamily.entries()].map(([family, paired]) => {
8301
- const weights = /* @__PURE__ */ new Set([...paired, ...unpaired]);
8302
- return { family, weights: weights.size === 0 ? [400] : [...weights].sort((a, b) => a - b) };
8301
+ const weights = /* @__PURE__ */ new Set([...paired, ...opts.pairedOnly === true ? [] : unpaired]);
8302
+ return { family, weights: weights.size === 0 ? opts.pairedOnly === true ? [] : [400] : [...weights].sort((a, b) => a - b) };
8303
8303
  });
8304
8304
  }
8305
8305
  function symbolFontGlyphCount(setDir, reps) {
@@ -9261,10 +9261,19 @@ function unprovisionedFamilies(setDir, cacheDir) {
9261
9261
  const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
9262
9262
  return declared.filter((f) => !provided.has(f.toLowerCase()));
9263
9263
  }
9264
+ function unprovisionedFaces(setDir, cacheDir) {
9265
+ return [
9266
+ ...unprovisionedFamilies(setDir, cacheDir),
9267
+ ...missingWeights(setDir, cacheDir).map((g) => {
9268
+ const missing = g.declared.filter((w) => !g.provided.includes(w));
9269
+ return `${g.family} (${missing.length === 1 ? "weight" : "weights"} ${missing.join(", ")})`;
9270
+ })
9271
+ ];
9272
+ }
9264
9273
  function missingWeights(setDir, cacheDir) {
9265
9274
  let needs;
9266
9275
  try {
9267
- needs = recordedFontNeeds(setDir);
9276
+ needs = recordedFontNeeds(setDir, { pairedOnly: true });
9268
9277
  } catch {
9269
9278
  return [];
9270
9279
  }
@@ -9468,7 +9477,8 @@ async function runVerify(opts) {
9468
9477
  } else if (opts.bar === "cert" && taskFontFamilies(task.set) === null) {
9469
9478
  warn(opts, "family coverage could not be established from this recording (no declared families) \u2014 the certification gate covers only cache non-emptiness here");
9470
9479
  }
9471
- for (const g of missingWeights(task.set)) {
9480
+ const weightGaps = missingWeights(task.set);
9481
+ for (const g of weightGaps) {
9472
9482
  warn(opts, `font weights (advisory): the recording implies weights ${g.declared.join(", ")} for "${g.family}" \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (certification stays family-gated; implied weights can leak across families)`);
9473
9483
  }
9474
9484
  const missing = task.configs.filter(
@@ -9580,7 +9590,8 @@ async function runVerify(opts) {
9580
9590
  // Machine-readable cause (adversarial review): a cert-bar failure
9581
9591
  // with zero pixel/behavior/composition failures was only
9582
9592
  // explainable from stderr prose.
9583
- ...certBlockedByAbsentInk.length > 0 ? { certBlockedByAbsentInk } : {}
9593
+ ...certBlockedByAbsentInk.length > 0 ? { certBlockedByAbsentInk } : {},
9594
+ ...weightGaps.length > 0 ? { fontWeightGaps: weightGaps } : {}
9584
9595
  };
9585
9596
  emitData(opts, report, () => {
9586
9597
  for (const s of statuses) {
@@ -9672,9 +9683,15 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
9672
9683
  const shippedFonts = requiredFontsManifest(cssFontFamilies(["styles.css", "tokens.css"].map((f) => path31.join(opts.bundleDir, f)).filter((f) => existsSync25(f)).map((f) => readFileSync22(f, "utf8")).join("\n")));
9673
9684
  if (shippedFonts.length > 0 && substitutedFamilies.length === 0) {
9674
9685
  process.stdout.write(`fonts: scored with Tendril-cache faces \u2014 a consuming app must provision the same families (the bundle ships fonts.css when faces are shippable; sha-pinned list in component.json requiredFonts)
9686
+ `);
9687
+ }
9688
+ for (const g of weightGaps) {
9689
+ process.stdout.write(`fonts (weight gap): "${g.family}" declares ${g.declared.join(", ")} \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (advisory, never a gate). Fix: tendril fonts resolve --set ${task.set}
9675
9690
  `);
9676
9691
  }
9677
9692
  process.stdout.write(`evidence: ${evidenceDir} (render/ref/diff per config)
9693
+ `);
9694
+ process.stdout.write(`eye check: tendril inspect ${opts.bundleDir} \u2014 magnified recorded-vs-rendered crops of every small node; scores cannot see shape
9678
9695
  `);
9679
9696
  });
9680
9697
  if (opts.bar === "cert" && substitutedFamilies.length > 0) {
@@ -9780,8 +9797,9 @@ DO NOT INVENT PIXELS FOR THESE POSES. Implement them ONLY as the composition of
9780
9797
  ${notRecorded}` : "";
9781
9798
  let fontProvisioning;
9782
9799
  if (existsSync26(manifestPath2)) {
9783
- const provided = verifiedFontFamilies().map((f) => f.family);
9784
- const unprovided = recordedFontFamilies(task.set).filter((r) => !provided.some((p) => p.toLowerCase() === r.toLowerCase()));
9800
+ const missingFams = unprovisionedFamilies(task.set);
9801
+ const unprovided = unprovisionedFaces(task.set);
9802
+ const weightOnly = missingFams.length === 0;
9785
9803
  if (unprovided.length > 0) {
9786
9804
  const names = unprovided.map((f) => `'${f}'`).join(", ");
9787
9805
  const PROPRIETARY = /^(sf pro|sf compact|sf mono|new york|pingfang|segoe ui|helvetica neue|proxima nova|avenir)/i;
@@ -9789,13 +9807,13 @@ ${notRecorded}` : "";
9789
9807
  fontProvisioning = {
9790
9808
  unprovided,
9791
9809
  question: {
9792
- prompt: `This design's text uses ${names}, which is not in the local font kit. How should it be handled?`,
9810
+ prompt: weightOnly ? `This design implies font ${unprovided.length === 1 ? "weight" : "weights"} the local kit lacks: ${names} (the ${missingFams.length === 0 && unprovided.length === 1 ? "family itself is" : "families themselves are"} provisioned). How should it be handled?` : `This design's text uses ${names}, which is not in the local font kit. How should it be handled?`,
9793
9811
  options: [
9794
- allProprietary ? `(Recommended) ${names} ${unprovided.length === 1 ? "is a proprietary face" : "are proprietary faces"} \u2014 Google Fonts cannot serve ${unprovided.length === 1 ? "it" : "them"}, so skip \`fonts resolve\`: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold a licence for (the file never leaves this machine), then re-run this brief \u2014 exact text fidelity.` : `(Recommended) Run \`tendril fonts resolve --set ${task.set}\` \u2014 open-source families are fetched automatically. Any face that fails there is one you license privately: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold (the file never leaves this machine). Then re-run this brief \u2014 exact text fidelity.`,
9795
- 'Continue with a substitute face: generation proceeds in a provided family, no config can score "certified" under it (cert-bar runs exit fonts-unproven after their report), the substitution is disclosed, and small text differences are expected and not fixable from CSS.'
9812
+ allProprietary && !weightOnly ? `(Recommended) ${names} ${unprovided.length === 1 ? "is a proprietary face" : "are proprietary faces"} \u2014 Google Fonts cannot serve ${unprovided.length === 1 ? "it" : "them"}, so skip \`fonts resolve\`: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold a licence for (the file never leaves this machine), then re-run this brief \u2014 exact text fidelity.` : `(Recommended) Run \`tendril fonts resolve --set ${task.set}\` \u2014 open-source families are fetched automatically. Any face that fails there is one you license privately: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold (the file never leaves this machine). Then re-run this brief \u2014 exact text fidelity.`,
9813
+ weightOnly ? "Continue with nearest-weight rendering: the family is provisioned, certification remains possible, and the gap rides the report as an advisory (fontWeightGaps); resolving removes it." : 'Continue with a substitute face: generation proceeds in a provided family, no config can score "certified" under it (cert-bar runs exit fonts-unproven after their report), the substitution is disclosed, and small text differences are expected and not fixable from CSS.'
9796
9814
  ]
9797
9815
  },
9798
- nonInteractive: opts.bar === "cert" ? `Resolve the families first (\`tendril fonts resolve --set ${task.set}\`) \u2014 this loop targets the cert bar, and scoring exits fonts-unproven under a substitute, so continuing without resolving is a dead end.` : "Continue with the substitute and state the substitution in your report."
9816
+ nonInteractive: weightOnly ? `Run \`tendril fonts resolve --set ${task.set}\` if quick, or continue \u2014 the weight gap is an advisory, never a gate.` : opts.bar === "cert" ? `Resolve the families first (\`tendril fonts resolve --set ${task.set}\`) \u2014 this loop targets the cert bar, and scoring exits fonts-unproven under a substitute, so continuing without resolving is a dead end.` : "Continue with the substitute and state the substitution in your report."
9799
9817
  };
9800
9818
  }
9801
9819
  }
@@ -10273,7 +10291,7 @@ var init_server = __esm({
10273
10291
  },
10274
10292
  {
10275
10293
  name: "tendril_permissions",
10276
- description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing. USE THIS when tendril/Figma tool calls keep hitting permission prompts: offer it to the user once \u2014 ONE approval here replaces a prompt per pipeline call (run 8 measured 16+ prompts for a single component's recording). The user must reload the session for new settings to apply.",
10294
+ description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing. OFFER THIS AT PIPELINE START whenever NO merged Claude settings file (project .claude/settings.local.json or .claude/settings.json, or user ~/.claude/settings.json) contains tendril MCP entries \u2014 plugin installs use mcp__plugin_tendril_tendril__*, direct claude-mcp-add installs use mcp__<server>__* (for those, write:true installs PLUGIN-prefixed names that will not match: list without writing and adapt the prefix instead). A FILE check, never prompt-watching \u2014 agents cannot observe permission prompts. ONE approval here replaces a prompt per pipeline call. Never run it unoffered; the user must reload the session for new settings to apply \u2014 say so.",
10277
10295
  schema: z12.object({
10278
10296
  write: z12.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries")
10279
10297
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",