@tendrilapp/cli 0.1.28 → 0.1.30

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
@@ -29,7 +29,13 @@ settings file the host merges — the project's
29
29
  user's `~/.claude/settings.json` — contains tendril MCP entries
30
30
  (plugin installs use `mcp__plugin_tendril_tendril__*`; direct
31
31
  `claude mcp add` installs use `mcp__<server-name>__*`) AND shell-
32
- surface entries (`Bash(ls:*)`-style prefixes plus `Write`/`Edit`).
32
+ surface entries (`Bash(ls:*)`-style prefixes plus `Write`/`Edit`)
33
+ AND Figma entries under the prefix THIS session's Figma tools
34
+ actually carry — take a Figma tool you can see (e.g. get_metadata)
35
+ and check its full `mcp__…` name against the allowlist; entries
36
+ under a different server's prefix are dead and DO NOT count (a
37
+ measured session held six such entries while every Figma call
38
+ prompted).
33
39
  THE MCP LIST ALONE IS NOT "PERMISSIONS ARE FINE": a run is mostly
34
40
  shell — mkdir, evidence reads, log writes, the printed npx commands —
35
41
  and a measured session whose every MCP call was covered still put
@@ -40,17 +46,22 @@ and offer `tendril_permissions` with `write: true` — it merges the
40
46
  per-tool allowlist PLUS the run's shell surface and project-scoped
41
47
  Write/Edit (with deny guards so that grant can never touch
42
48
  `./.claude/**` or `./.git/**`) into the project's
43
- settings.local.json (idempotent, touches nothing else; note it
44
- writes PLUGIN-prefixed names a direct-install user should run
45
- `tendril permissions --claude` and adapt the prefix instead). These
46
- are CONVENIENCE grants, not a security boundary the command's
47
- output names what they trade (read verbs read any file without
48
- prompting; host Bash patterns are best-effort prefix matches; the
49
- CLI subcommand grants carry the CLI's own write surface). RELAY that
50
- note to the user with the offer, never soften it, and say the user
51
- must reload the session to apply. Offer ONCE per session; never run
52
- it unoffered (it edits the user's settings), and if declined,
53
- proceed without mentioning it again.
49
+ settings.local.json (idempotent, touches nothing else). PREFIXES:
50
+ pass `figmaPrefix` (and `tendrilPrefix` for non-plugin installs)
51
+ spelled exactly as THIS session names those tools — the part before
52
+ `__get_metadata` whenever they differ from the plugin defaults; a
53
+ measured figma-remote session got six entries that could never
54
+ match because the write assumed the plugin shape. These are
55
+ CONVENIENCE grants, not a security boundary the command's output
56
+ names what they trade (read verbs read any file without prompting;
57
+ host Bash patterns are best-effort prefix matches; the CLI
58
+ subcommand grants carry the CLI's own write surface). RELAY that
59
+ note to the user with the offer, never soften it, and say BOTH that
60
+ a reload is required AND that the current session keeps prompting
61
+ until then — the savings land next session; that is expected, not a
62
+ failure. Offer ONCE per session; never run it unoffered (it edits
63
+ the user's settings), and if declined, proceed without mentioning
64
+ it again.
54
65
 
55
66
  Long generator/score waits: silence is not a health signal, and POLL
56
67
  cadence is not RELAY cadence (run 13: rounds landed 6–11 minutes apart,
@@ -243,8 +254,13 @@ Batch runs (several components in one session):
243
254
  CSS. Import every React API you use.
244
255
  3. `tendril_engine_score` → read the per-config results and feedback.
245
256
  Fix FAIL configs without regressing PASS configs; re-score.
246
- 4. Stop when `allPass` is true, or after two consecutive rounds with
247
- no improvement then report the honest final state.
257
+ 4. Stop when `allPass` is true. Two consecutive rounds with no
258
+ improvement is a TRIGGER TO INSPECT, not to stop: open the
259
+ verify-evidence diff images for the worst configs and look before
260
+ deciding (a measured run scored byte-identical twice, inspected
261
+ the diffs, and round 3 went 27/27 certified — a stop rule would
262
+ have shipped 5/27). Stop only after an inspection round produced
263
+ no fix hypothesis — then report the honest final state.
248
264
  5. MODEL SELECTION — mechanical AND asked. `engine brief` and
249
265
  `engine score` refuse to run without a declared model, so the
250
266
  choice must be settled first. When a user is present, ask before
@@ -66,11 +66,19 @@ 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 the MCP per-tool entries, the run's shell surface, and project-scoped Write/Edit with deny guards for ./.claude and ./.git into the project's .claude/settings.local.json, idempotent, never touches other keys), or list the entries without writing. The shell/Write grants are CONVENIENCE, not a security boundary \u2014 the output's note names exactly what they trade; RELAY it with the offer. 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.",
69
+ description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges the MCP per-tool entries, the run's shell surface, and project-scoped Write/Edit with deny guards for ./.claude and ./.git into the project's .claude/settings.local.json, idempotent, never touches other keys), or list the entries without writing. The shell/Write grants are CONVENIENCE, not a security boundary \u2014 the output's note names exactly what they trade; RELAY it with the offer. 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>__* \u2014 when this session's tool names differ from the plugin defaults, pass figmaPrefix/tendrilPrefix with the prefixes you actually see, or the written entries never match. 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
- write: z.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries")
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
+ figmaPrefix: z.string().optional().describe("The Figma server's entry prefix AS THIS SESSION NAMES ITS TOOLS \u2014 the part before __get_metadata (e.g. mcp__figma-remote, mcp__plugin_figma_figma). Pass it whenever the session's Figma tools are not mcp__plugin_figma_figma__* \u2014 entries written under the wrong prefix never match anything (measured: six dead entries in a figma-remote session)."),
73
+ tendrilPrefix: z.string().optional().describe("Same for the tendril server when this session's tendril tools are not mcp__plugin_tendril_tendril__* (e.g. mcp__tendril for claude mcp add installs).")
72
74
  }),
73
- argv: (i) => ["permissions", "--claude", ...i["write"] === true ? ["--write"] : []]
75
+ argv: (i) => [
76
+ "permissions",
77
+ "--claude",
78
+ ...i["write"] === true ? ["--write"] : [],
79
+ ...typeof i["figmaPrefix"] === "string" ? ["--figma-prefix", i["figmaPrefix"]] : [],
80
+ ...typeof i["tendrilPrefix"] === "string" ? ["--tendril-prefix", i["tendrilPrefix"]] : []
81
+ ]
74
82
  },
75
83
  {
76
84
  name: "tendril_doctor",
@@ -195,7 +203,7 @@ var TOOLS = [
195
203
  {
196
204
  name: "tendril_record_status",
197
205
  annotations: { readOnlyHint: true },
198
- description: "Recording-set completeness: per-rep recorded/missing tools.",
206
+ description: "Recording-set completeness: per-rep recorded/missing tools, files that exist but are unusable (invalid \u2014 re-record those), and whether the set-level token map is recorded. `complete` means USABLE by the next pipeline step, including the set-level get_variable_defs.",
199
207
  schema: z.object({ setDir: str("recording set directory") }),
200
208
  argv: (i) => ["record", "status", "--set", i["setDir"]]
201
209
  },
package/dist/tendril.js CHANGED
@@ -1083,14 +1083,54 @@ function planSet(setDir, component, symbols, opts = {}) {
1083
1083
  writeManifest(setDir, manifest);
1084
1084
  return { manifest, plan, resumed: false };
1085
1085
  }
1086
+ function usableEnvelope(file, tool) {
1087
+ let payload;
1088
+ try {
1089
+ payload = JSON.parse(readFileSync(file, "utf8"));
1090
+ } catch {
1091
+ return { ok: false, reason: "the recorded file is not JSON" };
1092
+ }
1093
+ const schema = tool === "get_screenshot" ? ImageEnvelopeSchema : TextEnvelopeSchema;
1094
+ if (!schema.safeParse(payload).success) {
1095
+ return { ok: false, reason: "not a verbatim tool envelope (structure the ingest boundary would refuse)" };
1096
+ }
1097
+ const content = checkEnvelopeContent(tool, payload);
1098
+ if (!content.ok) return { ok: false, reason: content.reason };
1099
+ if (tool === "get_metadata" && !/name="/.test(envelopeTextContent(payload))) {
1100
+ return { ok: false, reason: "node markup carries no name attribute \u2014 task authoring cannot derive the pose" };
1101
+ }
1102
+ return { ok: true };
1103
+ }
1086
1104
  function sessionStatus(setDir) {
1087
1105
  const manifest = loadManifest(setDir);
1088
1106
  const reps = manifest.reps.map((rep) => {
1089
- const recorded = RECORD_TOOLS.filter((t) => existsSync(path.join(setDir, rep.slug, `${t}.json`)));
1090
- const missing = byProtocolOrder(requiredToolsFor(manifest, rep.slug).filter((t) => !recorded.includes(t)));
1091
- return { slug: rep.slug, nodeId: rep.nodeId, recorded, missing };
1107
+ const required = requiredToolsFor(manifest, rep.slug);
1108
+ const invalid = [];
1109
+ const recorded = RECORD_TOOLS.filter((t) => {
1110
+ const file = path.join(setDir, rep.slug, `${t}.json`);
1111
+ if (!existsSync(file)) return false;
1112
+ const usable = usableEnvelope(file, t);
1113
+ if (!usable.ok) {
1114
+ invalid.push({
1115
+ tool: t,
1116
+ reason: required.includes(t) ? usable.reason : `${usable.reason} (optional for this rep \u2014 not counted against completeness; re-record only if you need this evidence)`
1117
+ });
1118
+ return false;
1119
+ }
1120
+ return true;
1121
+ });
1122
+ const missing = byProtocolOrder(required.filter((t) => !recorded.includes(t)));
1123
+ return { slug: rep.slug, nodeId: rep.nodeId, recorded, missing, ...invalid.length > 0 ? { invalid } : {} };
1092
1124
  });
1093
- return { reps, complete: reps.every((r) => r.missing.length === 0) };
1125
+ const setDefsFile = path.join(setDir, "get_variable_defs.json");
1126
+ const setDefs = existsSync(setDefsFile) ? usableEnvelope(setDefsFile, "get_variable_defs") : { ok: false, reason: "never recorded" };
1127
+ const setTokenMapRecorded = setDefs.ok;
1128
+ return {
1129
+ reps,
1130
+ setTokenMapRecorded,
1131
+ ...!setDefs.ok && setDefs.reason !== "never recorded" ? { setTokenMapInvalid: setDefs.reason } : {},
1132
+ complete: reps.every((r) => r.missing.length === 0) && setTokenMapRecorded
1133
+ };
1094
1134
  }
1095
1135
  function nextInstruction(setDir) {
1096
1136
  const { reps } = sessionStatus(setDir);
@@ -2192,6 +2232,8 @@ async function runTokenLint(css, fileLabel = "generated.css", definedVars2, reco
2192
2232
  property: "custom-property-ident",
2193
2233
  message: `var(${name}) is not a legal custom property reference \u2014 the browser drops the whole declaration`
2194
2234
  });
2235
+ } else if (name.startsWith("--tendril-")) {
2236
+ continue;
2195
2237
  } else if (definedVars2 !== void 0 && !definedVars2.has(name) && !ownDefs.has(name)) {
2196
2238
  violations.push({
2197
2239
  file: fileLabel,
@@ -3615,9 +3657,12 @@ function weightFromSubfamily(subfamily) {
3615
3657
  return void 0;
3616
3658
  }
3617
3659
  function systemFaceRefusal(family) {
3618
- if (/^\.?SF([ -]|$)/i.test(family) || /^San Francisco/i.test(family) || /^\.?Apple/i.test(family) || /^\.?New York$/i.test(family)) {
3660
+ if (/^\.?SF(NS|[ -]|$)/i.test(family) || /^San Francisco/i.test(family) || /^\.?Apple/i.test(family) || /^\.?New York\b/i.test(family) || /^\.?System Font\b/i.test(family)) {
3619
3661
  return "Apple's bundled system faces (San Francisco, New York, the Apple-named families) are licensed only for mock-ups of interfaces running on Apple operating systems, by registered Apple developers \u2014 a grant Tendril cannot verify, so these faces are never cached (ROADMAP 0i).";
3620
3662
  }
3663
+ if (/^\./.test(family)) {
3664
+ return "dot-prefixed families are the operating system's own hidden UI faces (macOS marks internal faces with a leading dot) \u2014 not user-licensable, so they are never cached (ROADMAP 0i).";
3665
+ }
3621
3666
  if (/^Segoe UI Variable/i.test(family)) {
3622
3667
  return "Segoe UI Variable cannot be licensed for use outside Microsoft products at all \u2014 an 'I have the rights' assertion has nothing it could attach to, so it is never cached (ROADMAP 0i).";
3623
3668
  }
@@ -3943,7 +3988,7 @@ function verifiedFontFamilies(cacheDir = DEFAULT_FONT_CACHE) {
3943
3988
  return [...byFamily.entries()].map(([family, w]) => ({ family, weights: [...w].sort((a, b) => a - b) }));
3944
3989
  }
3945
3990
  function addSystemFamily(family, opts = {}) {
3946
- const refusal = systemFaceRefusal(family);
3991
+ const refusal = systemFaceRefusal(family.trim());
3947
3992
  if (refusal !== void 0) return { added: [], skipped: [], overwrote: [], refusal };
3948
3993
  const plainStyle = (sub) => weightFromSubfamily(sub) !== void 0 || /^(regular|plain|)$/i.test(sub.trim());
3949
3994
  const faces = [...systemFacesForFamily(family, opts.dirs)].sort((a, b) => {
@@ -3961,6 +4006,11 @@ function addSystemFamily(family, opts = {}) {
3961
4006
  const taken = /* @__PURE__ */ new Set();
3962
4007
  for (const face of faces) {
3963
4008
  const skip = (reason) => skipped.push({ subfamily: face.subfamily, weight: face.weight, reason });
4009
+ const faceRefusal = systemFaceRefusal(face.family.trim());
4010
+ if (faceRefusal !== void 0) {
4011
+ skip(`refusal-class family \u2014 ${faceRefusal}`);
4012
+ continue;
4013
+ }
3964
4014
  if (opts.weights !== void 0 && !opts.weights.includes(face.weight)) continue;
3965
4015
  if (face.italic) {
3966
4016
  skip("italic \u2014 the cache cannot represent italic faces end to end yet; the upright face serves the family");
@@ -8129,10 +8179,11 @@ function nextPayload(setDir) {
8129
8179
  const instruction = nextInstruction(setDir);
8130
8180
  const status = sessionStatus(setDir);
8131
8181
  const progress = { recordedReps: status.reps.filter((x) => x.missing.length === 0).length, totalReps: status.reps.length };
8132
- if (instruction === null && !existsSync21(path27.join(setDir, "get_variable_defs.json"))) {
8182
+ if (instruction === null && !status.setTokenMapRecorded) {
8133
8183
  const manifest = loadManifest(setDir);
8134
8184
  const frameNode = Object.keys(manifest.sourceFrames ?? {})[0] ?? manifest.reps[0]?.nodeId ?? "";
8135
- 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 };
8185
+ const invalidNote = status.setTokenMapInvalid !== void 0 ? ` (the existing set-level file is UNUSABLE \u2014 ${status.setTokenMapInvalid} \u2014 re-record it)` : "";
8186
+ 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__.${invalidNote} ${ENVELOPE_HELP}`, progress };
8136
8187
  }
8137
8188
  return instruction === null ? { complete: true, progress } : {
8138
8189
  ...instruction,
@@ -8152,7 +8203,7 @@ function runRecordNext(opts) {
8152
8203
  const payload = nextPayload(opts.setDir);
8153
8204
  emitData(opts, payload, () => {
8154
8205
  if (payload["complete"] === true) {
8155
- process.stdout.write("set complete \u2014 every planned rep has its required envelopes\n");
8206
+ process.stdout.write("set complete \u2014 every planned rep has its required envelopes, all usable, and the set-level token map is recorded\n");
8156
8207
  return;
8157
8208
  }
8158
8209
  const progress = payload["progress"];
@@ -8446,10 +8497,21 @@ function runRecordStatus(opts) {
8446
8497
  for (const r of status.reps) {
8447
8498
  process.stdout.write(`${r.missing.length === 0 ? "DONE " : "TODO "} ${r.slug.padEnd(24)} recorded=[${r.recorded.join(",")}]${r.missing.length > 0 ? ` missing=[${r.missing.join(",")}]` : ""}
8448
8499
  `);
8449
- }
8450
- process.stdout.write(status.complete ? "\nset complete\n" : `
8451
- ${status.reps.filter((r) => r.missing.length > 0).length} rep(s) pending
8500
+ for (const inv of r.invalid ?? []) {
8501
+ process.stdout.write(` ${r.slug}/${inv.tool}: recorded file is UNUSABLE \u2014 ${inv.reason} (re-record this call)
8452
8502
  `);
8503
+ }
8504
+ }
8505
+ const pending = status.reps.filter((r) => r.missing.length > 0).length;
8506
+ process.stdout.write(
8507
+ status.complete ? "\nset complete\n" : pending > 0 ? `
8508
+ ${pending} rep(s) pending
8509
+ ` : status.setTokenMapInvalid !== void 0 ? `
8510
+ all reps recorded, but the SET-LEVEL token map file is UNUSABLE (${status.setTokenMapInvalid}) \u2014 re-record it via \`record next\`
8511
+ ` : `
8512
+ all reps recorded, but the SET-LEVEL token map is missing \u2014 run \`record next\` for the get_variable_defs step (without it briefs ship an empty token table)
8513
+ `
8514
+ );
8453
8515
  });
8454
8516
  }
8455
8517
  function narrowedRoles(derived, override) {
@@ -9260,6 +9322,9 @@ function authorBehaviors(api, extras = {}) {
9260
9322
  function envelopeText(file) {
9261
9323
  return envelopeFirstTextPart(JSON.parse(readFileSync19(file, "utf8")));
9262
9324
  }
9325
+ function metadataText(file) {
9326
+ return envelopeTextContent(JSON.parse(readFileSync19(file, "utf8")));
9327
+ }
9263
9328
  function dismissEvidence(setDir, repSlugs) {
9264
9329
  for (const slug of repSlugs) {
9265
9330
  const f = path29.join(setDir, slug, "get_design_context.json");
@@ -9381,7 +9446,7 @@ function recordedTextSlots(setDir, repSlugs) {
9381
9446
  for (const slug of repSlugs) {
9382
9447
  const metaFile = path29.join(setDir, slug, "get_metadata.json");
9383
9448
  if (!existsSync23(metaFile)) continue;
9384
- const name = symbolName(envelopeText(metaFile));
9449
+ const name = symbolName(metadataText(metaFile));
9385
9450
  if (name === void 0) continue;
9386
9451
  const values = /* @__PURE__ */ new Set();
9387
9452
  for (const part of name.split(",")) {
@@ -9484,7 +9549,7 @@ function authorTaskFromSet(setDir, opts = {}) {
9484
9549
  missing.push(rep.slug);
9485
9550
  continue;
9486
9551
  }
9487
- const name = symbolName(envelopeText(metaFile));
9552
+ const name = symbolName(metadataText(metaFile));
9488
9553
  if (name === void 0) {
9489
9554
  missing.push(rep.slug);
9490
9555
  continue;
@@ -9495,7 +9560,7 @@ function authorTaskFromSet(setDir, opts = {}) {
9495
9560
  throw new Error(`recording set ${setDir} is missing metadata for ${missing.length} rep(s): ${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ", \u2026" : ""}`);
9496
9561
  }
9497
9562
  const setMeta = path29.join(setDir, "get_metadata.json");
9498
- const latticeNames = manifest.latticeNames ?? (existsSync23(setMeta) ? [...envelopeText(setMeta).matchAll(/name="([^"]*)"/g)].map((m) => decodeXmlEntities(m[1])) : void 0);
9563
+ const latticeNames = manifest.latticeNames ?? (existsSync23(setMeta) ? [...metadataText(setMeta).matchAll(/name="([^"]*)"/g)].map((m) => decodeXmlEntities(m[1])) : void 0);
9499
9564
  const defaults = { ...manifest.defaults ?? {}, ...opts.defaults ?? {} };
9500
9565
  const recordedFonts = recordedFontFamilies(setDir);
9501
9566
  const textSlots = recordedTextSlots(setDir, manifest.reps.map((r) => r.slug));
@@ -9951,7 +10016,7 @@ function fontsCssFor(faces, bundleDir, cacheDir = DEFAULT_FONT_CACHE, substitute
9951
10016
  const family = face.family.replace(/['\\]/g, "").replace(/\*\//g, "");
9952
10017
  const decl = `@font-face { font-family: '${family}'; font-weight: ${face.weight}; font-style: normal; src: url(${target}) format('${format}'); }`;
9953
10018
  const license = normalizeFontLicense(face.license);
9954
- const terms = face.source.startsWith("system:") ? void 0 : REDISTRIBUTABLE.get(license);
10019
+ const terms = face.source.startsWith("system:") || face.source.startsWith("local:") ? void 0 : REDISTRIBUTABLE.get(license);
9955
10020
  if (terms === void 0) {
9956
10021
  if (face.source.startsWith("system:")) {
9957
10022
  lines.push(
@@ -10677,11 +10742,17 @@ import { existsSync as existsSync26, readFileSync as readFileSync22 } from "node
10677
10742
  import path33 from "node:path";
10678
10743
  async function runFontsResolve(opts) {
10679
10744
  const result = await resolveFonts(opts.family, opts.weights, opts.cacheDir);
10680
- emitData(opts, result, () => {
10745
+ const queryRefusal = systemFaceRefusal(opts.family.trim());
10746
+ emitData(opts, { ...result, ...queryRefusal !== void 0 ? { queryRefusal } : {} }, () => {
10681
10747
  for (const f of result.resolved) process.stdout.write(`resolved ${f.family} ${f.weight} (${f.sha256.slice(0, 12)}\u2026)
10682
10748
  `);
10683
10749
  for (const f of result.failures) process.stdout.write(`FAILED ${f.family} ${f.weight}: ${f.reason}
10684
10750
  `);
10751
+ if (queryRefusal !== void 0 && result.failures.length > 0) {
10752
+ process.stdout.write(` "${opts.family}" is a refusal-class family: ${queryRefusal}
10753
+ Options: proceed under a disclosed substitute (rules out certification), or register a file you license yourself: ${tendrilCommand(`fonts add ${quoteArg(opts.family)} <weight> <file>`)} \u2014 bundles declare the face and never carry its bytes.
10754
+ `);
10755
+ }
10685
10756
  });
10686
10757
  if (result.failures.length > 0) {
10687
10758
  warn(opts, `${result.failures.length} face(s) unresolved \u2014 verification will refuse to score under substitution`);
@@ -10739,7 +10810,8 @@ async function runFontsResolveSet(opts) {
10739
10810
  unlicensed.push(...result.failures);
10740
10811
  }
10741
10812
  }
10742
- emitData(opts, { set: setDir, needs, cached: cached2, resolved, relicensed, unlicensed, failures }, () => {
10813
+ const refusals = [...new Set(failures.map((f) => f.family))].map((family) => ({ family, reason: systemFaceRefusal(family.trim()) })).filter((r) => r.reason !== void 0);
10814
+ emitData(opts, { set: setDir, needs, cached: cached2, resolved, relicensed, unlicensed, failures, ...refusals.length > 0 ? { refusals } : {} }, () => {
10743
10815
  process.stdout.write(`set declares: ${needs.map((n) => `${n.family} (${n.weights.join(", ")})`).join(" \xB7 ")}
10744
10816
  `);
10745
10817
  for (const f of cached2) process.stdout.write(`cached ${f.family} ${f.weight}
@@ -10753,8 +10825,11 @@ async function runFontsResolveSet(opts) {
10753
10825
  for (const f of failures) process.stdout.write(`FAILED ${f.family} ${f.weight}: ${f.reason}
10754
10826
  `);
10755
10827
  for (const fam of [...new Set(failures.map((f) => f.family))]) {
10828
+ const refusal = systemFaceRefusal(fam);
10756
10829
  process.stdout.write(
10757
- ` if "${fam}" is installed on this machine: ${tendrilCommand(`fonts discover ${quoteArg(fam)}`)} then ${tendrilCommand(`fonts add-system ${quoteArg(fam)} --set ${quoteArg(setDir)}`)} (licence stays unknown; bundles declare, never copy)
10830
+ refusal !== void 0 ? ` "${fam}" is a refusal-class family: ${refusal}
10831
+ Options: proceed and let the mount substitute a provided face (disclosed; rules out certification), or register a file you license yourself: ${tendrilCommand(`fonts add ${quoteArg(fam)} <weight> <file>`)} \u2014 licensing is yours to judge; bundles declare the face and never carry its bytes.
10832
+ ` : ` if "${fam}" is installed on this machine: ${tendrilCommand(`fonts discover ${quoteArg(fam)}`)} then ${tendrilCommand(`fonts add-system ${quoteArg(fam)} --set ${quoteArg(setDir)}`)} (licence stays unknown; bundles declare, never copy)
10758
10833
  `
10759
10834
  );
10760
10835
  }
@@ -10855,12 +10930,20 @@ function runFontsAdd(opts) {
10855
10930
  });
10856
10931
  }
10857
10932
  function runFontsDiscover(opts) {
10858
- const faces = opts.family !== void 0 ? systemFacesForFamily(opts.family) : discoverSystemFaces();
10933
+ const faces = opts.family !== void 0 ? systemFacesForFamily(opts.family, opts.dirs) : discoverSystemFaces(opts.dirs);
10859
10934
  const rows = faces.map((f) => ({ ...f, ...systemFaceRefusal(f.family) !== void 0 ? { refused: systemFaceRefusal(f.family) } : {} }));
10860
- emitData(opts, { faces: rows, dirs: systemFontDirs() }, () => {
10935
+ const queryRefusal = opts.family !== void 0 ? systemFaceRefusal(opts.family) : void 0;
10936
+ emitData(opts, { faces: rows, dirs: opts.dirs ?? systemFontDirs(), ...queryRefusal !== void 0 ? { queryRefusal } : {} }, () => {
10861
10937
  if (rows.length === 0) {
10862
- process.stdout.write(`no matching system faces found (scanned: ${systemFontDirs().join(", ")})
10938
+ process.stdout.write(`no matching system faces found (scanned: ${(opts.dirs ?? systemFontDirs()).join(", ")})
10863
10939
  `);
10940
+ if (queryRefusal !== void 0) {
10941
+ process.stdout.write(
10942
+ `note: "${opts.family}" is a refusal-class family regardless \u2014 ${queryRefusal}
10943
+ A file you license yourself can still be registered for local scoring: ${tendrilCommand(`fonts add ${quoteArg(opts.family ?? "")} <weight> <file>`)} \u2014 licensing is yours to judge, and bundles declare the face but never carry its bytes.
10944
+ `
10945
+ );
10946
+ }
10864
10947
  return;
10865
10948
  }
10866
10949
  for (const f of rows) {
@@ -11065,14 +11148,15 @@ function latticeCoverage(setManifest, scoredConfigs) {
11065
11148
  const lattice = setManifest.latticeNames?.length;
11066
11149
  const established = setManifest.variantScope === "component-set";
11067
11150
  const notRecorded = setManifest.notRecorded !== void 0 && setManifest.notRecorded !== "" ? { notRecorded: setManifest.notRecorded } : {};
11068
- if (lattice === void 0) return { latticeConfigs: null, ...notRecorded };
11151
+ const 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" : setManifest.variantScope === "component-set" ? "the planner saw the component set and it declares no variant axes \u2014 the planned pose(s) are the whole set as Figma showed it, but there is no variant lattice to count" : "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";
11152
+ if (lattice === void 0) return { latticeConfigs: null, latticeUnestablished, ...notRecorded };
11069
11153
  if (established) return { latticeConfigs: lattice, unrecordedConfigs: Math.max(0, lattice - scoredConfigs), ...notRecorded };
11070
11154
  return {
11071
11155
  latticeConfigs: null,
11072
11156
  // The count is still worth reporting — it is just not a
11073
11157
  // denominator, and the name has to stop implying that it is.
11074
11158
  variantsPlanned: lattice,
11075
- 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",
11159
+ latticeUnestablished,
11076
11160
  ...notRecorded
11077
11161
  };
11078
11162
  }
@@ -11472,9 +11556,14 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
11472
11556
  `INCOMPLETE ${cov.unrecordedConfigs} of ${cov.latticeConfigs} lattice poses were NEVER RECORDED \u2014 nothing verifies them; any implementation of those poses is inference, not verified truth. Re-plan the set (full matrix is the default) and record the missing poses.
11473
11557
  `
11474
11558
  );
11475
- } else if (cov.latticeConfigs === null) {
11476
- process.stdout.write(`COVERAGE denominator unknown (set predates lattice tracking) \u2014 scored configs are verified; completeness is not established
11559
+ } else if (typeof cov.latticeConfigs === "number") {
11560
+ process.stdout.write(`LATTICE all ${cov.latticeConfigs} poses of the component set's own lattice are recorded and scored \u2014 the denominator is the set's, not the recorded subset's
11477
11561
  `);
11562
+ } else if (cov.latticeConfigs === null) {
11563
+ process.stdout.write(
11564
+ `COVERAGE denominator unknown \u2014 ${cov.latticeUnestablished ?? "set predates lattice tracking"} \u2014 scored configs are verified; completeness is not established
11565
+ `
11566
+ );
11478
11567
  }
11479
11568
  }
11480
11569
  process.stdout.write(`environment: chrome=${report.environment.chromeVersion ?? report.environment.chrome} fonts=${report.environment.fontsManifestSha256 ?? "UNRESOLVED"}
@@ -11683,7 +11772,7 @@ ${segments}`;
11683
11772
  `Run \`${tendrilCommand(
11684
11773
  `engine score ${quoteArg(ref)} ${quoteArg(candidateDirSuggestion)} --bar ${opts.bar} --model ${opts.model ?? "<declared-model>"} --json`
11685
11774
  )}\` \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.`,
11686
- "Apply the returned feedback and re-score. Stop when all checks pass or two consecutive scores fail to improve.",
11775
+ "Apply the returned feedback and re-score. Stop when all checks pass. Two consecutive non-improving scores mean INSPECT the verify-evidence diffs before deciding \u2014 stop only when inspection yields no fix hypothesis (a measured run was byte-identical twice, then went 27/27 after reading the diffs).",
11687
11776
  "Never edit recording sets; never claim scores yourself \u2014 only the score command's output counts."
11688
11777
  ]
11689
11778
  },
@@ -11696,7 +11785,10 @@ ${segments}`;
11696
11785
  );
11697
11786
  }
11698
11787
  function appendScoreHistory(candidateDir, entry) {
11699
- appendFileSync(path35.join(candidateDir, "score-history.jsonl"), `${JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry })}
11788
+ const file = path35.join(candidateDir, "score-history.jsonl");
11789
+ const starts = existsSync28(file) ? readFileSync24(file, "utf8").split("\n").filter((l) => l.includes('"event":"round-start"')).length : 0;
11790
+ const round = entry.event === "round-start" ? starts + 1 : Math.max(1, starts);
11791
+ appendFileSync(file, `${JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), round, ...entry })}
11700
11792
  `);
11701
11793
  }
11702
11794
  async function runEngineScore(opts) {
@@ -11784,7 +11876,10 @@ ${absentFindings.join("\n")}` : "";
11784
11876
 
11785
11877
  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(", ")}.` : ""}
11786
11878
  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.`;
11787
- const feedback = buildFeedback(scores, behaviors, bar, "files") + certificationFeedback + qualityFeedback;
11879
+ const stampNotice = `
11880
+
11881
+ PROVENANCE STAMP: this scoring call itself (the Tendril CLI) just wrote/refreshed a comment on line 1 of styles.css carrying these scores, marked non-authoritative. If your editor or host reports styles.css was modified externally, that modification is this scorer \u2014 expected, not tampering. Keep the comment; it self-invalidates on any edit and \`tendril verify\` recomputes it.`;
11882
+ const feedback = buildFeedback(scores, behaviors, bar, "files") + certificationFeedback + stampNotice + qualityFeedback;
11788
11883
  const emitted = emitBundleV1({
11789
11884
  bundleDir: candidateDir,
11790
11885
  task,
@@ -11849,7 +11944,7 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
11849
11944
  parityCoverage,
11850
11945
  evidenceDir,
11851
11946
  bundleManifest: emitted.written[0],
11852
- note: "styles.css was stamped with the bundle provenance comment on line 1 \u2014 preserve it in any post-score edit",
11947
+ note: "the Tendril CLI (this scoring call) wrote the bundle provenance comment onto styles.css line 1 \u2014 an EXPECTED external modification, not tampering; preserve it in any post-score edit (it self-invalidates on edit and verify recomputes it)",
11853
11948
  allPass,
11854
11949
  environment: { ...environmentStamp(taskFontFamilies(task.set)), ruler: cliVersion() },
11855
11950
  ...lcdTextEnabled() ? { environmentOverrides: ["enable-lcd-text"] } : {},
@@ -11977,7 +12072,7 @@ function runCodeConnect(opts) {
11977
12072
  const meta = path36.join(setDir, r.slug, "get_metadata.json");
11978
12073
  if (!existsSync29(meta)) return void 0;
11979
12074
  try {
11980
- return /name="([^"]*)"/.exec(envelopeFirstTextPart(JSON.parse(readFileSync25(meta, "utf8"))))?.[1];
12075
+ return /name="([^"]*)"/.exec(envelopeTextContent(JSON.parse(readFileSync25(meta, "utf8"))))?.[1];
11981
12076
  } catch {
11982
12077
  return void 0;
11983
12078
  }
@@ -12164,11 +12259,19 @@ var init_server = __esm({
12164
12259
  },
12165
12260
  {
12166
12261
  name: "tendril_permissions",
12167
- description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges the MCP per-tool entries, the run's shell surface, and project-scoped Write/Edit with deny guards for ./.claude and ./.git into the project's .claude/settings.local.json, idempotent, never touches other keys), or list the entries without writing. The shell/Write grants are CONVENIENCE, not a security boundary \u2014 the output's note names exactly what they trade; RELAY it with the offer. 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.",
12262
+ description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges the MCP per-tool entries, the run's shell surface, and project-scoped Write/Edit with deny guards for ./.claude and ./.git into the project's .claude/settings.local.json, idempotent, never touches other keys), or list the entries without writing. The shell/Write grants are CONVENIENCE, not a security boundary \u2014 the output's note names exactly what they trade; RELAY it with the offer. 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>__* \u2014 when this session's tool names differ from the plugin defaults, pass figmaPrefix/tendrilPrefix with the prefixes you actually see, or the written entries never match. 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.",
12168
12263
  schema: z12.object({
12169
- write: z12.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries")
12264
+ write: z12.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries"),
12265
+ figmaPrefix: z12.string().optional().describe("The Figma server's entry prefix AS THIS SESSION NAMES ITS TOOLS \u2014 the part before __get_metadata (e.g. mcp__figma-remote, mcp__plugin_figma_figma). Pass it whenever the session's Figma tools are not mcp__plugin_figma_figma__* \u2014 entries written under the wrong prefix never match anything (measured: six dead entries in a figma-remote session)."),
12266
+ tendrilPrefix: z12.string().optional().describe("Same for the tendril server when this session's tendril tools are not mcp__plugin_tendril_tendril__* (e.g. mcp__tendril for claude mcp add installs).")
12170
12267
  }),
12171
- argv: (i) => ["permissions", "--claude", ...i["write"] === true ? ["--write"] : []]
12268
+ argv: (i) => [
12269
+ "permissions",
12270
+ "--claude",
12271
+ ...i["write"] === true ? ["--write"] : [],
12272
+ ...typeof i["figmaPrefix"] === "string" ? ["--figma-prefix", i["figmaPrefix"]] : [],
12273
+ ...typeof i["tendrilPrefix"] === "string" ? ["--tendril-prefix", i["tendrilPrefix"]] : []
12274
+ ]
12172
12275
  },
12173
12276
  {
12174
12277
  name: "tendril_doctor",
@@ -12293,7 +12396,7 @@ var init_server = __esm({
12293
12396
  {
12294
12397
  name: "tendril_record_status",
12295
12398
  annotations: { readOnlyHint: true },
12296
- description: "Recording-set completeness: per-rep recorded/missing tools.",
12399
+ description: "Recording-set completeness: per-rep recorded/missing tools, files that exist but are unusable (invalid \u2014 re-record those), and whether the set-level token map is recorded. `complete` means USABLE by the next pipeline step, including the set-level get_variable_defs.",
12297
12400
  schema: z12.object({ setDir: str("recording set directory") }),
12298
12401
  argv: (i) => ["record", "status", "--set", i["setDir"]]
12299
12402
  },
@@ -12451,6 +12554,13 @@ function mergeAllowlist(file, entries, denyEntries = []) {
12451
12554
  }
12452
12555
  async function buildPermissions(options) {
12453
12556
  const LEGAL_TOOL_NAME = /^[A-Za-z0-9_-]+$/;
12557
+ for (const [flag, value] of [["--figma-prefix", options.figmaPrefix], ["--tendril-prefix", options.tendrilPrefix]]) {
12558
+ if (value !== void 0 && !LEGAL_MCP_PREFIX.test(value)) {
12559
+ throw new Error(`${flag} must look like mcp__<server-name> (letters, digits, _ and - only) \u2014 got ${JSON.stringify(value)}`);
12560
+ }
12561
+ }
12562
+ const figmaPrefix = options.figmaPrefix ?? FIGMA_PLUGIN_PREFIX;
12563
+ const tendrilPrefix = options.tendrilPrefix ?? TENDRIL_PLUGIN_PREFIX;
12454
12564
  const pipeline = new Set(FIGMA_TOOL_FALLBACK);
12455
12565
  let figmaTools = FIGMA_TOOL_FALLBACK;
12456
12566
  try {
@@ -12462,10 +12572,10 @@ async function buildPermissions(options) {
12462
12572
  }
12463
12573
  return {
12464
12574
  host: "claude",
12465
- serverEntries: [TENDRIL_PLUGIN_PREFIX, FIGMA_PLUGIN_PREFIX],
12575
+ serverEntries: [tendrilPrefix, figmaPrefix],
12466
12576
  toolEntries: [
12467
- ...TOOLS.map((t) => t.name).filter((n) => LEGAL_TOOL_NAME.test(n)).map((n) => `${TENDRIL_PLUGIN_PREFIX}__${n}`),
12468
- ...figmaTools.map((name) => `${FIGMA_PLUGIN_PREFIX}__${name}`)
12577
+ ...TOOLS.map((t) => t.name).filter((n) => LEGAL_TOOL_NAME.test(n)).map((n) => `${tendrilPrefix}__${n}`),
12578
+ ...figmaTools.map((name) => `${figmaPrefix}__${name}`)
12469
12579
  ],
12470
12580
  shellEntries: [...SHELL_READONLY_ENTRIES, ...SHELL_PIPELINE_ENTRIES],
12471
12581
  projectFileEntries: PROJECT_FILE_ENTRIES,
@@ -12473,7 +12583,7 @@ async function buildPermissions(options) {
12473
12583
  manualCautionEntries: MANUAL_CAUTION_ENTRIES,
12474
12584
  shellEntriesNote: "These are CONVENIENCE grants replacing the ~300 prompts a measured run costs \u2014 not a security boundary. What they trade, named: (1) the read verbs (cat/grep/head/\u2026) let the agent read ANY file without a prompt, secrets included \u2014 remove those entries if that is unacceptable; (2) Claude Code Bash patterns are prefix matches and redirection is not a command separator (`cat x > file` rides `Bash(cat:*)`) \u2014 the host's docs call argument patterns best-effort, not an enforcement boundary (pipes and `&&`/`;` chains do decompose, each piece prompting on its own); (3) the per-subcommand CLI grants carry those subcommands' own write surface (recordings, bundles, score history, --out paths) and the npx form trusts future @latest publishes. Never auto-written: a blanket `tendril` grant (an agent could silently widen this very allowlist via `tendril permissions --write`), `find` (arbitrary delete/exec via -delete/-exec), clobber verbs \u2014 those stay a manual, deliberate addition. Write/Edit are project-scoped, written only to the project-local file, with deny entries for ./.claude/** and ./.git/** so the grant can never rewrite this settings file or plant git hooks. Every entry is removable any time.",
12475
12585
  serverEntriesCaution: "The server-wide entries allow EVERY tool those servers serve \u2014 for tendril that includes tendril_generate_curated (spends your OpenRouter budget when configured), and for Figma every tool the desktop server exposes, beyond the read tools this pipeline uses. The per-tool list is the recommended default.",
12476
- directConfigNote: `Installed via claude mcp add instead of the plugin? Replace the prefixes: ${TENDRIL_PLUGIN_PREFIX}__<tool> becomes mcp__<your-server-name>__<tool> (same for Figma).`
12586
+ directConfigNote: `Prefixes default to the plugin install shape. A different install (claude mcp add, the remote Figma server, \u2026) names its tools mcp__<server-name>__<tool> \u2014 pass the real prefixes with --figma-prefix / --tendril-prefix (MCP: figmaPrefix / tendrilPrefix) so the written entries actually match; run 20 measured six Figma entries that could never fire because the session's server was figma-remote.`
12477
12587
  };
12478
12588
  }
12479
12589
  function writeSelection(result, user) {
@@ -12484,7 +12594,21 @@ async function runPermissions(flags) {
12484
12594
  printDescription(PERMISSIONS_DESCRIPTION);
12485
12595
  return;
12486
12596
  }
12487
- const result = await buildPermissions({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} });
12597
+ let result;
12598
+ try {
12599
+ result = await buildPermissions({
12600
+ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {},
12601
+ ...flags.figmaPrefix !== void 0 ? { figmaPrefix: flags.figmaPrefix } : {},
12602
+ ...flags.tendrilPrefix !== void 0 ? { tendrilPrefix: flags.tendrilPrefix } : {}
12603
+ });
12604
+ } catch (err) {
12605
+ fail(flags, ExitCode.InputValidation, {
12606
+ error: err instanceof Error ? err.message : String(err),
12607
+ code: "bad-prefix",
12608
+ remediation: "Pass the prefix exactly as the session names its MCP tools: the part before the final __<tool> (e.g. mcp__figma-remote, mcp__plugin_figma_figma)."
12609
+ });
12610
+ return;
12611
+ }
12488
12612
  if (flags.write) {
12489
12613
  const base = process.env["INIT_CWD"] ?? process.cwd();
12490
12614
  const file = flags.user ? path38.join(os8.homedir(), ".claude", "settings.json") : path38.join(base, ".claude", "settings.local.json");
@@ -12559,7 +12683,7 @@ ${result.directConfigNote}
12559
12683
  );
12560
12684
  });
12561
12685
  }
12562
- var FIGMA_TOOL_FALLBACK, SHELL_READONLY_ENTRIES, SHELL_PIPELINE_ENTRIES, PROJECT_FILE_ENTRIES, PROJECT_DENY_ENTRIES, MANUAL_CAUTION_ENTRIES, PERMISSIONS_DESCRIPTION, TENDRIL_PLUGIN_PREFIX, FIGMA_PLUGIN_PREFIX;
12686
+ var FIGMA_TOOL_FALLBACK, SHELL_READONLY_ENTRIES, SHELL_PIPELINE_ENTRIES, PROJECT_FILE_ENTRIES, PROJECT_DENY_ENTRIES, MANUAL_CAUTION_ENTRIES, PERMISSIONS_DESCRIPTION, TENDRIL_PLUGIN_PREFIX, FIGMA_PLUGIN_PREFIX, LEGAL_MCP_PREFIX;
12563
12687
  var init_permissions = __esm({
12564
12688
  "packages/cli/src/commands/permissions.ts"() {
12565
12689
  "use strict";
@@ -12610,6 +12734,8 @@ var init_permissions = __esm({
12610
12734
  { flag: "--write", description: "Merge the pipeline allowlist (MCP tools + shell surface + project Write/Edit, with deny guards for ./.claude and ./.git) into .claude/settings.local.json in the current project (idempotent; creates the file; never touches other keys)" },
12611
12735
  { flag: "--user", description: "With --write: target ~/.claude/settings.json instead \u2014 MCP tool entries ONLY; shell and Write/Edit grants stay project-local, never every-project" },
12612
12736
  { flag: "--mcp-url <url>", description: "Figma MCP endpoint to list live tool names from", default: DEFAULT_MCP_URL },
12737
+ { flag: "--figma-prefix <prefix>", description: "The session's real Figma MCP entry prefix (e.g. mcp__figma-remote) \u2014 the plugin-install default cannot match other install shapes" },
12738
+ { flag: "--tendril-prefix <prefix>", description: "The session's real tendril MCP entry prefix (e.g. mcp__tendril for claude mcp add installs)" },
12613
12739
  { flag: "--json", description: "Machine-readable output" }
12614
12740
  ],
12615
12741
  output: {
@@ -12624,6 +12750,7 @@ var init_permissions = __esm({
12624
12750
  };
12625
12751
  TENDRIL_PLUGIN_PREFIX = "mcp__plugin_tendril_tendril";
12626
12752
  FIGMA_PLUGIN_PREFIX = "mcp__plugin_figma_figma";
12753
+ LEGAL_MCP_PREFIX = /^mcp__[A-Za-z0-9_-]+$/;
12627
12754
  }
12628
12755
  });
12629
12756
 
@@ -14114,7 +14241,7 @@ function buildProgram() {
14114
14241
  ...local["out"] !== void 0 ? { out: local["out"] } : {}
14115
14242
  });
14116
14243
  });
14117
- program.command("permissions").description("Install (or print) the Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools) \u2014 no more transcribing tool names from prompts.").option("--claude", "Claude Code settings format (the default and currently only format)").option("--write", "merge the per-tool entries into .claude/settings.local.json in the current project (idempotent; never touches other keys)").option("--user", "with --write: target ~/.claude/settings.json (every project) instead").option("--mcp-url <url>", "Figma MCP endpoint to list live tool names from").action(async (_o, cmd) => {
14244
+ program.command("permissions").description("Install (or print) the Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools) \u2014 no more transcribing tool names from prompts.").option("--claude", "Claude Code settings format (the default and currently only format)").option("--write", "merge the per-tool entries into .claude/settings.local.json in the current project (idempotent; never touches other keys)").option("--user", "with --write: target ~/.claude/settings.json \u2014 MCP tool entries only; shell and Write/Edit grants stay project-local").option("--mcp-url <url>", "Figma MCP endpoint to list live tool names from").option("--figma-prefix <prefix>", "the session's real Figma MCP entry prefix (e.g. mcp__figma-remote) when it is not the plugin default").option("--tendril-prefix <prefix>", "the session's real tendril MCP entry prefix (e.g. mcp__tendril for claude mcp add installs)").action(async (_o, cmd) => {
14118
14245
  const flags = globalFlags(cmd.parent);
14119
14246
  const local = cmd.opts();
14120
14247
  const { runPermissions: runPermissions2 } = await Promise.resolve().then(() => (init_permissions(), permissions_exports));
@@ -14122,7 +14249,9 @@ function buildProgram() {
14122
14249
  ...flags,
14123
14250
  ...local["write"] !== void 0 ? { write: local["write"] } : {},
14124
14251
  ...local["user"] !== void 0 ? { user: local["user"] } : {},
14125
- ...local["mcpUrl"] !== void 0 ? { mcpUrl: local["mcpUrl"] } : {}
14252
+ ...local["mcpUrl"] !== void 0 ? { mcpUrl: local["mcpUrl"] } : {},
14253
+ ...local["figmaPrefix"] !== void 0 ? { figmaPrefix: local["figmaPrefix"] } : {},
14254
+ ...local["tendrilPrefix"] !== void 0 ? { tendrilPrefix: local["tendrilPrefix"] } : {}
14126
14255
  });
14127
14256
  });
14128
14257
  program.command("inspect").description("Build an eye-verifiable detail sheet from verify evidence: magnified recorded-vs-rendered crops of every small recorded node (icons, controls, marks) plus full-frame triples, in one static HTML page.").argument("<bundleDir>", "bundle directory with component.json and verify-evidence").option("--set <dir>", "recording set override (default: the bundle's provenance path)").option("--max-area <px2>", "node area ceiling for the detail sweep", "1024").action(async (bundleDir, _o, cmd) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",