@tendrilapp/cli 0.1.28 → 0.1.29

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,
@@ -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);
@@ -3615,9 +3655,12 @@ function weightFromSubfamily(subfamily) {
3615
3655
  return void 0;
3616
3656
  }
3617
3657
  function systemFaceRefusal(family) {
3618
- if (/^\.?SF([ -]|$)/i.test(family) || /^San Francisco/i.test(family) || /^\.?Apple/i.test(family) || /^\.?New York$/i.test(family)) {
3658
+ 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
3659
  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
3660
  }
3661
+ if (/^\./.test(family)) {
3662
+ 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).";
3663
+ }
3621
3664
  if (/^Segoe UI Variable/i.test(family)) {
3622
3665
  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
3666
  }
@@ -3943,7 +3986,7 @@ function verifiedFontFamilies(cacheDir = DEFAULT_FONT_CACHE) {
3943
3986
  return [...byFamily.entries()].map(([family, w]) => ({ family, weights: [...w].sort((a, b) => a - b) }));
3944
3987
  }
3945
3988
  function addSystemFamily(family, opts = {}) {
3946
- const refusal = systemFaceRefusal(family);
3989
+ const refusal = systemFaceRefusal(family.trim());
3947
3990
  if (refusal !== void 0) return { added: [], skipped: [], overwrote: [], refusal };
3948
3991
  const plainStyle = (sub) => weightFromSubfamily(sub) !== void 0 || /^(regular|plain|)$/i.test(sub.trim());
3949
3992
  const faces = [...systemFacesForFamily(family, opts.dirs)].sort((a, b) => {
@@ -3961,6 +4004,11 @@ function addSystemFamily(family, opts = {}) {
3961
4004
  const taken = /* @__PURE__ */ new Set();
3962
4005
  for (const face of faces) {
3963
4006
  const skip = (reason) => skipped.push({ subfamily: face.subfamily, weight: face.weight, reason });
4007
+ const faceRefusal = systemFaceRefusal(face.family.trim());
4008
+ if (faceRefusal !== void 0) {
4009
+ skip(`refusal-class family \u2014 ${faceRefusal}`);
4010
+ continue;
4011
+ }
3964
4012
  if (opts.weights !== void 0 && !opts.weights.includes(face.weight)) continue;
3965
4013
  if (face.italic) {
3966
4014
  skip("italic \u2014 the cache cannot represent italic faces end to end yet; the upright face serves the family");
@@ -8129,10 +8177,11 @@ function nextPayload(setDir) {
8129
8177
  const instruction = nextInstruction(setDir);
8130
8178
  const status = sessionStatus(setDir);
8131
8179
  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"))) {
8180
+ if (instruction === null && !status.setTokenMapRecorded) {
8133
8181
  const manifest = loadManifest(setDir);
8134
8182
  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 };
8183
+ const invalidNote = status.setTokenMapInvalid !== void 0 ? ` (the existing set-level file is UNUSABLE \u2014 ${status.setTokenMapInvalid} \u2014 re-record it)` : "";
8184
+ 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
8185
  }
8137
8186
  return instruction === null ? { complete: true, progress } : {
8138
8187
  ...instruction,
@@ -8152,7 +8201,7 @@ function runRecordNext(opts) {
8152
8201
  const payload = nextPayload(opts.setDir);
8153
8202
  emitData(opts, payload, () => {
8154
8203
  if (payload["complete"] === true) {
8155
- process.stdout.write("set complete \u2014 every planned rep has its required envelopes\n");
8204
+ 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
8205
  return;
8157
8206
  }
8158
8207
  const progress = payload["progress"];
@@ -8446,10 +8495,21 @@ function runRecordStatus(opts) {
8446
8495
  for (const r of status.reps) {
8447
8496
  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
8497
  `);
8449
- }
8450
- process.stdout.write(status.complete ? "\nset complete\n" : `
8451
- ${status.reps.filter((r) => r.missing.length > 0).length} rep(s) pending
8498
+ for (const inv of r.invalid ?? []) {
8499
+ process.stdout.write(` ${r.slug}/${inv.tool}: recorded file is UNUSABLE \u2014 ${inv.reason} (re-record this call)
8452
8500
  `);
8501
+ }
8502
+ }
8503
+ const pending = status.reps.filter((r) => r.missing.length > 0).length;
8504
+ process.stdout.write(
8505
+ status.complete ? "\nset complete\n" : pending > 0 ? `
8506
+ ${pending} rep(s) pending
8507
+ ` : status.setTokenMapInvalid !== void 0 ? `
8508
+ all reps recorded, but the SET-LEVEL token map file is UNUSABLE (${status.setTokenMapInvalid}) \u2014 re-record it via \`record next\`
8509
+ ` : `
8510
+ 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)
8511
+ `
8512
+ );
8453
8513
  });
8454
8514
  }
8455
8515
  function narrowedRoles(derived, override) {
@@ -9260,6 +9320,9 @@ function authorBehaviors(api, extras = {}) {
9260
9320
  function envelopeText(file) {
9261
9321
  return envelopeFirstTextPart(JSON.parse(readFileSync19(file, "utf8")));
9262
9322
  }
9323
+ function metadataText(file) {
9324
+ return envelopeTextContent(JSON.parse(readFileSync19(file, "utf8")));
9325
+ }
9263
9326
  function dismissEvidence(setDir, repSlugs) {
9264
9327
  for (const slug of repSlugs) {
9265
9328
  const f = path29.join(setDir, slug, "get_design_context.json");
@@ -9381,7 +9444,7 @@ function recordedTextSlots(setDir, repSlugs) {
9381
9444
  for (const slug of repSlugs) {
9382
9445
  const metaFile = path29.join(setDir, slug, "get_metadata.json");
9383
9446
  if (!existsSync23(metaFile)) continue;
9384
- const name = symbolName(envelopeText(metaFile));
9447
+ const name = symbolName(metadataText(metaFile));
9385
9448
  if (name === void 0) continue;
9386
9449
  const values = /* @__PURE__ */ new Set();
9387
9450
  for (const part of name.split(",")) {
@@ -9484,7 +9547,7 @@ function authorTaskFromSet(setDir, opts = {}) {
9484
9547
  missing.push(rep.slug);
9485
9548
  continue;
9486
9549
  }
9487
- const name = symbolName(envelopeText(metaFile));
9550
+ const name = symbolName(metadataText(metaFile));
9488
9551
  if (name === void 0) {
9489
9552
  missing.push(rep.slug);
9490
9553
  continue;
@@ -9495,7 +9558,7 @@ function authorTaskFromSet(setDir, opts = {}) {
9495
9558
  throw new Error(`recording set ${setDir} is missing metadata for ${missing.length} rep(s): ${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ", \u2026" : ""}`);
9496
9559
  }
9497
9560
  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);
9561
+ const latticeNames = manifest.latticeNames ?? (existsSync23(setMeta) ? [...metadataText(setMeta).matchAll(/name="([^"]*)"/g)].map((m) => decodeXmlEntities(m[1])) : void 0);
9499
9562
  const defaults = { ...manifest.defaults ?? {}, ...opts.defaults ?? {} };
9500
9563
  const recordedFonts = recordedFontFamilies(setDir);
9501
9564
  const textSlots = recordedTextSlots(setDir, manifest.reps.map((r) => r.slug));
@@ -9951,7 +10014,7 @@ function fontsCssFor(faces, bundleDir, cacheDir = DEFAULT_FONT_CACHE, substitute
9951
10014
  const family = face.family.replace(/['\\]/g, "").replace(/\*\//g, "");
9952
10015
  const decl = `@font-face { font-family: '${family}'; font-weight: ${face.weight}; font-style: normal; src: url(${target}) format('${format}'); }`;
9953
10016
  const license = normalizeFontLicense(face.license);
9954
- const terms = face.source.startsWith("system:") ? void 0 : REDISTRIBUTABLE.get(license);
10017
+ const terms = face.source.startsWith("system:") || face.source.startsWith("local:") ? void 0 : REDISTRIBUTABLE.get(license);
9955
10018
  if (terms === void 0) {
9956
10019
  if (face.source.startsWith("system:")) {
9957
10020
  lines.push(
@@ -10677,11 +10740,17 @@ import { existsSync as existsSync26, readFileSync as readFileSync22 } from "node
10677
10740
  import path33 from "node:path";
10678
10741
  async function runFontsResolve(opts) {
10679
10742
  const result = await resolveFonts(opts.family, opts.weights, opts.cacheDir);
10680
- emitData(opts, result, () => {
10743
+ const queryRefusal = systemFaceRefusal(opts.family.trim());
10744
+ emitData(opts, { ...result, ...queryRefusal !== void 0 ? { queryRefusal } : {} }, () => {
10681
10745
  for (const f of result.resolved) process.stdout.write(`resolved ${f.family} ${f.weight} (${f.sha256.slice(0, 12)}\u2026)
10682
10746
  `);
10683
10747
  for (const f of result.failures) process.stdout.write(`FAILED ${f.family} ${f.weight}: ${f.reason}
10684
10748
  `);
10749
+ if (queryRefusal !== void 0 && result.failures.length > 0) {
10750
+ process.stdout.write(` "${opts.family}" is a refusal-class family: ${queryRefusal}
10751
+ 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.
10752
+ `);
10753
+ }
10685
10754
  });
10686
10755
  if (result.failures.length > 0) {
10687
10756
  warn(opts, `${result.failures.length} face(s) unresolved \u2014 verification will refuse to score under substitution`);
@@ -10739,7 +10808,8 @@ async function runFontsResolveSet(opts) {
10739
10808
  unlicensed.push(...result.failures);
10740
10809
  }
10741
10810
  }
10742
- emitData(opts, { set: setDir, needs, cached: cached2, resolved, relicensed, unlicensed, failures }, () => {
10811
+ const refusals = [...new Set(failures.map((f) => f.family))].map((family) => ({ family, reason: systemFaceRefusal(family.trim()) })).filter((r) => r.reason !== void 0);
10812
+ emitData(opts, { set: setDir, needs, cached: cached2, resolved, relicensed, unlicensed, failures, ...refusals.length > 0 ? { refusals } : {} }, () => {
10743
10813
  process.stdout.write(`set declares: ${needs.map((n) => `${n.family} (${n.weights.join(", ")})`).join(" \xB7 ")}
10744
10814
  `);
10745
10815
  for (const f of cached2) process.stdout.write(`cached ${f.family} ${f.weight}
@@ -10753,8 +10823,11 @@ async function runFontsResolveSet(opts) {
10753
10823
  for (const f of failures) process.stdout.write(`FAILED ${f.family} ${f.weight}: ${f.reason}
10754
10824
  `);
10755
10825
  for (const fam of [...new Set(failures.map((f) => f.family))]) {
10826
+ const refusal = systemFaceRefusal(fam);
10756
10827
  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)
10828
+ refusal !== void 0 ? ` "${fam}" is a refusal-class family: ${refusal}
10829
+ 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.
10830
+ ` : ` 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
10831
  `
10759
10832
  );
10760
10833
  }
@@ -10855,12 +10928,20 @@ function runFontsAdd(opts) {
10855
10928
  });
10856
10929
  }
10857
10930
  function runFontsDiscover(opts) {
10858
- const faces = opts.family !== void 0 ? systemFacesForFamily(opts.family) : discoverSystemFaces();
10931
+ const faces = opts.family !== void 0 ? systemFacesForFamily(opts.family, opts.dirs) : discoverSystemFaces(opts.dirs);
10859
10932
  const rows = faces.map((f) => ({ ...f, ...systemFaceRefusal(f.family) !== void 0 ? { refused: systemFaceRefusal(f.family) } : {} }));
10860
- emitData(opts, { faces: rows, dirs: systemFontDirs() }, () => {
10933
+ const queryRefusal = opts.family !== void 0 ? systemFaceRefusal(opts.family) : void 0;
10934
+ emitData(opts, { faces: rows, dirs: opts.dirs ?? systemFontDirs(), ...queryRefusal !== void 0 ? { queryRefusal } : {} }, () => {
10861
10935
  if (rows.length === 0) {
10862
- process.stdout.write(`no matching system faces found (scanned: ${systemFontDirs().join(", ")})
10936
+ process.stdout.write(`no matching system faces found (scanned: ${(opts.dirs ?? systemFontDirs()).join(", ")})
10863
10937
  `);
10938
+ if (queryRefusal !== void 0) {
10939
+ process.stdout.write(
10940
+ `note: "${opts.family}" is a refusal-class family regardless \u2014 ${queryRefusal}
10941
+ 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.
10942
+ `
10943
+ );
10944
+ }
10864
10945
  return;
10865
10946
  }
10866
10947
  for (const f of rows) {
@@ -11472,6 +11553,9 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
11472
11553
  `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
11554
  `
11474
11555
  );
11556
+ } else if (typeof cov.latticeConfigs === "number") {
11557
+ 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
11558
+ `);
11475
11559
  } else if (cov.latticeConfigs === null) {
11476
11560
  process.stdout.write(`COVERAGE denominator unknown (set predates lattice tracking) \u2014 scored configs are verified; completeness is not established
11477
11561
  `);
@@ -11696,7 +11780,10 @@ ${segments}`;
11696
11780
  );
11697
11781
  }
11698
11782
  function appendScoreHistory(candidateDir, entry) {
11699
- appendFileSync(path35.join(candidateDir, "score-history.jsonl"), `${JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry })}
11783
+ const file = path35.join(candidateDir, "score-history.jsonl");
11784
+ const starts = existsSync28(file) ? readFileSync24(file, "utf8").split("\n").filter((l) => l.includes('"event":"round-start"')).length : 0;
11785
+ const round = entry.event === "round-start" ? starts + 1 : Math.max(1, starts);
11786
+ appendFileSync(file, `${JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), round, ...entry })}
11700
11787
  `);
11701
11788
  }
11702
11789
  async function runEngineScore(opts) {
@@ -11784,7 +11871,10 @@ ${absentFindings.join("\n")}` : "";
11784
11871
 
11785
11872
  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
11873
  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;
11874
+ const stampNotice = `
11875
+
11876
+ 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.`;
11877
+ const feedback = buildFeedback(scores, behaviors, bar, "files") + certificationFeedback + stampNotice + qualityFeedback;
11788
11878
  const emitted = emitBundleV1({
11789
11879
  bundleDir: candidateDir,
11790
11880
  task,
@@ -11849,7 +11939,7 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
11849
11939
  parityCoverage,
11850
11940
  evidenceDir,
11851
11941
  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",
11942
+ 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
11943
  allPass,
11854
11944
  environment: { ...environmentStamp(taskFontFamilies(task.set)), ruler: cliVersion() },
11855
11945
  ...lcdTextEnabled() ? { environmentOverrides: ["enable-lcd-text"] } : {},
@@ -11977,7 +12067,7 @@ function runCodeConnect(opts) {
11977
12067
  const meta = path36.join(setDir, r.slug, "get_metadata.json");
11978
12068
  if (!existsSync29(meta)) return void 0;
11979
12069
  try {
11980
- return /name="([^"]*)"/.exec(envelopeFirstTextPart(JSON.parse(readFileSync25(meta, "utf8"))))?.[1];
12070
+ return /name="([^"]*)"/.exec(envelopeTextContent(JSON.parse(readFileSync25(meta, "utf8"))))?.[1];
11981
12071
  } catch {
11982
12072
  return void 0;
11983
12073
  }
@@ -12164,11 +12254,19 @@ var init_server = __esm({
12164
12254
  },
12165
12255
  {
12166
12256
  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.",
12257
+ 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
12258
  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")
12259
+ write: z12.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries"),
12260
+ 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)."),
12261
+ 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
12262
  }),
12171
- argv: (i) => ["permissions", "--claude", ...i["write"] === true ? ["--write"] : []]
12263
+ argv: (i) => [
12264
+ "permissions",
12265
+ "--claude",
12266
+ ...i["write"] === true ? ["--write"] : [],
12267
+ ...typeof i["figmaPrefix"] === "string" ? ["--figma-prefix", i["figmaPrefix"]] : [],
12268
+ ...typeof i["tendrilPrefix"] === "string" ? ["--tendril-prefix", i["tendrilPrefix"]] : []
12269
+ ]
12172
12270
  },
12173
12271
  {
12174
12272
  name: "tendril_doctor",
@@ -12293,7 +12391,7 @@ var init_server = __esm({
12293
12391
  {
12294
12392
  name: "tendril_record_status",
12295
12393
  annotations: { readOnlyHint: true },
12296
- description: "Recording-set completeness: per-rep recorded/missing tools.",
12394
+ 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
12395
  schema: z12.object({ setDir: str("recording set directory") }),
12298
12396
  argv: (i) => ["record", "status", "--set", i["setDir"]]
12299
12397
  },
@@ -12451,6 +12549,13 @@ function mergeAllowlist(file, entries, denyEntries = []) {
12451
12549
  }
12452
12550
  async function buildPermissions(options) {
12453
12551
  const LEGAL_TOOL_NAME = /^[A-Za-z0-9_-]+$/;
12552
+ for (const [flag, value] of [["--figma-prefix", options.figmaPrefix], ["--tendril-prefix", options.tendrilPrefix]]) {
12553
+ if (value !== void 0 && !LEGAL_MCP_PREFIX.test(value)) {
12554
+ throw new Error(`${flag} must look like mcp__<server-name> (letters, digits, _ and - only) \u2014 got ${JSON.stringify(value)}`);
12555
+ }
12556
+ }
12557
+ const figmaPrefix = options.figmaPrefix ?? FIGMA_PLUGIN_PREFIX;
12558
+ const tendrilPrefix = options.tendrilPrefix ?? TENDRIL_PLUGIN_PREFIX;
12454
12559
  const pipeline = new Set(FIGMA_TOOL_FALLBACK);
12455
12560
  let figmaTools = FIGMA_TOOL_FALLBACK;
12456
12561
  try {
@@ -12462,10 +12567,10 @@ async function buildPermissions(options) {
12462
12567
  }
12463
12568
  return {
12464
12569
  host: "claude",
12465
- serverEntries: [TENDRIL_PLUGIN_PREFIX, FIGMA_PLUGIN_PREFIX],
12570
+ serverEntries: [tendrilPrefix, figmaPrefix],
12466
12571
  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}`)
12572
+ ...TOOLS.map((t) => t.name).filter((n) => LEGAL_TOOL_NAME.test(n)).map((n) => `${tendrilPrefix}__${n}`),
12573
+ ...figmaTools.map((name) => `${figmaPrefix}__${name}`)
12469
12574
  ],
12470
12575
  shellEntries: [...SHELL_READONLY_ENTRIES, ...SHELL_PIPELINE_ENTRIES],
12471
12576
  projectFileEntries: PROJECT_FILE_ENTRIES,
@@ -12473,7 +12578,7 @@ async function buildPermissions(options) {
12473
12578
  manualCautionEntries: MANUAL_CAUTION_ENTRIES,
12474
12579
  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
12580
  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).`
12581
+ 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
12582
  };
12478
12583
  }
12479
12584
  function writeSelection(result, user) {
@@ -12484,7 +12589,21 @@ async function runPermissions(flags) {
12484
12589
  printDescription(PERMISSIONS_DESCRIPTION);
12485
12590
  return;
12486
12591
  }
12487
- const result = await buildPermissions({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} });
12592
+ let result;
12593
+ try {
12594
+ result = await buildPermissions({
12595
+ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {},
12596
+ ...flags.figmaPrefix !== void 0 ? { figmaPrefix: flags.figmaPrefix } : {},
12597
+ ...flags.tendrilPrefix !== void 0 ? { tendrilPrefix: flags.tendrilPrefix } : {}
12598
+ });
12599
+ } catch (err) {
12600
+ fail(flags, ExitCode.InputValidation, {
12601
+ error: err instanceof Error ? err.message : String(err),
12602
+ code: "bad-prefix",
12603
+ 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)."
12604
+ });
12605
+ return;
12606
+ }
12488
12607
  if (flags.write) {
12489
12608
  const base = process.env["INIT_CWD"] ?? process.cwd();
12490
12609
  const file = flags.user ? path38.join(os8.homedir(), ".claude", "settings.json") : path38.join(base, ".claude", "settings.local.json");
@@ -12559,7 +12678,7 @@ ${result.directConfigNote}
12559
12678
  );
12560
12679
  });
12561
12680
  }
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;
12681
+ 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
12682
  var init_permissions = __esm({
12564
12683
  "packages/cli/src/commands/permissions.ts"() {
12565
12684
  "use strict";
@@ -12610,6 +12729,8 @@ var init_permissions = __esm({
12610
12729
  { 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
12730
  { 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
12731
  { flag: "--mcp-url <url>", description: "Figma MCP endpoint to list live tool names from", default: DEFAULT_MCP_URL },
12732
+ { 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" },
12733
+ { flag: "--tendril-prefix <prefix>", description: "The session's real tendril MCP entry prefix (e.g. mcp__tendril for claude mcp add installs)" },
12613
12734
  { flag: "--json", description: "Machine-readable output" }
12614
12735
  ],
12615
12736
  output: {
@@ -12624,6 +12745,7 @@ var init_permissions = __esm({
12624
12745
  };
12625
12746
  TENDRIL_PLUGIN_PREFIX = "mcp__plugin_tendril_tendril";
12626
12747
  FIGMA_PLUGIN_PREFIX = "mcp__plugin_figma_figma";
12748
+ LEGAL_MCP_PREFIX = /^mcp__[A-Za-z0-9_-]+$/;
12627
12749
  }
12628
12750
  });
12629
12751
 
@@ -14114,7 +14236,7 @@ function buildProgram() {
14114
14236
  ...local["out"] !== void 0 ? { out: local["out"] } : {}
14115
14237
  });
14116
14238
  });
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) => {
14239
+ 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
14240
  const flags = globalFlags(cmd.parent);
14119
14241
  const local = cmd.opts();
14120
14242
  const { runPermissions: runPermissions2 } = await Promise.resolve().then(() => (init_permissions(), permissions_exports));
@@ -14122,7 +14244,9 @@ function buildProgram() {
14122
14244
  ...flags,
14123
14245
  ...local["write"] !== void 0 ? { write: local["write"] } : {},
14124
14246
  ...local["user"] !== void 0 ? { user: local["user"] } : {},
14125
- ...local["mcpUrl"] !== void 0 ? { mcpUrl: local["mcpUrl"] } : {}
14247
+ ...local["mcpUrl"] !== void 0 ? { mcpUrl: local["mcpUrl"] } : {},
14248
+ ...local["figmaPrefix"] !== void 0 ? { figmaPrefix: local["figmaPrefix"] } : {},
14249
+ ...local["tendrilPrefix"] !== void 0 ? { tendrilPrefix: local["tendrilPrefix"] } : {}
14126
14250
  });
14127
14251
  });
14128
14252
  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.29",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",