@bridge-ds/mcp 0.5.0 → 0.7.0

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.
@@ -14,16 +14,19 @@ import {
14
14
  __export,
15
15
  aliasOf,
16
16
  approverEntries,
17
+ approverLists,
17
18
  assertSafeBranch,
18
19
  baseBranchOf,
19
20
  branchExists,
20
21
  canShip,
21
22
  changeTable,
23
+ changesMode,
22
24
  checkLayeredPair,
23
25
  classifyLayered,
24
26
  classifyRisk,
25
27
  comboKey,
26
28
  commitToNewLocalBranch,
29
+ commitTree,
27
30
  composeProposalBody,
28
31
  contrastDelta,
29
32
  contrastDeltaTable,
@@ -71,8 +74,9 @@ import {
71
74
  unreadableShipTeams,
72
75
  unreadableTeams,
73
76
  validateLayered,
77
+ variantKeyOf,
74
78
  whoCanShipSentence
75
- } from "./chunk-QTO4EP6V.js";
79
+ } from "./chunk-BZ3KXXMM.js";
76
80
 
77
81
  // src/lib.ts
78
82
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -102,11 +106,11 @@ function parseJson(text) {
102
106
  const skipWs = () => {
103
107
  while (i < n && isWs(text[i])) i++;
104
108
  };
105
- const fail2 = (msg) => {
109
+ const fail3 = (msg) => {
106
110
  throw new JsonParseError(`${msg} at offset ${i}`, i);
107
111
  };
108
112
  const parseString = () => {
109
- if (text[i] !== '"') fail2("Expected string");
113
+ if (text[i] !== '"') fail3("Expected string");
110
114
  const start = i;
111
115
  i++;
112
116
  while (i < n) {
@@ -121,7 +125,7 @@ function parseJson(text) {
121
125
  }
122
126
  i++;
123
127
  }
124
- return fail2("Unterminated string");
128
+ return fail3("Unterminated string");
125
129
  };
126
130
  const parseValue = () => {
127
131
  skipWs();
@@ -139,7 +143,7 @@ function parseJson(text) {
139
143
  skipWs();
140
144
  const key = parseString();
141
145
  skipWs();
142
- if (text[i] !== ":") fail2('Expected ":"');
146
+ if (text[i] !== ":") fail3('Expected ":"');
143
147
  i++;
144
148
  const value = parseValue();
145
149
  entries.push({ key, value });
@@ -152,7 +156,7 @@ function parseJson(text) {
152
156
  i++;
153
157
  break;
154
158
  }
155
- fail2('Expected "," or "}"');
159
+ fail3('Expected "," or "}"');
156
160
  }
157
161
  }
158
162
  const raw = text.slice(start, i);
@@ -175,7 +179,7 @@ function parseJson(text) {
175
179
  i++;
176
180
  break;
177
181
  }
178
- fail2('Expected "," or "]"');
182
+ fail3('Expected "," or "]"');
179
183
  }
180
184
  }
181
185
  const raw = text.slice(start, i);
@@ -194,7 +198,7 @@ function parseJson(text) {
194
198
  node = { kind: "null", value: null, raw: "null" };
195
199
  } else {
196
200
  const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i, i + 64));
197
- if (!m) return fail2("Unexpected token");
201
+ if (!m) return fail3("Unexpected token");
198
202
  i += m[0].length;
199
203
  node = { kind: "number", value: Number(m[0]), raw: m[0] };
200
204
  }
@@ -210,7 +214,7 @@ function parseJson(text) {
210
214
  const root = parseValue();
211
215
  const trailingStart = i;
212
216
  skipWs();
213
- if (i < n) fail2("Unexpected content after JSON value");
217
+ if (i < n) fail3("Unexpected content after JSON value");
214
218
  const trailing = text.slice(trailingStart);
215
219
  return { root, leading, trailing, style: detectStyle(text, root) };
216
220
  }
@@ -956,8 +960,8 @@ function encodePicks(picks) {
956
960
  var RISK_TIER = RISK_LABELS[3];
957
961
  function slugify(text, max = 40) {
958
962
  const s = text.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
959
- const cut = s.length <= max ? s : s.slice(0, max + 1).replace(/-[^-]*$/, "");
960
- return cut.replace(/-+$/g, "") || "update";
963
+ const cut2 = s.length <= max ? s : s.slice(0, max + 1).replace(/-[^-]*$/, "");
964
+ return cut2.replace(/-+$/g, "") || "update";
961
965
  }
962
966
 
963
967
  // ../core/src/changeset.ts
@@ -1571,10 +1575,16 @@ var GitHubClient = class _GitHubClient {
1571
1575
  async addComment(issueNumber, body) {
1572
1576
  await this.request("POST", `${this.repoPath}/issues/${issueNumber}/comments`, { body });
1573
1577
  }
1574
- /** Comments on an issue or pull request, oldest first (first 100: the sticky preview comment is posted early). */
1578
+ /**
1579
+ * Comments on an issue or pull request, oldest first (first 100: the sticky preview comment is posted early).
1580
+ * `authorAssociation` is GitHub's OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR, NONE... (suggestion approvals read it).
1581
+ */
1575
1582
  async listIssueComments(issueNumber) {
1576
- const r = await this.request("GET", `${this.repoPath}/issues/${issueNumber}/comments?per_page=100`);
1577
- return r.map((c) => ({ id: c.id, body: c.body ?? "", author: c.user?.login ?? "unknown", updatedAt: c.updated_at }));
1583
+ const r = await this.request(
1584
+ "GET",
1585
+ `${this.repoPath}/issues/${issueNumber}/comments?per_page=100`
1586
+ );
1587
+ return r.map((c) => ({ id: c.id, body: c.body ?? "", author: c.user?.login ?? "unknown", updatedAt: c.updated_at, createdAt: c.created_at ?? c.updated_at, authorAssociation: c.author_association ?? "NONE" }));
1578
1588
  }
1579
1589
  // --- Issues (component change requests) ---------------------------------------------------
1580
1590
  async createIssue(input) {
@@ -2498,7 +2508,7 @@ function finishPlan(p) {
2498
2508
  const prBody = composeProposalBody({ summary: p.summary, changes, contrast, impact, tier, validationErrors: newErrors.length, tool: p.tool ?? "Bridge", ...p.author ? { author: p.author } : {} });
2499
2509
  const changeset = config.changesets ? { path: `${config.changesets.dir.replace(/\/$/, "")}/bridge-${slugify(p.title, 48) || p.kind}.md`, content: formatChangeset(config.changesets.package, tier.bump, p.title.replace(/\.?$/, ".")) } : void 0;
2500
2510
  const files = [...result.touched.map((f) => ({ path: current.repoPath(f), content: result.files.get(f) })), ...changeset ? [changeset] : []];
2501
- const commitMessage = `${p.kind === "revert" ? "revert(tokens)" : "fix(tokens)"}: ${p.title.replace(/^Revert: /, "")}
2511
+ const commitMessage2 = `${p.kind === "revert" ? "revert(tokens)" : "fix(tokens)"}: ${p.title.replace(/^Revert: /, "")}
2502
2512
 
2503
2513
  ${p.source?.pr ? `Reverts #${p.source.pr.number}. ` : ""}Tier ${tier.tier}. Planned with ${p.tool ?? "Bridge"}.`;
2504
2514
  const blocked = newErrors.length || newFailures.length;
@@ -2513,7 +2523,7 @@ ${p.source?.pr ? `Reverts #${p.source.pr.number}. ` : ""}Tier ${tier.tier}. Plan
2513
2523
  tier,
2514
2524
  files,
2515
2525
  ...changeset ? { changeset } : {},
2516
- commitMessage,
2526
+ commitMessage: commitMessage2,
2517
2527
  prBody,
2518
2528
  notes
2519
2529
  };
@@ -2741,7 +2751,7 @@ function toOklch(rgb) {
2741
2751
  return { L, C, h };
2742
2752
  }
2743
2753
  var isHex = (s) => typeof s === "string" && /^#[0-9a-f]{6}$/i.test(s.trim());
2744
- var fromHex = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
2754
+ var fromHex = (hex2) => [1, 3, 5].map((i) => parseInt(hex2.slice(i, i + 2), 16) / 255);
2745
2755
  var round3 = (x) => Math.round(x * 1e3) / 1e3;
2746
2756
  var toHex = (rgb) => "#" + rgb.map((x) => Math.round(x * 255).toString(16).padStart(2, "0")).join("");
2747
2757
  function dtcgColor(rgb, alpha) {
@@ -2763,20 +2773,20 @@ function nearestStep(rgb) {
2763
2773
  for (const s of STEPS) if (Math.abs(LIGHTNESS[s] - L) < Math.abs(LIGHTNESS[best] - L)) best = s;
2764
2774
  return best;
2765
2775
  }
2766
- function anchoredRamp(hex, pinStep) {
2767
- const rgb = fromHex(hex);
2776
+ function anchoredRamp(hex2, pinStep) {
2777
+ const rgb = fromHex(hex2);
2768
2778
  const { L, C, h } = toOklch(rgb);
2769
2779
  const step = pinStep ?? nearestStep(rgb);
2770
2780
  const peak = Math.min(0.24, Math.max(0.02, C / BELL[step]));
2771
- return { hue: h, peak, step, distance: Math.abs(L - LIGHTNESS[step]), ramp: generateRamp({ hue: h, chroma: bellChroma(peak), pins: { [step]: hex.toUpperCase() } }) };
2781
+ return { hue: h, peak, step, distance: Math.abs(L - LIGHTNESS[step]), ramp: generateRamp({ hue: h, chroma: bellChroma(peak), pins: { [step]: hex2.toUpperCase() } }) };
2772
2782
  }
2773
2783
  function neutralOffset() {
2774
2784
  const off = {};
2775
- for (const [s, hex] of Object.entries(NEUTRAL_ANCHORS)) off[s] = toOklch(fromHex(hex)).L - LIGHTNESS[s];
2785
+ for (const [s, hex2] of Object.entries(NEUTRAL_ANCHORS)) off[s] = toOklch(fromHex(hex2)).L - LIGHTNESS[s];
2776
2786
  return (s) => off[s] ?? 0;
2777
2787
  }
2778
- function tintedNeutral(hex) {
2779
- const rgb = fromHex(hex);
2788
+ function tintedNeutral(hex2) {
2789
+ const rgb = fromHex(hex2);
2780
2790
  const { C, h } = toOklch(rgb);
2781
2791
  const ref = neutralChroma()(nearestStep(rgb));
2782
2792
  const scale = C < 4e-3 ? 0 : Math.min(2.5, C / ref);
@@ -2850,19 +2860,19 @@ function rampFor(input, pinStep, kind) {
2850
2860
  const ramp = kind === "neutral" ? generateRamp({ hue: hue2, chroma: neutralChroma(), offset: neutralOffset() }) : generateRamp({ hue: hue2, chroma: bellChroma(DEFAULT_PRIMARY_CHROMA) });
2851
2861
  return { ramp, anchor: `OKLCH hue ${Math.round(hue2)}` };
2852
2862
  }
2853
- const hex = String(input).trim();
2854
- if (!isHex(hex)) throw new Error(`"${hex}" is not a color. Use #rrggbb or a hue in degrees (0 to 360).`);
2863
+ const hex2 = String(input).trim();
2864
+ if (!isHex(hex2)) throw new Error(`"${hex2}" is not a color. Use #rrggbb or a hue in degrees (0 to 360).`);
2855
2865
  if (kind === "neutral") {
2856
- const n = tintedNeutral(hex);
2857
- return { ramp: n.ramp, anchor: `${hex.toUpperCase()} (hue ${Math.round(n.hue)})` };
2866
+ const n = tintedNeutral(hex2);
2867
+ return { ramp: n.ramp, anchor: `${hex2.toUpperCase()} (hue ${Math.round(n.hue)})` };
2858
2868
  }
2859
- const a = anchoredRamp(hex, pinStep);
2869
+ const a = anchoredRamp(hex2, pinStep);
2860
2870
  const issue = a.distance > PIN_TOLERANCE ? {
2861
2871
  level: "warning",
2862
2872
  code: "pin-off-ladder",
2863
- message: `${hex.toUpperCase()} is ${a.distance > 0.12 ? "much " : ""}${toOklch(fromHex(hex)).L > LIGHTNESS[a.step] ? "lighter" : "darker"} than the ${a.step} step it is pinned to, so the ramp around it will not read as one family. Primary actions use this step: pick a ${a.step >= 600 ? "deeper" : "lighter"} color.`
2873
+ message: `${hex2.toUpperCase()} is ${a.distance > 0.12 ? "much " : ""}${toOklch(fromHex(hex2)).L > LIGHTNESS[a.step] ? "lighter" : "darker"} than the ${a.step} step it is pinned to, so the ramp around it will not read as one family. Primary actions use this step: pick a ${a.step >= 600 ? "deeper" : "lighter"} color.`
2864
2874
  } : void 0;
2865
- return { ramp: a.ramp, anchor: `${hex.toUpperCase()} pinned at ${a.step}`, ...issue ? { issue } : {} };
2875
+ return { ramp: a.ramp, anchor: `${hex2.toUpperCase()} pinned at ${a.step}`, ...issue ? { issue } : {} };
2866
2876
  }
2867
2877
  function aliasRamp(doc, role, ramp, description) {
2868
2878
  const group = getAt(doc, `palette.${role}`);
@@ -3401,14 +3411,30 @@ function patchDocsJson(original, next) {
3401
3411
  function cleanDraftText(text) {
3402
3412
  return text.replace(/\s*\u2014\s*/g, ", ").replace(/,\s*,/g, ",").replace(/^,\s*/, "").replace(/[ \t]+/g, " ").trim();
3403
3413
  }
3414
+ var andList = (xs) => xs.length <= 1 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
3404
3415
  var capital = (s) => s.charAt(0).toUpperCase() + s.slice(1);
3416
+ var VARIANT_FIRST = ["variant", "tone", "intent", "appearance", "kind", "type", "style"];
3417
+ function shortVariant(key) {
3418
+ const pairs = key.split(", ").map((p) => {
3419
+ const at = p.indexOf("=");
3420
+ return at < 0 ? { k: "", v: p.trim() } : { k: p.slice(0, at).trim(), v: p.slice(at + 1).trim() };
3421
+ });
3422
+ const kept = pairs.filter((p) => p.v && !/^default$/i.test(p.v));
3423
+ if (!kept.length) return key;
3424
+ const rank2 = (k) => {
3425
+ const i = VARIANT_FIRST.indexOf(k.toLowerCase());
3426
+ return i < 0 ? VARIANT_FIRST.length : i;
3427
+ };
3428
+ return kept.map((p, i) => ({ ...p, i })).sort((a, b) => rank2(a.k) - rank2(b.k) || a.i - b.i).map((p) => p.v).join(" ");
3429
+ }
3430
+ var shortVariants = (list) => list ? andList(list.split("; ").map(shortVariant)) : "every variant";
3405
3431
  var DRAFT_LINE_MAX = 120;
3406
3432
  function clipText(text, max) {
3407
3433
  const t = text.replace(/\s+/g, " ").trim().replace(/\.$/, "");
3408
3434
  if (t.length <= max) return t;
3409
- const cut = t.slice(0, max + 1);
3410
- const at = cut.lastIndexOf(" ");
3411
- return (at > max * 0.5 ? cut.slice(0, at) : t.slice(0, max)).replace(/[\s,;:(]+$/, "");
3435
+ const cut2 = t.slice(0, max + 1);
3436
+ const at = cut2.lastIndexOf(" ");
3437
+ return (at > max * 0.5 ? cut2.slice(0, at) : t.slice(0, max)).replace(/[\s,;:(]+$/, "");
3412
3438
  }
3413
3439
  function sanitizeDraftLine(raw) {
3414
3440
  if (typeof raw !== "string") return "";
@@ -3445,8 +3471,8 @@ function validateDocsTask(x) {
3445
3471
  }
3446
3472
  var docsTaskKeys = (t) => t.kind === "sections" ? t.keys : [t.key];
3447
3473
  function parseDocsReply(text) {
3448
- const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
3449
- const body = fenced ? fenced[1] : text;
3474
+ const fenced2 = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
3475
+ const body = fenced2 ? fenced2[1] : text;
3450
3476
  const start = body.indexOf("{");
3451
3477
  const end = body.lastIndexOf("}");
3452
3478
  if (start < 0 || end <= start) throw new Error("The reply holds no JSON object.");
@@ -3769,6 +3795,16 @@ function parseRequestIssue(body) {
3769
3795
  return void 0;
3770
3796
  }
3771
3797
  }
3798
+ function declineReasonProblems(reason) {
3799
+ const r = reason.trim();
3800
+ if (!r) return ["Say why, in one or two sentences: the requester reads it."];
3801
+ if (r.length < 10) return ["Say a bit more, so the requester knows why: at least 10 characters."];
3802
+ return [];
3803
+ }
3804
+ function declineComment(reason, by) {
3805
+ const quote2 = reason.trim().split("\n").map((l) => `> ${l}`.trimEnd()).join("\n");
3806
+ return [`Declined${by ? ` by @${by.replace(/^@/, "")}` : ""} in Bridge Studio.`, "", quote2, "", "<sub>Closed as not planned. Reopen the issue if things change.</sub>"].join("\n");
3807
+ }
3772
3808
  function requestStatus(issue) {
3773
3809
  const prs = issue.linkedPrs ?? [];
3774
3810
  if (issue.state === "open") return prs.some((p) => p.state === "open") ? "in-progress" : "requested";
@@ -3905,9 +3941,9 @@ function serializePendingDrafts(p) {
3905
3941
  function clip(text, max) {
3906
3942
  const t = text.replace(/\s+/g, " ").trim().replace(/\.$/, "");
3907
3943
  if (t.length <= max) return t;
3908
- const cut = t.slice(0, max + 1);
3909
- const at = cut.lastIndexOf(" ");
3910
- return (at > max * 0.5 ? cut.slice(0, at) : t.slice(0, max)).replace(/[\s,;:]+$/, "");
3944
+ const cut2 = t.slice(0, max + 1);
3945
+ const at = cut2.lastIndexOf(" ");
3946
+ return (at > max * 0.5 ? cut2.slice(0, at) : t.slice(0, max)).replace(/[\s,;:]+$/, "");
3911
3947
  }
3912
3948
  function sentences(text) {
3913
3949
  const t = text.replace(/\s+/g, " ").trim();
@@ -3985,13 +4021,13 @@ function draftItemBrief(i) {
3985
4021
  return [`Item ${i.id}: component docs${i.component ? ` of ${i.component}` : ""} from Bridge Studio (${i.docs.path ?? i.docs.slug}): ${what}.`, "", i.docs.prompt].join("\n");
3986
4022
  }
3987
4023
  const kind = i.kind ? REQUEST_KINDS.find((k) => k.value === i.kind)?.label : void 0;
3988
- const cell3 = (s) => (s ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
4024
+ const cell4 = (s) => (s ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
3989
4025
  const lines2 = [`Item ${i.id}: ${i.scope === "request" ? "a component change request (a GitHub issue developers implement)" : "the description of a pull request with design token (variable) edits from Figma"}.`];
3990
4026
  if (i.component) lines2.push(`Component: ${i.component}`);
3991
4027
  if (kind) lines2.push(`Kind: ${kind}`);
3992
4028
  if (i.components?.length) lines2.push(`Components with a change request: ${i.components.join(", ")}`);
3993
- if (i.changes.length) lines2.push("", "| What | Now | In Figma |", "| --- | --- | --- |", ...i.changes.map((c) => `| ${cell3(c.field)} | ${cell3(c.before) || "none"} | ${cell3(c.after) || "removed"} |`));
3994
- if (i.variables?.length) lines2.push("", "| Token | Now | In Figma |", "| --- | --- | --- |", ...i.variables.map((v) => `| ${cell3(v.path)} | ${cell3(v.before) || "none"} | ${cell3(v.after) || "removed"} |`));
4029
+ if (i.changes.length) lines2.push("", "| What | Now | In Figma |", "| --- | --- | --- |", ...i.changes.map((c) => `| ${cell4(c.field)} | ${cell4(c.before) || "none"} | ${cell4(c.after) || "removed"} |`));
4030
+ if (i.variables?.length) lines2.push("", "| Token | Now | In Figma |", "| --- | --- | --- |", ...i.variables.map((v) => `| ${cell4(v.path)} | ${cell4(v.before) || "none"} | ${cell4(v.after) || "removed"} |`));
3995
4031
  if (!i.changes.length && !i.variables?.length && !i.lines?.length) lines2.push("", "No change detected: rely on the notes and the images.");
3996
4032
  if (i.draft.title || i.draft.summary) lines2.push("", "Starting draft, written from the changes only:", `Title: ${i.draft.title}`, `Summary: ${i.draft.summary}`);
3997
4033
  if (i.notes?.trim()) lines2.push("", "The designer's notes (keep their intent):", i.notes.trim());
@@ -4157,6 +4193,14 @@ async function closeExplorationOnGitHub(gh, ref) {
4157
4193
  return { number: p.number, url: p.url, branch: p.headRef };
4158
4194
  }
4159
4195
 
4196
+ // ../core/src/requests-github.ts
4197
+ async function declineRequestOnGitHub(gh, n, reason, by) {
4198
+ const problems = declineReasonProblems(reason);
4199
+ if (problems.length) throw new GitHubError(422, problems.join(" "));
4200
+ await gh.addComment(n, declineComment(reason, by));
4201
+ await gh.updateIssue(n, { state: "closed", state_reason: "not_planned" });
4202
+ }
4203
+
4160
4204
  // ../core/src/review-gate.ts
4161
4205
  function parseTier(labels, body) {
4162
4206
  const found = [];
@@ -4614,309 +4658,325 @@ function branchWithJiraKey(branch, key) {
4614
4658
  return i < 0 ? `${key}-${branch}` : `${branch.slice(0, i + 1)}${key}-${branch.slice(i + 1)}`;
4615
4659
  }
4616
4660
 
4617
- // ../core/src/docs-proposal.ts
4618
- var DOCS_EDITOR_ORIGIN = {
4619
- where: "in the Bridge docs editor",
4620
- footer: "Written by a person in the Bridge docs editor. No AI involved in this text.",
4621
- label: "source: bridge-web"
4622
- };
4623
- var DOCS_MCP_ORIGIN = {
4624
- where: "with Claude through the Bridge MCP server",
4625
- footer: 'Proposed with the Bridge MCP server on behalf of a person. Docs Claude drafted say "draftedBy": "claude" until a reviewer accepts them.',
4626
- label: "source: mcp"
4627
- };
4628
- var EM_DASH = /\u2014/;
4629
- function validateDocs(d) {
4630
- const out = [];
4631
- if (!d.summary.trim()) out.push({ field: "summary", message: "Add a summary: one or two sentences on what it is for." });
4632
- const lists = ["whenToUse", "whenNotToUse", "accessibility", "contentGuidelines"];
4633
- for (const k of lists) (d[k] ?? []).forEach((x, i) => !x.trim() && out.push({ field: `${k}.${i}`, message: "Fill or remove the empty item." }));
4634
- for (const k of ["dos", "donts"]) d[k].forEach((g, i) => (!g.title.trim() || !g.text.trim()) && out.push({ field: `${k}.${i}`, message: "Give every item a title and a sentence." }));
4635
- const prose = JSON.stringify([d.summary, d.whenToUse, d.whenNotToUse, d.dos, d.donts, d.accessibility, d.contentGuidelines ?? [], d.anatomy ?? {}]);
4636
- if (EM_DASH.test(prose)) out.push({ field: "voice", message: "Replace the em dashes with a colon, a comma or two sentences." });
4637
- return out;
4661
+ // ../core/src/suggestion-review.ts
4662
+ var SUGGESTION_REVIEW_MARK = "bridge-suggestion-review";
4663
+ var SUGGESTION_APPLIED_MARK = "bridge-suggestion-applied";
4664
+ var EVENTS = ["approve", "request-changes", "comment"];
4665
+ var ROLE_TEXT = { design: "for design", tech: "for tech", approval: "" };
4666
+ function composeSuggestionReviewComment(r) {
4667
+ const who = r.by ? ` by @${r.by.replace(/^@/, "")}` : "";
4668
+ const role = r.role ? ROLE_TEXT[r.role] : "";
4669
+ const head = r.event === "approve" ? `Approved${role ? ` ${role}` : ""}${who} in Bridge.` : r.event === "request-changes" ? `Changes requested${who} in Bridge.` : `Comment${who} in Bridge.`;
4670
+ const quote2 = r.message?.trim() ? r.message.trim().split("\n").map((l) => `> ${l}`.trimEnd()).join("\n") : "";
4671
+ const record = { event: r.event, ...r.role ? { role: r.role } : {}, payloadSha: r.payloadSha, at: r.at };
4672
+ return [head, ...quote2 ? ["", quote2] : [], "", `<sub>Applies to version \`${r.payloadSha.slice(0, 7)}\` of this suggestion.</sub>`, "", `<!-- ${SUGGESTION_REVIEW_MARK} ${JSON.stringify(record)} -->`].join("\n");
4673
+ }
4674
+ function parseSuggestionReviewComment(body) {
4675
+ const m = new RegExp(`<!-- ${SUGGESTION_REVIEW_MARK} (\\{[^\\n]*?\\}) -->`).exec(body ?? "");
4676
+ if (!m) return null;
4677
+ try {
4678
+ const r = JSON.parse(m[1]);
4679
+ if (!r || !EVENTS.includes(r.event) || typeof r.payloadSha !== "string" || !/^[0-9a-f]{64}$/.test(r.payloadSha)) return null;
4680
+ return {
4681
+ event: r.event,
4682
+ ...r.role === "design" || r.role === "tech" || r.role === "approval" ? { role: r.role } : {},
4683
+ payloadSha: r.payloadSha,
4684
+ at: typeof r.at === "string" ? r.at : ""
4685
+ };
4686
+ } catch {
4687
+ return null;
4688
+ }
4638
4689
  }
4639
- function cleanDocs(d) {
4640
- const list = (xs) => (xs ?? []).map((x) => x.trim()).filter(Boolean);
4641
- const guide = (xs) => xs.map((g) => ({ ...g, title: g.title.trim(), text: g.text.trim() })).filter((g) => g.title || g.text);
4642
- return {
4643
- ...d,
4644
- summary: d.summary.trim(),
4645
- whenToUse: list(d.whenToUse),
4646
- whenNotToUse: list(d.whenNotToUse),
4647
- dos: guide(d.dos),
4648
- donts: guide(d.donts),
4649
- accessibility: list(d.accessibility),
4650
- contentGuidelines: list(d.contentGuidelines),
4651
- anatomy: Object.fromEntries(Object.entries(d.anatomy ?? {}).filter(([k, v]) => k.trim() && v.trim()))
4652
- };
4690
+ function composeSuggestionAppliedComment(r) {
4691
+ const where2 = r.pr ? `in pull request #${r.pr}` : r.branch ? `on \`${r.branch}\`` : r.commit ? `in commit ${r.commit.slice(0, 7)}` : "locally";
4692
+ const record = Object.fromEntries(Object.entries(r).filter(([, v]) => v !== void 0));
4693
+ return [
4694
+ `Being applied ${where2}${r.by ? ` by @${r.by.replace(/^@/, "")}` : ""}.${r.forced ? " Applied before every approval was in." : ""}`,
4695
+ "",
4696
+ `<!-- ${SUGGESTION_APPLIED_MARK} ${JSON.stringify(record)} -->`
4697
+ ].join("\n");
4653
4698
  }
4654
- var docsFileText = (d, original) => patchDocsJson(original, cleanDocs(d));
4655
- var slugDate = (d) => d.toISOString().slice(0, 10).replace(/-/g, "");
4656
- var docsSlug = (nameOrSlug) => nameOrSlug.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9-]+/g, "-").slice(0, 40);
4657
- function defaultDocsFooter(edits, origin) {
4658
- if (origin.label !== DOCS_EDITOR_ORIGIN.label) return origin.footer;
4659
- const claude = edits.some((e) => {
4660
- try {
4661
- return JSON.parse(e.after).draftedBy === "claude";
4662
- } catch {
4663
- return false;
4699
+ function parseSuggestionAppliedComment(body) {
4700
+ const m = new RegExp(`<!-- ${SUGGESTION_APPLIED_MARK} (\\{[^\\n]*?\\}) -->`).exec(body ?? "");
4701
+ if (!m) return null;
4702
+ try {
4703
+ const r = JSON.parse(m[1]);
4704
+ return r && typeof r.payloadSha === "string" ? { ...r, payloadSha: r.payloadSha, at: typeof r.at === "string" ? r.at : "" } : null;
4705
+ } catch {
4706
+ return null;
4707
+ }
4708
+ }
4709
+ var norm2 = (l) => l.trim().replace(/^@/, "").toLowerCase();
4710
+ var COLLABORATOR = ["OWNER", "MEMBER", "COLLABORATOR"];
4711
+ function summarizeSuggestionApprovals(comments, gov, opts) {
4712
+ const lists = approverLists(gov.owners, gov.rights ?? null, gov.teams);
4713
+ const owners = new Set([...lists.design, ...lists.tech].map(norm2));
4714
+ const anyOwner = owners.size > 0;
4715
+ const reviews = [];
4716
+ const latestOld = /* @__PURE__ */ new Map();
4717
+ const ignored = /* @__PURE__ */ new Set();
4718
+ const ordered2 = comments.map((c, i) => ({ c, i })).sort((a, b) => String(a.c.createdAt ?? "").localeCompare(String(b.c.createdAt ?? "")) || a.i - b.i);
4719
+ ordered2.forEach(({ c }, i) => {
4720
+ const r = parseSuggestionReviewComment(c.body);
4721
+ if (!r || r.event === "comment" || !c.author) return;
4722
+ const login = norm2(c.author);
4723
+ if (opts.author && login === norm2(opts.author)) return;
4724
+ const counts = anyOwner ? owners.has(login) : COLLABORATOR.includes((c.authorAssociation ?? "").toUpperCase());
4725
+ if (!counts) {
4726
+ ignored.add(c.author);
4727
+ return;
4728
+ }
4729
+ const at = String(i).padStart(6, "0");
4730
+ if (r.payloadSha !== opts.payloadSha) {
4731
+ latestOld.set(c.author, r.event);
4732
+ reviews.push({ user: { login: c.author }, state: "DISMISSED", submitted_at: at });
4733
+ return;
4664
4734
  }
4735
+ latestOld.delete(c.author);
4736
+ reviews.push({ user: { login: c.author }, state: r.event === "approve" ? "APPROVED" : "CHANGES_REQUESTED", submitted_at: at });
4665
4737
  });
4666
- return claude ? `Drafted by Claude AI (the docs say "draftedBy": "claude"), sent by a person ${origin.where}.` : origin.footer;
4738
+ const summary2 = summarizeApprovals(reviews, lists, opts.author ?? void 0);
4739
+ const stale = [...latestOld.keys()].filter((l) => !summary2.approvers.includes(l) && !summary2.changesRequested.includes(l));
4740
+ return { ...summary2, stale, ignored: [...ignored].filter((l) => !summary2.approvers.includes(l)) };
4741
+ }
4742
+ function suggestionReviewState(tier, approvals) {
4743
+ if (approvals.changesRequested.length) return "changes-requested";
4744
+ return pendingApprovals(tier, approvals).length ? "needs-review" : "approved";
4667
4745
  }
4668
- function withAuthorLine(body, line) {
4669
- const i = body.lastIndexOf("\n---\n");
4670
- return i < 0 ? `${body.trimEnd()}
4671
4746
 
4672
- ---
4673
- ${line}` : `${body.slice(0, i + 5)}${line}`;
4747
+ // ../core/src/figma-push.ts
4748
+ var where = (r) => r.allVariants ? "every variant" : shortVariants(r.variants.join("; "));
4749
+ var cap = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
4750
+ function planComponentPush(d, ctx) {
4751
+ const issues = [...d.unknown];
4752
+ const reasons = [];
4753
+ const edits = /* @__PURE__ */ new Map();
4754
+ const also = /* @__PURE__ */ new Set();
4755
+ for (const r of d.rebinds) {
4756
+ const label = `${cap(r.kind)} in ${where(r)}`;
4757
+ if (!r.after.length) {
4758
+ issues.push({ what: `${label} is no longer bound to a variable: it uses a raw value.`, todo: "Bind it to a variable, or create the variable and push it with the variables, then push again." });
4759
+ continue;
4760
+ }
4761
+ const missing = r.after.filter((p) => !ctx.tierOf(p));
4762
+ if (missing.length) {
4763
+ issues.push({ what: `${label} uses ${missing.join(", ")}, a variable the library does not have yet.`, todo: "Push the variable first (Variables, above), then push this component again." });
4764
+ continue;
4765
+ }
4766
+ if (!r.before.length) {
4767
+ reasons.push(`${label} is bound to ${r.after.join(", ")} where code paints nothing: Claude AI adds it to the CSS.`);
4768
+ continue;
4769
+ }
4770
+ if (r.before.length !== 1 || r.after.length !== 1) {
4771
+ reasons.push(`${label} moved from ${r.before.join(", ")} to ${r.after.join(", ")}: several variables on one property, so Claude AI writes the CSS.`);
4772
+ continue;
4773
+ }
4774
+ const [from, to] = [r.before[0], r.after[0]];
4775
+ if (ctx.tierOf(from) !== "component") {
4776
+ reasons.push(`Code paints ${r.kind} with ${from} directly, not through a ${d.component} token: Claude AI changes the CSS to ${to}.`);
4777
+ continue;
4778
+ }
4779
+ const elsewhere = ctx.usedElsewhere(from, r);
4780
+ if (elsewhere.variants.length) {
4781
+ const v = elsewhere.variants;
4782
+ const list2 = v.length > 3 ? `${shortVariants(v.slice(0, 3).join("; ")).replace(/ and /, ", ")} and ${v.length - 3} more` : shortVariants(v.join("; "));
4783
+ reasons.push(`${from} also paints ${list2}: pointing it to ${to} would change them too, so Claude AI adds a token for ${where(r)}.`);
4784
+ continue;
4785
+ }
4786
+ for (const c of elsewhere.components) also.add(c);
4787
+ const prev = edits.get(from);
4788
+ if (prev && prev.value !== `{${to}}`) {
4789
+ reasons.push(`${from} moved to two different variables (${String(prev.value).slice(1, -1)} and ${to}): Claude AI splits it.`);
4790
+ edits.delete(from);
4791
+ continue;
4792
+ }
4793
+ edits.set(from, { op: "set", path: from, value: `{${to}}` });
4794
+ }
4795
+ const list = [...edits.values()];
4796
+ if (d.needsCode || reasons.length) {
4797
+ return {
4798
+ route: "claude-draft",
4799
+ said: `Claude AI drafts the code from what you changed, then a person reviews it.${list.length ? ` ${list.length === 1 ? "Its token swap goes" : "Its token swaps go"} with it.` : ""}`,
4800
+ edits: list,
4801
+ reasons,
4802
+ issues,
4803
+ alsoChanges: [...also]
4804
+ };
4805
+ }
4806
+ if (list.length) {
4807
+ const n = list.length;
4808
+ const others = [...also];
4809
+ const names3 = others.length > 3 ? [...others.slice(0, 3), `${others.length - 3} more`] : others;
4810
+ const alsoLine = names3.length ? ` It also repaints ${names3.length > 1 ? `${names3.slice(0, -1).join(", ")} and ${names3[names3.length - 1]}` : names3[0]}: they use the same token${n === 1 ? "" : "s"}.` : "";
4811
+ return { route: "token-change", said: `Exact, no AI: ${n === 1 ? `${list[0].path} points` : `${n} ${d.component} tokens point`} to the new variable${n === 1 ? "" : "s"}, checked for contrast and reviewed like any token change.${alsoLine}`, edits: list, reasons, issues, alsoChanges: others };
4812
+ }
4813
+ return { route: "manual", said: issues.length ? "Fix it in Figma first: nothing is sent until Bridge can read it." : "Nothing to send.", edits: [], reasons, issues, alsoChanges: [] };
4814
+ }
4815
+ function specsUsage(specs, component) {
4816
+ const own = specs.components.find((x) => x.name === component) ?? specs.components.find((x) => x.component === component);
4817
+ return (path, r) => {
4818
+ const variants = [];
4819
+ const components = [];
4820
+ for (const c of specs.components) {
4821
+ for (const v of c.variants) {
4822
+ if (!Object.values(v.values).some((fields) => Object.values(fields ?? {}).includes(path))) continue;
4823
+ const key = variantKeyOf(v.props);
4824
+ if (c === own) {
4825
+ if (!r.variants.includes(key)) variants.push(key);
4826
+ } else if (!components.includes(c.name)) components.push(c.name);
4827
+ }
4828
+ }
4829
+ return { variants, components };
4830
+ };
4674
4831
  }
4675
- var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4676
- function isDocsProposalBranch(branch, component) {
4677
- const slugs = /* @__PURE__ */ new Set([docsSlug(component.slug ?? component.name), docsSlug(component.name), docsSlug(component.name.toLowerCase())]);
4678
- return [...slugs].some((s) => new RegExp(`^docs/(?:[A-Z][A-Z0-9]*-\\d+-)?${escapeRe(s)}-\\d{8}-[a-z0-9]+$`).test(branch));
4832
+ function componentContrastPairs(edits, pairs, tokens) {
4833
+ const out = /* @__PURE__ */ new Map();
4834
+ for (const e of edits) {
4835
+ const target = tokens.aliasOf(e.path);
4836
+ if (!target) continue;
4837
+ const group = e.path.split(".")[0];
4838
+ const sibling = (other) => tokens.paths.find((p) => p !== e.path && p.split(".")[0] === group && tokens.aliasOf(p) === other) ?? other;
4839
+ for (const p of pairs) {
4840
+ if (p.foreground === target) out.set(`${e.path}|${sibling(p.background)}`, { ...p, foreground: e.path, background: sibling(p.background) });
4841
+ if (p.background === target) out.set(`${sibling(p.foreground)}|${e.path}`, { ...p, foreground: sibling(p.foreground), background: e.path });
4842
+ }
4843
+ }
4844
+ return [...out.values()];
4679
4845
  }
4680
- function findOpenDocsProposal(pulls, component, base) {
4681
- const hit = pulls.filter((p) => p.state === "open" && isDocsProposalBranch(p.headRef, component) && (!base || !p.baseRef || p.baseRef === base)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
4682
- return hit ? { number: hit.number, url: hit.url, branch: hit.headRef, author: hit.author, title: hit.title, updatedAt: hit.updatedAt } : null;
4846
+ var FROM_FIGMA_LABEL = "from-figma";
4847
+ var WAITING_FOR_CLAUDE_LABEL = "waiting-for-claude";
4848
+ var DRAFTED_BY_CLAUDE_LABEL = "drafted-by-claude";
4849
+ var FIGMA_SPEC_DIR = ".bridge/figma";
4850
+ var ORIGIN_MARK = "bridge-from-figma";
4851
+ var hidden = (data) => `<!-- ${ORIGIN_MARK} ${JSON.stringify(data).replace(/-->/g, "--\\u003e")} -->`;
4852
+ function parseFigmaOrigin(body) {
4853
+ if (!body) return void 0;
4854
+ const m = new RegExp(`<!-- ${ORIGIN_MARK} (\\{[\\s\\S]*?\\}) -->`).exec(body);
4855
+ if (!m) return void 0;
4856
+ try {
4857
+ const o = JSON.parse(m[1]);
4858
+ return typeof o.component === "string" && (o.route === "token-change" || o.route === "claude-draft") ? o : void 0;
4859
+ } catch {
4860
+ return void 0;
4861
+ }
4683
4862
  }
4684
- async function findOpenDocsProposalOnGitHub(gh, component, base) {
4685
- return findOpenDocsProposal(await gh.listPullRequests("open"), component, base);
4863
+ function figmaOriginSection(o) {
4864
+ const design = o.url ? `[${o.figmaName}](${o.url})` : `"${o.figmaName}"`;
4865
+ const who = o.requestedBy ? ` by @${o.requestedBy}` : "";
4866
+ const status = o.route === "token-change" ? "Exact token change, no AI: Bridge read the token swap from the Figma component." : o.status === "drafted" ? "**Drafted by Claude AI** from the Figma spec. A person reviews it before it ships." : `**Waiting for Claude AI:** this branch carries the spec only, as a request. Claude writes the code here (\`bridge_get_figma_change\`, or \`bridge figma show <number>\`), then it is labelled ${DRAFTED_BY_CLAUDE_LABEL}.`;
4867
+ return [`**From Figma:** ${design}${who}. Once it ships, a pull in the DS Bridge plugin rebuilds the component from this code.`, "", status, "", hidden(o)].join("\n");
4686
4868
  }
4687
- async function continueDocsProposalOnGitHub(gh, open, edit, opts = {}) {
4688
- const message = opts.message ?? `docs(${docsSlug(edit.component)}): edit usage guidance in the Bridge docs editor`;
4689
- const cur = await gh.getFile(edit.path, open.branch);
4690
- if (cur?.text === edit.after) return "";
4691
- const sha = await gh.putFile({ path: edit.path, content: edit.after, branch: open.branch, message, sha: cur?.sha });
4692
- if (opts.authorLine) {
4693
- const line = opts.authorLine;
4694
- await gh.getPullRequest(open.number).then((pr) => gh.updatePullRequest(open.number, { body: withAuthorLine(pr.body, line) })).catch(() => void 0);
4869
+ function withFigmaOrigin(body, o) {
4870
+ let b = (body ?? "").trim();
4871
+ const at = b.indexOf("**From Figma:**");
4872
+ const mark = b.indexOf(`<!-- ${ORIGIN_MARK} `);
4873
+ if (at >= 0 && mark > at) {
4874
+ const end = b.indexOf("-->", mark);
4875
+ b = `${b.slice(0, at)}${b.slice(end + 3)}`.trim();
4695
4876
  }
4696
- return sha;
4697
- }
4698
- function docsProposal(edits, opts) {
4699
- const origin = opts.origin ?? DOCS_EDITOR_ORIGIN;
4700
- if (!edits.length) throw new Error("No docs edits in the draft.");
4701
- const names3 = edits.map((e) => e.component);
4702
- const list = names3.length === 1 ? names3[0] : `${names3.slice(0, -1).join(", ")} and ${names3[names3.length - 1]}`;
4703
- const title = opts.title?.trim() || `docs(${names3.length === 1 ? edits[0].slug : "components"}): usage guidance for ${list}`;
4704
- const slug2 = docsSlug(names3.length === 1 ? edits[0].slug : `${edits[0].slug}-and-${names3.length - 1}-more`);
4705
- const branch = `docs/${slug2}-${slugDate(opts.date)}-${opts.date.getTime().toString(36).slice(-4)}`;
4706
- const csPath = `${opts.changesetDir ?? ".changeset"}/docs-${slug2}-${opts.date.getTime().toString(36)}.md`;
4707
- const pkg = opts.packageName ?? "@bridge-ds/react";
4708
- const changeset = `---
4709
- "${pkg}": patch
4710
- ---
4877
+ return `${figmaOriginSection(o)}${b ? `
4711
4878
 
4712
- Usage guidance for ${list}.
4879
+ ${b}` : ""}`;
4880
+ }
4881
+ var figmaSpecSlug = (d) => slugify(`${d.component} ${d.title}`, 48) || "component-change";
4882
+ var figmaSpecPath = (d) => `${FIGMA_SPEC_DIR}/${figmaSpecSlug(d)}.json`;
4883
+ var kindLabel2 = (k) => REQUEST_KINDS.find((x) => x.value === k)?.label ?? "Change";
4884
+ function formatFigmaSpec(d, plan) {
4885
+ return `${JSON.stringify({ ...d, tokenEdits: plan.edits, reasons: plan.reasons, unknown: plan.issues }, null, 2)}
4713
4886
  `;
4714
- const files = [...edits.map((e) => ({ path: e.path, content: e.after })), { path: csPath, content: changeset }];
4715
- const risk = classifyRisk({ files: files.map((f) => ({ path: f.path, status: "modified" })) });
4716
- const rows = edits.map((e) => {
4717
- let state = "edited";
4718
- try {
4719
- const d = JSON.parse(e.after);
4720
- const gaps = missingProse(d);
4721
- state = gaps.length ? `still missing: ${gaps.join(", ")}` : "every section filled";
4722
- } catch {
4723
- state = "not valid JSON";
4724
- }
4725
- return `- \`${e.path}\` (${e.before === null ? "new" : "changed"}, ${state})`;
4726
- });
4727
- const body = [
4728
- `## Docs change`,
4887
+ }
4888
+ function parseFigmaSpec(text) {
4889
+ if (!text) return void 0;
4890
+ try {
4891
+ const s = JSON.parse(text);
4892
+ return s && s.v === 1 && typeof s.component === "string" && Array.isArray(s.changes) ? s : void 0;
4893
+ } catch {
4894
+ return void 0;
4895
+ }
4896
+ }
4897
+ function figmaDraftBrief(d, ctx) {
4898
+ const dir = `${(ctx.componentsDir ?? "packages/react/src/components").replace(/\/+$/, "")}/${d.component}`;
4899
+ const suggested = "suggestion" in ctx;
4900
+ const variants = Object.entries(d.variantAxes).map(([axis, values]) => `${axis}: ${values.join(", ")}`);
4901
+ const props = d.properties.filter((p) => p.type !== "VARIANT").map((p) => `${p.name} (${p.type.toLowerCase().replace("_", " ")}, default ${p.default || "empty"})`);
4902
+ return [
4903
+ suggested ? "Draft the code for a component change made in Figma, sent as a Bridge suggestion (this issue)." : `Draft the code for a component change made in Figma, on the library branch ${ctx.branch} (draft pull request #${ctx.pr}).`,
4729
4904
  "",
4730
- `Usage guidance for ${list}, written ${origin.where}.`,
4905
+ d.coded ? `Component: ${d.component} (${dir})` : `New component: ${d.component} (create ${dir})`,
4906
+ `Kind: ${kindLabel2(d.kind)}`,
4907
+ `Change: ${d.title}`,
4908
+ ...d.line?.trim() ? [`What changes: ${d.line.trim()}`] : [],
4731
4909
  "",
4732
- ...rows,
4910
+ d.summary.trim(),
4733
4911
  "",
4734
- `**Risk:** ${risk.label}. Docs only: the published components do not change. Changeset: patch.`,
4912
+ ...d.changes.length ? ["Changes in Figma:", ...d.changes.map((c) => `- ${c.field}: ${c.before ?? "(none)"} -> ${c.after ?? "(removed)"}`), ""] : [],
4913
+ ...variants.length ? ["Variant matrix in Figma:", ...variants.map((v) => `- ${v}`), ""] : [],
4914
+ ...props.length ? ["Properties in Figma:", ...props.map((p) => `- ${p}`), ""] : [],
4915
+ ...d.tokenEdits?.length ? ["Token swaps to make too (exact, with bridge_propose_token_change on this branch or by hand in the token files):", ...d.tokenEdits.map((e) => `- ${e.path} -> ${String(e.value)}`), ""] : [],
4916
+ ...d.reasons?.length ? ["Why this needs code:", ...d.reasons.map((r) => `- ${r}`), ""] : [],
4917
+ `Spec: ${ctx.specPath} ${suggested ? "in the suggestion" : "on the branch"} has the bound variables of every variant (token paths), the layers of every variant and what code gave Figma before.`,
4918
+ ...d.figma?.url ? [`Design: ${d.figma.url}`] : [],
4919
+ ...d.images?.length ? ["Screenshot:", ...d.images.map((u) => `- ${u}`)] : [],
4735
4920
  "",
4736
- opts.notes?.trim() ? `## Notes
4737
-
4738
- ${opts.notes.trim()}
4739
- ` : "",
4740
- "---",
4741
- opts.authorLine?.trim() || defaultDocsFooter(edits, origin)
4742
- ].filter((x, i, a) => !(x === "" && a[i - 1] === "")).join("\n");
4743
- return { branch, title, body, files, risk, bump: "patch", labels: ["docs", origin.label, `tier-${risk.tier}`] };
4921
+ "Steps:",
4922
+ "suggestion" in ctx ? "1. Apply the suggestion on a new branch: bridge apply <this issue number> (it writes the spec file), or start a branch from the default branch." : `1. git fetch origin && git checkout ${ctx.branch}`,
4923
+ `2. Write the change in ${dir}: the TSX on Base UI, the CSS module with tokens only (the bound variables of the spec, as token paths), the stories (examples), docs.json and a changeset. Follow ${ctx.agentsFile ?? "AGENTS.md"}.`,
4924
+ "3. Run npm run specs so component-specs.json matches the code, then npm test.",
4925
+ "suggestion" in ctx ? '4. Commit, push and open a pull request that says "Closes #<this issue number>", so the suggestion closes when it ships. A person reviews it.' : `4. Commit and push to ${ctx.branch}, then call bridge_mark_figma_change_drafted { pr: ${ctx.pr} } (CLI: bridge figma drafted ${ctx.pr}). The branch is then labelled ${DRAFTED_BY_CLAUDE_LABEL} and a person reviews it.`,
4926
+ "",
4927
+ "Rules:",
4928
+ "- Never invent a token or a value: use the variables the spec lists. A value the library lacks is a token change, said in the pull request.",
4929
+ "- Keep every existing variant and prop unchanged unless the spec changes it.",
4930
+ `- If the spec is unclear, comment on the ${suggested ? "suggestion" : "pull request"} instead of guessing.`
4931
+ ].join("\n");
4744
4932
  }
4745
- async function proposeDocsOnGitHub(gh, edits, opts) {
4746
- if (opts.continueOpen !== false && edits.length === 1) {
4747
- const e = edits[0];
4748
- const open = await findOpenDocsProposalOnGitHub(gh, { name: e.component, slug: e.slug }, opts.base).catch(() => null);
4749
- if (open) {
4750
- const sha = await continueDocsProposalOnGitHub(gh, open, e, { authorLine: opts.authorLine?.trim() || defaultDocsFooter(edits, opts.origin ?? DOCS_EDITOR_ORIGIN) });
4751
- return { number: open.number, url: open.url, branch: open.branch, title: open.title, continued: true, unchanged: !sha };
4752
- }
4753
- }
4754
- const p = docsProposal(edits, { title: opts.title, notes: opts.notes, date: opts.date ?? /* @__PURE__ */ new Date(), origin: opts.origin, authorLine: opts.authorLine });
4755
- const prTitle = titleWithJiraKey(p.title, opts.jira ?? null);
4756
- const branch = branchWithJiraKey(p.branch, opts.jira ?? null);
4757
- const mainSha = await gh.getBranchSha(opts.base);
4758
- if (!mainSha) throw new Error(`Branch ${opts.base} not found.`);
4759
- const tree = await gh.createTree(await gh.getCommitTree(mainSha), p.files.map((f) => ({ path: f.path, content: f.content })));
4760
- const commit = await gh.createCommit(`${prTitle}
4761
-
4762
- ${opts.commitNote ?? "Proposed with the Bridge docs editor."}`, tree, [mainSha]);
4763
- await gh.createBranch(branch, commit);
4764
- const pr = await gh.createPullRequest({ title: prTitle, head: branch, base: opts.base, body: p.body, draft: false });
4765
- await gh.addLabels(pr.number, [...p.labels, ...opts.extraLabels ?? []]).catch(() => false);
4766
- return { number: pr.number, url: pr.url, branch, title: prTitle, continued: false, unchanged: false };
4933
+ function figmaDraftBody(d, plan, o) {
4934
+ const cell4 = (s) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
4935
+ const rows = d.changes.length ? ["| What | Code now | In Figma |", "| --- | --- | --- |", ...d.changes.map((c) => `| ${cell4(c.field)} | ${cell4(c.before ?? "")} | ${cell4(c.after ?? "")} |`), ""] : [];
4936
+ return withFigmaOrigin(
4937
+ [
4938
+ `## ${d.coded ? `${kindLabel2(d.kind)} for ${d.component}` : `New component: ${d.component}`}`,
4939
+ "",
4940
+ d.summary.trim(),
4941
+ "",
4942
+ ...d.line?.trim() ? [`**What changes:** ${d.line.trim()}`, ""] : [],
4943
+ ...rows,
4944
+ ...d.images?.length ? [...d.images.map((u, i) => `![${d.component} in Figma ${i + 1}](${u})`), ""] : [],
4945
+ ...plan.edits.length ? ["**Token swaps to make too:**", ...plan.edits.map((e) => `- \`${e.path}\` -> \`${String(e.value)}\``), ""] : [],
4946
+ ...plan.reasons.length ? ["**Why this needs code:**", ...plan.reasons.map((r) => `- ${r}`), ""] : [],
4947
+ ...plan.issues.length ? ["**Left in Figma (the designer fixes these first):**", ...plan.issues.map((i) => `- ${i.what} ${i.todo}`), ""] : [],
4948
+ `**Spec:** \`${o.spec ?? figmaSpecPath(d)}\` (variant matrix, properties, bound variables and layers of every variant).`
4949
+ ].join("\n"),
4950
+ o
4951
+ );
4767
4952
  }
4768
- async function commitFileOnGitHub(gh, input) {
4769
- const cur = await gh.getFile(input.path, input.branch);
4770
- if (!cur) throw new Error(`${input.path} is not on ${input.branch}. Check the component name and the branch.`);
4771
- const content = input.next(cur.text);
4772
- if (content === cur.text) return "";
4773
- return gh.putFile({ path: input.path, content, branch: input.branch, message: input.message, sha: cur.sha });
4953
+ var figmaBranchTitle = (d) => requestIssueTitle(d);
4954
+ function figmaPushStatus(x) {
4955
+ const labels = x.labels ?? [];
4956
+ if (x.state === "merged") return { key: "shipped", label: "Shipped", sentence: "It shipped. Pull to rebuild the component from the reviewed code.", rebuild: true };
4957
+ if (x.state !== "open") return { key: "closed", label: "Closed", sentence: "The library closed it without shipping. Pull to bring the component back to the code.", rebuild: true };
4958
+ if (labels.includes(WAITING_FOR_CLAUDE_LABEL)) return { key: "waiting-for-claude", label: "Waiting for Claude AI", sentence: "The spec is on the library branch. Claude AI writes the code there, then a person reviews it.", rebuild: false };
4959
+ if (labels.includes(DRAFTED_BY_CLAUDE_LABEL)) return { key: "drafted", label: "Drafted by Claude AI", sentence: "Claude AI drafted the code. A person reviews it before it ships.", rebuild: false };
4960
+ return { key: "in-review", label: "In review", sentence: x.route === "token-change" ? "Owners review the token change." : "Owners review it.", rebuild: false };
4774
4961
  }
4775
4962
 
4776
- // ../core/src/share-branch.ts
4777
- var packageDir = (name) => `packages/${name.replace(/^@[^/]+\//, "")}/`;
4778
- function touchesPackages(files, packages) {
4779
- const dirs = packages.map(packageDir);
4780
- return files.some((f) => dirs.some((d) => f.startsWith(d)));
4781
- }
4782
- function shareState(x) {
4783
- if (!touchesPackages(x.files, x.packages)) return { kind: "no-packages" };
4784
- const c = readPreviewComment(x.comment);
4785
- if (x.lifecycle === "merged" || x.lifecycle === "closed") {
4786
- const kind = x.lifecycle === "merged" ? "shipped" : "closed";
4787
- if (!c || !c.command || c.source === "none") return { kind, stillWorks: null, command: null };
4788
- return { kind, stillWorks: c.source === "pkg.pr.new" ? true : c.source === "release" ? false : null, command: c.command };
4789
- }
4790
- if (!c) return { kind: "building" };
4791
- if (c.sha7 && !x.headSha.startsWith(c.sha7)) return { kind: "building" };
4792
- if (!c.command || c.source === "none") return { kind: "failed" };
4793
- return { kind: "ready", command: c.command, source: c.source };
4794
- }
4795
- var stableCommand = (packages) => `npm i ${packages.map((p) => `${p}@latest`).join(" ")}`;
4796
- function stateSentence(s) {
4797
- switch (s.kind) {
4798
- case "ready":
4799
- return null;
4800
- case "building":
4801
- return "The preview version is being prepared. It is usually ready a couple of minutes after each update to the branch.";
4802
- case "failed":
4803
- return "The preview version could not be prepared for the latest update. Update the branch to try again, or ask a developer to run it again on GitHub.";
4804
- case "no-packages":
4805
- return "This branch does not touch the library tokens or components, so there is no preview version to install.";
4806
- case "shipped":
4807
- return s.stillWorks === true ? "This branch was merged. Its preview version still installs, and products get it with the next library release." : s.stillWorks === false ? "This branch was merged, so its preview version was removed. Products get it with the next library release." : "This branch was merged. Products get it with the next library release.";
4808
- case "closed":
4809
- return s.stillWorks === true ? "This branch was closed without merging. Its preview version still installs, but it will not reach the library." : s.stillWorks === false ? "This branch was closed without merging, so its preview version was removed." : "This branch was closed without merging.";
4810
- }
4811
- }
4812
- var quote = (t) => `"${t.trim().replace(/"/g, "'")}"`;
4813
- function shareMessage(x) {
4814
- const back = stableCommand(x.packages);
4815
- const s = x.state;
4816
- if (s.kind === "ready") {
4817
- const lasts = s.source === "pkg.pr.new" ? "This preview stays available. Each update to the change makes a new one: ask me for the latest command." : "This preview is replaced on each update to the change and removed when it ships or closes: ask me for the latest command if the install fails.";
4818
- return {
4819
- subject: `Try the design system change ${quote(x.title)} before it ships`,
4820
- body: [
4821
- "Hi,",
4822
- "",
4823
- `Could you try the design system change ${quote(x.title)} in our product before it ships?`,
4824
- ...x.tryUrl ? ["", `See it in the demo product first: ${x.tryUrl}`] : [],
4825
- "",
4826
- "Install the preview version in a staging branch (not production):",
4827
- s.command,
4828
- "",
4829
- lasts,
4830
- "",
4831
- "To go back to the stable version:",
4832
- back,
4833
- "",
4834
- `Details for developers: ${x.url}`
4835
- ].join("\n")
4836
- };
4837
- }
4838
- if ((s.kind === "shipped" || s.kind === "closed") && s.command) {
4839
- const what = s.kind === "shipped" ? `The design system change ${quote(x.title)} has shipped. You get it with the next library release.` : `The design system change ${quote(x.title)} was closed without shipping.`;
4840
- const preview = s.stillWorks === false ? "Its preview version was removed, so reinstalling it fails." : s.stillWorks === true ? "Its preview version still installs, but it is not the released version." : "";
4841
- return {
4842
- subject: `Design system change ${quote(x.title)} ${s.kind === "shipped" ? "has shipped" : "was closed"}`,
4843
- body: ["Hi,", "", [what, preview].filter(Boolean).join(" "), "", "If you installed its preview, go back to the stable version:", back, "", `Details for developers: ${x.url}`].join("\n")
4844
- };
4845
- }
4846
- return null;
4847
- }
4848
-
4849
- // ../core/src/try-in-product.ts
4850
- function tryInProductUrl(preview, pr, screen) {
4851
- const url = new URL(preview);
4852
- url.pathname = "/";
4853
- url.search = "";
4854
- url.searchParams.set("bridge", String(pr));
4855
- if (screen) {
4856
- url.searchParams.set("tenant", screen.tenant);
4857
- url.searchParams.set("theme", screen.theme);
4858
- }
4859
- url.hash = screen ? screen.route : "";
4860
- return url.href;
4861
- }
4862
-
4863
- // ../core/src/combine-branches.ts
4864
- var MIN_TOGETHER = 2;
4865
- var MAX_TOGETHER = 4;
4866
- function onlyTokens(files, tokensRoot) {
4867
- const root = `${tokensRoot.replace(/^\/+|\/+$/g, "")}/`;
4868
- return files.length > 0 && files.every((f) => f.startsWith(root) && f.endsWith(".tokens.json") || /contrast\.checks\.json$/.test(f) || f.startsWith(".changeset/"));
4869
- }
4870
- function tokenFilesOf(files, tokensRoot) {
4871
- const root = `${tokensRoot.replace(/^\/+|\/+$/g, "")}/`;
4872
- return files.filter((f) => f.startsWith(root) && f.endsWith(".tokens.json")).map((f) => f.slice(root.length));
4873
- }
4874
- var changeList = (numbers) => numbers.join(",");
4875
- function tryTogetherUrl(admin, numbers, picks) {
4876
- const url = new URL(admin);
4877
- url.pathname = "/";
4878
- url.hash = "";
4879
- const use = encodePicks(picks);
4880
- url.search = `?bridge=${changeList(numbers)}${use ? `&bridge-use=${encodeURIComponent(use)}` : ""}`;
4881
- return url.href;
4882
- }
4883
- var COMBINE_COMPARE_LIMIT = 300;
4884
- async function readCombineBranch(gh, pull, opts) {
4885
- const compare = await gh.compareDetailed(opts.base, pull.headSha);
4886
- const files = compare.files;
4887
- const names3 = files.flatMap((f) => f.previous_filename ? [f.filename, f.previous_filename] : [f.filename]);
4888
- const start = compare.mergeBase;
4889
- if (!start || files.length >= COMBINE_COMPARE_LIMIT || !onlyTokens(names3, opts.tokensRoot)) return { number: pull.number, title: pull.title, tokens: false, edits: null };
4890
- const root = opts.tokensRoot.replace(/^\/+|\/+$/g, "");
4891
- const tokenFiles = files.filter((f) => tokenFilesOf([f.filename], root).length);
4892
- const source = { number: pull.number, base: {}, head: {}, files: tokenFiles.map((f) => tokenFilesOf([f.filename], root)[0]) };
4893
- await Promise.all(
4894
- tokenFiles.map(async (f) => {
4895
- const rel = tokenFilesOf([f.filename], root)[0];
4896
- source.base[rel] = f.status === "added" ? void 0 : await gh.readFile(f.previous_filename ?? f.filename, start);
4897
- source.head[rel] = f.status === "removed" ? void 0 : await gh.readFile(f.filename, pull.headSha);
4898
- })
4899
- );
4900
- return { number: pull.number, title: pull.title, tokens: true, edits: changeEdits(source) };
4901
- }
4902
-
4903
- // ../core/src/prod-branch.ts
4904
- var PIECES_MARK = "bridge-pieces";
4905
- var NEEDED_MARK = "bridge-needed-by";
4906
- var SECTION = "### Missing from the library";
4907
- var NEEDED_BY_LABEL = "needed-by-product";
4908
- var hiddenJson = (mark, data) => `<!-- ${mark} ${JSON.stringify(data).replace(/-->/g, "--\\u003e")} -->`;
4909
- var readHidden = (mark, body) => {
4910
- if (!body) return void 0;
4911
- const m = new RegExp(`<!-- ${mark} (\\{[\\s\\S]*?\\}) -->`).exec(body);
4912
- if (!m) return void 0;
4913
- try {
4914
- return JSON.parse(m[1]);
4915
- } catch {
4916
- return void 0;
4963
+ // ../core/src/prod-branch.ts
4964
+ var PIECES_MARK = "bridge-pieces";
4965
+ var NEEDED_MARK = "bridge-needed-by";
4966
+ var SECTION = "### Missing from the library";
4967
+ var NEEDED_BY_LABEL = "needed-by-product";
4968
+ var hiddenJson = (mark, data) => `<!-- ${mark} ${JSON.stringify(data).replace(/-->/g, "--\\u003e")} -->`;
4969
+ var readHidden = (mark, body) => {
4970
+ if (!body) return void 0;
4971
+ const m = new RegExp(`<!-- ${mark} (\\{[\\s\\S]*?\\}) -->`).exec(body);
4972
+ if (!m) return void 0;
4973
+ try {
4974
+ return JSON.parse(m[1]);
4975
+ } catch {
4976
+ return void 0;
4917
4977
  }
4918
4978
  };
4919
- var KIND_WORD = { "token-change": "token change", exploration: "library exploration", request: "component request" };
4979
+ var KIND_WORD = { "token-change": "token change", exploration: "library exploration", request: "component request", suggestion: "suggested token change" };
4920
4980
  function parsePiecesRecord(body) {
4921
4981
  const r = readHidden(PIECES_MARK, body);
4922
4982
  if (!r || !Array.isArray(r.pieces) || !Array.isArray(r.links) || typeof r.checkedAt !== "string") return void 0;
@@ -4926,7 +4986,7 @@ function piecesSection(r) {
4926
4986
  const link = (p) => r.links.find((l) => l.piece === p.id);
4927
4987
  const lines2 = r.pieces.length ? r.pieces.map((p) => {
4928
4988
  const l = link(p);
4929
- return `- **${p.title}**: ${p.summary}${l ? ` ${cap(KIND_WORD[l.kind])}: #${l.number}.` : " No library branch yet."}`;
4989
+ return `- **${p.title}**: ${p.summary}${l ? ` ${cap2(KIND_WORD[l.kind])}: #${l.number}.` : " No library branch yet."}`;
4930
4990
  }) : ["- Nothing: every value and component comes from the library."];
4931
4991
  return [SECTION, "", `Checked ${r.checkedAt.slice(0, 10)}${r.headSha ? ` on ${r.headSha.slice(0, 7)}` : ""} by Bridge.`, "", ...lines2, "", hiddenJson(PIECES_MARK, r)].join("\n");
4932
4992
  }
@@ -4969,7 +5029,7 @@ function withNeededBy(body, n) {
4969
5029
 
4970
5030
  ${b}` : ""}`;
4971
5031
  }
4972
- var cap = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
5032
+ var cap2 = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
4973
5033
  var forLine = (b) => `${b.productName} needs it for "${b.title.replace(/"/g, "'")}" (#${b.pr})`;
4974
5034
  function libraryBranchPlan(piece, branch, opts = {}) {
4975
5035
  const neededBy = {
@@ -4993,11 +5053,23 @@ function libraryBranchPlan(piece, branch, opts = {}) {
4993
5053
  path: t.path,
4994
5054
  type: t.type,
4995
5055
  value: t.value,
4996
- description: `${cap(piece.name)}: ${forLine(branch)}.${files.length > 1 ? " Same value in every theme: review it in each." : ""}`,
5056
+ description: `${cap2(piece.name)}: ${forLine(branch)}.${files.length > 1 ? " Same value in every theme: review it in each." : ""}`,
4997
5057
  ...file ? { file } : {}
4998
5058
  }));
4999
5059
  return { kind: "token-change", title: `Add ${t.path}`, summary: `${forLine(branch)}: ${piece.summary}${near}`, edits, neededBy };
5000
5060
  }
5061
+ if (piece.kind === "variant" && piece.component && opts.mode === "suggest") {
5062
+ const request2 = {
5063
+ component: piece.component,
5064
+ kind: "new-variant",
5065
+ title: piece.title,
5066
+ summary: `${forLine(branch)}: ${piece.summary}${near}`,
5067
+ changes: piece.values.slice(0, 6).map((v) => ({ field: v.property ?? v.category, after: v.value })),
5068
+ source: opts.source ?? "mcp",
5069
+ ...opts.requestedBy ? { requestedBy: opts.requestedBy } : {}
5070
+ };
5071
+ return { kind: "request", title: piece.title, request: request2, neededBy };
5072
+ }
5001
5073
  if (piece.kind === "variant" && piece.component) {
5002
5074
  const values = piece.values.map((v) => `${v.property ?? v.category} ${v.value}`).join(", ");
5003
5075
  const exploration = {
@@ -5014,7 +5086,7 @@ function libraryBranchPlan(piece, branch, opts = {}) {
5014
5086
  ].filter(Boolean).join(" ");
5015
5087
  return { kind: "exploration", title: piece.title, exploration, neededBy, brief };
5016
5088
  }
5017
- const name = piece.component ?? cap(piece.name);
5089
+ const name = piece.component ?? cap2(piece.name);
5018
5090
  const request = {
5019
5091
  component: name,
5020
5092
  kind: "new-component",
@@ -5037,15 +5109,29 @@ var STATUS_LABEL = {
5037
5109
  requested: "Requested",
5038
5110
  "in-progress": "In progress",
5039
5111
  done: "Done",
5040
- declined: "Declined"
5112
+ declined: "Declined",
5113
+ outdated: "Outdated"
5041
5114
  };
5042
5115
  var names2 = (people2) => people2.length <= 2 ? people2.map((p) => `@${p}`).join(" and ") : `@${people2[0]} and ${people2.length - 1} more`;
5116
+ var SUGGESTION_LINK_STATUS = {
5117
+ "needs-review": "in-review",
5118
+ "changes-requested": "changes",
5119
+ approved: "approved",
5120
+ applying: "in-progress",
5121
+ applied: "shipped",
5122
+ declined: "declined",
5123
+ outdated: "outdated"
5124
+ };
5043
5125
  function linkStatus(x) {
5044
5126
  const make = (key, people2 = [], verb = "reviews") => {
5045
5127
  const label = STATUS_LABEL[key];
5046
5128
  const tail = people2.length ? ` \xB7 ${names2(people2)} ${people2.length === 1 ? verb : verb.replace(/s$/, "")}` : "";
5047
5129
  return { key, label, people: people2, line: `${label}${tail}` };
5048
5130
  };
5131
+ if (x.kind === "suggestion") {
5132
+ const key = SUGGESTION_LINK_STATUS[x.status];
5133
+ return make(key, key === "in-progress" ? [...x.assignees ?? []] : [], "builds");
5134
+ }
5049
5135
  if (x.kind === "request") {
5050
5136
  if (x.state === "closed") return make(x.stateReason === "not_planned" ? "declined" : "done");
5051
5137
  return x.implemented ? make("in-progress", [...x.assignees ?? []], "builds") : make("requested", [...x.assignees ?? []], "builds");
@@ -5063,6 +5149,11 @@ function linkStatus(x) {
5063
5149
  return make("in-review", waiting);
5064
5150
  }
5065
5151
  function libraryPreviewState(x) {
5152
+ if (x.kind === "suggestion") {
5153
+ if (x.lifecycle === "closed") return { key: "declined", sentence: `The library declined this suggestion.${x.nearest ? ` Ask Claude to use ${x.nearest} instead.` : ""}`, action: null };
5154
+ if (x.lifecycle === "merged") return x.behindMain === 0 ? { key: "switched", sentence: "Your branch uses the published version.", action: null } : { key: "published", sentence: "The library applied the suggestion. Switch your branch to the published version.", action: { kind: "switch", label: "Switch to the published version" } };
5155
+ return { key: "nothing-yet", sentence: "Nothing to preview yet: owners review the suggestion, then a developer applies it. Your branch keeps its own value until then.", action: null };
5156
+ }
5066
5157
  if (x.kind === "request") {
5067
5158
  if (x.lifecycle === "closed") return { key: "declined", sentence: `The library declined this request.${x.nearest ? ` Ask Claude to use ${x.nearest} instead.` : ""}`, action: null };
5068
5159
  if (x.lifecycle === "merged") return x.behindMain === 0 ? { key: "switched", sentence: "Your branch uses the published version.", action: null } : { key: "published", sentence: "The library built it. Switch your branch to the published version, then ask Claude to use it.", action: { kind: "switch", label: "Switch to the published version" } };
@@ -5114,129 +5205,1226 @@ function proposedTokenPath(nearest, name, category) {
5114
5205
  return [...parts.slice(0, -1), slug2].join(".");
5115
5206
  }
5116
5207
 
5117
- // ../core/src/prod-branch-github.ts
5118
- var PRODUCT_FILE = /\.(css|scss|sass|less|tsx|jsx|ts|js|mjs|cjs|mts|cts|astro|vue|svelte)$/;
5119
- var SKIP_FILE = /\.d\.[cm]?ts$|\.(test|spec|stories|story)\.[cm]?[jt]sx?$|\.min\.(css|js)$/;
5120
- var PIECES_FILE_LIMIT = 40;
5121
- var cleanPath = (p) => p.trim().replace(/^\.?\/+|\/+$/g, "");
5122
- function productOf(products, pull, files) {
5123
- const product = productOfBranch(products, { labels: pull.labels, files });
5124
- if (product.key === defaultProduct(products).key) throw new GitHubError(422, `This branch is a library branch (${product.name}), not a product branch: missing pieces are checked on a branch started for a product.`);
5125
- return product;
5208
+ // ../core/src/suggestions.ts
5209
+ var SUGGESTION_SCHEMA = "bridge.suggestion/1";
5210
+ var SUGGESTION_LABEL = "bridge-suggestion";
5211
+ var SUGGESTION_KINDS = ["token-change", "docs", "component-spec", "brand", "revert", "config"];
5212
+ var SUGGESTION_STATE_LABELS = {
5213
+ "needs-review": "suggestion: needs review",
5214
+ "changes-requested": "suggestion: changes requested",
5215
+ approved: "suggestion: approved",
5216
+ applying: "suggestion: applying"
5217
+ };
5218
+ var SUGGESTION_STATUS_LABEL = {
5219
+ "needs-review": "Needs review",
5220
+ "changes-requested": "Changes requested",
5221
+ approved: "Approved, ready to apply",
5222
+ applying: "Being applied",
5223
+ applied: "Applied",
5224
+ declined: "Declined",
5225
+ outdated: "Outdated"
5226
+ };
5227
+ var SUGGESTION_LIMITS = {
5228
+ body: 65536,
5229
+ /** The readable part of the body (tables are cut with "and N more"). */
5230
+ human: 12e3,
5231
+ /** The JSON block in the body. */
5232
+ inline: 48e3,
5233
+ /** The JSON block of one continuation comment. */
5234
+ part: 6e4,
5235
+ /** Continuation comments at most (about 480 KB of file contents). */
5236
+ maxParts: 8
5237
+ };
5238
+ var SuggestionError = class extends Error {
5239
+ constructor(code2, message) {
5240
+ super(message);
5241
+ this.code = code2;
5242
+ this.name = "SuggestionError";
5243
+ }
5244
+ code;
5245
+ };
5246
+ var SUGGESTION_TOO_LARGE = "Too large for a suggestion: split it, or ask a developer to open it directly.";
5247
+ var utf8 = (text) => new TextEncoder().encode(text);
5248
+ var hex = (words) => words.map((w) => (w >>> 0).toString(16).padStart(8, "0")).join("");
5249
+ function padded(bytes) {
5250
+ const len = bytes.length;
5251
+ const total = Math.ceil((len + 9) / 64) * 64;
5252
+ const buf = new Uint8Array(total);
5253
+ buf.set(bytes);
5254
+ buf[len] = 128;
5255
+ const view = new DataView(buf.buffer);
5256
+ view.setUint32(total - 8, Math.floor(len / 536870912), false);
5257
+ view.setUint32(total - 4, len * 8 >>> 0, false);
5258
+ return view;
5259
+ }
5260
+ var K256 = [
5261
+ 1116352408,
5262
+ 1899447441,
5263
+ 3049323471,
5264
+ 3921009573,
5265
+ 961987163,
5266
+ 1508970993,
5267
+ 2453635748,
5268
+ 2870763221,
5269
+ 3624381080,
5270
+ 310598401,
5271
+ 607225278,
5272
+ 1426881987,
5273
+ 1925078388,
5274
+ 2162078206,
5275
+ 2614888103,
5276
+ 3248222580,
5277
+ 3835390401,
5278
+ 4022224774,
5279
+ 264347078,
5280
+ 604807628,
5281
+ 770255983,
5282
+ 1249150122,
5283
+ 1555081692,
5284
+ 1996064986,
5285
+ 2554220882,
5286
+ 2821834349,
5287
+ 2952996808,
5288
+ 3210313671,
5289
+ 3336571891,
5290
+ 3584528711,
5291
+ 113926993,
5292
+ 338241895,
5293
+ 666307205,
5294
+ 773529912,
5295
+ 1294757372,
5296
+ 1396182291,
5297
+ 1695183700,
5298
+ 1986661051,
5299
+ 2177026350,
5300
+ 2456956037,
5301
+ 2730485921,
5302
+ 2820302411,
5303
+ 3259730800,
5304
+ 3345764771,
5305
+ 3516065817,
5306
+ 3600352804,
5307
+ 4094571909,
5308
+ 275423344,
5309
+ 430227734,
5310
+ 506948616,
5311
+ 659060556,
5312
+ 883997877,
5313
+ 958139571,
5314
+ 1322822218,
5315
+ 1537002063,
5316
+ 1747873779,
5317
+ 1955562222,
5318
+ 2024104815,
5319
+ 2227730452,
5320
+ 2361852424,
5321
+ 2428436474,
5322
+ 2756734187,
5323
+ 3204031479,
5324
+ 3329325298
5325
+ ];
5326
+ function sha256Hex(text) {
5327
+ const view = padded(utf8(text));
5328
+ const h = [1779033703, 3144134277, 1013904242, 2773480762, 1359893119, 2600822924, 528734635, 1541459225];
5329
+ const w = new Array(64);
5330
+ const rotr = (x, n) => x >>> n | x << 32 - n;
5331
+ for (let off = 0; off < view.byteLength; off += 64) {
5332
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4, false);
5333
+ for (let i = 16; i < 64; i++) {
5334
+ const a2 = w[i - 15];
5335
+ const b2 = w[i - 2];
5336
+ const s0 = rotr(a2, 7) ^ rotr(a2, 18) ^ a2 >>> 3;
5337
+ const s1 = rotr(b2, 17) ^ rotr(b2, 19) ^ b2 >>> 10;
5338
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1 | 0;
5339
+ }
5340
+ let [a, b, c, d, e, f, g, hh] = h;
5341
+ for (let i = 0; i < 64; i++) {
5342
+ const t1 = hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + (e & f ^ ~e & g) + K256[i] + w[i] | 0;
5343
+ const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) | 0;
5344
+ hh = g;
5345
+ g = f;
5346
+ f = e;
5347
+ e = d + t1 | 0;
5348
+ d = c;
5349
+ c = b;
5350
+ b = a;
5351
+ a = t1 + t2 | 0;
5352
+ }
5353
+ h[0] = h[0] + a | 0;
5354
+ h[1] = h[1] + b | 0;
5355
+ h[2] = h[2] + c | 0;
5356
+ h[3] = h[3] + d | 0;
5357
+ h[4] = h[4] + e | 0;
5358
+ h[5] = h[5] + f | 0;
5359
+ h[6] = h[6] + g | 0;
5360
+ h[7] = h[7] + hh | 0;
5361
+ }
5362
+ return hex(h);
5363
+ }
5364
+ function sha1Bytes(bytes) {
5365
+ const view = padded(bytes);
5366
+ const h = [1732584193, 4023233417, 2562383102, 271733878, 3285377520];
5367
+ const w = new Array(80);
5368
+ const rotl = (x, n) => x << n | x >>> 32 - n;
5369
+ for (let off = 0; off < view.byteLength; off += 64) {
5370
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4, false);
5371
+ for (let i = 16; i < 80; i++) w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
5372
+ let [a, b, c, d, e] = h;
5373
+ for (let i = 0; i < 80; i++) {
5374
+ const [f, k] = i < 20 ? [b & c | ~b & d, 1518500249] : i < 40 ? [b ^ c ^ d, 1859775393] : i < 60 ? [b & c | b & d | c & d, 2400959708] : [b ^ c ^ d, 3395469782];
5375
+ const t = rotl(a, 5) + f + e + k + w[i] | 0;
5376
+ e = d;
5377
+ d = c;
5378
+ c = rotl(b, 30);
5379
+ b = a;
5380
+ a = t;
5381
+ }
5382
+ h[0] = h[0] + a | 0;
5383
+ h[1] = h[1] + b | 0;
5384
+ h[2] = h[2] + c | 0;
5385
+ h[3] = h[3] + d | 0;
5386
+ h[4] = h[4] + e | 0;
5387
+ }
5388
+ return hex(h);
5389
+ }
5390
+ function gitBlobSha(text) {
5391
+ const body = utf8(text);
5392
+ const head = utf8(`blob ${body.length}\0`);
5393
+ const all = new Uint8Array(head.length + body.length);
5394
+ all.set(head);
5395
+ all.set(body, head.length);
5396
+ return sha1Bytes(all);
5397
+ }
5398
+ function canonicalJson(value) {
5399
+ return JSON.stringify(sortKeys(value));
5400
+ }
5401
+ function sortKeys(v) {
5402
+ if (Array.isArray(v)) return v.map((x) => x === void 0 ? null : sortKeys(x));
5403
+ if (v && typeof v === "object" && !(v.toJSON instanceof Function)) {
5404
+ const out = {};
5405
+ for (const k of Object.keys(v).sort()) {
5406
+ const x = v[k];
5407
+ if (x !== void 0) out[k] = sortKeys(x);
5408
+ }
5409
+ return out;
5410
+ }
5411
+ return v;
5126
5412
  }
5127
- async function readProductBranchOnGitHub(gh, pr, opts) {
5128
- const pull = await gh.getPullRequest(pr);
5129
- const cmp = await gh.compareDetailed(pull.baseRef, pull.headSha);
5130
- const product = productOf(opts.products, pull, cmp.files.map((f) => f.filename));
5131
- const dir = product.path ? `${cleanPath(product.path)}/` : "";
5132
- const changed = cmp.files.filter((f) => f.status !== "removed" && (!dir || f.filename.startsWith(dir)) && PRODUCT_FILE.test(f.filename) && !SKIP_FILE.test(f.filename) && !f.filename.split("/").includes("node_modules"));
5133
- const read = changed.slice(0, PIECES_FILE_LIMIT);
5134
- const start = cmp.mergeBase ?? pull.baseRef;
5135
- const files = await Promise.all(
5136
- read.map(async (f) => {
5137
- const [before, after] = await Promise.all([f.status === "added" ? Promise.resolve(void 0) : gh.getFileText(f.previous_filename ?? f.filename, start), gh.getFileText(f.filename, pull.headSha)]);
5138
- return { path: f.filename, before: before ?? null, after: after ?? null };
5139
- })
5140
- );
5141
- return { pull, product, files, skipped: changed.length - read.length, record: parsePiecesRecord(pull.body) };
5413
+ function shaForm(p) {
5414
+ if (!p.files) return p;
5415
+ return { ...p, files: p.files.map(({ part: _part, ...f }) => f) };
5142
5416
  }
5143
- async function recordPiecesOnGitHub(gh, pr, check) {
5144
- const pull = await gh.getPullRequest(pr);
5145
- const record = mergePiecesRecord(parsePiecesRecord(pull.body), { checkedAt: check.checkedAt ?? (/* @__PURE__ */ new Date()).toISOString(), headSha: check.headSha ?? pull.headSha, pieces: check.pieces, fixes: check.fixes });
5146
- await gh.updatePullRequest(pr, { body: withPiecesRecord(pull.body, record) });
5147
- return record;
5417
+ function suggestionPayloadSha(p) {
5418
+ return sha256Hex(canonicalJson(shaForm(p)));
5148
5419
  }
5149
- var productWriter = (gh, branch) => isExplorationBranch(branch) ? gh.withBranchPrefix(EXPLORATION_PREFIX) : gh;
5150
- async function useLibraryPreviewOnGitHub(gh, input) {
5151
- const message = `chore(bridge): use the preview of ${input.libraryBranch} (#${input.libraryNumber})
5152
-
5153
- The product branch takes the library branch before it ships. Switch to the published version once it does.`;
5154
- try {
5155
- const r = await productWriter(gh, input.productBranch).mergeIntoBranch(input.productBranch, input.libraryBranch, message);
5156
- return { ...r, said: r.status === "merged" ? "Your branch now uses the preview of the library branch." : "Your branch already has the latest preview." };
5157
- } catch (err) {
5158
- if (err instanceof GitHubError && err.status === 409) return { status: "conflict", sha: null, said: `Your branch and the library branch change the same lines. A developer merges ${input.libraryBranch} into ${input.productBranch} by hand.` };
5159
- throw err;
5420
+ var KIND_SUBJECT = {
5421
+ "token-change": "Tokens",
5422
+ docs: "Docs",
5423
+ "component-spec": "Component",
5424
+ brand: "Brand",
5425
+ revert: "Revert",
5426
+ config: "Settings"
5427
+ };
5428
+ function suggestionSubject(p) {
5429
+ if (p.kind === "component-spec" && p.componentSpec?.component) return p.componentSpec.component;
5430
+ if (p.kind === "docs" && p.docs?.length) {
5431
+ const names3 = [...new Set(p.docs.map((d) => d.component))];
5432
+ return names3.length === 1 ? names3[0] : KIND_SUBJECT.docs;
5160
5433
  }
5434
+ return KIND_SUBJECT[p.kind];
5161
5435
  }
5162
- function productBranchRef(pull, product) {
5163
- const name = /<!-- bridge-exploration (\{[\s\S]*?\}) -->/.exec(pull.body ?? "");
5164
- let title = pull.title.replace(/^[A-Z][A-Z0-9_]*-\d+\s+/, "").replace(/^Exploration:\s*/i, "");
5165
- let author = pull.author;
5166
- if (name) {
5167
- try {
5168
- const e = JSON.parse(name[1]);
5169
- if (e.name) title = e.name;
5170
- if (e.author) author = e.author;
5171
- } catch {
5172
- }
5173
- }
5174
- return { pr: pull.number, title: title.charAt(0).toUpperCase() + title.slice(1), branch: pull.headRef, url: pull.url, productKey: product.key, productName: product.name, ...author ? { author } : {} };
5436
+ function suggestionIssueTitle(p) {
5437
+ const t = p.title.trim().replace(/\.$/, "");
5438
+ return `${suggestionSubject(p)}: ${t.charAt(0).toLowerCase()}${t.slice(1)}`;
5175
5439
  }
5176
- async function openLibraryBranchOnGitHub(gh, input) {
5177
- const pull = await gh.getPullRequest(input.pr);
5178
- const record = parsePiecesRecord(pull.body);
5179
- const piece = record?.pieces.find((p) => p.id === input.piece);
5180
- if (!record || !piece) throw new GitHubError(404, record ? `No piece "${input.piece}" on #${input.pr}. Pieces: ${record.pieces.map((p) => p.id).join(", ") || "none"}.` : `#${input.pr} was not checked yet: find its missing pieces first (bridge_missing_pieces with record, or \`bridge pieces ${input.pr} --record\`).`);
5181
- const product = productOf(input.products, pull, await gh.listPullRequestFiles(input.pr).then((f) => f.map((x) => x.filename), () => []));
5182
- const plan = libraryBranchPlan(piece, productBranchRef(pull, product), { source: input.source, requestedBy: input.openedBy });
5183
- const existing = record.links.find((l) => l.piece === piece.id);
5184
- if (existing) return { status: "already-linked", plan, link: existing, preview: null, warnings: [] };
5185
- if (input.dryRun) return { status: "dry-run", plan, link: null, preview: null, warnings: [] };
5186
- if (!gh.canWrite) throw new GitHubError(401, "Opening a library branch needs a GitHub token (contents, pull requests and issues: write).");
5187
- const warnings = [];
5188
- const at = (/* @__PURE__ */ new Date()).toISOString();
5189
- const who = { ...input.openedBy ? { openedBy: input.openedBy } : {}, ...input.agent ? { agent: input.agent } : {} };
5190
- let link;
5191
- if (plan.kind === "token-change") {
5192
- if (!input.writeTokens) throw new GitHubError(422, "This piece is a token: open it where the token files are read (Bridge Studio, bridge_open_library_branch or `bridge pieces open`).");
5193
- const r = await input.writeTokens(plan);
5194
- link = { piece: piece.id, kind: "token-change", number: r.number, url: r.url, title: plan.title, branch: r.branch, at, ...who };
5195
- } else if (plan.kind === "exploration") {
5196
- const r = await startExplorationOnGitHub(gh, { ...plan.exploration, ...input.openedBy ? { author: input.openedBy } : {}, ...input.agent ? { agent: input.agent } : {} }, { base: input.base, ...input.exploration, labels: [NEEDED_BY_LABEL] });
5197
- link = { piece: piece.id, kind: "exploration", number: r.pr, url: r.url, title: plan.title, branch: r.branch, at, ...who };
5198
- } else {
5199
- const issue = composeRequestIssue(plan.request, { labels: [NEEDED_BY_LABEL] });
5200
- const r = await gh.createIssue(issue);
5201
- link = { piece: piece.id, kind: "request", number: r.number, url: r.url, title: plan.title, at, ...who };
5440
+ function suggestionLabels(p, opts = {}) {
5441
+ return [
5442
+ .../* @__PURE__ */ new Set([
5443
+ SUGGESTION_LABEL,
5444
+ `suggestion: ${p.kind}`,
5445
+ `source: ${p.source}`,
5446
+ `tier: ${p.tier.tier}`,
5447
+ SUGGESTION_STATE_LABELS[opts.state ?? "needs-review"],
5448
+ ...p.links?.neededBy ? [NEEDED_BY_LABEL] : [],
5449
+ ...p.links?.figma || p.source === "figma" ? [FROM_FIGMA_LABEL] : [],
5450
+ ...p.links?.exploration ? [EXPLORATION_LABEL] : [],
5451
+ ...opts.labels ?? []
5452
+ ])
5453
+ ];
5454
+ }
5455
+ function suggestionKindFromLabels(labels) {
5456
+ for (const l of labels) {
5457
+ const m = /^suggestion: (.+)$/.exec(l);
5458
+ if (m && SUGGESTION_KINDS.includes(m[1])) return m[1];
5202
5459
  }
5203
- try {
5204
- if (link.kind === "request") {
5205
- const issue = await gh.getIssue(link.number);
5206
- await gh.updateIssue(link.number, { body: withNeededBy(issue.body, plan.neededBy) });
5207
- } else {
5208
- const lib = await gh.getPullRequest(link.number);
5209
- await gh.updatePullRequest(link.number, { body: withNeededBy(lib.body, plan.neededBy) });
5210
- await gh.addLabels(link.number, [NEEDED_BY_LABEL]);
5211
- }
5212
- } catch (err) {
5213
- if (err instanceof GitHubError && err.auth === "rejected") throw err;
5214
- warnings.push(`The library side does not say who needs it yet (${err.message}). Add "Needed by #${input.pr}" to #${link.number} on GitHub.`);
5460
+ return void 0;
5461
+ }
5462
+ function suggestionStateFromLabels(labels) {
5463
+ return Object.entries(SUGGESTION_STATE_LABELS).find(([, l]) => labels.includes(l))?.[0];
5464
+ }
5465
+ function suggestionStatus(i) {
5466
+ const prs = i.linkedPrs ?? [];
5467
+ if (i.state === "closed") return prs.some((p) => p.merged) || i.stateReason === "completed" ? "applied" : "declined";
5468
+ if (prs.some((p) => p.state === "open") || i.applied) return "applying";
5469
+ if (i.outdated) return "outdated";
5470
+ return i.review ?? "needs-review";
5471
+ }
5472
+ function suggestionStateLabel(status) {
5473
+ return status in SUGGESTION_STATE_LABELS ? SUGGESTION_STATE_LABELS[status] : null;
5474
+ }
5475
+ var bridgeMcpInstallCommand = (repo) => `claude mcp add bridge -s user -- npx -y @bridge-ds/mcp --repo ${repo?.trim() || "<owner/name>"}`;
5476
+ var MARK3 = "bridge-suggestion";
5477
+ var PART_SCHEMA = "bridge.suggestion-part/1";
5478
+ var cell3 = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
5479
+ var safeText = (s) => s.replace(/<!--/g, "&lt;!--");
5480
+ var sourceLabel2 = (s) => s === "figma" ? "Figma" : s === "studio" ? "Bridge Studio" : s === "cli" ? "the CLI" : "Claude (MCP)";
5481
+ var show2 = (v) => v === void 0 ? "" : typeof v === "string" ? v : JSON.stringify(v);
5482
+ function table(head, rows, max) {
5483
+ if (!rows.length) return [];
5484
+ const shown = rows.slice(0, max);
5485
+ return [
5486
+ `| ${head.join(" | ")} |`,
5487
+ `| ${head.map(() => "---").join(" | ")} |`,
5488
+ ...shown.map((r) => `| ${r.map((c) => cell3(c)).join(" | ")} |`),
5489
+ ...rows.length > max ? ["", `and ${rows.length - max} more`] : []
5490
+ ];
5491
+ }
5492
+ function changesSection(p, max) {
5493
+ const out = [];
5494
+ if (p.tokens?.changes.length) {
5495
+ const rows = p.tokens.changes.map((c) => [
5496
+ `\`${c.path}\`${c.kind === "renamed" && c.from ? ` (was \`${c.from}\`)` : c.kind === "added" ? " (new)" : c.kind === "removed" ? " (removed)" : ""}`,
5497
+ c.beforeCss ?? show2(c.before),
5498
+ c.afterCss ?? show2(c.after)
5499
+ ]);
5500
+ out.push(...table(["Token", "Now", "Suggested"], rows, max), "");
5501
+ } else if (p.tokens?.edits.length) {
5502
+ const rows = p.tokens.edits.map((e) => [`\`${e.path}\``, e.op === "rename" ? `rename to \`${e.newPath ?? ""}\`` : e.op, show2(e.value)]);
5503
+ out.push(...table(["Token", "Edit", "Value"], rows, max), "");
5215
5504
  }
5216
- const fresh = await gh.getPullRequest(input.pr);
5217
- const next = withLink(parsePiecesRecord(fresh.body) ?? record, link);
5218
- await gh.updatePullRequest(input.pr, { body: withPiecesRecord(fresh.body, next) });
5219
- let preview = null;
5220
- if ((input.usePreview ?? true) && link.kind === "token-change" && link.branch) {
5221
- preview = await useLibraryPreviewOnGitHub(gh, { productBranch: pull.headRef, libraryBranch: link.branch, libraryNumber: link.number }).catch((err) => {
5222
- warnings.push(`Your branch does not use the preview yet: ${err.message}`);
5223
- return null;
5224
- });
5505
+ if (p.docs?.length) out.push(...table(["Component", "Docs file"], p.docs.map((d) => [d.component, `\`${d.path}\``]), max), "");
5506
+ if (p.componentSpec) out.push(`**Component spec:** ${p.componentSpec.component} (\`${p.componentSpec.specPath}\`)`, "");
5507
+ if (p.files?.length) {
5508
+ const rows = p.files.map((f) => [`\`${f.path}\``, f.beforeSha === null ? "new" : f.afterSha === null ? "deleted" : "changed"]);
5509
+ out.push(...table(["File", "Change"], rows, max), "");
5225
5510
  }
5226
- return { status: "opened", plan, link, preview, warnings };
5511
+ return out;
5227
5512
  }
5228
- var isNote = (path) => path.startsWith(`${EXPLORATION_NOTES_DIR}/`);
5229
- async function linkedLibraryBranchOnGitHub(gh, link, product, piece) {
5230
- const nearest = piece?.nearest?.label;
5231
- if (link.kind === "request") {
5232
- const issue = await gh.getIssue(link.number);
5233
- const status2 = linkStatus({ kind: "request", state: issue.state, stateReason: issue.stateReason });
5234
- const lifecycle2 = issue.state === "closed" ? issue.stateReason === "not_planned" ? "closed" : "merged" : "open";
5235
- const behindMain2 = lifecycle2 === "merged" ? await gh.compare(product.baseRef, product.headSha).then((c) => c.behindBy, () => null) : null;
5236
- const preview2 = libraryPreviewState({ kind: "request", lifecycle: lifecycle2, hasChanges: false, missing: null, total: null, behindMain: behindMain2, nearest });
5237
- return { link, piece, status: status2, preview: preview2, people: status2.people };
5238
- }
5239
- const lib = await gh.getPullRequest(link.number);
5513
+ function checksSection(p, max) {
5514
+ const c = p.checks;
5515
+ const failing = c.contrast.filter((x) => !x.pass);
5516
+ const rows = [...failing, ...c.contrast.filter((x) => x.pass)].map((x) => [x.pair, x.mode ?? "", x.before.toFixed(2), x.after.toFixed(2), String(x.min), x.pass ? "pass" : "**fails**"]);
5517
+ return [
5518
+ "## Checks when suggested",
5519
+ "",
5520
+ `- Token validation: ${c.newErrors ? `**${c.newErrors} new error(s)**` : "no new errors"}`,
5521
+ `- Contrast: ${c.contrast.length ? failing.length ? `**${failing.length} failing pair(s)**` : `${c.contrast.length} pair(s) checked, all pass` : "no pair affected"}`,
5522
+ `- Risk tier: ${p.tier.tier} (\`${p.tier.bump}\`), ${p.tier.tier === 1 ? "one approval" : "design and tech approval"}`,
5523
+ `- Impact: ${c.impact ? `${c.impact} token(s) resolve to a new value` : "no other token changes value"}`,
5524
+ "",
5525
+ ...rows.length ? [...table(["Pair", "Mode", "Before", "After", "Min", "Result"], rows, max), ""] : []
5526
+ ];
5527
+ }
5528
+ function linksSection(p) {
5529
+ const l = p.links ?? {};
5530
+ const out = [];
5531
+ if (l.neededBy) out.push(`- Needed by ${l.neededBy.productName}: [#${l.neededBy.pr} ${cell3(l.neededBy.title)}](${l.neededBy.url})`);
5532
+ if (l.figma?.url) out.push(`- Design: [Open in Figma](${l.figma.url})`);
5533
+ if (l.exploration) out.push(`- Exploration: \`${l.exploration}\``);
5534
+ if (l.supersedes) out.push(`- Replaces suggestion #${l.supersedes}`);
5535
+ for (const v of p.previews ?? []) out.push(`- ${cell3(v.label)}: ${v.url}`);
5536
+ return out.length ? ["## Previews and links", "", ...out, ""] : [];
5537
+ }
5538
+ function humanPart(p, details, max, repo) {
5539
+ return [
5540
+ `## Suggested change: ${safeText(p.title.trim())}`,
5541
+ "",
5542
+ safeText(p.summary.trim()),
5543
+ "",
5544
+ ...changesSection(p, max),
5545
+ ...details?.trim() ? [safeText(details.trim()), ""] : [],
5546
+ ...checksSection(p, max),
5547
+ ...linksSection(p),
5548
+ "## How to apply",
5549
+ "",
5550
+ "- CLI: `bridge apply <this issue number>` (add `--dry-run` to check it first, `--patch` for a patch file)",
5551
+ '- Claude Code with the Bridge MCP server: ask "Apply Bridge suggestion #<this issue number>" (tool `bridge_apply_suggestion`).',
5552
+ ` One-time install: \`${bridgeMcpInstallCommand(repo)}\``,
5553
+ "",
5554
+ "The pull request that applies it must say `Closes #<this issue number>`, so this suggestion closes when it merges.",
5555
+ "",
5556
+ `<sub>Suggested from ${sourceLabel2(p.source)}${p.suggestedBy ? ` by @${p.suggestedBy.replace(/^@/, "")}` : ""} with Bridge.</sub>`
5557
+ ].join("\n");
5558
+ }
5559
+ function fitHuman(p, details, repo) {
5560
+ for (const max of [50, 20, 8, 3]) {
5561
+ const h2 = humanPart(p, details, max, repo);
5562
+ if (h2.length <= SUGGESTION_LIMITS.human) return h2;
5563
+ }
5564
+ const h = humanPart(p, void 0, 3, repo);
5565
+ if (h.length <= SUGGESTION_LIMITS.human) return h;
5566
+ const note = "\n\n_The description is cut here: the full change is in the machine-readable block below._";
5567
+ return h.slice(0, SUGGESTION_LIMITS.human - note.length) + note;
5568
+ }
5569
+ var pretty = (v) => JSON.stringify(sortKeys(v), null, 2);
5570
+ var fit = (v) => {
5571
+ const p = pretty(v);
5572
+ return p.length <= SUGGESTION_LIMITS.inline ? p : canonicalJson(v);
5573
+ };
5574
+ var fenced = (json) => ["```json", json, "```"].join("\n");
5575
+ function payloadBlock(json, sha, parts) {
5576
+ return [
5577
+ "<details><summary>Machine-readable change (Bridge suggestion v1)</summary>",
5578
+ "",
5579
+ `<!-- ${MARK3}:v1 sha256=${sha}${parts ? ` parts=${parts}` : ""} -->`,
5580
+ fenced(json),
5581
+ "",
5582
+ "</details>"
5583
+ ].join("\n");
5584
+ }
5585
+ function partComment(i, n, json) {
5586
+ return [
5587
+ `<details><summary>Machine-readable change, part ${i} of ${n} (Bridge suggestion v1)</summary>`,
5588
+ "",
5589
+ `<!-- ${MARK3}:v1 part ${i}/${n} sha256=${sha256Hex(json)} -->`,
5590
+ fenced(json),
5591
+ "",
5592
+ "</details>"
5593
+ ].join("\n");
5594
+ }
5595
+ function escapedEnd(s, from, budget) {
5596
+ let used = 0;
5597
+ let i = from;
5598
+ while (i < s.length) {
5599
+ const c = s.charCodeAt(i);
5600
+ let w = 1;
5601
+ let len = 1;
5602
+ if (c === 34 || c === 92 || c === 8 || c === 12 || c === 10 || c === 13 || c === 9) len = 2;
5603
+ else if (c < 32) len = 6;
5604
+ else if (c >= 55296 && c <= 56319) {
5605
+ const d = s.charCodeAt(i + 1);
5606
+ if (d >= 56320 && d <= 57343) {
5607
+ w = 2;
5608
+ len = 2;
5609
+ } else len = 6;
5610
+ } else if (c >= 56320 && c <= 57343) len = 6;
5611
+ if (used + len > budget) break;
5612
+ used += len;
5613
+ i += w;
5614
+ }
5615
+ return i;
5616
+ }
5617
+ function composeSuggestionIssue(payload, opts = {}) {
5618
+ const problems = suggestionShapeProblems(payload);
5619
+ if (problems.length) throw new SuggestionError("invalid", problems.join(" "));
5620
+ if (payload.checks.blocked) throw new SuggestionError("invalid", `This change cannot be suggested: ${payload.checks.blocked}`);
5621
+ const full = shaForm(payload);
5622
+ const sha = suggestionPayloadSha(full);
5623
+ const human = fitHuman(full, opts.details, opts.repo);
5624
+ const base = { title: suggestionIssueTitle(full), labels: suggestionLabels(full, { state: opts.state, labels: opts.labels }), assignees: opts.assignees ?? [], sha };
5625
+ const inline = fit(full);
5626
+ if (inline.length <= SUGGESTION_LIMITS.inline) {
5627
+ return { ...base, body: `${human}
5628
+
5629
+ ${payloadBlock(inline, sha, 0)}
5630
+ `, parts: [] };
5631
+ }
5632
+ const moved = (full.files ?? []).map((f, i) => ({ f, i })).filter(({ f }) => typeof f.content === "string" && f.content.length > 0);
5633
+ const parts = [];
5634
+ const firstPart = /* @__PURE__ */ new Map();
5635
+ const partJson = (chunks, i, n2) => pretty({ schema: PART_SCHEMA, part: i, of: n2, files: chunks });
5636
+ const emptyOverhead = partJson([], 9, 9).length;
5637
+ let cur = [];
5638
+ let room = SUGGESTION_LIMITS.part - emptyOverhead - 64;
5639
+ for (const { f, i } of moved) {
5640
+ const text = f.content;
5641
+ let at = 0;
5642
+ while (at < text.length) {
5643
+ const entryOverhead = partJson([{ i, path: f.path, text: "" }], 9, 9).length - emptyOverhead + 8;
5644
+ if (room - entryOverhead < 256) {
5645
+ parts.push(cur);
5646
+ cur = [];
5647
+ room = SUGGESTION_LIMITS.part - emptyOverhead - 64;
5648
+ }
5649
+ const end = escapedEnd(text, at, room - entryOverhead);
5650
+ if (end === at) throw new SuggestionError("too-large", SUGGESTION_TOO_LARGE);
5651
+ if (!firstPart.has(i)) firstPart.set(i, parts.length + 1);
5652
+ const chunk = { i, path: f.path, text: text.slice(at, end) };
5653
+ cur.push(chunk);
5654
+ room -= partJson([chunk], 9, 9).length - emptyOverhead + 8;
5655
+ at = end;
5656
+ }
5657
+ }
5658
+ if (cur.length) parts.push(cur);
5659
+ if (parts.length > SUGGESTION_LIMITS.maxParts) throw new SuggestionError("too-large", SUGGESTION_TOO_LARGE);
5660
+ const skeleton = {
5661
+ ...full,
5662
+ files: (full.files ?? []).map((f, i) => {
5663
+ if (!firstPart.has(i)) return f;
5664
+ const { content: _content, ...rest } = f;
5665
+ return { ...rest, part: firstPart.get(i) };
5666
+ })
5667
+ };
5668
+ const skeletonJson = fit(skeleton);
5669
+ if (skeletonJson.length > SUGGESTION_LIMITS.inline) throw new SuggestionError("too-large", SUGGESTION_TOO_LARGE);
5670
+ const n = parts.length;
5671
+ const partBodies = parts.map((chunks, k) => {
5672
+ const json = partJson(chunks, k + 1, n);
5673
+ if (json.length > SUGGESTION_LIMITS.part) throw new SuggestionError("too-large", SUGGESTION_TOO_LARGE);
5674
+ return partComment(k + 1, n, json);
5675
+ });
5676
+ const body = `${human}
5677
+
5678
+ ${payloadBlock(skeletonJson, sha, n)}
5679
+ `;
5680
+ if (body.length > SUGGESTION_LIMITS.body || partBodies.some((b) => b.length > SUGGESTION_LIMITS.body)) throw new SuggestionError("too-large", SUGGESTION_TOO_LARGE);
5681
+ return { ...base, body, parts: partBodies };
5682
+ }
5683
+ function fencedAfter(text, from) {
5684
+ const m = /```json\r?\n([\s\S]*?)\r?\n```/.exec(text.slice(from));
5685
+ return m?.[1];
5686
+ }
5687
+ var fail = (reason, message) => ({ ok: false, reason, message });
5688
+ function parseSuggestionIssue(body, opts = {}) {
5689
+ const text = body ?? "";
5690
+ const markers = [...text.matchAll(new RegExp(`^<!-- ${MARK3}:v(\\d+) sha256=([0-9a-f]{64})(?: parts=(\\d+))? -->$`, "gm"))];
5691
+ const marker = markers[markers.length - 1];
5692
+ if (!marker) return fail("no-block", "This issue has no Bridge suggestion block.");
5693
+ if (marker[1] !== "1") return fail("unsupported-version", `This suggestion uses version ${marker[1]} of the format. Update Bridge to read it.`);
5694
+ const json = fencedAfter(text, marker.index + marker[0].length);
5695
+ if (json === void 0) return fail("invalid", "The suggestion block has no JSON. Someone may have edited it by hand: ask the author to send it again.");
5696
+ let raw;
5697
+ try {
5698
+ raw = JSON.parse(json);
5699
+ } catch {
5700
+ return fail("invalid", "The suggestion JSON does not parse. Someone may have edited it by hand: ask the author to send it again.");
5701
+ }
5702
+ const schema = raw?.schema;
5703
+ if (typeof schema === "string" && schema !== SUGGESTION_SCHEMA) {
5704
+ const v = /^bridge\.suggestion\/(\d+)$/.exec(schema);
5705
+ return v ? fail("unsupported-version", `This suggestion uses ${schema}. Update Bridge to read it.`) : fail("invalid", `"${schema}" is not a Bridge suggestion schema.`);
5706
+ }
5707
+ const problems = suggestionShapeProblems(raw);
5708
+ if (problems.length) return fail("invalid", `The suggestion block is not valid: ${problems.join(" ")}`);
5709
+ const payload = raw;
5710
+ const markedSha = marker[2];
5711
+ const expected = marker[3] ? Number(marker[3]) : 0;
5712
+ let edited = false;
5713
+ if (expected > 0 || payload.files?.some((f) => f.part !== void 0)) {
5714
+ const want = Math.max(expected, ...(payload.files ?? []).map((f) => f.part ?? 0));
5715
+ const found = /* @__PURE__ */ new Map();
5716
+ const author = opts.author?.toLowerCase();
5717
+ for (const c of opts.comments ?? []) {
5718
+ if (author && c.author && c.author.toLowerCase() !== author) continue;
5719
+ const m = new RegExp(`^<!-- ${MARK3}:v1 part (\\d+)/(\\d+) sha256=([0-9a-f]{64}) -->$`, "m").exec(c.body);
5720
+ if (!m) continue;
5721
+ const i = Number(m[1]);
5722
+ if (found.has(i)) continue;
5723
+ const pj = fencedAfter(c.body, m.index + m[0].length);
5724
+ if (pj === void 0) continue;
5725
+ if (sha256Hex(pj) !== m[3]) edited = true;
5726
+ try {
5727
+ const part = JSON.parse(pj);
5728
+ if (part.schema !== PART_SCHEMA || !Array.isArray(part.files)) continue;
5729
+ found.set(i, part.files.filter((x) => x && typeof x.i === "number" && typeof x.text === "string"));
5730
+ } catch {
5731
+ continue;
5732
+ }
5733
+ }
5734
+ for (let i = 1; i <= want; i++) {
5735
+ if (!found.has(i)) return fail("missing-part", `This suggestion is incomplete: part ${i} of ${want} is missing. It is still being written, or a comment was deleted: ask the author to send it again.`);
5736
+ }
5737
+ const files = (payload.files ?? []).map((f, idx) => {
5738
+ if (f.part === void 0) return f;
5739
+ let content = "";
5740
+ for (let i = 1; i <= want; i++) for (const ch of found.get(i)) if (ch.i === idx && ch.path === f.path) content += ch.text;
5741
+ const { part: _part, ...rest } = f;
5742
+ return { ...rest, content };
5743
+ });
5744
+ payload.files = files;
5745
+ }
5746
+ const sha = suggestionPayloadSha(payload);
5747
+ return { ok: true, payload, sha, markedSha, edited: edited || sha !== markedSha, parts: expected };
5748
+ }
5749
+ function suggestionShapeProblems(raw) {
5750
+ const out = [];
5751
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ["The payload must be a JSON object."];
5752
+ const p = raw;
5753
+ if (p.schema !== SUGGESTION_SCHEMA) out.push(`"schema" must be "${SUGGESTION_SCHEMA}".`);
5754
+ if (!SUGGESTION_KINDS.includes(p.kind)) out.push(`"kind" must be one of ${SUGGESTION_KINDS.join(", ")}.`);
5755
+ if (typeof p.title !== "string" || !p.title.trim()) out.push('"title" must be a sentence.');
5756
+ if (typeof p.summary !== "string") out.push('"summary" must be text.');
5757
+ if (!["figma", "studio", "cli", "mcp"].includes(p.source)) out.push('"source" must be figma, studio, cli or mcp.');
5758
+ if (typeof p.createdAt !== "string") out.push('"createdAt" must be an ISO date.');
5759
+ const base = p.base;
5760
+ if (!base || typeof base.ref !== "string" || typeof base.sha !== "string" || !/^[0-9a-f]{7,64}$/.test(base.sha)) out.push('"base" must be { "ref", "sha" } with the commit the change was planned on.');
5761
+ const tier = p.tier;
5762
+ if (!tier || ![1, 2, 3].includes(tier.tier) || !["patch", "minor", "major"].includes(tier.bump)) out.push('"tier" must be { "tier": 1, 2 or 3, "bump": patch, minor or major }.');
5763
+ const checks = p.checks;
5764
+ if (!checks || typeof checks.newErrors !== "number" || !Array.isArray(checks.contrast) || typeof checks.impact !== "number" || !(checks.blocked === null || typeof checks.blocked === "string"))
5765
+ out.push('"checks" must be { "newErrors", "contrast", "blocked", "impact" }.');
5766
+ const tokens = p.tokens;
5767
+ if (tokens !== void 0 && (!tokens || !Array.isArray(tokens.edits) || !Array.isArray(tokens.changes))) out.push('"tokens" must be { "edits", "changes" }.');
5768
+ if (p.docs !== void 0 && !(Array.isArray(p.docs) && p.docs.every((d) => d && typeof d.component === "string" && typeof d.path === "string"))) out.push('"docs" must be a list of { "component", "path", "after" }.');
5769
+ const spec = p.componentSpec;
5770
+ if (spec !== void 0 && (!spec || typeof spec.component !== "string" || typeof spec.specPath !== "string" || typeof spec.brief !== "string")) out.push('"componentSpec" must be { "component", "specPath", "spec", "brief" }.');
5771
+ if (p.files !== void 0) {
5772
+ if (!Array.isArray(p.files)) out.push('"files" must be a list.');
5773
+ else {
5774
+ const seen = /* @__PURE__ */ new Set();
5775
+ for (const f of p.files) {
5776
+ const ok2 = f && typeof f.path === "string" && (f.beforeSha === null || typeof f.beforeSha === "string") && (f.afterSha === null || typeof f.afterSha === "string") && (f.content === void 0 || f.content === null || typeof f.content === "string") && (f.part === void 0 || Number.isInteger(f.part) && f.part >= 1 && f.part <= SUGGESTION_LIMITS.maxParts);
5777
+ if (!ok2) {
5778
+ out.push('Each of "files" must be { "path", "beforeSha", "afterSha", "content"? }.');
5779
+ break;
5780
+ }
5781
+ if (seen.has(f.path)) out.push(`"${f.path}" is listed twice in "files".`);
5782
+ seen.add(f.path);
5783
+ }
5784
+ }
5785
+ }
5786
+ return out;
5787
+ }
5788
+ function unsafePathProblem(path) {
5789
+ if (typeof path !== "string" || !path.length) return "A path is empty.";
5790
+ if (/[\0-\x1f\x7f]/.test(path)) return `"${path.replace(/[\0-\x1f\x7f]/g, "?")}" has control characters.`;
5791
+ if (path.includes("\\")) return `"${path}" uses backslashes: paths use forward slashes.`;
5792
+ if (path.startsWith("/") || /^[A-Za-z]:/.test(path) || path.startsWith("~")) return `"${path}" is absolute: paths are relative to the repository root.`;
5793
+ const segs = path.split("/");
5794
+ if (segs.some((s) => s === "" || s === "." || s === "..")) return `"${path}" has an empty, "." or ".." part: paths stay inside the repository.`;
5795
+ if (segs.some((s) => s.toLowerCase() === ".git")) return `"${path}" is inside .git.`;
5796
+ return null;
5797
+ }
5798
+ var under = (path, root) => {
5799
+ const r = root.replace(/\/+$/, "");
5800
+ return !r || r === "." || path === r || path.startsWith(`${r}/`);
5801
+ };
5802
+ function isGuardrailPath(path, guardrails) {
5803
+ return guardrails.some((g) => g.endsWith("/") ? path.startsWith(g) || path === g.slice(0, -1) : path === g);
5804
+ }
5805
+ function suggestionAllowedRoots(kind, config) {
5806
+ const tokenRoot2 = tokensPath(config) || ".";
5807
+ const changesets = config.changesets?.dir ? [config.changesets.dir] : [];
5808
+ switch (kind) {
5809
+ case "token-change":
5810
+ return [tokenRoot2, ...changesets];
5811
+ case "docs":
5812
+ return [...config.components?.dir ? [config.components.dir] : [], ...changesets];
5813
+ case "component-spec":
5814
+ return [".bridge/figma"];
5815
+ case "brand":
5816
+ return [tokenRoot2, ...changesets, CONFIG_FILE];
5817
+ case "config":
5818
+ return [CONFIG_FILE];
5819
+ case "revert":
5820
+ return void 0;
5821
+ }
5822
+ }
5823
+ function suggestionPathProblems(p, config) {
5824
+ const out = [];
5825
+ const roots = suggestionAllowedRoots(p.kind, config);
5826
+ const guardrails = config.governance.guardrails;
5827
+ const check = (path) => {
5828
+ const bad = unsafePathProblem(path);
5829
+ if (bad) return out.push(bad);
5830
+ if (roots && !roots.some((r) => under(path, r))) return out.push(`"${path}" is outside what a ${p.kind} suggestion may change (${roots.join(", ")}).`);
5831
+ const configAllowed = path === CONFIG_FILE && (p.kind === "config" || p.kind === "brand");
5832
+ if (!configAllowed && isGuardrailPath(path, guardrails)) return out.push(`"${path}" is a guardrail file: change it in a pull request, not a suggestion.`);
5833
+ return 0;
5834
+ };
5835
+ for (const f of p.files ?? []) check(f.path);
5836
+ for (const d of p.docs ?? []) check(d.path);
5837
+ if (p.componentSpec) check(p.componentSpec.specPath);
5838
+ for (const e of p.tokens?.edits ?? []) {
5839
+ if (e.file === void 0) continue;
5840
+ const bad = unsafePathProblem(e.file);
5841
+ if (bad) out.push(bad);
5842
+ }
5843
+ return [...new Set(out)];
5844
+ }
5845
+
5846
+ // ../core/src/suggestions-github.ts
5847
+ async function createSuggestionOnGitHub(gh, payload, opts = {}) {
5848
+ const issue = composeSuggestionIssue(payload, { repo: gh.fullName, ...opts });
5849
+ const created = await gh.createIssue({ title: issue.title, body: issue.body, labels: issue.labels, assignees: issue.assignees });
5850
+ for (const [i, part] of issue.parts.entries()) {
5851
+ try {
5852
+ await gh.createIssueComment(created.number, part);
5853
+ } catch (err) {
5854
+ const why = err instanceof Error ? err.message : String(err);
5855
+ throw new GitHubError(err instanceof GitHubError ? err.status : 500, `Suggestion #${created.number} is incomplete: part ${i + 1} of ${issue.parts.length} could not be posted (${why}). Send it again, then close #${created.number}.`);
5856
+ }
5857
+ }
5858
+ const warnings = [];
5859
+ const missing = issue.labels.filter((l) => !created.labels.includes(l));
5860
+ if (created.labels.length && missing.length) warnings.push(`GitHub did not keep these labels on #${created.number}: ${missing.join(", ")}.`);
5861
+ return { number: created.number, url: created.url, sha: issue.sha, parts: issue.parts.length, warnings };
5862
+ }
5863
+ function linkedPullsOf(n, pulls) {
5864
+ return pulls.filter((p) => closingIssueNumbers(p.body).includes(n)).map((p) => ({ number: p.number, url: p.url, state: p.state === "open" ? "open" : "closed", merged: p.state === "merged" }));
5865
+ }
5866
+ async function listSuggestionsOnGitHub(gh, opts = {}) {
5867
+ const issues = await gh.listIssues({ label: SUGGESTION_LABEL, state: opts.state ?? "open", perPage: opts.perPage ?? 50 });
5868
+ const pulls = opts.pulls ?? (issues.length ? await gh.listPullRequests("all").catch(() => []) : []);
5869
+ return issues.map((i) => listItem(i, pulls));
5870
+ }
5871
+ function listItem(i, pulls) {
5872
+ const linkedPrs = linkedPullsOf(i.number, pulls);
5873
+ const cached = suggestionStateFromLabels(i.labels);
5874
+ const t = i.labels.map((l) => /^tier: ([123])$/.exec(l)?.[1]).find(Boolean);
5875
+ return {
5876
+ number: i.number,
5877
+ title: i.title,
5878
+ url: i.url,
5879
+ author: i.author,
5880
+ createdAt: i.createdAt,
5881
+ state: i.state,
5882
+ stateReason: i.stateReason,
5883
+ labels: i.labels,
5884
+ kind: suggestionKindFromLabels(i.labels),
5885
+ tier: t ? Number(t) : void 0,
5886
+ linkedPrs,
5887
+ status: suggestionStatus({ state: i.state, stateReason: i.stateReason, linkedPrs, applied: cached === "applying", review: cached === "applying" ? void 0 : cached })
5888
+ };
5889
+ }
5890
+ async function getSuggestionOnGitHub(gh, n, gov, opts = {}) {
5891
+ const [issue, comments, pulls] = await Promise.all([gh.getIssue(n), gh.listIssueComments(n), opts.pulls ? Promise.resolve(opts.pulls) : gh.listPullRequests("all").catch(() => [])]);
5892
+ const parsed = parseSuggestionIssue(issue.body, { comments, author: issue.author || void 0 });
5893
+ const linkedPrs = linkedPullsOf(n, pulls);
5894
+ const applied = comments.flatMap((c) => parseSuggestionAppliedComment(c.body) ?? []);
5895
+ const approvals = parsed.ok ? summarizeSuggestionApprovals(comments, gov, { payloadSha: parsed.sha, author: issue.author }) : void 0;
5896
+ const status = suggestionStatus({
5897
+ state: issue.state,
5898
+ stateReason: issue.stateReason,
5899
+ linkedPrs,
5900
+ applied: parsed.ok && applied.some((a) => a.payloadSha === parsed.sha),
5901
+ outdated: opts.outdated,
5902
+ review: parsed.ok && approvals ? suggestionReviewState(parsed.payload.tier.tier, approvals) : void 0
5903
+ });
5904
+ return { issue, parsed, comments, approvals, linkedPrs, applied, status };
5905
+ }
5906
+ async function syncSuggestionStateLabel(gh, n, labels, status) {
5907
+ const want = suggestionStateLabel(status);
5908
+ let ok2 = true;
5909
+ for (const l of Object.values(SUGGESTION_STATE_LABELS)) {
5910
+ if (l === want || !labels.includes(l)) continue;
5911
+ try {
5912
+ await gh.removeLabel(n, l);
5913
+ } catch {
5914
+ ok2 = false;
5915
+ }
5916
+ }
5917
+ if (want && !labels.includes(want)) ok2 = await gh.addLabels(n, [want]) && ok2;
5918
+ return ok2 ? null : `The state label on #${n} could not be updated; the status shown in Bridge is still right.`;
5919
+ }
5920
+ async function reviewSuggestionOnGitHub(gh, n, input, gov) {
5921
+ if (input.event === "request-changes" && !input.message?.trim()) throw new GitHubError(422, "Say what should change, in a sentence or two: the author reads it.");
5922
+ const before = await getSuggestionOnGitHub(gh, n, gov);
5923
+ if (!before.parsed.ok) throw new GitHubError(422, `#${n} cannot be reviewed: ${before.parsed.message}`);
5924
+ if (before.issue.state === "closed") throw new GitHubError(422, `#${n} is closed. Reopen it on GitHub to review it again.`);
5925
+ const sha = before.parsed.sha;
5926
+ if (input.payloadSha && input.payloadSha !== sha) throw new GitHubError(409, `#${n} changed since you opened it. Reload it and review the new version.`);
5927
+ const by = input.by ?? (await gh.getViewer().catch(() => ({ login: "" }))).login;
5928
+ const body = composeSuggestionReviewComment({ event: input.event, role: input.role, payloadSha: sha, at: (input.at ?? /* @__PURE__ */ new Date()).toISOString(), by: by || void 0, message: input.message });
5929
+ const posted = await gh.createIssueComment(n, body);
5930
+ const after = await getSuggestionOnGitHub(gh, n, gov);
5931
+ const status = after.status;
5932
+ const who = (after.comments.find((c) => c.id === posted.id)?.author ?? by).toLowerCase();
5933
+ const counts = input.event === "comment" || [...after.approvals?.approvers ?? [], ...after.approvals?.changesRequested ?? []].some((l) => l.toLowerCase() === who);
5934
+ const warnings = [];
5935
+ if (!counts) warnings.push("Your review is posted but does not count: only the owners listed in bridge.config.json approve suggestions.");
5936
+ if (before.parsed.edited) warnings.push(`#${n} was edited by hand after Bridge wrote it: check the machine-readable block before applying it.`);
5937
+ const labelWarning = await syncSuggestionStateLabel(gh, n, before.issue.labels, status);
5938
+ if (labelWarning) warnings.push(labelWarning);
5939
+ return { commentUrl: posted.url, payloadSha: sha, status, counts, warnings };
5940
+ }
5941
+ async function linkSuggestionPrOnGitHub(gh, n, link) {
5942
+ if (!link.pr && !link.branch && !link.commit) throw new GitHubError(422, "Say where it is applied: a pull request number, a branch or a commit.");
5943
+ const issue = await gh.getIssue(n);
5944
+ await gh.addComment(n, composeSuggestionAppliedComment({ by: link.by, branch: link.branch, commit: link.commit, pr: link.pr, payloadSha: link.payloadSha, forced: link.forced, at: (link.at ?? /* @__PURE__ */ new Date()).toISOString() }));
5945
+ const warning = await syncSuggestionStateLabel(gh, n, issue.labels, "applying");
5946
+ return { warnings: warning ? [warning] : [] };
5947
+ }
5948
+ async function declineSuggestionOnGitHub(gh, n, reason, by) {
5949
+ const issue = await gh.getIssue(n);
5950
+ await declineRequestOnGitHub(gh, n, reason, by);
5951
+ const warning = await syncSuggestionStateLabel(gh, n, issue.labels, "declined");
5952
+ return { warnings: warning ? [warning] : [] };
5953
+ }
5954
+
5955
+ // ../core/src/docs-proposal.ts
5956
+ var DOCS_EDITOR_ORIGIN = {
5957
+ where: "in the Bridge docs editor",
5958
+ footer: "Written by a person in the Bridge docs editor. No AI involved in this text.",
5959
+ label: "source: bridge-web"
5960
+ };
5961
+ var DOCS_MCP_ORIGIN = {
5962
+ where: "with Claude through the Bridge MCP server",
5963
+ footer: 'Proposed with the Bridge MCP server on behalf of a person. Docs Claude drafted say "draftedBy": "claude" until a reviewer accepts them.',
5964
+ label: "source: mcp"
5965
+ };
5966
+ var EM_DASH = /\u2014/;
5967
+ function validateDocs(d) {
5968
+ const out = [];
5969
+ if (!d.summary.trim()) out.push({ field: "summary", message: "Add a summary: one or two sentences on what it is for." });
5970
+ const lists = ["whenToUse", "whenNotToUse", "accessibility", "contentGuidelines"];
5971
+ for (const k of lists) (d[k] ?? []).forEach((x, i) => !x.trim() && out.push({ field: `${k}.${i}`, message: "Fill or remove the empty item." }));
5972
+ for (const k of ["dos", "donts"]) d[k].forEach((g, i) => (!g.title.trim() || !g.text.trim()) && out.push({ field: `${k}.${i}`, message: "Give every item a title and a sentence." }));
5973
+ const prose = JSON.stringify([d.summary, d.whenToUse, d.whenNotToUse, d.dos, d.donts, d.accessibility, d.contentGuidelines ?? [], d.anatomy ?? {}]);
5974
+ if (EM_DASH.test(prose)) out.push({ field: "voice", message: "Replace the em dashes with a colon, a comma or two sentences." });
5975
+ return out;
5976
+ }
5977
+ function cleanDocs(d) {
5978
+ const list = (xs) => (xs ?? []).map((x) => x.trim()).filter(Boolean);
5979
+ const guide = (xs) => xs.map((g) => ({ ...g, title: g.title.trim(), text: g.text.trim() })).filter((g) => g.title || g.text);
5980
+ return {
5981
+ ...d,
5982
+ summary: d.summary.trim(),
5983
+ whenToUse: list(d.whenToUse),
5984
+ whenNotToUse: list(d.whenNotToUse),
5985
+ dos: guide(d.dos),
5986
+ donts: guide(d.donts),
5987
+ accessibility: list(d.accessibility),
5988
+ contentGuidelines: list(d.contentGuidelines),
5989
+ anatomy: Object.fromEntries(Object.entries(d.anatomy ?? {}).filter(([k, v]) => k.trim() && v.trim()))
5990
+ };
5991
+ }
5992
+ var docsFileText = (d, original) => patchDocsJson(original, cleanDocs(d));
5993
+ var slugDate = (d) => d.toISOString().slice(0, 10).replace(/-/g, "");
5994
+ var docsSlug = (nameOrSlug) => nameOrSlug.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9-]+/g, "-").slice(0, 40);
5995
+ function defaultDocsFooter(edits, origin) {
5996
+ if (origin.label !== DOCS_EDITOR_ORIGIN.label) return origin.footer;
5997
+ const claude = edits.some((e) => {
5998
+ try {
5999
+ return JSON.parse(e.after).draftedBy === "claude";
6000
+ } catch {
6001
+ return false;
6002
+ }
6003
+ });
6004
+ return claude ? `Drafted by Claude AI (the docs say "draftedBy": "claude"), sent by a person ${origin.where}.` : origin.footer;
6005
+ }
6006
+ function withAuthorLine(body, line) {
6007
+ const i = body.lastIndexOf("\n---\n");
6008
+ return i < 0 ? `${body.trimEnd()}
6009
+
6010
+ ---
6011
+ ${line}` : `${body.slice(0, i + 5)}${line}`;
6012
+ }
6013
+ var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6014
+ function isDocsProposalBranch(branch, component) {
6015
+ const slugs = /* @__PURE__ */ new Set([docsSlug(component.slug ?? component.name), docsSlug(component.name), docsSlug(component.name.toLowerCase())]);
6016
+ return [...slugs].some((s) => new RegExp(`^docs/(?:[A-Z][A-Z0-9]*-\\d+-)?${escapeRe(s)}-\\d{8}-[a-z0-9]+$`).test(branch));
6017
+ }
6018
+ function findOpenDocsProposal(pulls, component, base) {
6019
+ const hit = pulls.filter((p) => p.state === "open" && isDocsProposalBranch(p.headRef, component) && (!base || !p.baseRef || p.baseRef === base)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
6020
+ return hit ? { number: hit.number, url: hit.url, branch: hit.headRef, author: hit.author, title: hit.title, updatedAt: hit.updatedAt } : null;
6021
+ }
6022
+ async function findOpenDocsProposalOnGitHub(gh, component, base) {
6023
+ return findOpenDocsProposal(await gh.listPullRequests("open"), component, base);
6024
+ }
6025
+ async function continueDocsProposalOnGitHub(gh, open, edit, opts = {}) {
6026
+ const message = opts.message ?? `docs(${docsSlug(edit.component)}): edit usage guidance in the Bridge docs editor`;
6027
+ const cur = await gh.getFile(edit.path, open.branch);
6028
+ if (cur?.text === edit.after) return "";
6029
+ const sha = await gh.putFile({ path: edit.path, content: edit.after, branch: open.branch, message, sha: cur?.sha });
6030
+ if (opts.authorLine) {
6031
+ const line = opts.authorLine;
6032
+ await gh.getPullRequest(open.number).then((pr) => gh.updatePullRequest(open.number, { body: withAuthorLine(pr.body, line) })).catch(() => void 0);
6033
+ }
6034
+ return sha;
6035
+ }
6036
+ function docsProposal(edits, opts) {
6037
+ const origin = opts.origin ?? DOCS_EDITOR_ORIGIN;
6038
+ if (!edits.length) throw new Error("No docs edits in the draft.");
6039
+ const names3 = edits.map((e) => e.component);
6040
+ const list = names3.length === 1 ? names3[0] : `${names3.slice(0, -1).join(", ")} and ${names3[names3.length - 1]}`;
6041
+ const title = opts.title?.trim() || `docs(${names3.length === 1 ? edits[0].slug : "components"}): usage guidance for ${list}`;
6042
+ const slug2 = docsSlug(names3.length === 1 ? edits[0].slug : `${edits[0].slug}-and-${names3.length - 1}-more`);
6043
+ const branch = `docs/${slug2}-${slugDate(opts.date)}-${opts.date.getTime().toString(36).slice(-4)}`;
6044
+ const csPath = `${opts.changesetDir ?? ".changeset"}/docs-${slug2}-${opts.date.getTime().toString(36)}.md`;
6045
+ const pkg = opts.packageName ?? "@bridge-ds/react";
6046
+ const changeset = `---
6047
+ "${pkg}": patch
6048
+ ---
6049
+
6050
+ Usage guidance for ${list}.
6051
+ `;
6052
+ const files = [...edits.map((e) => ({ path: e.path, content: e.after })), { path: csPath, content: changeset }];
6053
+ const risk = classifyRisk({ files: files.map((f) => ({ path: f.path, status: "modified" })) });
6054
+ const rows = edits.map((e) => {
6055
+ let state = "edited";
6056
+ try {
6057
+ const d = JSON.parse(e.after);
6058
+ const gaps = missingProse(d);
6059
+ state = gaps.length ? `still missing: ${gaps.join(", ")}` : "every section filled";
6060
+ } catch {
6061
+ state = "not valid JSON";
6062
+ }
6063
+ return `- \`${e.path}\` (${e.before === null ? "new" : "changed"}, ${state})`;
6064
+ });
6065
+ const body = [
6066
+ `## Docs change`,
6067
+ "",
6068
+ `Usage guidance for ${list}, written ${origin.where}.`,
6069
+ "",
6070
+ ...rows,
6071
+ "",
6072
+ `**Risk:** ${risk.label}. Docs only: the published components do not change. Changeset: patch.`,
6073
+ "",
6074
+ opts.notes?.trim() ? `## Notes
6075
+
6076
+ ${opts.notes.trim()}
6077
+ ` : "",
6078
+ "---",
6079
+ opts.authorLine?.trim() || defaultDocsFooter(edits, origin)
6080
+ ].filter((x, i, a) => !(x === "" && a[i - 1] === "")).join("\n");
6081
+ return { branch, title, body, files, risk, bump: "patch", labels: ["docs", origin.label, `tier-${risk.tier}`] };
6082
+ }
6083
+ var docsSource = (origin) => origin.label === DOCS_MCP_ORIGIN.label ? "mcp" : "studio";
6084
+ function docsSuggestionPayload(edits, opts) {
6085
+ const origin = opts.origin ?? DOCS_EDITOR_ORIGIN;
6086
+ const date = opts.date ?? /* @__PURE__ */ new Date();
6087
+ const p = docsProposal(edits, { title: opts.title, date, origin, packageName: opts.packageName, changesetDir: opts.changesetDir });
6088
+ const names3 = edits.map((e) => e.component);
6089
+ const list = names3.length === 1 ? names3[0] : `${names3.slice(0, -1).join(", ")} and ${names3[names3.length - 1]}`;
6090
+ const docs = edits.map((e) => {
6091
+ let after;
6092
+ try {
6093
+ after = JSON.parse(e.after);
6094
+ } catch {
6095
+ throw new Error(`${e.path} is not valid JSON: fix it before sending.`);
6096
+ }
6097
+ return { component: e.component, path: e.path, after: cleanDocs(after) };
6098
+ });
6099
+ const docPaths = new Set(edits.map((e) => e.path));
6100
+ const files = p.files.filter((f) => !docPaths.has(f.path)).map((f) => ({ path: f.path, beforeSha: null, afterSha: gitBlobSha(f.content), content: f.content }));
6101
+ const by = opts.suggestedBy?.replace(/^@/, "");
6102
+ return {
6103
+ schema: SUGGESTION_SCHEMA,
6104
+ kind: "docs",
6105
+ title: opts.title?.trim() || (names3.length === 1 ? "Usage guidance" : `Usage guidance for ${list}`),
6106
+ summary: [`Usage guidance for ${list}, written ${origin.where}.`, opts.notes?.trim() ?? ""].filter(Boolean).join("\n\n"),
6107
+ source: opts.source ?? docsSource(origin),
6108
+ ...by ? { suggestedBy: by } : {},
6109
+ createdAt: date.toISOString(),
6110
+ base: opts.base,
6111
+ tier: { tier: p.risk.tier, bump: "patch" },
6112
+ docs,
6113
+ files,
6114
+ checks: { newErrors: 0, contrast: [], blocked: null, impact: 0 }
6115
+ };
6116
+ }
6117
+ async function suggestDocsOnGitHub(gh, edits, opts) {
6118
+ if (!edits.length) throw new Error("No docs edits in the draft.");
6119
+ const sha = opts.baseSha ?? await gh.getBranchSha(opts.base);
6120
+ if (!sha) throw new Error(`Branch ${opts.base} not found.`);
6121
+ const origin = opts.origin ?? DOCS_EDITOR_ORIGIN;
6122
+ const payload = docsSuggestionPayload(edits, { base: { ref: opts.base, sha }, title: opts.title, notes: opts.notes, date: opts.date, origin, source: opts.source, suggestedBy: opts.suggestedBy });
6123
+ const p = docsProposal(edits, { date: opts.date ?? /* @__PURE__ */ new Date(), origin });
6124
+ const rows = p.body.split("\n").filter((l) => l.startsWith("- `"));
6125
+ const details = [...rows, "", opts.authorLine?.trim() || defaultDocsFooter(edits, origin)].join("\n");
6126
+ const created = await createSuggestionOnGitHub(gh, payload, { labels: ["docs", ...opts.extraLabels ?? []], details });
6127
+ return { status: "suggested", number: created.number, url: created.url, title: payload.title, sha: created.sha, warnings: created.warnings };
6128
+ }
6129
+ async function proposeDocsOnGitHub(gh, edits, opts) {
6130
+ if (opts.mode === "suggest") return suggestDocsOnGitHub(gh, edits, opts);
6131
+ if (opts.continueOpen !== false && edits.length === 1) {
6132
+ const e = edits[0];
6133
+ const open = await findOpenDocsProposalOnGitHub(gh, { name: e.component, slug: e.slug }, opts.base).catch(() => null);
6134
+ if (open) {
6135
+ const sha = await continueDocsProposalOnGitHub(gh, open, e, { authorLine: opts.authorLine?.trim() || defaultDocsFooter(edits, opts.origin ?? DOCS_EDITOR_ORIGIN) });
6136
+ return { status: "continued", number: open.number, url: open.url, branch: open.branch, title: open.title, continued: true, unchanged: !sha };
6137
+ }
6138
+ }
6139
+ const p = docsProposal(edits, { title: opts.title, notes: opts.notes, date: opts.date ?? /* @__PURE__ */ new Date(), origin: opts.origin, authorLine: opts.authorLine });
6140
+ const prTitle = titleWithJiraKey(p.title, opts.jira ?? null);
6141
+ const branch = branchWithJiraKey(p.branch, opts.jira ?? null);
6142
+ const mainSha = await gh.getBranchSha(opts.base);
6143
+ if (!mainSha) throw new Error(`Branch ${opts.base} not found.`);
6144
+ const tree = await gh.createTree(await gh.getCommitTree(mainSha), p.files.map((f) => ({ path: f.path, content: f.content })));
6145
+ const commit = await gh.createCommit(`${prTitle}
6146
+
6147
+ ${opts.commitNote ?? "Proposed with the Bridge docs editor."}`, tree, [mainSha]);
6148
+ await gh.createBranch(branch, commit);
6149
+ const pr = await gh.createPullRequest({ title: prTitle, head: branch, base: opts.base, body: p.body, draft: false });
6150
+ await gh.addLabels(pr.number, [...p.labels, ...opts.extraLabels ?? []]).catch(() => false);
6151
+ return { status: "opened", number: pr.number, url: pr.url, branch, title: prTitle, continued: false, unchanged: false };
6152
+ }
6153
+ async function commitFileOnGitHub(gh, input) {
6154
+ const cur = await gh.getFile(input.path, input.branch);
6155
+ if (!cur) throw new Error(`${input.path} is not on ${input.branch}. Check the component name and the branch.`);
6156
+ const content = input.next(cur.text);
6157
+ if (content === cur.text) return "";
6158
+ return gh.putFile({ path: input.path, content, branch: input.branch, message: input.message, sha: cur.sha });
6159
+ }
6160
+
6161
+ // ../core/src/share-branch.ts
6162
+ var packageDir = (name) => `packages/${name.replace(/^@[^/]+\//, "")}/`;
6163
+ function touchesPackages(files, packages) {
6164
+ const dirs = packages.map(packageDir);
6165
+ return files.some((f) => dirs.some((d) => f.startsWith(d)));
6166
+ }
6167
+ function shareState(x) {
6168
+ if (!touchesPackages(x.files, x.packages)) return { kind: "no-packages" };
6169
+ const c = readPreviewComment(x.comment);
6170
+ if (x.lifecycle === "merged" || x.lifecycle === "closed") {
6171
+ const kind = x.lifecycle === "merged" ? "shipped" : "closed";
6172
+ if (!c || !c.command || c.source === "none") return { kind, stillWorks: null, command: null };
6173
+ return { kind, stillWorks: c.source === "pkg.pr.new" ? true : c.source === "release" ? false : null, command: c.command };
6174
+ }
6175
+ if (!c) return { kind: "building" };
6176
+ if (c.sha7 && !x.headSha.startsWith(c.sha7)) return { kind: "building" };
6177
+ if (!c.command || c.source === "none") return { kind: "failed" };
6178
+ return { kind: "ready", command: c.command, source: c.source };
6179
+ }
6180
+ var stableCommand = (packages) => `npm i ${packages.map((p) => `${p}@latest`).join(" ")}`;
6181
+ function stateSentence(s) {
6182
+ switch (s.kind) {
6183
+ case "ready":
6184
+ return null;
6185
+ case "building":
6186
+ return "The preview version is being prepared. It is usually ready a couple of minutes after each update to the branch.";
6187
+ case "failed":
6188
+ return "The preview version could not be prepared for the latest update. Update the branch to try again, or ask a developer to run it again on GitHub.";
6189
+ case "no-packages":
6190
+ return "This branch does not touch the library tokens or components, so there is no preview version to install.";
6191
+ case "shipped":
6192
+ return s.stillWorks === true ? "This branch was merged. Its preview version still installs, and products get it with the next library release." : s.stillWorks === false ? "This branch was merged, so its preview version was removed. Products get it with the next library release." : "This branch was merged. Products get it with the next library release.";
6193
+ case "closed":
6194
+ return s.stillWorks === true ? "This branch was closed without merging. Its preview version still installs, but it will not reach the library." : s.stillWorks === false ? "This branch was closed without merging, so its preview version was removed." : "This branch was closed without merging.";
6195
+ }
6196
+ }
6197
+ var quote = (t) => `"${t.trim().replace(/"/g, "'")}"`;
6198
+ function shareMessage(x) {
6199
+ const back = stableCommand(x.packages);
6200
+ const s = x.state;
6201
+ if (s.kind === "ready") {
6202
+ const lasts = s.source === "pkg.pr.new" ? "This preview stays available. Each update to the change makes a new one: ask me for the latest command." : "This preview is replaced on each update to the change and removed when it ships or closes: ask me for the latest command if the install fails.";
6203
+ return {
6204
+ subject: `Try the design system change ${quote(x.title)} before it ships`,
6205
+ body: [
6206
+ "Hi,",
6207
+ "",
6208
+ `Could you try the design system change ${quote(x.title)} in our product before it ships?`,
6209
+ ...x.tryUrl ? ["", `See it in the demo product first: ${x.tryUrl}`] : [],
6210
+ "",
6211
+ "Install the preview version in a staging branch (not production):",
6212
+ s.command,
6213
+ "",
6214
+ lasts,
6215
+ "",
6216
+ "To go back to the stable version:",
6217
+ back,
6218
+ "",
6219
+ `Details for developers: ${x.url}`
6220
+ ].join("\n")
6221
+ };
6222
+ }
6223
+ if ((s.kind === "shipped" || s.kind === "closed") && s.command) {
6224
+ const what = s.kind === "shipped" ? `The design system change ${quote(x.title)} has shipped. You get it with the next library release.` : `The design system change ${quote(x.title)} was closed without shipping.`;
6225
+ const preview = s.stillWorks === false ? "Its preview version was removed, so reinstalling it fails." : s.stillWorks === true ? "Its preview version still installs, but it is not the released version." : "";
6226
+ return {
6227
+ subject: `Design system change ${quote(x.title)} ${s.kind === "shipped" ? "has shipped" : "was closed"}`,
6228
+ body: ["Hi,", "", [what, preview].filter(Boolean).join(" "), "", "If you installed its preview, go back to the stable version:", back, "", `Details for developers: ${x.url}`].join("\n")
6229
+ };
6230
+ }
6231
+ return null;
6232
+ }
6233
+
6234
+ // ../core/src/try-in-product.ts
6235
+ function tryInProductUrl(preview, pr, screen) {
6236
+ const url = new URL(preview);
6237
+ url.pathname = "/";
6238
+ url.search = "";
6239
+ url.searchParams.set("bridge", String(pr));
6240
+ if (screen) {
6241
+ url.searchParams.set("tenant", screen.tenant);
6242
+ url.searchParams.set("theme", screen.theme);
6243
+ }
6244
+ url.hash = screen ? screen.route : "";
6245
+ return url.href;
6246
+ }
6247
+
6248
+ // ../core/src/combine-branches.ts
6249
+ var MIN_TOGETHER = 2;
6250
+ var MAX_TOGETHER = 4;
6251
+ function onlyTokens(files, tokensRoot) {
6252
+ const root = `${tokensRoot.replace(/^\/+|\/+$/g, "")}/`;
6253
+ return files.length > 0 && files.every((f) => f.startsWith(root) && f.endsWith(".tokens.json") || /contrast\.checks\.json$/.test(f) || f.startsWith(".changeset/"));
6254
+ }
6255
+ function tokenFilesOf(files, tokensRoot) {
6256
+ const root = `${tokensRoot.replace(/^\/+|\/+$/g, "")}/`;
6257
+ return files.filter((f) => f.startsWith(root) && f.endsWith(".tokens.json")).map((f) => f.slice(root.length));
6258
+ }
6259
+ var changeList = (numbers) => numbers.join(",");
6260
+ function tryTogetherUrl(admin, numbers, picks) {
6261
+ const url = new URL(admin);
6262
+ url.pathname = "/";
6263
+ url.hash = "";
6264
+ const use = encodePicks(picks);
6265
+ url.search = `?bridge=${changeList(numbers)}${use ? `&bridge-use=${encodeURIComponent(use)}` : ""}`;
6266
+ return url.href;
6267
+ }
6268
+ var COMBINE_COMPARE_LIMIT = 300;
6269
+ async function readCombineBranch(gh, pull, opts) {
6270
+ const compare = await gh.compareDetailed(opts.base, pull.headSha);
6271
+ const files = compare.files;
6272
+ const names3 = files.flatMap((f) => f.previous_filename ? [f.filename, f.previous_filename] : [f.filename]);
6273
+ const start = compare.mergeBase;
6274
+ if (!start || files.length >= COMBINE_COMPARE_LIMIT || !onlyTokens(names3, opts.tokensRoot)) return { number: pull.number, title: pull.title, tokens: false, edits: null };
6275
+ const root = opts.tokensRoot.replace(/^\/+|\/+$/g, "");
6276
+ const tokenFiles = files.filter((f) => tokenFilesOf([f.filename], root).length);
6277
+ const source = { number: pull.number, base: {}, head: {}, files: tokenFiles.map((f) => tokenFilesOf([f.filename], root)[0]) };
6278
+ await Promise.all(
6279
+ tokenFiles.map(async (f) => {
6280
+ const rel = tokenFilesOf([f.filename], root)[0];
6281
+ source.base[rel] = f.status === "added" ? void 0 : await gh.readFile(f.previous_filename ?? f.filename, start);
6282
+ source.head[rel] = f.status === "removed" ? void 0 : await gh.readFile(f.filename, pull.headSha);
6283
+ })
6284
+ );
6285
+ return { number: pull.number, title: pull.title, tokens: true, edits: changeEdits(source) };
6286
+ }
6287
+
6288
+ // ../core/src/prod-branch-github.ts
6289
+ var PRODUCT_FILE = /\.(css|scss|sass|less|tsx|jsx|ts|js|mjs|cjs|mts|cts|astro|vue|svelte)$/;
6290
+ var SKIP_FILE = /\.d\.[cm]?ts$|\.(test|spec|stories|story)\.[cm]?[jt]sx?$|\.min\.(css|js)$/;
6291
+ var PIECES_FILE_LIMIT = 40;
6292
+ var cleanPath = (p) => p.trim().replace(/^\.?\/+|\/+$/g, "");
6293
+ function productOf(products, pull, files) {
6294
+ const product = productOfBranch(products, { labels: pull.labels, files });
6295
+ if (product.key === defaultProduct(products).key) throw new GitHubError(422, `This branch is a library branch (${product.name}), not a product branch: missing pieces are checked on a branch started for a product.`);
6296
+ return product;
6297
+ }
6298
+ async function readProductBranchOnGitHub(gh, pr, opts) {
6299
+ const pull = await gh.getPullRequest(pr);
6300
+ const cmp = await gh.compareDetailed(pull.baseRef, pull.headSha);
6301
+ const product = productOf(opts.products, pull, cmp.files.map((f) => f.filename));
6302
+ const dir = product.path ? `${cleanPath(product.path)}/` : "";
6303
+ const changed = cmp.files.filter((f) => f.status !== "removed" && (!dir || f.filename.startsWith(dir)) && PRODUCT_FILE.test(f.filename) && !SKIP_FILE.test(f.filename) && !f.filename.split("/").includes("node_modules"));
6304
+ const read = changed.slice(0, PIECES_FILE_LIMIT);
6305
+ const start = cmp.mergeBase ?? pull.baseRef;
6306
+ const files = await Promise.all(
6307
+ read.map(async (f) => {
6308
+ const [before, after] = await Promise.all([f.status === "added" ? Promise.resolve(void 0) : gh.getFileText(f.previous_filename ?? f.filename, start), gh.getFileText(f.filename, pull.headSha)]);
6309
+ return { path: f.filename, before: before ?? null, after: after ?? null };
6310
+ })
6311
+ );
6312
+ return { pull, product, files, skipped: changed.length - read.length, record: parsePiecesRecord(pull.body) };
6313
+ }
6314
+ async function recordPiecesOnGitHub(gh, pr, check) {
6315
+ const pull = await gh.getPullRequest(pr);
6316
+ const record = mergePiecesRecord(parsePiecesRecord(pull.body), { checkedAt: check.checkedAt ?? (/* @__PURE__ */ new Date()).toISOString(), headSha: check.headSha ?? pull.headSha, pieces: check.pieces, fixes: check.fixes });
6317
+ await gh.updatePullRequest(pr, { body: withPiecesRecord(pull.body, record) });
6318
+ return record;
6319
+ }
6320
+ var productWriter = (gh, branch) => isExplorationBranch(branch) ? gh.withBranchPrefix(EXPLORATION_PREFIX) : gh;
6321
+ async function useLibraryPreviewOnGitHub(gh, input) {
6322
+ const message = `chore(bridge): use the preview of ${input.libraryBranch} (#${input.libraryNumber})
6323
+
6324
+ The product branch takes the library branch before it ships. Switch to the published version once it does.`;
6325
+ try {
6326
+ const r = await productWriter(gh, input.productBranch).mergeIntoBranch(input.productBranch, input.libraryBranch, message);
6327
+ return { ...r, said: r.status === "merged" ? "Your branch now uses the preview of the library branch." : "Your branch already has the latest preview." };
6328
+ } catch (err) {
6329
+ if (err instanceof GitHubError && err.status === 409) return { status: "conflict", sha: null, said: `Your branch and the library branch change the same lines. A developer merges ${input.libraryBranch} into ${input.productBranch} by hand.` };
6330
+ throw err;
6331
+ }
6332
+ }
6333
+ function productBranchRef(pull, product) {
6334
+ const name = /<!-- bridge-exploration (\{[\s\S]*?\}) -->/.exec(pull.body ?? "");
6335
+ let title = pull.title.replace(/^[A-Z][A-Z0-9_]*-\d+\s+/, "").replace(/^Exploration:\s*/i, "");
6336
+ let author = pull.author;
6337
+ if (name) {
6338
+ try {
6339
+ const e = JSON.parse(name[1]);
6340
+ if (e.name) title = e.name;
6341
+ if (e.author) author = e.author;
6342
+ } catch {
6343
+ }
6344
+ }
6345
+ return { pr: pull.number, title: title.charAt(0).toUpperCase() + title.slice(1), branch: pull.headRef, url: pull.url, productKey: product.key, productName: product.name, ...author ? { author } : {} };
6346
+ }
6347
+ async function openLibraryBranchOnGitHub(gh, input) {
6348
+ const pull = await gh.getPullRequest(input.pr);
6349
+ const record = parsePiecesRecord(pull.body);
6350
+ const piece = record?.pieces.find((p) => p.id === input.piece);
6351
+ if (!record || !piece) throw new GitHubError(404, record ? `No piece "${input.piece}" on #${input.pr}. Pieces: ${record.pieces.map((p) => p.id).join(", ") || "none"}.` : `#${input.pr} was not checked yet: find its missing pieces first (bridge_missing_pieces with record, or \`bridge pieces ${input.pr} --record\`).`);
6352
+ const product = productOf(input.products, pull, await gh.listPullRequestFiles(input.pr).then((f) => f.map((x) => x.filename), () => []));
6353
+ const mode = input.mode ?? "direct";
6354
+ const plan = libraryBranchPlan(piece, productBranchRef(pull, product), { source: input.source, requestedBy: input.openedBy, mode });
6355
+ const existing = record.links.find((l) => l.piece === piece.id);
6356
+ if (existing) return { status: "already-linked", plan, link: existing, preview: null, warnings: [] };
6357
+ if (input.dryRun) return { status: "dry-run", plan, link: null, preview: null, warnings: [] };
6358
+ if (!gh.canWrite) throw new GitHubError(401, mode === "suggest" ? "Suggesting a library change needs a GitHub token (issues: write)." : "Opening a library branch needs a GitHub token (contents, pull requests and issues: write).");
6359
+ const warnings = [];
6360
+ const at = (/* @__PURE__ */ new Date()).toISOString();
6361
+ const who = { ...input.openedBy ? { openedBy: input.openedBy } : {}, ...input.agent ? { agent: input.agent } : {} };
6362
+ let link;
6363
+ if (plan.kind === "token-change") {
6364
+ if (!input.writeTokens) throw new GitHubError(422, "This piece is a token: open it where the token files are read (Bridge Studio, bridge_open_library_branch or `bridge pieces open`).");
6365
+ const r = await input.writeTokens(plan, { mode });
6366
+ link = r.status === "suggested" ? { piece: piece.id, kind: "suggestion", number: r.number, url: r.url, title: plan.title, at, ...who } : { piece: piece.id, kind: "token-change", number: r.number, url: r.url, title: plan.title, branch: r.branch, at, ...who };
6367
+ } else if (plan.kind === "exploration") {
6368
+ const r = await startExplorationOnGitHub(gh, { ...plan.exploration, ...input.openedBy ? { author: input.openedBy } : {}, ...input.agent ? { agent: input.agent } : {} }, { base: input.base, ...input.exploration, labels: [NEEDED_BY_LABEL] });
6369
+ link = { piece: piece.id, kind: "exploration", number: r.pr, url: r.url, title: plan.title, branch: r.branch, at, ...who };
6370
+ } else {
6371
+ const issue = composeRequestIssue(plan.request, { labels: [NEEDED_BY_LABEL] });
6372
+ const r = await gh.createIssue(issue);
6373
+ link = { piece: piece.id, kind: "request", number: r.number, url: r.url, title: plan.title, at, ...who };
6374
+ }
6375
+ try {
6376
+ if (link.kind === "request" || link.kind === "suggestion") {
6377
+ const issue = await gh.getIssue(link.number);
6378
+ await gh.updateIssue(link.number, { body: withNeededBy(issue.body, plan.neededBy) });
6379
+ if (link.kind === "suggestion" && !issue.labels.includes(NEEDED_BY_LABEL)) await gh.addLabels(link.number, [NEEDED_BY_LABEL]);
6380
+ } else {
6381
+ const lib = await gh.getPullRequest(link.number);
6382
+ await gh.updatePullRequest(link.number, { body: withNeededBy(lib.body, plan.neededBy) });
6383
+ await gh.addLabels(link.number, [NEEDED_BY_LABEL]);
6384
+ }
6385
+ } catch (err) {
6386
+ if (err instanceof GitHubError && err.auth === "rejected") throw err;
6387
+ warnings.push(`The library side does not say who needs it yet (${err.message}). Add "Needed by #${input.pr}" to #${link.number} on GitHub.`);
6388
+ }
6389
+ try {
6390
+ const fresh = await gh.getPullRequest(input.pr);
6391
+ const next = withLink(parsePiecesRecord(fresh.body) ?? record, link);
6392
+ await gh.updatePullRequest(input.pr, { body: withPiecesRecord(fresh.body, next) });
6393
+ } catch (err) {
6394
+ if (mode !== "suggest") throw err;
6395
+ warnings.push(`#${input.pr} does not list #${link.number} yet (${err.message}). Add "Library: #${link.number}" to #${input.pr} on GitHub, so this piece is not sent twice.`);
6396
+ }
6397
+ let preview = null;
6398
+ if ((input.usePreview ?? true) && link.kind === "token-change" && link.branch) {
6399
+ preview = await useLibraryPreviewOnGitHub(gh, { productBranch: pull.headRef, libraryBranch: link.branch, libraryNumber: link.number }).catch((err) => {
6400
+ warnings.push(`Your branch does not use the preview yet: ${err.message}`);
6401
+ return null;
6402
+ });
6403
+ }
6404
+ return { status: link.kind === "suggestion" ? "suggested" : "opened", plan, link, preview, warnings };
6405
+ }
6406
+ var isNote = (path) => path.startsWith(`${EXPLORATION_NOTES_DIR}/`);
6407
+ async function linkedLibraryBranchOnGitHub(gh, link, product, piece) {
6408
+ const nearest = piece?.nearest?.label;
6409
+ if (link.kind === "suggestion") {
6410
+ const issue = await gh.getIssue(link.number);
6411
+ const cached = suggestionStateFromLabels(issue.labels);
6412
+ const s = suggestionStatus({ state: issue.state, stateReason: issue.stateReason, applied: cached === "applying", review: cached === "applying" ? void 0 : cached });
6413
+ const status2 = linkStatus({ kind: "suggestion", status: s });
6414
+ const lifecycle2 = s === "applied" ? "merged" : issue.state === "closed" ? "closed" : "open";
6415
+ const behindMain2 = lifecycle2 === "merged" ? await gh.compare(product.baseRef, product.headSha).then((c) => c.behindBy, () => null) : null;
6416
+ const preview2 = libraryPreviewState({ kind: "suggestion", lifecycle: lifecycle2, hasChanges: false, missing: null, total: null, behindMain: behindMain2, nearest });
6417
+ return { link, piece, status: status2, preview: preview2, people: status2.people };
6418
+ }
6419
+ if (link.kind === "request") {
6420
+ const issue = await gh.getIssue(link.number);
6421
+ const status2 = linkStatus({ kind: "request", state: issue.state, stateReason: issue.stateReason });
6422
+ const lifecycle2 = issue.state === "closed" ? issue.stateReason === "not_planned" ? "closed" : "merged" : "open";
6423
+ const behindMain2 = lifecycle2 === "merged" ? await gh.compare(product.baseRef, product.headSha).then((c) => c.behindBy, () => null) : null;
6424
+ const preview2 = libraryPreviewState({ kind: "request", lifecycle: lifecycle2, hasChanges: false, missing: null, total: null, behindMain: behindMain2, nearest });
6425
+ return { link, piece, status: status2, preview: preview2, people: status2.people };
6426
+ }
6427
+ const lib = await gh.getPullRequest(link.number);
5240
6428
  const reviews = await gh.listReviews(link.number).catch(() => []);
5241
6429
  const status = linkStatus({ kind: link.kind, state: lib.state, draft: lib.draft, labels: lib.labels, requestedReviewers: lib.requestedReviewers, reviews: reviews.map((r) => ({ user: r.user, state: r.state })) });
5242
6430
  const lifecycle = lib.state === "merged" ? "merged" : lib.state === "open" ? "open" : "closed";
@@ -5310,21 +6498,436 @@ function planTokenProposal(input) {
5310
6498
  ...config.changesets ? [{ path: changesetPath, content: formatChangeset(config.changesets.package, tier.bump, summary2) }] : []
5311
6499
  ];
5312
6500
  const blocked = newErrors.length || newFailures.length ? [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ") : null;
5313
- return { title: input.title, summary: summary2, before, after, changes, tier, contrast, newFailures, newErrors, impact, notes: r.notes, touched, files, body, slug: slug2, blocked };
6501
+ return { title: input.title, summary: summary2, edits: input.edits, before, after, changes, tier, contrast, newFailures, newErrors, impact, notes: r.notes, touched, files, body, slug: slug2, blocked };
6502
+ }
6503
+ async function writeTokenProposalOnGitHub(gh, plan, opts) {
6504
+ if (plan.blocked) throw new Error(`Not opened: ${plan.blocked}. Bridge never opens a branch that breaks the gate.`);
6505
+ const jira = opts.jira ?? null;
6506
+ let branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}`, jira);
6507
+ for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}-${i}`, jira);
6508
+ const commit = await gh.commitToNewBranch({ base: opts.base, branch, message: `feat(tokens): ${plan.title}
6509
+
6510
+ Proposed through ${opts.tool ?? "Bridge MCP"}. Tier ${plan.tier.tier}.`, files: plan.files });
6511
+ const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
6512
+ const draft = plan.tier.tier >= 2;
6513
+ const pr = await gh.createPullRequest({ base: opts.base, head: branch, title: titleWithJiraKey(`feat(tokens): ${plan.title}`, jira), body: plan.body, draft });
6514
+ await gh.addLabels(pr.number, labels);
6515
+ return { branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft };
6516
+ }
6517
+ var tokenLabels = (plan, before = [], after = []) => [
6518
+ .../* @__PURE__ */ new Set([...before, `tier: ${plan.tier.tier}`, ...plan.tier.tier === 3 ? ["tokens"] : [], ...plan.tier.bump === "major" ? ["breaking"] : [], ...after])
6519
+ ];
6520
+ function tokenSuggestionPayload(plan, ctx) {
6521
+ const tokenFiles = new Set(plan.touched);
6522
+ const files = plan.files.filter((f) => !tokenFiles.has(f.path)).map((f) => ({ path: f.path, beforeSha: null, afterSha: f.content === null ? null : gitBlobSha(f.content), content: f.content }));
6523
+ return {
6524
+ schema: SUGGESTION_SCHEMA,
6525
+ kind: "token-change",
6526
+ title: plan.title,
6527
+ summary: plan.summary,
6528
+ source: ctx.source,
6529
+ ...ctx.suggestedBy ? { suggestedBy: ctx.suggestedBy.replace(/^@/, "") } : {},
6530
+ createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
6531
+ base: ctx.base,
6532
+ tier: { tier: plan.tier.tier, bump: plan.tier.bump },
6533
+ tokens: {
6534
+ edits: plan.edits,
6535
+ changes: plan.changes.map((c) => ({ kind: c.kind, path: c.path, from: c.from, file: c.file, before: c.before, after: c.after, beforeCss: c.beforeCss, afterCss: c.afterCss }))
6536
+ },
6537
+ ...files.length ? { files } : {},
6538
+ checks: {
6539
+ newErrors: plan.newErrors.length,
6540
+ contrast: plan.contrast.map((d) => ({ pair: `${d.fg} on ${d.bg}`, mode: d.modes || void 0, before: d.before ?? 0, after: d.after ?? 0, min: d.min, pass: d.passAfter })),
6541
+ blocked: plan.blocked,
6542
+ impact: plan.impact.total
6543
+ },
6544
+ ...ctx.links ? { links: ctx.links } : {},
6545
+ ...ctx.previews?.length ? { previews: ctx.previews } : {}
6546
+ };
6547
+ }
6548
+ async function suggestTokenProposalOnGitHub(gh, plan, opts) {
6549
+ if (plan.blocked) throw new Error(`Not suggested: ${plan.blocked}. Bridge never suggests a change that breaks the gate.`);
6550
+ const sha = opts.baseSha ?? await gh.getBranchSha(opts.base);
6551
+ if (!sha) throw new Error(`Branch ${opts.base} not found. Check the default branch in bridge.config.json.`);
6552
+ const links = opts.neededBy || opts.links ? { ...opts.links, ...opts.neededBy ? { neededBy: opts.neededBy } : {} } : void 0;
6553
+ const payload = tokenSuggestionPayload(plan, { base: { ref: opts.base, sha }, source: opts.source, suggestedBy: opts.suggestedBy, links, previews: opts.previews });
6554
+ const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
6555
+ const created = await createSuggestionOnGitHub(gh, payload, { labels, assignees: opts.assignees, details: plan.body });
6556
+ return { status: "suggested", number: created.number, url: created.url, sha: created.sha, labels, warnings: created.warnings };
6557
+ }
6558
+
6559
+ // ../core/src/figma-push-github.ts
6560
+ var pushSource = (d) => ["figma", "studio", "cli", "mcp"].includes(d.source) ? d.source : "figma";
6561
+ var specTier = (d) => ({ tier: 2, bump: d.kind === "new-component" || d.kind === "new-variant" || d.kind === "prop-change" ? "minor" : "patch" });
6562
+ function componentSpecSuggestionPayload(d, plan, ctx) {
6563
+ const specPath = figmaSpecPath(d);
6564
+ const spec = JSON.parse(formatFigmaSpec(d, plan));
6565
+ const brief = figmaDraftBrief({ ...d, tokenEdits: plan.edits, reasons: plan.reasons }, { suggestion: true, specPath, componentsDir: ctx.componentsDir });
6566
+ const by = (ctx.suggestedBy ?? d.requestedBy)?.replace(/^@/, "");
6567
+ const figma = d.figma?.fileKey || d.figma?.nodeId || d.figma?.url ? { ...d.figma.fileKey ? { fileKey: d.figma.fileKey } : {}, ...d.figma.nodeId ? { nodeId: d.figma.nodeId } : {}, ...d.figma.url ? { url: d.figma.url } : {} } : void 0;
6568
+ return {
6569
+ schema: SUGGESTION_SCHEMA,
6570
+ kind: "component-spec",
6571
+ title: d.title,
6572
+ summary: d.summary.trim() || d.title,
6573
+ source: pushSource(d),
6574
+ ...by ? { suggestedBy: by } : {},
6575
+ createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
6576
+ base: ctx.base,
6577
+ tier: specTier(d),
6578
+ componentSpec: { component: d.component, specPath, spec, brief },
6579
+ checks: { newErrors: 0, contrast: [], blocked: null, impact: 0 },
6580
+ ...figma ? { links: { figma } } : {}
6581
+ };
6582
+ }
6583
+ async function openComponentPushOnGitHub(gh, input) {
6584
+ const d = input.diff;
6585
+ if (!input.plan && !input.tokens) throw new Error("Route the change first: pass the token facts or the plan.");
6586
+ const plan = input.plan ?? planComponentPush(d, input.tokens);
6587
+ if (plan.route === "manual") return { status: "not-sent", plan, link: null };
6588
+ if (input.dryRun) return { status: "dry-run", plan, link: null };
6589
+ const mode = input.mode ?? "direct";
6590
+ if (!gh.canWrite) throw new GitHubError(401, mode === "suggest" ? "Suggesting a component change needs a GitHub token (issues: write)." : "Sending a component change needs a GitHub token (contents and pull requests: write).");
6591
+ const origin = {
6592
+ component: d.component,
6593
+ figmaName: d.figmaName,
6594
+ route: plan.route,
6595
+ kind: d.kind,
6596
+ ...d.figma?.nodeId ? { nodeId: d.figma.nodeId } : {},
6597
+ ...d.figma?.fileKey ? { fileKey: d.figma.fileKey } : {},
6598
+ ...d.figma?.url ? { url: d.figma.url } : {},
6599
+ ...d.requestedBy ? { requestedBy: d.requestedBy } : {}
6600
+ };
6601
+ if (plan.route === "token-change") {
6602
+ if (!input.writeTokens) throw new GitHubError(422, "This change is a token change: send it where the token files are read (the DS Bridge plugin, bridge_push_figma_component or `bridge figma push`).");
6603
+ const figma = { ...origin.fileKey ? { fileKey: origin.fileKey } : {}, ...origin.nodeId ? { nodeId: origin.nodeId } : {}, ...origin.url ? { url: origin.url } : {} };
6604
+ const r = await input.writeTokens({ edits: plan.edits, title: figmaBranchTitle(d), summary: d.summary.trim() || d.title, bodyExtra: [figmaOriginSection(origin)], mode, figma });
6605
+ await gh.addLabels(r.number, [FROM_FIGMA_LABEL]);
6606
+ if (r.status === "suggested") return { status: "suggested", plan, link: null, suggestion: { route: "token-change", number: r.number, url: r.url } };
6607
+ return { status: "opened", plan, link: { route: "token-change", number: r.number, url: r.url, branch: r.branch } };
6608
+ }
6609
+ if (mode === "suggest") {
6610
+ const sha = input.baseSha ?? await gh.getBranchSha(input.base);
6611
+ if (!sha) throw new GitHubError(404, `Branch ${input.base} not found. Check the default branch in bridge.config.json.`);
6612
+ const payload = componentSpecSuggestionPayload(d, plan, { base: { ref: input.base, sha }, suggestedBy: input.suggestedBy });
6613
+ const created = await createSuggestionOnGitHub(gh, payload, { labels: [...input.labels ?? [], WAITING_FOR_CLAUDE_LABEL] });
6614
+ return { status: "suggested", plan, link: null, suggestion: { route: "claude-draft", number: created.number, url: created.url } };
6615
+ }
6616
+ const spec = figmaSpecPath(d);
6617
+ const o = { ...origin, spec, status: "spec" };
6618
+ let branch = `${input.branchPrefix}figma-${figmaSpecSlug(d)}`;
6619
+ for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${input.branchPrefix}figma-${figmaSpecSlug(d)}-${i}`;
6620
+ await gh.commitToNewBranch({
6621
+ base: input.base,
6622
+ branch,
6623
+ message: `chore(figma): spec for ${figmaBranchTitle(d)}
6624
+
6625
+ Sent from Figma with ${input.tool ?? "the DS Bridge Figma plugin"}. Claude AI drafts the code on this branch; a person reviews it.`,
6626
+ files: [{ path: spec, content: formatFigmaSpec(d, plan) }]
6627
+ });
6628
+ const pr = await gh.createPullRequest({ base: input.base, head: branch, title: figmaBranchTitle(d), body: figmaDraftBody(d, plan, o), draft: true });
6629
+ await gh.addLabels(pr.number, [.../* @__PURE__ */ new Set([...input.labels ?? [], FROM_FIGMA_LABEL, REQUEST_LABEL, WAITING_FOR_CLAUDE_LABEL])]);
6630
+ return { status: "opened", plan, link: { route: "claude-draft", number: pr.number, url: pr.url, branch } };
6631
+ }
6632
+ async function listFigmaChangesOnGitHub(gh, opts = {}) {
6633
+ const pulls = await gh.listPullRequests(opts.state ?? "open");
6634
+ const out = [];
6635
+ for (const p of pulls) {
6636
+ if (!p.labels.includes(FROM_FIGMA_LABEL)) continue;
6637
+ const origin = parseFigmaOrigin(p.body);
6638
+ if (!origin) continue;
6639
+ out.push({ number: p.number, title: p.title, url: p.url, branch: p.headRef, origin, status: figmaPushStatus({ state: p.state, draft: p.draft, labels: p.labels, route: origin.route }) });
6640
+ }
6641
+ return out;
6642
+ }
6643
+ async function figmaChangeOnGitHub(gh, n, opts = {}) {
6644
+ const pull = await gh.getPullRequest(n);
6645
+ const origin = parseFigmaOrigin(pull.body);
6646
+ if (!origin) throw new GitHubError(404, `#${n} was not sent from Figma (no "From Figma" block in its description).`);
6647
+ const status = figmaPushStatus({ state: pull.state, draft: pull.draft, labels: pull.labels, route: origin.route });
6648
+ if (origin.route === "token-change") return { pull, origin, status, spec: void 0, brief: void 0 };
6649
+ const text = origin.spec ? await gh.getFileText(origin.spec, pull.headSha).catch(() => void 0) : void 0;
6650
+ const spec = parseFigmaSpec(text);
6651
+ if (!spec) throw new GitHubError(404, `The spec ${origin.spec ?? FIGMA_SPEC_DIR} is missing from ${pull.headRef}. Send the component from Figma again.`);
6652
+ const brief = figmaDraftBrief(spec, { pr: n, branch: pull.headRef, specPath: origin.spec, componentsDir: opts.componentsDir });
6653
+ return { pull, origin, status, spec, brief };
6654
+ }
6655
+ async function markFigmaChangeDraftedOnGitHub(gh, n) {
6656
+ if (!gh.canWrite) throw new GitHubError(401, "Marking a branch drafted needs a GitHub token (pull requests: write).");
6657
+ const pull = await gh.getPullRequest(n);
6658
+ const origin = parseFigmaOrigin(pull.body);
6659
+ if (!origin || origin.route !== "claude-draft") throw new GitHubError(422, `#${n} is not a Claude draft branch from Figma.`);
6660
+ if (pull.state !== "open") throw new GitHubError(422, `#${n} is ${pull.state}: only an open branch is marked drafted.`);
6661
+ const files = (await gh.listPullRequestFiles(n)).map((f) => f.filename).filter((f) => !f.startsWith(`${FIGMA_SPEC_DIR}/`));
6662
+ if (!files.length) throw new GitHubError(422, `${pull.headRef} only carries the spec: write the component code there first, push it, then mark it drafted.`);
6663
+ await gh.updatePullRequest(n, { body: withFigmaOrigin(pull.body, { ...origin, status: "drafted" }) });
6664
+ await gh.removeLabel(n, WAITING_FOR_CLAUDE_LABEL).catch(() => void 0);
6665
+ await gh.addLabels(n, [DRAFTED_BY_CLAUDE_LABEL]);
6666
+ const labels = [...pull.labels.filter((l) => l !== WAITING_FOR_CLAUDE_LABEL), DRAFTED_BY_CLAUDE_LABEL];
6667
+ return { status: figmaPushStatus({ state: pull.state, labels, route: origin.route }), files };
6668
+ }
6669
+
6670
+ // ../core/src/suggestion-apply.ts
6671
+ var SUGGESTION_APPLY_EXIT = {
6672
+ ready: 0,
6673
+ "already-applied": 0,
6674
+ conflict: 3,
6675
+ "not-approved": 1,
6676
+ blocked: 1,
6677
+ refused: 1
6678
+ };
6679
+ var same2 = (a, b) => canonicalJson(a) === canonicalJson(b);
6680
+ var show3 = (v) => v === void 0 ? "nothing" : typeof v === "string" ? v : JSON.stringify(v);
6681
+ function tokenRoot(config) {
6682
+ const r = config.tokens.root.replace(/\/+$/, "");
6683
+ return r === "." ? "" : r;
6684
+ }
6685
+ function tokenFileOf(config, path) {
6686
+ const root = tokenRoot(config);
6687
+ if (root && !path.startsWith(`${root}/`)) return null;
6688
+ if (!selectTokenFiles(config, [path]).length) return null;
6689
+ return root ? path.slice(root.length + 1) : path;
6690
+ }
6691
+ function planTokenEdit(set, e, changes) {
6692
+ const base = { type: "token", path: e.path, edit: e, ...e.file ? { file: e.file } : {} };
6693
+ const defsAt = (path) => set.defs.filter((d) => d.path === path && (!e.file || d.file === e.file));
6694
+ const recorded = (file, path = e.path) => changes.find((c) => c.path === path && c.file === file && (e.op === "rename" ? c.kind === "renamed" : true));
6695
+ const conflict = (detail, extra = {}) => ({ op: { ...base, state: "conflict", detail, ...extra }, notes: [] });
6696
+ const done = (detail) => ({ op: { ...base, state: "already-applied", detail }, notes: [] });
6697
+ let result;
6698
+ try {
6699
+ result = applyTokenEdits(set, [e]);
6700
+ } catch (err) {
6701
+ const why = err instanceof Error ? err.message : String(err);
6702
+ switch (e.op) {
6703
+ case "rename": {
6704
+ const hasOld = defsAt(e.path).length > 0;
6705
+ const hasNew = set.defs.some((d) => d.path === e.newPath);
6706
+ if (!hasOld && hasNew) return done(`Already renamed to ${e.newPath}.`);
6707
+ return conflict(hasOld && hasNew ? `Both ${e.path} and ${e.newPath} exist now: rename it by hand.` : `${e.path} no longer exists: ${why}`);
6708
+ }
6709
+ case "remove":
6710
+ if (!defsAt(e.path).length) return done("Already removed.");
6711
+ return conflict(why);
6712
+ case "add": {
6713
+ const existing = defsAt(e.path);
6714
+ if (!existing.length) return conflict(why);
6715
+ const want = (file) => {
6716
+ const c = recorded(file);
6717
+ if (c && c.after !== void 0) return c.after;
6718
+ return coerceValue(e.value, e.type ?? existing.find((d2) => d2.file === file)?.type);
6719
+ };
6720
+ const all = existing.every((d2) => same2(d2.value, want(d2.file)));
6721
+ if (all) return done("Already added with the suggested value.");
6722
+ const d = existing.find((x) => !same2(x.value, want(x.file)));
6723
+ return conflict(`Someone added ${e.path} with another value since.`, { file: d.file, after: want(d.file), now: d.value });
6724
+ }
6725
+ default:
6726
+ return conflict(defsAt(e.path).length ? why : `${e.path} no longer exists, so its value cannot be set.`);
6727
+ }
6728
+ }
6729
+ if (!result.touched.length) return done("Already has the suggested value.");
6730
+ if (e.op === "set" || e.op === "remove" || e.op === "rename") {
6731
+ for (const d of defsAt(e.path)) {
6732
+ const c = recorded(d.file, e.op === "rename" ? e.newPath ?? e.path : e.path);
6733
+ if (!c) continue;
6734
+ const okNow = same2(d.value, c.before) || e.op === "set" && same2(d.value, c.after);
6735
+ if (!okNow) return conflict(`${e.path} changed since it was suggested (now ${show3(d.value)}, suggested from ${show3(c.before)}).`, { file: d.file, before: c.before, after: c.after, now: d.value });
6736
+ }
6737
+ }
6738
+ const verb = e.op === "set" ? "Set" : e.op === "add" ? "Add" : e.op === "remove" ? "Remove" : `Rename to ${e.newPath ?? ""}`;
6739
+ return { op: { ...base, state: "apply", detail: `${verb} in ${result.touched.join(", ")}.` }, files: result.files, notes: result.notes };
6740
+ }
6741
+ async function planFile(ws, baseSha, f) {
6742
+ const base = { type: "file", path: f.path };
6743
+ const cur = await ws.read(f.path);
6744
+ const curSha = cur?.sha ?? null;
6745
+ const theirs = f.afterSha === null ? null : f.content ?? null;
6746
+ if (curSha === f.afterSha) return { op: { ...base, state: "already-applied", detail: f.afterSha === null ? "Already deleted." : "Already has the suggested content." } };
6747
+ if (curSha === f.beforeSha) return { op: { ...base, state: "apply", detail: f.afterSha === null ? "Delete it." : f.beforeSha === null ? "Create it." : "Replace it with the suggested content." }, content: theirs };
6748
+ if (f.beforeSha === null) return { op: { ...base, state: "conflict", detail: "The suggestion creates this file, but a different one exists now." } };
6749
+ if (!cur) return { op: { ...base, state: "conflict", detail: "The suggestion changes this file, but it was deleted since." } };
6750
+ if (theirs === null) return { op: { ...base, state: "conflict", detail: "The suggestion deletes this file, but it changed since." } };
6751
+ const original = await ws.readAt(baseSha, f.path);
6752
+ if (original === null || gitBlobSha(original) !== f.beforeSha || !ws.merge) {
6753
+ return { op: { ...base, state: "conflict", detail: original === null ? "The file changed since and its version at the suggestion base cannot be read, so it cannot be merged." : "The file changed since and cannot be merged here." } };
6754
+ }
6755
+ const merged = await ws.merge({ path: f.path, ours: cur.text, base: original, theirs });
6756
+ if (!merged.clean) return { op: { ...base, state: "conflict", detail: `The file changed since and the merge has ${merged.conflicts} conflict(s).`, hunks: merged.conflicts } };
6757
+ if (merged.text === cur.text) return { op: { ...base, state: "already-applied", detail: "Already has the suggested lines." } };
6758
+ return { op: { ...base, state: "apply-merged", detail: "The file changed since: merged with the suggested lines, no conflict." }, content: merged.text };
6759
+ }
6760
+ async function planSuggestionApply(input) {
6761
+ const { parsed, config, workspace: ws } = input;
6762
+ const payload = parsed.payload;
6763
+ const notes = [];
6764
+ const out = (outcome, message, rest2 = {}) => ({
6765
+ outcome,
6766
+ exitCode: SUGGESTION_APPLY_EXIT[outcome],
6767
+ message,
6768
+ payloadSha: parsed.sha,
6769
+ payload,
6770
+ gate: null,
6771
+ ops: [],
6772
+ writes: [],
6773
+ checks: null,
6774
+ problems: [],
6775
+ notes,
6776
+ ...payload.componentSpec ? { brief: payload.componentSpec.brief } : {},
6777
+ ...rest2
6778
+ });
6779
+ const problems = suggestionPathProblems(payload, config);
6780
+ if (payload.checks.blocked) problems.push(`It was suggested with a failing check (${payload.checks.blocked}): ask the author to fix it and send it again.`);
6781
+ for (const f of payload.files ?? []) {
6782
+ if (f.afterSha !== null && typeof f.content !== "string") problems.push(`"${f.path}" has no content in the suggestion: it is incomplete, ask the author to send it again.`);
6783
+ else if (f.afterSha !== null && gitBlobSha(f.content) !== f.afterSha) problems.push(`"${f.path}" does not match the content it was suggested with: someone edited it by hand.`);
6784
+ }
6785
+ if (problems.length) return out("refused", "Not applied: the suggestion asks for something Bridge never writes.", { problems: [...new Set(problems)] });
6786
+ if (parsed.edited && !input.acceptEdited) {
6787
+ return out("refused", 'Not applied: the suggestion was edited by hand since Bridge wrote it. Read the change, then apply it with "accept edited" if it is right.', {
6788
+ problems: ["The payload no longer matches the sha Bridge recorded."]
6789
+ });
6790
+ }
6791
+ const root = tokenRoot(config);
6792
+ const tokenFiles = selectTokenFiles(config, await ws.list());
6793
+ const targets = [
6794
+ ...(payload.files ?? []).map((f) => f.path),
6795
+ ...(payload.docs ?? []).map((d) => d.path),
6796
+ ...payload.componentSpec ? [payload.componentSpec.specPath] : [],
6797
+ ...payload.tokens?.edits.length ? tokenFiles.map((f) => root ? `${root}/${f}` : f) : []
6798
+ ];
6799
+ if (ws.isSymlink) {
6800
+ const links = [];
6801
+ for (const p of new Set(targets)) if (await ws.isSymlink(p)) links.push(`"${p}" is a symbolic link (or inside one): Bridge never writes through links.`);
6802
+ if (links.length) return out("refused", "Not applied: the suggestion writes through a symbolic link.", { problems: links });
6803
+ }
6804
+ const governance = input.governance ?? { owners: config.governance.owners, rights: config.governance.rights ?? null };
6805
+ const approvals = summarizeSuggestionApprovals(input.comments, governance, { payloadSha: parsed.sha, author: input.author });
6806
+ const review = suggestionReviewState(payload.tier.tier, approvals);
6807
+ const missing = pendingApprovals(payload.tier.tier, approvals);
6808
+ const approved = review === "approved";
6809
+ const gate = { review, missing, approvals, forced: !approved && Boolean(input.forceUnapproved), edited: parsed.edited };
6810
+ const ops = [];
6811
+ const writes = /* @__PURE__ */ new Map();
6812
+ const current = /* @__PURE__ */ new Map();
6813
+ for (const f of tokenFiles) {
6814
+ const r = await ws.read(root ? `${root}/${f}` : f);
6815
+ if (r) current.set(f, r.text);
6816
+ }
6817
+ const before = new LayeredTokenSet(config, current);
6818
+ let set = before;
6819
+ const appliedEdits = [];
6820
+ for (const e of payload.tokens?.edits ?? []) {
6821
+ const r = planTokenEdit(set, e, payload.tokens.changes);
6822
+ ops.push(r.op);
6823
+ notes.push(...r.notes);
6824
+ if (r.op.state === "apply" && r.files) {
6825
+ set = new LayeredTokenSet(config, r.files);
6826
+ appliedEdits.push(e);
6827
+ }
6828
+ }
6829
+ for (const [f, text] of set.files) if (text !== before.files.get(f)) writes.set(set.repoPath(f), text);
6830
+ for (const f of payload.files ?? []) {
6831
+ if (writes.has(f.path)) {
6832
+ notes.push(`${f.path}: the token edits write this file, its copy in the suggestion is not used.`);
6833
+ continue;
6834
+ }
6835
+ const r = await planFile(ws, payload.base.sha, f);
6836
+ ops.push(r.op);
6837
+ if (r.op.state === "apply" || r.op.state === "apply-merged") writes.set(f.path, r.content ?? null);
6838
+ }
6839
+ for (const d of payload.docs ?? []) {
6840
+ const base = { type: "docs", path: d.path };
6841
+ const docs = d.after;
6842
+ let text;
6843
+ try {
6844
+ const issues = validateDocs(docs);
6845
+ if (issues.length) {
6846
+ problems.push(`${d.path}: ${issues.map((i) => i.message).join(" ")}`);
6847
+ continue;
6848
+ }
6849
+ const cur = await ws.read(d.path);
6850
+ text = docsFileText(docs, cur?.text ?? null);
6851
+ if (cur && text === cur.text) {
6852
+ ops.push({ ...base, state: "already-applied", detail: "The docs already say this." });
6853
+ continue;
6854
+ }
6855
+ ops.push({ ...base, state: "apply", detail: cur ? "Update the changed fields; other fields stay as they are now." : "Create the docs file." });
6856
+ writes.set(d.path, text);
6857
+ } catch (err) {
6858
+ problems.push(`${d.path}: the suggested docs are not a docs.json Bridge can read (${err instanceof Error ? err.message : String(err)}).`);
6859
+ }
6860
+ }
6861
+ if (payload.componentSpec) {
6862
+ const s = payload.componentSpec;
6863
+ const text = `${JSON.stringify(s.spec, null, 2)}
6864
+ `;
6865
+ const cur = await ws.read(s.specPath);
6866
+ if (cur && cur.text === text) ops.push({ type: "component-spec", path: s.specPath, state: "already-applied", detail: "The spec file is already there." });
6867
+ else {
6868
+ ops.push({ type: "component-spec", path: s.specPath, state: "apply", detail: `Write the spec; the ${s.component} code itself is written from the brief.` });
6869
+ writes.set(s.specPath, text);
6870
+ }
6871
+ }
6872
+ const afterFiles = new Map(set.files);
6873
+ for (const [p, content] of writes) {
6874
+ const tf = tokenFileOf(config, p);
6875
+ if (!tf) continue;
6876
+ if (content === null) afterFiles.delete(tf);
6877
+ else afterFiles.set(tf, content);
6878
+ }
6879
+ const after = new LayeredTokenSet(config, afterFiles);
6880
+ const tokenChanges = diffLayered(before, after);
6881
+ const issueKey4 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
6882
+ const prevErrors = new Set(validateLayered(before).map(issueKey4));
6883
+ const newErrors = tokenChanges.length ? validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey4(i))) : [];
6884
+ let pairs = [];
6885
+ if (config.contrast && tokenChanges.length) {
6886
+ try {
6887
+ pairs = parseContrastChecks((await ws.read(config.contrast.path))?.text);
6888
+ } catch {
6889
+ notes.push(`${config.contrast.path} does not parse: contrast was not checked.`);
6890
+ }
6891
+ }
6892
+ const modes = config.contrast?.modes;
6893
+ const contrast = pairs.length ? contrastDelta(runLayeredContrast(before, pairs, modes), runLayeredContrast(after, pairs, modes)) : [];
6894
+ const newFailures = contrast.filter((d) => d.passBefore && !d.passAfter);
6895
+ const otherFiles = [...writes.keys()].filter((p) => !tokenFileOf(config, p));
6896
+ const tier = classifyRisk({ tokenChanges: tokenChanges.map((c) => ({ kind: c.kind, path: c.path, ...c.kind === "changed" ? { descriptionOnly: c.fields.length > 0 && c.fields.every((x) => x === "description") } : {} })), files: otherFiles.map((path) => ({ path })), config });
6897
+ const tierIncreased = writes.size > 0 && tier.tier > payload.tier.tier;
6898
+ const checks = { tierWhenSuggested: payload.tier.tier, tier, tierIncreased, newErrors, newFailures, contrast, tokenChanges };
6899
+ const rest = { gate, ops, checks, problems };
6900
+ if (problems.length) return out("refused", "Not applied: part of the suggestion is not valid.", rest);
6901
+ const conflicts = ops.filter((o) => o.state === "conflict");
6902
+ if (conflicts.length) {
6903
+ return out("conflict", `Not applied: ${conflicts.length} change(s) conflict with the code now. Ask the author to refresh the suggestion on today's code, or apply those parts by hand.`, rest);
6904
+ }
6905
+ if (!writes.size) return out("already-applied", "Already applied: the code has every change of this suggestion.", rest);
6906
+ if (newErrors.length || newFailures.length) {
6907
+ const why = [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ");
6908
+ return out("blocked", `Not applied: on today's code it would add ${why}. Ask the author to refresh the suggestion.`, rest);
6909
+ }
6910
+ if (tierIncreased && !input.forceUnapproved) {
6911
+ return out("blocked", `Not applied: on today's code it is a tier ${tier.tier} change, it was approved as tier ${payload.tier.tier}. It needs a new approval.`, rest);
6912
+ }
6913
+ if (!approved && !input.forceUnapproved) {
6914
+ const who = review === "changes-requested" ? `changes requested by ${approvals.changesRequested.map((l) => `@${l}`).join(", ")}` : `missing ${missing.map((m) => m === "approval" ? "an approval" : `${m} approval`).join(" and ")}`;
6915
+ return out("not-approved", `Not applied: it is not approved yet (${who}). Ask the owners to review it on the issue.`, rest);
6916
+ }
6917
+ const forced = gate.forced || tierIncreased && Boolean(input.forceUnapproved);
6918
+ return out("ready", `Ready: ${writes.size} file(s) to write${forced ? ", without every approval" : ""}.`, {
6919
+ ...rest,
6920
+ gate: { ...gate, forced },
6921
+ writes: [...writes].map(([path, content]) => ({ path, content })).sort((a, b) => a.path.localeCompare(b.path))
6922
+ });
5314
6923
  }
5315
- async function writeTokenProposalOnGitHub(gh, plan, opts) {
5316
- if (plan.blocked) throw new Error(`Not opened: ${plan.blocked}. Bridge never opens a branch that breaks the gate.`);
5317
- const jira = opts.jira ?? null;
5318
- let branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}`, jira);
5319
- for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}-${i}`, jira);
5320
- const commit = await gh.commitToNewBranch({ base: opts.base, branch, message: `feat(tokens): ${plan.title}
5321
-
5322
- Proposed through ${opts.tool ?? "Bridge MCP"}. Tier ${plan.tier.tier}.`, files: plan.files });
5323
- const labels = [.../* @__PURE__ */ new Set([...opts.labels ?? [], `tier: ${plan.tier.tier}`, ...plan.tier.tier === 3 ? ["tokens"] : [], ...plan.tier.bump === "major" ? ["breaking"] : [], ...opts.labelsAfter ?? []])];
5324
- const draft = plan.tier.tier >= 2;
5325
- const pr = await gh.createPullRequest({ base: opts.base, head: branch, title: titleWithJiraKey(`feat(tokens): ${plan.title}`, jira), body: plan.body, draft });
5326
- await gh.addLabels(pr.number, labels);
5327
- return { branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft };
6924
+ function suggestionAppliedComment(plan, where2) {
6925
+ return composeSuggestionAppliedComment({ ...where2, payloadSha: plan.payloadSha, ...plan.gate?.forced ? { forced: true } : {} });
6926
+ }
6927
+ function suggestionApplyTable(plan) {
6928
+ const label = { "already-applied": "already applied", apply: "apply", "apply-merged": "apply (merged)", conflict: "**conflict**" };
6929
+ const cell4 = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
6930
+ return ["| Change | Result | Detail |", "| --- | --- | --- |", ...plan.ops.map((o) => `| \`${cell4(o.path)}\` | ${label[o.state]} | ${cell4(o.detail)} |`)].join("\n");
5328
6931
  }
5329
6932
 
5330
6933
  // ../core/src/node/git-history.ts
@@ -5333,8 +6936,8 @@ var RS = "";
5333
6936
  function localGitHistoryBackend(root, config) {
5334
6937
  const trees = /* @__PURE__ */ new Map();
5335
6938
  const blobs = /* @__PURE__ */ new Map();
5336
- const tokenRoot = config.tokens.root.replace(/\/$/, "");
5337
- const prefix = tokenRoot === "." || !tokenRoot ? "" : `${tokenRoot}/`;
6939
+ const tokenRoot2 = config.tokens.root.replace(/\/$/, "");
6940
+ const prefix = tokenRoot2 === "." || !tokenRoot2 ? "" : `${tokenRoot2}/`;
5338
6941
  const lsTree = (sha, path) => {
5339
6942
  const k = `${sha}:${path}`;
5340
6943
  let t = trees.get(k);
@@ -5371,7 +6974,7 @@ function localGitHistoryBackend(root, config) {
5371
6974
  });
5372
6975
  },
5373
6976
  async tokenFiles(sha) {
5374
- const entries = await lsTree(sha, tokenRoot === "." ? "" : tokenRoot);
6977
+ const entries = await lsTree(sha, tokenRoot2 === "." ? "" : tokenRoot2);
5375
6978
  const picked = new Set(selectTokenFiles(config, entries.map((e) => e.path)));
5376
6979
  const wanted = entries.filter((e) => picked.has(prefix ? e.path.slice(prefix.length) : e.path));
5377
6980
  return new Map(await Promise.all(wanted.map(async (e) => [prefix ? e.path.slice(prefix.length) : e.path, await blob(e.sha)])));
@@ -6001,14 +7604,14 @@ function scanClassString(text) {
6001
7604
  const out = [];
6002
7605
  for (const m of text.matchAll(/[^\s"'`]+/g)) {
6003
7606
  const raw = m[0];
6004
- let cut = -1;
7607
+ let cut2 = -1;
6005
7608
  let depth = 0;
6006
7609
  for (let i = 0; i < raw.length; i++) {
6007
7610
  if (raw[i] === "[") depth++;
6008
7611
  else if (raw[i] === "]") depth--;
6009
- else if (raw[i] === ":" && depth === 0) cut = i;
7612
+ else if (raw[i] === ":" && depth === 0) cut2 = i;
6010
7613
  }
6011
- const cls = raw.slice(cut + 1).replace(/^!/, "");
7614
+ const cls = raw.slice(cut2 + 1).replace(/^!/, "");
6012
7615
  const index = m.index + raw.length - cls.length;
6013
7616
  const arb = /^-?([a-z][a-z-]*?)-\[(.+)\]$/.exec(cls);
6014
7617
  if (arb) {
@@ -6182,8 +7785,8 @@ function tailwindFindings(hits, offset, pos, ds) {
6182
7785
  else if (cat === "font") cat = /^\d{3}$/.test(v) ? "font-weight" : "font-family";
6183
7786
  if (cat === "color" && !isColor) continue;
6184
7787
  const suggestion = ds.suggest(cat, v);
6185
- const cap2 = h.kind === "palette" && suggestion && suggestion.confidence > 0.6 ? { ...suggestion, confidence: 0.6, level: "medium" } : suggestion;
6186
- findings.push({ ...pos(offset + h.index), category: cat, value: h.cls, source: "tailwind", ...cap2 ? { suggestion: cap2 } : {} });
7788
+ const cap3 = h.kind === "palette" && suggestion && suggestion.confidence > 0.6 ? { ...suggestion, confidence: 0.6, level: "medium" } : suggestion;
7789
+ findings.push({ ...pos(offset + h.index), category: cat, value: h.cls, source: "tailwind", ...cap3 ? { suggestion: cap3 } : {} });
6187
7790
  }
6188
7791
  return { findings, themeRefs };
6189
7792
  }
@@ -6496,16 +8099,16 @@ function applyAllowlist(entries, findings, components) {
6496
8099
  return { findings: keep(findings), components: keep(components), ignored, used };
6497
8100
  }
6498
8101
  function parseAllowlist(json) {
6499
- const fail2 = (msg) => {
8102
+ const fail3 = (msg) => {
6500
8103
  throw new Error(`allowlist: ${msg}`);
6501
8104
  };
6502
- if (!json || typeof json !== "object" || !Array.isArray(json.entries)) fail2('expected { "entries": [...] }');
8105
+ if (!json || typeof json !== "object" || !Array.isArray(json.entries)) fail3('expected { "entries": [...] }');
6503
8106
  return json.entries.map((e, i) => {
6504
8107
  const x = e;
6505
- if (typeof x.file !== "string" || !x.file) fail2(`entry ${i} needs "file"`);
6506
- if (typeof x.line !== "number" || !Number.isInteger(x.line) || x.line < 1) fail2(`entry ${i} (${x.file}) needs a "line" number`);
6507
- if (typeof x.reason !== "string" || !x.reason.trim()) fail2(`entry ${i} (${x.file}:${x.line}) needs a "reason"`);
6508
- if (x.what !== void 0 && typeof x.what !== "string") fail2(`entry ${i} (${x.file}:${x.line}): "what" must be a string`);
8108
+ if (typeof x.file !== "string" || !x.file) fail3(`entry ${i} needs "file"`);
8109
+ if (typeof x.line !== "number" || !Number.isInteger(x.line) || x.line < 1) fail3(`entry ${i} (${x.file}) needs a "line" number`);
8110
+ if (typeof x.reason !== "string" || !x.reason.trim()) fail3(`entry ${i} (${x.file}:${x.line}) needs a "reason"`);
8111
+ if (x.what !== void 0 && typeof x.what !== "string") fail3(`entry ${i} (${x.file}:${x.line}): "what" must be a string`);
6509
8112
  return { file: x.file, line: x.line, ...x.what !== void 0 ? { what: x.what } : {}, reason: x.reason.trim() };
6510
8113
  });
6511
8114
  }
@@ -7191,9 +8794,9 @@ var DesignSystemIndex = class {
7191
8794
  return generic ? this.make(generic, 1, 0.3) : void 0;
7192
8795
  }
7193
8796
  case "shadow": {
7194
- const norm3 = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
8797
+ const norm4 = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
7195
8798
  const cands = this.entries(["shadow"]);
7196
- const exact = cands.find((e) => norm3(e.css) === norm3(value));
8799
+ const exact = cands.find((e) => norm4(e.css) === norm4(value));
7197
8800
  if (exact) return this.make(exact, 0, 1);
7198
8801
  const t = shadowNums(value);
7199
8802
  const scored = cands.map((e) => {
@@ -7260,6 +8863,194 @@ function loadAllowlist(path) {
7260
8863
  return parseAllowlist(JSON.parse(readFileSync(path, "utf8")));
7261
8864
  }
7262
8865
 
8866
+ // ../core/src/node/suggestion-apply-node.ts
8867
+ import { execFile } from "node:child_process";
8868
+ import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
8869
+ import { tmpdir } from "node:os";
8870
+ import { dirname as dirname2, join as join2 } from "node:path";
8871
+ function gitCode(root, args) {
8872
+ return new Promise((resolve4) => {
8873
+ execFile("git", args, { cwd: root, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
8874
+ const code2 = err ? typeof err.code === "number" ? err.code : -1 : 0;
8875
+ resolve4({ code: code2, stdout, stderr });
8876
+ });
8877
+ });
8878
+ }
8879
+ async function treeEntry(root, ref, path) {
8880
+ const r = await gitCode(root, ["ls-tree", ref, "--", path]);
8881
+ const m = /^(\d+) (\w+) ([0-9a-f]+)\t/.exec(r.stdout);
8882
+ return r.code === 0 && m ? { mode: m[1], type: m[2], sha: m[3] } : null;
8883
+ }
8884
+ var prefixes = (path) => path.split("/").map((_, i, segs) => segs.slice(0, i + 1).join("/"));
8885
+ async function mergeText(root, input) {
8886
+ const dir = await mkdtemp(join2(tmpdir(), "bridge-merge-"));
8887
+ try {
8888
+ const [o, b, t] = ["current", "base", "suggested"].map((n) => join2(dir, n));
8889
+ await Promise.all([writeFile(o, input.ours), writeFile(b, input.base), writeFile(t, input.theirs)]);
8890
+ const r = await gitCode(root, ["merge-file", "-p", "-L", "current", "-L", "base", "-L", "suggested", o, b, t]);
8891
+ if (r.code < 0 || r.code > 127) throw new Error(`git merge-file failed: ${r.stderr.trim() || `exit ${r.code}`}`);
8892
+ return { clean: r.code === 0, text: r.stdout, conflicts: r.code };
8893
+ } finally {
8894
+ await rm(dir, { recursive: true, force: true });
8895
+ }
8896
+ }
8897
+ async function fileDiff(path, before, after) {
8898
+ if (before === after) return "";
8899
+ const dir = await mkdtemp(join2(tmpdir(), "bridge-diff-"));
8900
+ try {
8901
+ const side = async (name, text) => {
8902
+ if (text === null) return "/dev/null";
8903
+ const p = join2(dir, name, path);
8904
+ await mkdir(dirname2(p), { recursive: true });
8905
+ await writeFile(p, text);
8906
+ return `${name}/${path}`;
8907
+ };
8908
+ const a = await side("a", before);
8909
+ const b = await side("b", after);
8910
+ const r = await new Promise((resolve4) => execFile("git", ["diff", "--no-index", "--no-color", "--src-prefix=", "--dst-prefix=", "--", a, b], { cwd: dir, maxBuffer: 32 * 1024 * 1024 }, (_e, stdout) => resolve4({ stdout })));
8911
+ return r.stdout;
8912
+ } finally {
8913
+ await rm(dir, { recursive: true, force: true });
8914
+ }
8915
+ }
8916
+ function gitWorkspace(root, opts = {}) {
8917
+ const { ref } = opts;
8918
+ let fetched = false;
8919
+ return {
8920
+ async read(path) {
8921
+ if (ref) {
8922
+ const e = await treeEntry(root, ref, path);
8923
+ if (!e || e.type !== "blob" || e.mode === "120000") return null;
8924
+ return { text: await git(root, ["cat-file", "blob", e.sha]), sha: e.sha };
8925
+ }
8926
+ try {
8927
+ const st = await lstat(join2(root, path));
8928
+ if (!st.isFile()) return null;
8929
+ const text = await readFile(join2(root, path), "utf8");
8930
+ return { text, sha: gitBlobSha(text) };
8931
+ } catch {
8932
+ return null;
8933
+ }
8934
+ },
8935
+ async readAt(at, path) {
8936
+ if ((await gitCode(root, ["cat-file", "-e", `${at}^{commit}`])).code !== 0 && !fetched) {
8937
+ fetched = true;
8938
+ await gitCode(root, ["fetch", "--no-tags", "--quiet", "origin", at]);
8939
+ }
8940
+ const e = await treeEntry(root, at, path);
8941
+ if (e?.type === "blob") return git(root, ["cat-file", "blob", e.sha]);
8942
+ return opts.readBase ? opts.readBase(path, at) : null;
8943
+ },
8944
+ async list() {
8945
+ if (ref) return listFilesAt(root, ref);
8946
+ const out = await git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
8947
+ const all = [...new Set(out.split("\0").filter(Boolean))];
8948
+ const present = await Promise.all(all.map((p) => lstat(join2(root, p)).then((s) => s.isFile(), () => false)));
8949
+ return all.filter((_, i) => present[i]);
8950
+ },
8951
+ async isSymlink(path) {
8952
+ for (const p of prefixes(path)) {
8953
+ if (ref) {
8954
+ const e = await treeEntry(root, ref, p);
8955
+ if (!e) return false;
8956
+ if (e.mode === "120000") return true;
8957
+ } else {
8958
+ try {
8959
+ if ((await lstat(join2(root, p))).isSymbolicLink()) return true;
8960
+ } catch {
8961
+ return false;
8962
+ }
8963
+ }
8964
+ }
8965
+ return false;
8966
+ },
8967
+ merge: (input) => mergeText(root, input)
8968
+ };
8969
+ }
8970
+ var COMMIT_TYPE = { "token-change": "feat(tokens)", docs: "docs", "component-spec": "feat", brand: "feat(tokens)", revert: "revert", config: "chore(config)" };
8971
+ function commitMessage(plan, n) {
8972
+ const p = plan.payload;
8973
+ const type = `${COMMIT_TYPE[p.kind] ?? "chore"}${p.tier.bump === "major" ? "!" : ""}`;
8974
+ return [`${type}: ${p.title.trim()}`, "", p.summary.trim(), "", `Applies Bridge suggestion #${n} (version ${plan.payloadSha.slice(0, 7)}).`].filter((l, i, a) => !(l === "" && a[i - 1] === "")).join("\n");
8975
+ }
8976
+ async function readConfigAt(ws) {
8977
+ const text = (await ws.read(CONFIG_FILE))?.text;
8978
+ if (!text) throw new Error(`No ${CONFIG_FILE} here: run \`bridge init\` first, or apply from the repository root.`);
8979
+ return loadConfig(text);
8980
+ }
8981
+ async function applySuggestionLocally(input) {
8982
+ const mode = input.mode ?? "branch";
8983
+ const base = input.base ?? "HEAD";
8984
+ const { root } = input;
8985
+ const wsOpts = { readBase: input.readBase };
8986
+ const ws = gitWorkspace(root, mode === "write" ? wsOpts : { ...wsOpts, ref: base });
8987
+ const config = input.config ?? await readConfigAt(ws);
8988
+ const planFor = (workspace) => planSuggestionApply({ ...input, config, workspace });
8989
+ const plan = await planFor(ws);
8990
+ const at = (input.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
8991
+ const result = (status, rest = {}) => ({ status, exitCode: plan.exitCode, message: plan.message, plan, ...rest });
8992
+ if (plan.outcome !== "ready") return result(plan.outcome);
8993
+ const readNow = async (path) => (await ws.read(path))?.text ?? null;
8994
+ const diffOf = async () => (await Promise.all(plan.writes.map(async (w) => fileDiff(w.path, await readNow(w.path), w.content)))).join("");
8995
+ if (input.dryRun) return result("dry-run", { message: `Dry run: ${plan.message.replace(/^Ready: /, "")} Nothing written.`, diff: await diffOf() });
8996
+ if (mode === "patch") {
8997
+ const { sha } = await commitTree(root, { base, message: commitMessage(plan, input.number), files: plan.writes });
8998
+ const patch = await git(root, ["format-patch", "-1", "--stdout", sha]);
8999
+ return result("patch", { message: "Patch ready: apply it with `git am`.", patch, commit: sha });
9000
+ }
9001
+ if (mode === "write") {
9002
+ const paths = plan.writes.map((w) => w.path);
9003
+ const dirty = (await git(root, ["status", "--porcelain", "-z", "--", ...paths])).split("\0").filter((l) => l.length > 3).map((l) => l.slice(3));
9004
+ if (dirty.length) {
9005
+ return result("refused", {
9006
+ exitCode: 1,
9007
+ message: `Not written: these files have uncommitted changes: ${dirty.join(", ")}. Commit or stash them, or apply on a branch instead.`
9008
+ });
9009
+ }
9010
+ const diff = await diffOf();
9011
+ for (const w of plan.writes) {
9012
+ const full = join2(root, w.path);
9013
+ if (w.content === null) await rm(full, { force: true });
9014
+ else {
9015
+ await mkdir(dirname2(full), { recursive: true });
9016
+ await writeFile(full, w.content);
9017
+ }
9018
+ }
9019
+ return result("written", {
9020
+ message: `Written: ${paths.length} file(s) in the working tree. Review them, commit, and say "Closes #${input.number}" in the pull request.`,
9021
+ written: paths,
9022
+ diff,
9023
+ appliedComment: suggestionAppliedComment(plan, { by: input.by, at })
9024
+ });
9025
+ }
9026
+ const prefix = config.governance.branchPrefix;
9027
+ const wanted = input.branch ?? `${prefix}suggestion-${input.number}-${slugify(plan.payload.title, 40)}`;
9028
+ let branch = wanted;
9029
+ for (let i = 2; await branchExists(root, branch); i++) {
9030
+ const there = await planFor(gitWorkspace(root, { ...wsOpts, ref: branch }));
9031
+ if (there.outcome === "already-applied") {
9032
+ const commit = (await git(root, ["rev-parse", `refs/heads/${branch}`])).trim();
9033
+ return result("branch-exists", {
9034
+ exitCode: 0,
9035
+ message: `Already on ${branch}: that branch has every change of this suggestion. Push it and open a pull request that says "Closes #${input.number}".`,
9036
+ branch,
9037
+ commit,
9038
+ appliedComment: suggestionAppliedComment(plan, { by: input.by, branch, commit, at })
9039
+ });
9040
+ }
9041
+ branch = `${wanted}-${i}`;
9042
+ }
9043
+ const protectedBranches = [.../* @__PURE__ */ new Set([...protectedBranchesOf(config), baseBranchOf(config)])];
9044
+ const res = await commitToNewLocalBranch(root, { base, branch, message: commitMessage(plan, input.number), files: plan.writes, protectedBranches, branchPrefix: prefix });
9045
+ return result("branch-created", {
9046
+ message: `Applied on ${branch}, your working tree is untouched. Push it and open a pull request that says "Closes #${input.number}".`,
9047
+ branch,
9048
+ commit: res.sha,
9049
+ diff: res.diff,
9050
+ appliedComment: suggestionAppliedComment(plan, { by: input.by, branch, commit: res.sha, at })
9051
+ });
9052
+ }
9053
+
7263
9054
  // src/config.ts
7264
9055
  var SCHEMA_URL = "https://raw.githubusercontent.com/peterbono/ai-design-system-demo/main/packages/core/bridge.config.schema.json";
7265
9056
  var TIER_NAMES = {
@@ -7419,10 +9210,10 @@ var core = {
7419
9210
  };
7420
9211
 
7421
9212
  // src/source.ts
7422
- import { readdir, readFile } from "node:fs/promises";
7423
- import { join as join2 } from "node:path";
9213
+ import { readdir, readFile as readFile2 } from "node:fs/promises";
9214
+ import { join as join3 } from "node:path";
7424
9215
  var SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "coverage", "storybook-static", ".turbo", ".next", ".astro"]);
7425
- var under = (prefix) => (f) => !prefix || prefix === "." || f === prefix || f.startsWith(`${prefix.replace(/\/$/, "")}/`);
9216
+ var under2 = (prefix) => (f) => !prefix || prefix === "." || f === prefix || f.startsWith(`${prefix.replace(/\/$/, "")}/`);
7426
9217
  var LocalSource = class {
7427
9218
  constructor(root) {
7428
9219
  this.root = root;
@@ -7438,7 +9229,7 @@ var LocalSource = class {
7438
9229
  const walk2 = async (rel) => {
7439
9230
  let entries;
7440
9231
  try {
7441
- entries = await readdir(join2(this.root, rel), { withFileTypes: true });
9232
+ entries = await readdir(join3(this.root, rel), { withFileTypes: true });
7442
9233
  } catch {
7443
9234
  return;
7444
9235
  }
@@ -7454,7 +9245,7 @@ var LocalSource = class {
7454
9245
  }
7455
9246
  async read(path) {
7456
9247
  try {
7457
- return await readFile(join2(this.root, path), "utf8");
9248
+ return await readFile2(join3(this.root, path), "utf8");
7458
9249
  } catch {
7459
9250
  return null;
7460
9251
  }
@@ -7474,7 +9265,7 @@ var GitRefSource = class {
7474
9265
  }
7475
9266
  async list(prefix = "") {
7476
9267
  this.files ??= listFilesAt(this.root, this.ref);
7477
- return (await this.files).filter(under(prefix));
9268
+ return (await this.files).filter(under2(prefix));
7478
9269
  }
7479
9270
  read(path) {
7480
9271
  return showFile(this.root, this.ref, path);
@@ -7495,7 +9286,7 @@ var GitHubSource = class {
7495
9286
  }
7496
9287
  async list(prefix = "") {
7497
9288
  this.tree ??= this.client.listFiles(this.ref);
7498
- return (await this.tree).filter(under(prefix));
9289
+ return (await this.tree).filter(under2(prefix));
7499
9290
  }
7500
9291
  read(path) {
7501
9292
  let p = this.cache.get(path);
@@ -7517,7 +9308,7 @@ function client(repo, o, rules) {
7517
9308
  ...o.rawUrl ? { rawUrl: o.rawUrl } : {},
7518
9309
  protectedBranches: rules.protectedBranches,
7519
9310
  branchPrefix: rules.branchPrefix,
7520
- tokenHint: "set GITHUB_TOKEN (or BRIDGE_GITHUB_TOKEN) in the environment of the Bridge MCP server, read access to this repository is enough"
9311
+ tokenHint: "set GITHUB_TOKEN (or BRIDGE_GITHUB_TOKEN) in the environment of the Bridge MCP server: read access to this repository is enough to read, suggestions and requests need Issues: read and write, and direct changes (branches and pull requests) need Contents and Pull requests: read and write"
7521
9312
  });
7522
9313
  }
7523
9314
  var BridgeContext = class _BridgeContext {
@@ -7939,8 +9730,8 @@ __export(ops_exports, {
7939
9730
  visualImpact: () => visualImpact
7940
9731
  });
7941
9732
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
7942
- import { mkdir as mkdir2, writeFile as writeFile2, access } from "node:fs/promises";
7943
- import { dirname as dirname3, isAbsolute, join as join4, resolve as resolve3 } from "node:path";
9733
+ import { mkdir as mkdir3, writeFile as writeFile3, access } from "node:fs/promises";
9734
+ import { dirname as dirname4, isAbsolute, join as join5, resolve as resolve3 } from "node:path";
7944
9735
 
7945
9736
  // ../core/src/audit/missing-pieces.ts
7946
9737
  var lines = (text) => text.split(/\r?\n/);
@@ -8012,7 +9803,7 @@ function aspectOf(values) {
8012
9803
  for (const a of ["tone", "shape", "size"]) if (values.some((v) => ASPECT[v.category] === a)) return a;
8013
9804
  return "style";
8014
9805
  }
8015
- var norm2 = (f) => f.category === "color" ? f.value.toLowerCase() : f.value.replace(/\s+/g, "");
9806
+ var norm3 = (f) => f.category === "color" ? f.value.toLowerCase() : f.value.replace(/\s+/g, "");
8016
9807
  function nearestOf(s) {
8017
9808
  if (!s) return void 0;
8018
9809
  return { token: s.token, cssVar: s.cssVar, value: s.value, level: s.level, label: tokenLabel(s.token) };
@@ -8099,7 +9890,7 @@ function missingPieces(input) {
8099
9890
  variants.set(key2, v);
8100
9891
  continue;
8101
9892
  }
8102
- const key = `${finding.category}|${norm2(finding)}`;
9893
+ const key = `${finding.category}|${norm3(finding)}`;
8103
9894
  const t = tokens.get(key) ?? { category: finding.category, value: finding.value, name, hits: [] };
8104
9895
  if (!t.name && name) t.name = name;
8105
9896
  t.hits.push(hit);
@@ -8117,13 +9908,13 @@ function missingPieces(input) {
8117
9908
  foreign.set(c.name, e);
8118
9909
  }
8119
9910
  }
8120
- const where = (hits) => hits.slice(0, 5).map((h) => ({ file: h.file, line: h.line }));
9911
+ const where2 = (hits) => hits.slice(0, 5).map((h) => ({ file: h.file, line: h.line }));
8121
9912
  const pieces = [];
8122
9913
  for (const v of variants.values()) {
8123
9914
  const values = [];
8124
9915
  for (const h of v.hits) {
8125
9916
  const pv = { category: h.finding.category, ...h.finding.property ? { property: h.finding.property } : {}, value: h.finding.value };
8126
- if (!values.some((x) => x.category === pv.category && x.property === pv.property && norm2(x) === norm2(pv))) values.push(pv);
9917
+ if (!values.some((x) => x.category === pv.category && x.property === pv.property && norm3(x) === norm3(pv))) values.push(pv);
8127
9918
  }
8128
9919
  const aspect = aspectOf(values);
8129
9920
  const bg = v.hits.find((h) => /background/.test(h.finding.property ?? ""))?.finding.suggestion;
@@ -8139,7 +9930,7 @@ function missingPieces(input) {
8139
9930
  aspect,
8140
9931
  values,
8141
9932
  ...nearest ? { nearest } : {},
8142
- where: where(v.hits)
9933
+ where: where2(v.hits)
8143
9934
  });
8144
9935
  }
8145
9936
  for (const [name, e] of foreign) {
@@ -8151,7 +9942,7 @@ function missingPieces(input) {
8151
9942
  name,
8152
9943
  component: name.split(".").pop(),
8153
9944
  values: [],
8154
- where: where(e.hits)
9945
+ where: where2(e.hits)
8155
9946
  });
8156
9947
  }
8157
9948
  for (const t of tokens.values()) {
@@ -8168,7 +9959,7 @@ function missingPieces(input) {
8168
9959
  name: pname,
8169
9960
  values: [{ category: t.category, ...t.hits[0].finding.property ? { property: t.hits[0].finding.property } : {}, value: t.value }],
8170
9961
  ...nearest ? { nearest } : {},
8171
- where: where(t.hits),
9962
+ where: where2(t.hits),
8172
9963
  token: { path, type: TOKEN_TYPE[t.category] ?? "dimension", value: t.value, files: tokenFiles }
8173
9964
  });
8174
9965
  }
@@ -8319,8 +10110,8 @@ __export(explorations_exports, {
8319
10110
  toExplorationBranch: () => toExplorationBranch,
8320
10111
  writeToExploration: () => writeToExploration
8321
10112
  });
8322
- import { mkdir, rm, writeFile } from "node:fs/promises";
8323
- import { dirname as dirname2, join as join3 } from "node:path";
10113
+ import { mkdir as mkdir2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
10114
+ import { dirname as dirname3, join as join4 } from "node:path";
8324
10115
 
8325
10116
  // src/branches.ts
8326
10117
  var branches_exports = {};
@@ -8476,19 +10267,19 @@ async function whatNeedsMe(ctx, a = {}) {
8476
10267
  const pulls = await gh.listPullRequests("open");
8477
10268
  const queue = reviewQueue(pulls.map((p) => ({ ...p, state: p.state, head: { ref: p.headRef }, user: { login: p.author }, labels: p.labels.map((name) => ({ name })) }))).slice(0, a.limit ?? 20);
8478
10269
  const who = await people(gh, config);
8479
- const same2 = (x, y) => Boolean(y && x.toLowerCase() === y.toLowerCase());
10270
+ const same3 = (x, y) => Boolean(y && x.toLowerCase() === y.toLowerCase());
8480
10271
  const rows = await Promise.all(
8481
10272
  queue.map(async (p) => {
8482
10273
  const f = await branchFacts(ctx, gh, p.number, viewer).catch(() => null);
8483
- if (!f) return { pr: p.number, title: p.title, url: p.url, author: p.author, status: "unknown", statusLabel: STATUS_WORDS.unknown, yours: same2(p.author, viewer), needsYou: null };
8484
- const mine = same2(p.author, viewer);
8485
- const inDesign = viewer ? who.lists.design.some((x) => same2(x, viewer)) : false;
8486
- const inTech = viewer ? who.lists.tech.some((x) => same2(x, viewer)) : false;
10274
+ if (!f) return { pr: p.number, title: p.title, url: p.url, author: p.author, status: "unknown", statusLabel: STATUS_WORDS.unknown, yours: same3(p.author, viewer), needsYou: null };
10275
+ const mine = same3(p.author, viewer);
10276
+ const inDesign = viewer ? who.lists.design.some((x) => same3(x, viewer)) : false;
10277
+ const inTech = viewer ? who.lists.tech.some((x) => same3(x, viewer)) : false;
8487
10278
  let needsYou = null;
8488
10279
  if (viewer && !mine) {
8489
10280
  if (f.status === "needs-design" && inDesign) needsYou = "Your design approval";
8490
10281
  else if (f.status === "needs-tech" && inTech) needsYou = "Your tech approval";
8491
- else if (f.status === "needs-approval" && !f.approvals.approvers.some((x) => same2(x, viewer))) needsYou = "Your approval";
10282
+ else if (f.status === "needs-approval" && !f.approvals.approvers.some((x) => same3(x, viewer))) needsYou = "Your approval";
8492
10283
  else if (f.status === "ready" && f.gate.canShip) needsYou = "Ready for you to merge";
8493
10284
  }
8494
10285
  if (viewer && mine) {
@@ -8945,11 +10736,11 @@ async function writeToExploration(ctx, branch, input) {
8945
10736
  };
8946
10737
  }
8947
10738
  for (const f of input.files) {
8948
- const abs = join3(ctx.root, f.path);
8949
- if (f.content === null) await rm(abs, { force: true });
10739
+ const abs = join4(ctx.root, f.path);
10740
+ if (f.content === null) await rm2(abs, { force: true });
8950
10741
  else {
8951
- await mkdir(dirname2(abs), { recursive: true });
8952
- await writeFile(abs, f.content);
10742
+ await mkdir2(dirname3(abs), { recursive: true });
10743
+ await writeFile2(abs, f.content);
8953
10744
  }
8954
10745
  }
8955
10746
  const paths = input.files.map((f) => f.path);
@@ -8960,6 +10751,379 @@ async function writeToExploration(ctx, branch, input) {
8960
10751
  };
8961
10752
  }
8962
10753
 
10754
+ // src/suggestions.ts
10755
+ var suggestions_exports = {};
10756
+ __export(suggestions_exports, {
10757
+ SUGGEST_RIGHTS: () => SUGGEST_RIGHTS,
10758
+ appliedNext: () => appliedNext,
10759
+ applySuggestionOp: () => applySuggestionOp,
10760
+ changesFor: () => changesFor,
10761
+ createSuggestionOp: () => createSuggestionOp,
10762
+ fileSuggestion: () => fileSuggestion,
10763
+ filesSuggestionPayload: () => filesSuggestionPayload,
10764
+ getSuggestionOp: () => getSuggestionOp,
10765
+ githubWorkspace: () => githubWorkspace,
10766
+ linkSuggestionOp: () => linkSuggestionOp,
10767
+ listSuggestionsOp: () => listSuggestionsOp,
10768
+ noSuggestionAccess: () => noSuggestionAccess,
10769
+ reviewSuggestionOp: () => reviewSuggestionOp,
10770
+ suggestionBase: () => suggestionBase,
10771
+ suggestionGovernance: () => suggestionGovernance,
10772
+ suggestionNumber: () => suggestionNumber
10773
+ });
10774
+ var SUGGEST_RIGHTS = "Issues: read and write (Contents: read is enough)";
10775
+ async function changesFor(ctx, explicit) {
10776
+ if (explicit === "suggest" || explicit === "direct") return explicit;
10777
+ const { config } = await ctx.config();
10778
+ return changesMode(config);
10779
+ }
10780
+ var appliedNext = (n) => `The owners review it on the issue (bridge_review_suggestion, or Bridge Studio). Once approved, a developer applies it: \`bridge apply ${n}\` in the repo, or in Claude Code with the Bridge MCP server, "Apply Bridge suggestion #${n}" (bridge_apply_suggestion).`;
10781
+ function noSuggestionAccess(ctx, gh) {
10782
+ const local = ctx.mode === "local" && ctx.isGit ? ', or pass changes "direct" (the CLI: --direct) to commit it on a new local branch instead' : "";
10783
+ if (!gh) return `Nothing sent: a suggestion is a GitHub issue and no GitHub repo is known. Set "repo" in ${CONFIG_FILE} or add an origin remote${local}.`;
10784
+ return `Nothing sent: a suggestion is a GitHub issue, which needs a GitHub token with ${SUGGEST_RIGHTS} on ${gh.fullName}. Set GITHUB_TOKEN (or BRIDGE_GITHUB_TOKEN)${local}.`;
10785
+ }
10786
+ async function suggestionBase(ctx, gh, config) {
10787
+ const ref = ctx.ref ?? baseBranchOf(config);
10788
+ if (ctx.mode === "local" && ctx.isGit && ctx.root) {
10789
+ const sha2 = (await git(ctx.root, ["rev-parse", "HEAD"]).catch(() => "")).trim();
10790
+ if (/^[0-9a-f]{40}$/.test(sha2)) return { ref: baseBranchOf(config), sha: sha2 };
10791
+ }
10792
+ const sha = await gh.getBranchSha(ref);
10793
+ if (!sha) throw new Error(`Branch ${ref} not found on ${gh.fullName}. Check the default branch in ${CONFIG_FILE}.`);
10794
+ return { ref, sha };
10795
+ }
10796
+ async function filesSuggestionPayload(ctx, input) {
10797
+ const files = await Promise.all(
10798
+ input.files.map(async (f) => {
10799
+ const now = await ctx.source.read(f.path);
10800
+ return { path: f.path, beforeSha: now === null ? null : gitBlobSha(now), afterSha: f.content === null ? null : gitBlobSha(f.content), content: f.content };
10801
+ })
10802
+ );
10803
+ return {
10804
+ schema: SUGGESTION_SCHEMA,
10805
+ kind: input.kind,
10806
+ title: input.title,
10807
+ summary: input.summary.trim() || input.title,
10808
+ source: input.source,
10809
+ ...input.suggestedBy ? { suggestedBy: input.suggestedBy.replace(/^@/, "") } : {},
10810
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
10811
+ base: input.base,
10812
+ tier: input.tier,
10813
+ files,
10814
+ checks: { newErrors: 0, contrast: [], blocked: null, impact: 0, ...input.checks }
10815
+ };
10816
+ }
10817
+ async function fileSuggestion(ctx, build, opts = {}) {
10818
+ const { config } = await ctx.config();
10819
+ const gh = await ctx.github();
10820
+ if (opts.dryRun) {
10821
+ const base2 = gh ? await suggestionBase(ctx, gh, config).catch(() => ({ ref: baseBranchOf(config), sha: "0000000" })) : { ref: baseBranchOf(config), sha: "0000000" };
10822
+ const payload2 = await build(base2, null);
10823
+ const issue = composeSuggestionIssue(payload2, { labels: [...config.governance.labels, ...opts.labels ?? []], details: opts.details, repo: gh?.fullName });
10824
+ return { status: "dry-run", suggestion: { title: issue.title, labels: issue.labels, body: issue.body, parts: issue.parts.length } };
10825
+ }
10826
+ if (!gh || !ctx.canWriteRemote) return { status: "preview", message: noSuggestionAccess(ctx, gh) };
10827
+ const base = await suggestionBase(ctx, gh, config);
10828
+ const viewer = await viewerOf(ctx, gh);
10829
+ const payload = await build(base, viewer);
10830
+ const problems = suggestionPathProblems(payload, config);
10831
+ if (problems.length) throw new Error(`Not suggested: ${problems.join(" ")}`);
10832
+ const labels = [.../* @__PURE__ */ new Set([...config.governance.labels, ...opts.labels ?? []])];
10833
+ const created = await createSuggestionOnGitHub(gh, payload, { labels, details: opts.details, assignees: config.integrations?.requests?.assignees ?? [] });
10834
+ return { status: "suggested", number: created.number, url: created.url, sha: created.sha, labels, warnings: created.warnings, next: appliedNext(created.number) };
10835
+ }
10836
+ function suggestionNumber(input) {
10837
+ if (typeof input === "number" && Number.isInteger(input) && input > 0) return input;
10838
+ const m = /^#?(\d+)$/.exec(String(input).trim()) ?? /\/issues\/(\d+)/.exec(String(input));
10839
+ if (!m) throw new Error(`"${input}" is not a suggestion number. Pass the issue number (42 or "#42") or its link.`);
10840
+ return Number(m[1]);
10841
+ }
10842
+ async function suggestionGovernance(ctx, gh) {
10843
+ const { config: here } = await ctx.config();
10844
+ let config = here;
10845
+ try {
10846
+ const text = await gh.readFile(CONFIG_FILE, baseBranchOf(here));
10847
+ if (text) config = loadConfig(text);
10848
+ } catch {
10849
+ }
10850
+ const { owners, rights } = config.governance;
10851
+ const hasTeams = [...owners.design, ...owners.tech].some((o) => o.includes("/"));
10852
+ const teams = hasTeams ? await new TeamDirectory(gh).resolve(owners).catch(() => void 0) : void 0;
10853
+ return { config, gov: { owners, rights: rights ?? null, ...teams ? { teams } : {} } };
10854
+ }
10855
+ var OPEN_STATUSES = ["needs-review", "changes-requested", "approved", "applying", "outdated"];
10856
+ async function listSuggestionsOp(ctx, a = {}) {
10857
+ const gh = await reader(ctx);
10858
+ const status = a.status ?? "all";
10859
+ const state = a.state ?? (status === "all" ? "open" : OPEN_STATUSES.includes(status) ? "open" : "closed");
10860
+ let rows = await listSuggestionsOnGitHub(gh, { state, perPage: 100 });
10861
+ if (status !== "all") rows = rows.filter((r) => r.status === status);
10862
+ if (a.kind) rows = rows.filter((r) => r.kind === a.kind);
10863
+ const total = rows.length;
10864
+ rows = rows.slice(0, a.limit ?? 100);
10865
+ const approved = rows.filter((r) => r.status === "approved");
10866
+ return {
10867
+ repo: gh.fullName,
10868
+ label: SUGGESTION_LABEL,
10869
+ state,
10870
+ status,
10871
+ total,
10872
+ suggestions: rows.map((r) => ({
10873
+ number: r.number,
10874
+ title: r.title,
10875
+ kind: r.kind ?? null,
10876
+ tier: r.tier ?? null,
10877
+ status: r.status,
10878
+ said: SUGGESTION_STATUS_LABEL[r.status],
10879
+ author: r.author,
10880
+ url: r.url,
10881
+ linkedPrs: r.linkedPrs
10882
+ })),
10883
+ next: approved.length ? `Ready to apply: ${approved.map((r) => `#${r.number}`).join(", ")}. Apply one with bridge_apply_suggestion { number } (dry run first), or \`bridge apply <n>\`.` : rows.length ? "Read one with bridge_get_suggestion { number }: its change, checks and who still has to approve it. Statuses come from labels here; bridge_get_suggestion counts the approvals." : "No suggestion matches."
10884
+ };
10885
+ }
10886
+ var roleText = (m) => m === "approval" ? "one approval" : `${m} approval`;
10887
+ async function getSuggestionOp(ctx, a) {
10888
+ const n = suggestionNumber(a.number);
10889
+ const gh = await reader(ctx);
10890
+ const { gov } = await suggestionGovernance(ctx, gh);
10891
+ const d = await getSuggestionOnGitHub(gh, n, gov);
10892
+ const base = { number: n, url: d.issue.url, title: d.issue.title, state: d.issue.state, status: d.status, said: SUGGESTION_STATUS_LABEL[d.status], author: d.issue.author, labels: d.issue.labels, linkedPrs: d.linkedPrs };
10893
+ if (!d.parsed.ok) {
10894
+ if (!d.issue.labels.includes(SUGGESTION_LABEL) && d.parsed.reason === "no-block") throw new Error(`Issue #${n} is not a Bridge suggestion: it has no "${SUGGESTION_LABEL}" label and no suggestion block.`);
10895
+ return { ...base, problem: d.parsed.message, next: "Ask the author to send it again from Bridge." };
10896
+ }
10897
+ const p = d.parsed.payload;
10898
+ const approvals = d.approvals;
10899
+ const missing = pendingApprovals(p.tier.tier, approvals);
10900
+ return {
10901
+ ...base,
10902
+ kind: p.kind,
10903
+ summary: p.summary,
10904
+ source: p.source,
10905
+ ...p.suggestedBy ? { suggestedBy: p.suggestedBy } : {},
10906
+ createdAt: p.createdAt,
10907
+ base: p.base,
10908
+ tier: p.tier.tier,
10909
+ bump: p.tier.bump,
10910
+ version: d.parsed.sha.slice(0, 7),
10911
+ payloadSha: d.parsed.sha,
10912
+ ...d.parsed.edited ? { edited: "The machine-readable block was edited by hand after Bridge wrote it. Applying it needs acceptEdited (the CLI: --accept-edited) once you checked it." } : {},
10913
+ change: {
10914
+ ...p.tokens ? { tokens: p.tokens.changes.length ? p.tokens.changes.map((c) => ({ kind: c.kind, path: c.path, ...c.from ? { from: c.from } : {}, file: c.file, before: c.beforeCss ?? c.before, after: c.afterCss ?? c.after })) : p.tokens.edits } : {},
10915
+ ...p.docs ? { docs: p.docs.map((x) => ({ component: x.component, path: x.path })) } : {},
10916
+ ...p.componentSpec ? { componentSpec: { component: p.componentSpec.component, specPath: p.componentSpec.specPath } } : {},
10917
+ ...p.files?.length ? { files: p.files.map((f) => ({ path: f.path, change: f.beforeSha === null ? "new" : f.afterSha === null ? "deleted" : "changed" })) } : {}
10918
+ },
10919
+ checks: p.checks,
10920
+ ...p.links ? { links: p.links } : {},
10921
+ ...p.previews?.length ? { previews: p.previews } : {},
10922
+ approvals: {
10923
+ approvedBy: approvals.approvers,
10924
+ missing: missing.map(roleText),
10925
+ changesRequestedBy: approvals.changesRequested,
10926
+ ...approvals.stale.length ? { staleReviews: approvals.stale } : {},
10927
+ ...approvals.ignored.length ? { notCounted: approvals.ignored } : {}
10928
+ },
10929
+ applied: d.applied,
10930
+ ...p.componentSpec ? { brief: p.componentSpec.brief } : {},
10931
+ next: d.status === "approved" ? `Approved. Apply it: bridge_apply_suggestion { number: ${n}, mode: "dry-run" } first, then mode "branch" (or \`bridge apply ${n}\`). The pull request must say "Closes #${n}".` : d.status === "needs-review" ? `Waiting for ${missing.map(roleText).join(" and ") || "a review"} from the owners in ${CONFIG_FILE}: bridge_review_suggestion { number: ${n}, event: "approve" }.` : d.status === "changes-requested" ? "Changes requested: the author sends a new version (approvals then start again)." : d.status === "applying" ? `Being applied${d.linkedPrs.length ? ` in ${d.linkedPrs.map((x) => `#${x.number}`).join(", ")}` : ""}. It closes when the pull request merges.` : d.status === "outdated" ? "Outdated: the code changed since it was suggested. Ask the author to refresh it." : `${SUGGESTION_STATUS_LABEL[d.status]}: nothing to do.`
10932
+ };
10933
+ }
10934
+ async function reviewSuggestionOp(ctx, a) {
10935
+ const n = suggestionNumber(a.number);
10936
+ const gh = await writer(ctx, "Reviewing a suggestion", "Issues: read and write");
10937
+ const { gov } = await suggestionGovernance(ctx, gh);
10938
+ const viewer = await viewerOf(ctx, gh);
10939
+ if (a.event === "decline") {
10940
+ if (!a.message?.trim()) throw new Error("Say why it is declined, in a sentence: the author reads it.");
10941
+ if (a.payloadSha) {
10942
+ const d = await getSuggestionOnGitHub(gh, n, gov);
10943
+ if (d.parsed.ok && !d.parsed.sha.startsWith(a.payloadSha)) throw new Error(`#${n} changed since you read it. Read it again (bridge_get_suggestion) before declining.`);
10944
+ }
10945
+ const r2 = await declineSuggestionOnGitHub(gh, n, a.message.trim(), viewer);
10946
+ return { number: n, status: "declined", said: SUGGESTION_STATUS_LABEL.declined, ...r2.warnings.length ? { warnings: r2.warnings } : {}, next: "Closed as not planned, with your reason. Reopen it on GitHub to look at it again." };
10947
+ }
10948
+ if (a.event !== "comment" && viewer) {
10949
+ const lists = approverLists(gov.owners, gov.rights ?? null, gov.teams ?? void 0);
10950
+ const owners = [...lists.design, ...lists.tech].map((l) => l.toLowerCase().replace(/^@/, ""));
10951
+ if (owners.length && !owners.includes(viewer.toLowerCase())) {
10952
+ throw new Error(`Only the owners in ${CONFIG_FILE} approve or request changes on suggestions, and @${viewer} is not one. Comment instead (event "comment"), or ask an owner.`);
10953
+ }
10954
+ }
10955
+ const payloadSha = a.payloadSha && /^[0-9a-f]{64}$/.test(a.payloadSha) ? a.payloadSha : void 0;
10956
+ if (a.payloadSha && !payloadSha) {
10957
+ const d = await getSuggestionOnGitHub(gh, n, gov);
10958
+ if (d.parsed.ok && !d.parsed.sha.startsWith(a.payloadSha)) throw new Error(`#${n} changed since you read it. Read it again (bridge_get_suggestion) and review the new version.`);
10959
+ }
10960
+ const r = await reviewSuggestionOnGitHub(gh, n, { event: a.event, role: a.role, message: a.message, payloadSha, ...viewer ? { by: viewer } : {} }, gov);
10961
+ return {
10962
+ number: n,
10963
+ event: a.event,
10964
+ counts: r.counts,
10965
+ version: r.payloadSha.slice(0, 7),
10966
+ status: r.status,
10967
+ said: SUGGESTION_STATUS_LABEL[r.status],
10968
+ comment: r.commentUrl,
10969
+ ...r.warnings.length ? { warnings: r.warnings } : {},
10970
+ next: r.status === "approved" ? `Approved: a developer applies it with \`bridge apply ${n}\` or bridge_apply_suggestion.` : r.status === "changes-requested" ? "The author sends a new version; approvals start again on it." : "Waiting for the other approvals."
10971
+ };
10972
+ }
10973
+ function githubWorkspace(gh, ref) {
10974
+ let files;
10975
+ return {
10976
+ read: async (path) => await gh.getFile(path, ref) ?? null,
10977
+ readAt: async (sha, path) => await gh.readFile(path, sha).catch(() => void 0) ?? null,
10978
+ list: () => files ??= gh.listFiles(ref)
10979
+ };
10980
+ }
10981
+ var MAX_DIFF = 12e3;
10982
+ var cut = (s) => s && s.length > MAX_DIFF ? `${s.slice(0, MAX_DIFF)}
10983
+ ... (truncated)` : s;
10984
+ function planFacts(plan) {
10985
+ return {
10986
+ outcome: plan.outcome,
10987
+ exitCode: plan.exitCode,
10988
+ version: plan.payloadSha.slice(0, 7),
10989
+ table: suggestionApplyTable(plan),
10990
+ ...plan.problems.length ? { problems: plan.problems } : {},
10991
+ ...plan.notes.length ? { notes: plan.notes } : {},
10992
+ ...plan.gate ? { approvals: { review: plan.gate.review, missing: plan.gate.missing.map(roleText), forced: plan.gate.forced, edited: plan.gate.edited } } : {},
10993
+ ...plan.checks ? {
10994
+ checks: {
10995
+ tier: plan.checks.tier.tier,
10996
+ tierWhenSuggested: plan.checks.tierWhenSuggested,
10997
+ tierIncreased: plan.checks.tierIncreased,
10998
+ newErrors: plan.checks.newErrors.length,
10999
+ newContrastFailures: plan.checks.newFailures.map((f) => `${f.fg} on ${f.bg}${f.modes ? ` (${f.modes})` : ""}`)
11000
+ }
11001
+ } : {},
11002
+ ...plan.brief ? { brief: plan.brief } : {}
11003
+ };
11004
+ }
11005
+ async function applySuggestionOp(ctx, a) {
11006
+ const n = suggestionNumber(a.number);
11007
+ const gh = await reader(ctx);
11008
+ const { config, gov } = await suggestionGovernance(ctx, gh);
11009
+ const d = await getSuggestionOnGitHub(gh, n, gov);
11010
+ if (!d.parsed.ok) {
11011
+ if (!d.issue.labels.includes(SUGGESTION_LABEL) && d.parsed.reason === "no-block") throw new Error(`Issue #${n} is not a Bridge suggestion: it has no "${SUGGESTION_LABEL}" label and no suggestion block.`);
11012
+ return { number: n, status: "refused", outcome: "refused", exitCode: 1, message: `Not applied: ${d.parsed.message}` };
11013
+ }
11014
+ if (d.status === "declined") return { number: n, status: "refused", outcome: "refused", exitCode: 1, message: `Not applied: #${n} was declined. Reopen it on GitHub first if it should go in after all.` };
11015
+ const parsed = d.parsed;
11016
+ const dryRun = a.dryRun || a.mode === "dry-run";
11017
+ const mode = a.mode && a.mode !== "dry-run" ? a.mode : "branch";
11018
+ const common = { parsed, comments: d.comments, governance: gov, author: d.issue.author || null, acceptEdited: a.acceptEdited, forceUnapproved: a.forceUnapproved };
11019
+ if (ctx.mode !== "local" || !ctx.isGit || !ctx.root) {
11020
+ const ref = ctx.ref ?? baseBranchOf(config);
11021
+ const plan = await planSuggestionApply({ ...common, config, workspace: githubWorkspace(gh, ref) });
11022
+ return {
11023
+ number: n,
11024
+ status: plan.outcome === "ready" ? "planned" : plan.outcome,
11025
+ message: plan.message,
11026
+ ...planFacts(plan),
11027
+ ...plan.outcome === "ready" ? { files: plan.writes } : {},
11028
+ next: plan.outcome === "ready" ? `Bridge reads ${gh.fullName} over the API and writes nothing here. Write these files on a new branch, commit, push, and open a pull request whose body says "Closes #${n}", then bridge_link_suggestion { number: ${n}, pr }.` : plan.message
11029
+ };
11030
+ }
11031
+ const viewer = await viewerOf(ctx, gh);
11032
+ const r = await applySuggestionLocally({
11033
+ ...common,
11034
+ root: ctx.root,
11035
+ number: n,
11036
+ mode,
11037
+ dryRun,
11038
+ ...a.base ? { base: a.base } : {},
11039
+ ...a.branch ? { branch: a.branch } : {},
11040
+ ...viewer ? { by: viewer } : {},
11041
+ readBase: async (path, sha) => await gh.readFile(path, sha).catch(() => void 0) ?? null
11042
+ });
11043
+ let recorded = false;
11044
+ const warnings = [];
11045
+ if (r.appliedComment && !dryRun && (r.status === "branch-created" || r.status === "written" || r.status === "branch-exists")) {
11046
+ if (ctx.canWriteRemote) {
11047
+ try {
11048
+ await gh.addComment(n, r.appliedComment);
11049
+ recorded = true;
11050
+ const w = await syncSuggestionStateLabel(gh, n, d.issue.labels, "applying");
11051
+ if (w) warnings.push(w);
11052
+ } catch (e) {
11053
+ warnings.push(`#${n} does not say it is being applied yet (${e.message}). It will once the pull request says "Closes #${n}".`);
11054
+ }
11055
+ } else warnings.push(`#${n} does not say it is being applied: that needs a GitHub token with Issues write. It will once the pull request says "Closes #${n}".`);
11056
+ }
11057
+ const next = r.status === "dry-run" ? `Nothing written. Apply it: bridge_apply_suggestion { number: ${n}, mode: "branch" } (or "write" for the working tree, "patch" for a patch).` : r.status === "branch-created" || r.status === "branch-exists" ? `Push ${r.branch} (git push -u origin ${r.branch}) and open a pull request whose body says "Closes #${n}", then bridge_link_suggestion { number: ${n}, pr }.` : r.status === "written" ? `Review the files, commit them on a branch, push, and open a pull request whose body says "Closes #${n}", then bridge_link_suggestion { number: ${n}, pr }.` : r.status === "patch" ? `Apply the patch with \`git am\` on a branch, then open a pull request whose body says "Closes #${n}".` : r.message;
11058
+ return {
11059
+ number: n,
11060
+ status: r.status,
11061
+ mode: dryRun ? "dry-run" : mode,
11062
+ message: r.message,
11063
+ ...planFacts(r.plan),
11064
+ exitCode: r.exitCode,
11065
+ ...r.branch ? { branch: r.branch } : {},
11066
+ ...r.commit ? { commit: r.commit } : {},
11067
+ ...r.written ? { written: r.written } : {},
11068
+ ...r.patch ? { patch: r.patch } : {},
11069
+ ...r.diff ? { diff: cut(r.diff) } : {},
11070
+ recorded,
11071
+ ...warnings.length ? { warnings } : {},
11072
+ next
11073
+ };
11074
+ }
11075
+ async function linkSuggestionOp(ctx, a) {
11076
+ const n = suggestionNumber(a.number);
11077
+ const gh = await writer(ctx, "Linking a suggestion", "Issues: read and write");
11078
+ if (!a.pr && !a.branch && !a.commit) throw new Error("Say where it is applied: pr (the pull request number), branch or commit.");
11079
+ const { gov } = await suggestionGovernance(ctx, gh);
11080
+ const d = await getSuggestionOnGitHub(gh, n, gov);
11081
+ if (!d.parsed.ok) throw new Error(`#${n} cannot be linked: ${d.parsed.message}`);
11082
+ const warnings = [];
11083
+ let branch = a.branch;
11084
+ if (a.pr) {
11085
+ const pr = await gh.getPullRequest(a.pr);
11086
+ branch ??= pr.headRef;
11087
+ if (!closingIssueNumbers(pr.body).includes(n)) warnings.push(`#${a.pr} does not say "Closes #${n}": add it to the pull request description, so the suggestion closes when it merges.`);
11088
+ }
11089
+ const viewer = await viewerOf(ctx, gh);
11090
+ const r = await linkSuggestionPrOnGitHub(gh, n, { pr: a.pr, branch, commit: a.commit, by: viewer ?? void 0, payloadSha: d.parsed.sha, forced: a.forced });
11091
+ warnings.push(...r.warnings);
11092
+ return { number: n, status: "applying", said: SUGGESTION_STATUS_LABEL.applying, ...a.pr ? { pr: a.pr } : {}, ...branch ? { branch } : {}, ...warnings.length ? { warnings } : {}, next: "It closes when the pull request merges." };
11093
+ }
11094
+ async function createSuggestionOp(ctx, a) {
11095
+ const { config } = await ctx.config();
11096
+ const gh = await ctx.github();
11097
+ const raw = a.payload ?? {};
11098
+ if (!SUGGESTION_KINDS.includes(raw.kind)) throw new Error(`"kind" must be one of ${SUGGESTION_KINDS.join(", ")}.`);
11099
+ const needsBase = !raw.base;
11100
+ const base = needsBase ? gh ? await suggestionBase(ctx, gh, config).catch(() => null) : null : raw.base;
11101
+ if (!base) throw new Error(`No base commit: pass "base" { "ref", "sha" }, or set "repo" in ${CONFIG_FILE} so Bridge reads it.`);
11102
+ const viewer = gh && !a.dryRun ? await viewerOf(ctx, gh) : null;
11103
+ const payload = {
11104
+ schema: SUGGESTION_SCHEMA,
11105
+ source: a.source ?? "mcp",
11106
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
11107
+ checks: { newErrors: 0, contrast: [], blocked: null, impact: 0 },
11108
+ ...viewer ? { suggestedBy: viewer } : {},
11109
+ ...raw,
11110
+ base
11111
+ };
11112
+ const shape2 = suggestionShapeProblems(payload);
11113
+ if (shape2.length) throw new Error(`The payload is not a suggestion: ${shape2.join(" ")}`);
11114
+ if (payload.checks.blocked) throw new Error(`Not suggested: ${payload.checks.blocked}. Bridge never suggests a change that breaks the gate.`);
11115
+ const paths = suggestionPathProblems(payload, config);
11116
+ if (paths.length) throw new Error(`Not suggested: ${paths.join(" ")}`);
11117
+ const labels = [.../* @__PURE__ */ new Set([...config.governance.labels, ...a.labels ?? []])];
11118
+ if (a.dryRun) {
11119
+ const issue = composeSuggestionIssue(payload, { labels, repo: gh?.fullName });
11120
+ return { status: "dry-run", title: issue.title, labels: issue.labels, body: issue.body, parts: issue.parts.length, version: issue.sha.slice(0, 7) };
11121
+ }
11122
+ if (!gh || !ctx.canWriteRemote) return { status: "preview", message: noSuggestionAccess(ctx, gh), payload };
11123
+ const created = await createSuggestionOnGitHub(gh, payload, { labels, repo: gh.fullName, assignees: config.integrations?.requests?.assignees ?? [] });
11124
+ return { status: "suggested", number: created.number, url: created.url, version: created.sha.slice(0, 7), ...created.warnings.length ? { warnings: created.warnings } : {}, next: appliedNext(created.number) };
11125
+ }
11126
+
8963
11127
  // src/ops.ts
8964
11128
  var prefixPath = (set, f) => set.repoPath(f);
8965
11129
  function normalizeTokenPath(set, input) {
@@ -8979,6 +11143,7 @@ async function getConfig(ctx) {
8979
11143
  source: ctx.describe(),
8980
11144
  configOrigin: origin === "file" ? "bridge.config.json" : "detected (no bridge.config.json; run `bridge init` to pin it)",
8981
11145
  write: ctx.mode === "local" ? ctx.isGit ? "local branches only (never pushes)" : "none (not a git repo)" : ctx.canWriteRemote ? "pull requests on new bridge/* branches" : "read-only (set GITHUB_TOKEN to open PRs)",
11146
+ changes: changesMode(config) === "suggest" ? `suggest: token, docs, brand, revert and Figma changes are filed as suggestions (GitHub issues a developer applies with bridge apply or bridge_apply_suggestion)${ctx.canWriteRemote ? "" : "; filing one needs GITHUB_TOKEN with Issues write"}. Pass changes "direct" to a tool to open a branch instead.` : 'direct: changes open branches and pull requests. Pass changes "suggest" to a tool to file a suggestion instead.',
8982
11147
  repo: gh ? gh.fullName : null,
8983
11148
  tokenFiles: set.fileNames.length,
8984
11149
  tokens: set.allPaths().length,
@@ -9306,6 +11471,25 @@ async function proposeTokenChange(ctx, a) {
9306
11471
  if (newErrors.length || newFailures.length) {
9307
11472
  return { status: "blocked", why: [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and "), newErrors, ...report, hint: "Fix the edits (or add edits for the aliases they break). Bridge never opens a PR that breaks the gate." };
9308
11473
  }
11474
+ if (!target && await changesFor(ctx, a.changes) === "suggest") {
11475
+ const source = a.source ?? "mcp";
11476
+ const shown = { ...report, changesMode: "suggest", files: files.map((f) => f.path) };
11477
+ const gh2 = await ctx.github();
11478
+ if (a.dryRun) return { status: "dry-run", ...shown, suggestion: { title: suggestionIssueTitle({ kind: "token-change", title: a.title }), body }, fileContents: files };
11479
+ if (!gh2 || !ctx.canWriteRemote) return { status: "preview", message: noSuggestionAccess(ctx, gh2), ...shown, fileContents: files };
11480
+ const base2 = await suggestionBase(ctx, gh2, config);
11481
+ const viewer = await viewerOf(ctx, gh2);
11482
+ const s = await suggestTokenProposalOnGitHub(gh2, plan, {
11483
+ base: base2.ref,
11484
+ baseSha: base2.sha,
11485
+ source,
11486
+ ...viewer ? { suggestedBy: viewer } : {},
11487
+ labels: config.governance.labels,
11488
+ labelsAfter: marks.labels,
11489
+ assignees: config.integrations?.requests?.assignees ?? []
11490
+ });
11491
+ return { status: "suggested", ...shown, number: s.number, url: s.url, version: s.sha.slice(0, 7), labels: s.labels, ...s.warnings.length ? { warnings: s.warnings } : {}, product: product.name, next: appliedNext(s.number) };
11492
+ }
9309
11493
  let branch = target ? target.branch : branchWithJiraKey(`${config.governance.branchPrefix}${slugTitle}`, jira);
9310
11494
  const preview = { ...report, branch, files: files.map((f) => f.path), prBody: body };
9311
11495
  if (a.dryRun) return { status: "dry-run", ...preview, fileContents: files };
@@ -9457,8 +11641,8 @@ async function getReview(ctx, a) {
9457
11641
  const delta2 = pairs.length ? core.contrastDelta(core.runContrast(before, pairs, config.contrast?.modes), core.runContrast(after, pairs, config.contrast?.modes)) : [];
9458
11642
  const prevErrors = new Set(core.validate(before).map(issueKey3));
9459
11643
  const errors = core.validate(after).filter((i) => i.level === "error");
9460
- const tokenRoot = config.tokens.root.replace(/\/$/, "");
9461
- const otherFiles = changedFiles.filter((f) => !f.startsWith(`${tokenRoot}/`) && !f.startsWith(`${config.changesets?.dir ?? ".changeset"}/`));
11644
+ const tokenRoot2 = config.tokens.root.replace(/\/$/, "");
11645
+ const otherFiles = changedFiles.filter((f) => !f.startsWith(`${tokenRoot2}/`) && !f.startsWith(`${config.changesets?.dir ?? ".changeset"}/`));
9462
11646
  const tier = core.classify(changes, otherFiles, config);
9463
11647
  const imp = core.impact(before, after, changes, 20);
9464
11648
  const labelTier = Array.isArray(meta.labels) ? meta.labels.map((l) => /^tier:\s*(\d)/i.exec(l)?.[1]).find(Boolean) : void 0;
@@ -9515,12 +11699,12 @@ async function scaffoldComponentOp(ctx, a) {
9515
11699
  const write = ctx.mode === "local" && a.write !== false;
9516
11700
  if (write) {
9517
11701
  for (const f of res.files) {
9518
- const abs = join4(ctx.root, f.path);
11702
+ const abs = join5(ctx.root, f.path);
9519
11703
  if (await access(abs).then(() => true, () => false)) throw new Error(`${f.path} exists; not overwriting.`);
9520
11704
  }
9521
11705
  for (const f of res.files) {
9522
- await mkdir2(dirname3(join4(ctx.root, f.path)), { recursive: true });
9523
- await writeFile2(join4(ctx.root, f.path), f.content);
11706
+ await mkdir3(dirname4(join5(ctx.root, f.path)), { recursive: true });
11707
+ await writeFile3(join5(ctx.root, f.path), f.content);
9524
11708
  }
9525
11709
  }
9526
11710
  return { written: write, tier: 2, files: write ? res.files.map((f) => f.path) : res.files, nextSteps: res.notes };
@@ -9532,8 +11716,8 @@ async function auditUi(ctx, code2) {
9532
11716
  async function visualImpact(ctx, a, run2) {
9533
11717
  if (ctx.mode !== "local" || !ctx.root) throw new Error("Visual impact needs a local checkout (start Bridge with --root): it builds Storybook and the app at both refs.");
9534
11718
  if (!ctx.isGit) throw new Error(`${ctx.root} is not a git repository.`);
9535
- const out = a.out ? resolve3(ctx.root, a.out) : join4(ctx.root, "visual-impact");
9536
- const runImpact = run2 ?? (await import("./src-DHJOM5PQ.js")).runImpact;
11719
+ const out = a.out ? resolve3(ctx.root, a.out) : join5(ctx.root, "visual-impact");
11720
+ const runImpact = run2 ?? (await import("./src-ABI7LARD.js")).runImpact;
9537
11721
  const r = await runImpact({
9538
11722
  root: ctx.root,
9539
11723
  base: a.base ?? "main",
@@ -9566,7 +11750,7 @@ async function visualImpact(ctx, a, run2) {
9566
11750
  status: s.status,
9567
11751
  percent: s.percent,
9568
11752
  boxes: s.boxes.length,
9569
- ...s.images.composite ? { composite: join4(r.outDir, s.images.composite) } : {},
11753
+ ...s.images.composite ? { composite: join5(r.outDir, s.images.composite) } : {},
9570
11754
  ...s.error ? { error: s.error } : {}
9571
11755
  })),
9572
11756
  outDir: r.outDir,
@@ -9810,6 +11994,17 @@ async function proposeRevert(ctx, a) {
9810
11994
  const plan = await buildPlan(ctx, a, ctx.mode === "local" ? "the bridge CLI" : "Bridge MCP");
9811
11995
  const report = planReport(plan);
9812
11996
  if (plan.status !== "ready") return { ...report, hint: plan.status === "blocked" ? "Bridge never opens a change that breaks the gate." : "Nothing to propose." };
11997
+ if (await changesFor(ctx, a.changes) === "suggest") {
11998
+ const summary2 = plan.commitMessage.split("\n").slice(1).join("\n").trim() || plan.prTitle;
11999
+ const r = await fileSuggestion(
12000
+ ctx,
12001
+ (base2, by) => filesSuggestionPayload(ctx, { kind: "revert", title: plan.prTitle, summary: summary2, files: plan.files, tier: { tier: plan.tier.tier, bump: plan.tier.bump }, base: base2, source: a.source ?? "mcp", suggestedBy: by, checks: { newErrors: plan.newErrors.length } }),
12002
+ { labels: [plan.kind === "revert" ? "revert" : "restore", ...plan.tier.tier === 3 ? ["tokens"] : [], ...plan.tier.bump === "major" ? ["breaking"] : []], details: plan.prBody, dryRun: a.dryRun }
12003
+ );
12004
+ if (r.status === "dry-run") return { ...report, status: "dry-run", changesMode: "suggest", suggestion: r.suggestion, fileContents: plan.files };
12005
+ if (r.status === "preview") return { ...report, status: "preview", message: r.message, changesMode: "suggest", fileContents: plan.files };
12006
+ return { ...report, status: "suggested", changesMode: "suggest", number: r.number, url: r.url, version: r.sha.slice(0, 7), labels: r.labels, ...r.warnings.length ? { warnings: r.warnings } : {}, next: r.next };
12007
+ }
9813
12008
  if (a.dryRun) return { ...report, status: "dry-run", prBody: plan.prBody, fileContents: plan.files };
9814
12009
  const prefix = config.governance.branchPrefix;
9815
12010
  let branch = plan.branch;
@@ -9929,6 +12124,17 @@ async function proposeBrand(ctx, a) {
9929
12124
  const prefix = config.governance.branchPrefix;
9930
12125
  const stem2 = `${prefix}brand-${plan.slug}`;
9931
12126
  let branch = stem2;
12127
+ if (await changesFor(ctx, a.changes) === "suggest") {
12128
+ const summary2 = a.notes?.trim() || `New brand "${plan.slug}", started from ${plan.from}. ${report.contrast.line} (${plan.contrast.themes.join(" and ")}).`;
12129
+ const r = await fileSuggestion(
12130
+ ctx,
12131
+ (base2, by) => filesSuggestionPayload(ctx, { kind: "brand", title: plan.title, summary: summary2, files, tier: { tier: plan.risk.tier, bump: plan.risk.bump }, base: base2, source: a.source ?? "mcp", suggestedBy: by }),
12132
+ { labels: ["tokens"], details: report.prBody, dryRun: a.dryRun }
12133
+ );
12134
+ if (r.status === "dry-run") return { status: "dry-run", ...report, changesMode: "suggest", suggestion: r.suggestion, fileContents: files };
12135
+ if (r.status === "preview") return { status: "preview", message: r.message, ...report, changesMode: "suggest", fileContents: files };
12136
+ return { status: "suggested", ...report, changesMode: "suggest", number: r.number, url: r.url, version: r.sha.slice(0, 7), labels: r.labels, ...r.warnings.length ? { warnings: r.warnings } : {}, next: r.next };
12137
+ }
9932
12138
  if (a.dryRun) return { status: "dry-run", ...report, branch, fileContents: files };
9933
12139
  const message = `feat(tokens): add the ${plan.name} brand
9934
12140
 
@@ -9996,15 +12202,15 @@ function requestFromIssue(issue) {
9996
12202
  const parsed = parseRequestIssue(issue.body);
9997
12203
  if (parsed) return parsed;
9998
12204
  const m = /^\s*([^:]{1,60}):\s*(.+)$/.exec(issue.title);
9999
- const kindLabel2 = issue.labels.map((l) => /^request:\s*(.+)$/.exec(l)?.[1]?.trim()).find((k) => k && KINDS2.has(k));
10000
- const sourceLabel2 = issue.labels.map((l) => /^source:\s*(.+)$/.exec(l)?.[1]?.trim()).find((s) => s === "figma" || s === "studio" || s === "cli" || s === "mcp");
12205
+ const kindLabel3 = issue.labels.map((l) => /^request:\s*(.+)$/.exec(l)?.[1]?.trim()).find((k) => k && KINDS2.has(k));
12206
+ const sourceLabel3 = issue.labels.map((l) => /^source:\s*(.+)$/.exec(l)?.[1]?.trim()).find((s) => s === "figma" || s === "studio" || s === "cli" || s === "mcp");
10001
12207
  return {
10002
12208
  component: m ? m[1].trim() : "Unknown",
10003
- kind: kindLabel2 ?? "other",
12209
+ kind: kindLabel3 ?? "other",
10004
12210
  title: m ? m[2].trim() : issue.title,
10005
12211
  summary: issue.body.trim() || issue.title,
10006
12212
  changes: [],
10007
- source: sourceLabel2 ?? "studio"
12213
+ source: sourceLabel3 ?? "studio"
10008
12214
  };
10009
12215
  }
10010
12216
  function statusOf(issue, prs) {
@@ -10092,7 +12298,7 @@ async function createRequest(ctx, a) {
10092
12298
  ${marks.line}`, labels: [...composed.labels, ...marks.labels] } : composed;
10093
12299
  if (a.dryRun) return { status: "dry-run", issue };
10094
12300
  const gh = await requireGitHub2(ctx);
10095
- if (!ctx.canWriteRemote) throw new Error("Creating a request needs GITHUB_TOKEN (issues: write on this repository). Without it Bridge is read-only.");
12301
+ if (!ctx.canWriteRemote) throw new Error("Creating a request needs GITHUB_TOKEN with Issues: read and write on this repository (Contents: read is enough, like for suggestions). Without it Bridge is read-only.");
10096
12302
  const created = await gh.createIssue(issue);
10097
12303
  return {
10098
12304
  status: "created",
@@ -10200,7 +12406,8 @@ async function saveDocs(ctx, a) {
10200
12406
  return docsFileText(cleanDocs(merged), current);
10201
12407
  };
10202
12408
  const gh = await ctx.github();
10203
- const open = a.pr === void 0 && gh ? await findOpenDocsProposalOnGitHub(gh, { name, slug: docsSlug(name) }, ctx.ref ?? baseBranchOf(config)).catch(() => null) : null;
12409
+ const mode = a.pr === void 0 ? await changesFor(ctx, a.changes) : "direct";
12410
+ const open = a.pr === void 0 && gh && mode === "direct" ? await findOpenDocsProposalOnGitHub(gh, { name, slug: docsSlug(name) }, ctx.ref ?? baseBranchOf(config)).catch(() => null) : null;
10204
12411
  const target = a.pr ?? open?.number;
10205
12412
  if (target !== void 0) {
10206
12413
  const n = branchNumber(target);
@@ -10236,7 +12443,36 @@ async function saveDocs(ctx, a) {
10236
12443
  path,
10237
12444
  content: text,
10238
12445
  product: product.name,
10239
- ...a.dryRun ? {} : { said: `Nothing sent: saving docs for review needs a GitHub token with ${RIGHTS.docs}. Set GITHUB_TOKEN, or write the file yourself on a branch.` }
12446
+ changesMode: mode,
12447
+ ...a.dryRun ? {} : { said: mode === "suggest" ? noSuggestionAccess(ctx, gh) : `Nothing sent: saving docs for review needs a GitHub token with ${RIGHTS.docs}. Set GITHUB_TOKEN, or write the file yourself on a branch.` }
12448
+ };
12449
+ }
12450
+ if (mode === "suggest") {
12451
+ const base = await suggestionBase(ctx, gh, config);
12452
+ const viewer = await viewerOf(ctx, gh);
12453
+ const r2 = await proposeDocsOnGitHub(gh, [edit], {
12454
+ base: base.ref,
12455
+ baseSha: base.sha,
12456
+ mode: "suggest",
12457
+ title: a.title,
12458
+ notes,
12459
+ jira,
12460
+ origin: DOCS_MCP_ORIGIN,
12461
+ extraLabels: marks.labels,
12462
+ source: a.source ?? "mcp",
12463
+ ...viewer ? { suggestedBy: viewer } : {}
12464
+ });
12465
+ return {
12466
+ status: "suggested",
12467
+ number: r2.number,
12468
+ url: r2.url,
12469
+ title: r2.title,
12470
+ path,
12471
+ product: product.name,
12472
+ changesMode: mode,
12473
+ ..."warnings" in r2 && r2.warnings.length ? { warnings: r2.warnings } : {},
12474
+ said: `Suggested the ${name} docs as #${r2.number}. Docs only: one approval, then a developer applies it.`,
12475
+ next: appliedNext(r2.number)
10240
12476
  };
10241
12477
  }
10242
12478
  const r = await proposeDocsOnGitHub(gh.withBranchPrefix("docs/"), [edit], {
@@ -10343,13 +12579,18 @@ async function missingPiecesOp(ctx, a) {
10343
12579
  next: r.pieces.length ? `${a.record ? "" : "Record them (record: true) so Studio shows them, then "}open a library branch for each with bridge_open_library_branch { pr: ${n}, piece: "<id>" }. Fixes are values and components the library already has: use them in the product code instead.` : "Nothing is missing from the library. Fix the values that equal a token, if any."
10344
12580
  };
10345
12581
  }
10346
- function tokenWriter(ctx, gh, source) {
10347
- return async (plan) => {
12582
+ function tokenWriter(ctx, gh, source, viewer) {
12583
+ return async (plan, how) => {
10348
12584
  const { config } = await ctx.config();
10349
12585
  const [before, pairs] = await Promise.all([ctx.tokens(), core.readContrastPairs(ctx.source, config).catch(() => [])]);
10350
12586
  const p = planTokenProposal({ before, pairs, edits: plan.edits, title: plan.title, summary: plan.summary, config, tool: source === "mcp" ? "Bridge MCP" : "the bridge CLI" });
10351
12587
  if (!p) throw new Error("The token already exists with that value: nothing to add.");
10352
- if (p.blocked) throw new Error(`The library branch was not opened: ${p.blocked}. Change the value (bridge_propose_token_change with dryRun shows why).`);
12588
+ if (p.blocked) throw new Error(`The library ${how.mode === "suggest" ? "suggestion was not sent" : "branch was not opened"}: ${p.blocked}. Change the value (bridge_propose_token_change with dryRun shows why).`);
12589
+ if (how.mode === "suggest") {
12590
+ const base = await suggestionBase(ctx, gh, config);
12591
+ const s = await suggestTokenProposalOnGitHub(gh, p, { base: base.ref, baseSha: base.sha, source, ...viewer ? { suggestedBy: viewer } : {}, labels: config.governance.labels, labelsAfter: [NEEDED_BY_LABEL], neededBy: plan.neededBy });
12592
+ return { status: "suggested", number: s.number, url: s.url };
12593
+ }
10353
12594
  const opened = await writeTokenProposalOnGitHub(gh, p, { base: baseBranchOf(config), branchPrefix: config.governance.branchPrefix, labels: config.governance.labels, labelsAfter: [NEEDED_BY_LABEL], tool: source === "mcp" ? "Bridge MCP (source: mcp)" : "the bridge CLI" });
10354
12595
  return { number: opened.pr, url: opened.url, branch: opened.branch };
10355
12596
  };
@@ -10357,7 +12598,8 @@ function tokenWriter(ctx, gh, source) {
10357
12598
  async function openLibraryBranchOp(ctx, a) {
10358
12599
  const n = branchNumber(a.pr);
10359
12600
  const source = a.source ?? "mcp";
10360
- const gh = a.dryRun ? await reader(ctx) : await writer(ctx, "Opening a library branch", NEED);
12601
+ const mode = await changesFor(ctx, a.changes);
12602
+ const gh = a.dryRun ? await reader(ctx) : mode === "suggest" ? await writer(ctx, "Suggesting a library change", SUGGEST_RIGHTS) : await writer(ctx, "Opening a library branch", NEED);
10361
12603
  const { config } = await ctx.config();
10362
12604
  const viewer = await viewerOf(ctx, gh);
10363
12605
  const s = settingsOf(config);
@@ -10367,7 +12609,8 @@ async function openLibraryBranchOp(ctx, a) {
10367
12609
  base: s.base,
10368
12610
  products: productsOf(config),
10369
12611
  exploration: { packages: s.packages, apps: s.apps },
10370
- writeTokens: tokenWriter(ctx, gh, source),
12612
+ writeTokens: tokenWriter(ctx, gh, source, viewer),
12613
+ mode,
10371
12614
  ...viewer ? { openedBy: viewer } : {},
10372
12615
  ...source === "mcp" ? { agent: "claude" } : {},
10373
12616
  source,
@@ -10375,8 +12618,8 @@ async function openLibraryBranchOp(ctx, a) {
10375
12618
  dryRun: a.dryRun
10376
12619
  });
10377
12620
  const plan = r.plan;
10378
- const what = plan.kind === "token-change" ? "token change" : plan.kind === "exploration" ? "library exploration" : "component request";
10379
- const next = r.status === "dry-run" ? `Run it without dryRun to open the ${what}.` : plan.kind === "exploration" ? `Build it on ${r.link?.branch}: ${plan.brief} Then bridge_use_library_preview { pr: ${n}, piece: "${a.piece}" } so the product branch shows it.` : plan.kind === "request" ? "A person or Claude builds the component from the request (bridge_get_request). The product branch keeps its own version until it ships." : r.preview?.status === "conflict" ? r.preview.said : "Design and tech owners review the token change. Once it ships, bridge_switch_to_published moves the product branch to the real version.";
12621
+ const what = plan.kind === "token-change" ? mode === "suggest" ? "token suggestion" : "token change" : plan.kind === "exploration" ? "library exploration" : "component request";
12622
+ const next = r.status === "dry-run" ? `Run it without dryRun to open the ${what}.` : r.status === "suggested" && r.link ? appliedNext(r.link.number) : plan.kind === "exploration" ? `Build it on ${r.link?.branch}: ${plan.brief} Then bridge_use_library_preview { pr: ${n}, piece: "${a.piece}" } so the product branch shows it.` : plan.kind === "request" ? "A person or Claude builds the component from the request (bridge_get_request). The product branch keeps its own version until it ships." : r.preview?.status === "conflict" ? r.preview.said : "Design and tech owners review the token change. Once it ships, bridge_switch_to_published moves the product branch to the real version.";
10380
12623
  return {
10381
12624
  status: r.status,
10382
12625
  pr: n,
@@ -10386,6 +12629,7 @@ async function openLibraryBranchOp(ctx, a) {
10386
12629
  ...plan.kind === "token-change" ? { edits: plan.edits } : {},
10387
12630
  ...plan.kind === "exploration" ? { exploration: plan.exploration.name, brief: plan.brief } : {},
10388
12631
  ...plan.kind === "request" ? { request: plan.request.title } : {},
12632
+ changesMode: mode,
10389
12633
  neededBy: `${plan.neededBy.title} (#${plan.neededBy.pr}) in ${plan.neededBy.productName}`,
10390
12634
  ...r.link ? { library: { kind: r.link.kind, number: r.link.number, url: r.link.url, ...r.link.branch ? { branch: r.link.branch } : {} } } : {},
10391
12635
  ...r.preview ? { preview: r.preview } : {},
@@ -10435,15 +12679,118 @@ async function switchToPublishedOp(ctx, a) {
10435
12679
  return { pr: n, ...await switchToPublishedOnGitHub(gh, n) };
10436
12680
  }
10437
12681
 
12682
+ // src/figma.ts
12683
+ var figma_exports = {};
12684
+ __export(figma_exports, {
12685
+ figmaChangesOp: () => figmaChangesOp,
12686
+ getFigmaChangeOp: () => getFigmaChangeOp,
12687
+ markFigmaDraftedOp: () => markFigmaDraftedOp,
12688
+ pushFigmaComponentOp: () => pushFigmaComponentOp
12689
+ });
12690
+ var NEED2 = "Contents and Pull requests: read and write";
12691
+ async function tokenFacts(ctx, set, component) {
12692
+ const { config } = await ctx.config();
12693
+ const text = await ctx.source.read(config.components?.specs ?? DEFAULT_COMPONENT_SPECS_FILE).catch(() => void 0);
12694
+ const usage = text ? specsUsage(parseComponentSpecs(text), component) : () => ({ variants: [], components: [] });
12695
+ return { tierOf: (p) => set.tierOf(p), usedElsewhere: usage };
12696
+ }
12697
+ async function figmaChangesOp(ctx, a = {}) {
12698
+ const gh = await reader(ctx);
12699
+ const rows = await listFigmaChangesOnGitHub(gh, { state: a.state ?? "open" });
12700
+ return {
12701
+ changes: rows.map((r) => ({ pr: r.number, title: r.title, url: r.url, branch: r.branch, component: r.origin.component, route: r.origin.route === "token-change" ? "exact token change" : "Claude AI drafts", status: r.status.label, said: r.status.sentence })),
12702
+ next: rows.some((r) => r.status.key === "waiting-for-claude") ? "Draft one: bridge_get_figma_change { pr } gives the spec and the steps." : rows.length ? "Owners review them on GitHub or in Bridge Studio." : "Nothing sent from Figma is open."
12703
+ };
12704
+ }
12705
+ async function getFigmaChangeOp(ctx, a) {
12706
+ const n = branchNumber(a.pr);
12707
+ const gh = await reader(ctx);
12708
+ const { config } = await ctx.config();
12709
+ const r = await figmaChangeOnGitHub(gh, n, { componentsDir: config.components?.dir });
12710
+ return {
12711
+ pr: n,
12712
+ title: r.pull.title,
12713
+ url: r.pull.url,
12714
+ branch: r.pull.headRef,
12715
+ component: r.origin.component,
12716
+ route: r.origin.route === "token-change" ? "exact token change" : "Claude AI drafts",
12717
+ status: r.status.label,
12718
+ said: r.status.sentence,
12719
+ ...r.spec ? { spec: { path: r.origin.spec, changes: r.spec.changes, variantAxes: r.spec.variantAxes, properties: r.spec.properties, tokenEdits: r.spec.tokenEdits ?? [], reasons: r.spec.reasons ?? [] } } : {},
12720
+ ...r.brief ? { brief: r.brief } : {},
12721
+ next: r.brief && r.status.key === "waiting-for-claude" ? "Follow the brief, push the code to the branch, then bridge_mark_figma_change_drafted." : r.status.sentence
12722
+ };
12723
+ }
12724
+ async function markFigmaDraftedOp(ctx, a) {
12725
+ const n = branchNumber(a.pr);
12726
+ const gh = await writer(ctx, "Marking a Figma change drafted", NEED2);
12727
+ const r = await markFigmaChangeDraftedOnGitHub(gh, n);
12728
+ return { pr: n, status: r.status.label, said: r.status.sentence, files: r.files, next: "Send it for review when it is ready (bridge_send_for_review), or tell the designer it is ready to look at." };
12729
+ }
12730
+ async function pushFigmaComponentOp(ctx, a) {
12731
+ const d = a.diff;
12732
+ if (!d || d.v !== 1 || typeof d.component !== "string" || !Array.isArray(d.changes)) throw new Error("The diff is not a Bridge component diff (v 1): see the spec of a branch from bridge_get_figma_change for its shape.");
12733
+ const source = a.source ?? "mcp";
12734
+ const tool = source === "mcp" ? "Bridge MCP" : "the bridge CLI";
12735
+ const { config } = await ctx.config();
12736
+ const set = await ctx.tokens();
12737
+ const tokens = await tokenFacts(ctx, set, d.component);
12738
+ const mode = await changesFor(ctx, a.changes);
12739
+ const gh = a.dryRun ? await reader(ctx) : mode === "suggest" ? await writer(ctx, "Suggesting a component change from Figma", SUGGEST_RIGHTS) : await writer(ctx, "Sending a component change from Figma", NEED2);
12740
+ const viewer = a.dryRun ? null : await viewerOf(ctx, gh);
12741
+ const base = a.dryRun || mode !== "suggest" ? null : await suggestionBase(ctx, gh, config);
12742
+ const pairs = await core.readContrastPairs(ctx.source, config).catch(() => []);
12743
+ const aliasOfPath = (p) => aliasOf(set.defsOf(p)[0]?.value) ?? void 0;
12744
+ const r = await openComponentPushOnGitHub(gh, {
12745
+ diff: { ...d, source: d.source ?? source },
12746
+ tokens,
12747
+ base: baseBranchOf(config),
12748
+ branchPrefix: config.governance.branchPrefix,
12749
+ labels: [...config.governance.labels, `source: ${source}`],
12750
+ tool,
12751
+ dryRun: a.dryRun,
12752
+ mode,
12753
+ ...viewer ? { suggestedBy: viewer } : {},
12754
+ ...base ? { base: base.ref, baseSha: base.sha } : {},
12755
+ writeTokens: async ({ edits, title, summary: summary2, bodyExtra, mode: how, figma }) => {
12756
+ const all = [...pairs, ...componentContrastPairs(edits, pairs, { aliasOf: aliasOfPath, paths: set.allPaths() })];
12757
+ const p = planTokenProposal({ before: set, pairs: all, edits, title, summary: summary2, config, tool, bodyExtra });
12758
+ if (!p) throw new Error("The library already paints it this way: nothing to send.");
12759
+ if (p.blocked) throw new Error(`Not sent: ${p.blocked}. Pick another variable.`);
12760
+ if (how === "suggest") {
12761
+ const b = base ?? await suggestionBase(ctx, gh, config);
12762
+ const s = await suggestTokenProposalOnGitHub(gh, p, { base: b.ref, baseSha: b.sha, source, ...viewer ? { suggestedBy: viewer } : {}, labels: config.governance.labels, labelsAfter: [`source: ${source}`], links: { figma } });
12763
+ return { status: "suggested", number: s.number, url: s.url };
12764
+ }
12765
+ const opened = await writeTokenProposalOnGitHub(gh, p, { base: baseBranchOf(config), branchPrefix: config.governance.branchPrefix, labels: config.governance.labels, labelsAfter: [`source: ${source}`], tool: `${tool} (source: ${source})` });
12766
+ return { number: opened.pr, url: opened.url, branch: opened.branch };
12767
+ }
12768
+ });
12769
+ const plan = r.plan;
12770
+ return {
12771
+ status: r.status,
12772
+ component: d.component,
12773
+ route: plan.route === "token-change" ? "exact token change" : plan.route === "claude-draft" ? "Claude AI drafts" : "fix in Figma",
12774
+ said: plan.said,
12775
+ ...plan.edits.length ? { edits: plan.edits } : {},
12776
+ ...plan.reasons.length ? { reasons: plan.reasons } : {},
12777
+ ...plan.issues.length ? { fixInFigma: plan.issues } : {},
12778
+ changesMode: mode,
12779
+ ...r.link ? { library: r.link } : {},
12780
+ ...r.suggestion ? { suggestion: r.suggestion } : {},
12781
+ next: r.status === "dry-run" ? `Run it without dryRun to ${mode === "suggest" ? "file the suggestion" : "open the library branch"}.` : r.status === "not-sent" ? "Nothing was sent: fix these in Figma, then push again." : r.suggestion ? `${r.suggestion.route === "claude-draft" ? "A component spec suggestion: Claude AI drafts the code from it once a developer applies it (bridge_apply_suggestion writes the spec file and returns the brief). " : ""}${appliedNext(r.suggestion.number)}` : plan.route === "claude-draft" ? `Draft the code: bridge_get_figma_change { pr: ${r.link.number} }.` : "Design and tech owners review the token change. Once it ships, a pull in the DS Bridge plugin rebuilds the component."
12782
+ };
12783
+ }
12784
+
10438
12785
  // src/server.ts
10439
- var VERSION = true ? "0.5.0" : "dev";
12786
+ var VERSION = true ? "0.7.0" : "dev";
10440
12787
  var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
10441
- var fail = (e) => ({ content: [{ type: "text", text: `Error: ${e.message ?? String(e)}` }], isError: true });
12788
+ var fail2 = (e) => ({ content: [{ type: "text", text: `Error: ${e.message ?? String(e)}` }], isError: true });
10442
12789
  var run = (fn) => async (a) => {
10443
12790
  try {
10444
12791
  return ok(await fn(a));
10445
12792
  } catch (e) {
10446
- return fail(e);
12793
+ return fail2(e);
10447
12794
  }
10448
12795
  };
10449
12796
  var ModeArg = z.record(z.string(), z.string()).describe('Mode values, e.g. {"theme":"dark","brand":"ocean"}. Omitted modes use their default. See bridge_get_config for the modes this repo has.');
@@ -10458,6 +12805,7 @@ var EditArg = z.object({
10458
12805
  mode: ModeArg.optional().describe('Pick the file that defines the token for this mode, e.g. {"theme":"dark"}. Alternative to `file`.')
10459
12806
  });
10460
12807
  var RO = { readOnlyHint: true, openWorldHint: false };
12808
+ var ChangesArg = z.enum(["suggest", "direct"]).optional().describe('How this change reaches the code: "suggest" files a suggestion (a GitHub issue the owners approve and a developer applies, Issues write only), "direct" opens a branch and a pull request. Default: "changes" in bridge.config.json, else "suggest". Only set it when the person asked.');
10461
12809
  function createServer(ctx) {
10462
12810
  const server = new McpServer(
10463
12811
  { name: "bridge", version: VERSION },
@@ -10465,6 +12813,7 @@ function createServer(ctx) {
10465
12813
  instructions: [
10466
12814
  "Bridge exposes a design system repo (DTCG tokens, components, docs) to you.",
10467
12815
  "Every write goes through a branch and a pull request, never straight to the default branch: the library only changes through reviewed pull requests, and on a protected repo GitHub refuses direct pushes anyway.",
12816
+ 'How changes reach the code is the "changes" setting of bridge.config.json (bridge_get_config says which): "suggest" (the default) files token, docs, brand, revert, missing piece and Figma changes as suggestions, GitHub issues with the exact change that the owners approve (bridge_review_suggestion) and a developer applies (bridge_apply_suggestion, or `bridge apply <n>`); "direct" opens branches and pull requests. Writing tools take "changes" to pick one for a call; only pass it when the person asked. When the person says "Apply Bridge suggestion #n", call bridge_apply_suggestion with mode "dry-run" first, show the result, then apply it on a branch.',
10468
12817
  "Read before you write: bridge_get_guidelines for the repo rules, bridge_search_tokens / bridge_get_token before using or proposing a token.",
10469
12818
  "Never hardcode a value in UI code when a token exists; never invent a token name (check it with bridge_get_token).",
10470
12819
  "bridge_audit measures how a product codebase adopts the design system (hardcoded values, foreign UI components) and suggests the tokens and components to use.",
@@ -10477,6 +12826,7 @@ function createServer(ctx) {
10477
12826
  "Studio parity: a designer can do in Claude what they do in Bridge Studio. Branches in review: bridge_what_needs_me (the queue), bridge_branch_status, bridge_send_for_review, bridge_back_to_draft, bridge_review_branch, bridge_ask_someone_else, bridge_comment, bridge_update_from_main (token conflicts too), bridge_try_branch (try it in a product, share it), bridge_combine_branches, bridge_close_branch, bridge_reopen_branch, bridge_merge_branch (same gate as Studio, owner override with a reason). Docs: bridge_save_docs. Writes act under the person's GitHub token: only do them when the person asked, and merge only when they explicitly asked to merge.",
10478
12827
  'Products: work starts for a product (bridge_list_products). Tools that start work take "product"; without it Bridge uses the product of the current folder or repo. Ask the person when it is not obvious.',
10479
12828
  "Product branches and the library: when a designer explores a product feature with you (an exploration for a product), check it with bridge_missing_pieces (record: true) before they share it; for each piece the library lacks, offer bridge_open_library_branch, which opens the library side linked to their branch. bridge_product_branch says where each library branch stands; bridge_switch_to_published moves the product branch to the real version once the library ships.",
12829
+ "Components from Figma: the DS Bridge plugin sends a component changed in Figma to a library branch. Token swaps go as exact token changes; new variants, properties and components go to a branch carrying the spec, waiting for Claude AI. When the person asks you to draft one, bridge_figma_changes lists them and bridge_get_figma_change gives the spec and the steps; write the code on that branch, push it, then bridge_mark_figma_change_drafted. Never claim a branch is drafted before its code is pushed.",
10480
12830
  'Explorations try experimental variables and components in a product staging branch without touching main: an explore/<slug> branch with a draft pull request labeled exploration, previews and a snapshot package. bridge_start_exploration starts one, bridge_list_explorations lists them. To work on one, pass "branch": "explore/<slug>" to bridge_propose_token_change, bridge_draft_docs and bridge_get_request, and never open a pull request to main from it: a person promotes it to review or closes it.'
10481
12831
  ].join(" ")
10482
12832
  }
@@ -10605,7 +12955,7 @@ function createServer(ctx) {
10605
12955
  "bridge_propose_token_change",
10606
12956
  {
10607
12957
  title: "Propose token change",
10608
- description: 'Propose token edits as a reviewable change. Applies edits with minimal JSON diffs, validates, runs the contrast gate before/after, computes the risk tier and semver bump, writes a changeset and a designer-friendly PR body. Blocked (nothing written) if it adds validation errors or breaks a contrast pair. Local repo: commits to a new local bridge/* branch without touching your working tree (never pushes). GitHub with token: new branch + draft PR labelled "source: mcp" and its tier. Never writes to main. Use dryRun to preview. Tell the user token changes are Tier 3.',
12958
+ description: 'Propose token edits as a reviewable change. Applies edits with minimal JSON diffs, validates, runs the contrast gate before/after, computes the risk tier and semver bump, writes a changeset and a designer-friendly PR body. Blocked (nothing written) if it adds validation errors or breaks a contrast pair. Suggest mode (the default unless bridge.config.json says "changes": "direct"): files a suggestion, a GitHub issue with the exact edits, the checks and the tier, status "suggested" with its number; owners approve it and a developer applies it (bridge_apply_suggestion). Direct mode: local repo, commits to a new local bridge/* branch without touching your working tree (never pushes); GitHub with token, new branch + draft PR labelled "source: mcp" and its tier. With branch or pr it writes to that branch whatever the mode. Never writes to main. Use dryRun to preview. Tell the user token changes are Tier 3.',
10609
12959
  inputSchema: {
10610
12960
  title: z.string().min(3).describe('Short imperative title, e.g. "Darken muted text in dark theme". Becomes the branch name and PR title.'),
10611
12961
  summary: z.string().optional().describe("Why: the problem and the intent, for the reviewers. Plain sentences, no em dash."),
@@ -10614,11 +12964,12 @@ function createServer(ctx) {
10614
12964
  branch: z.string().optional().describe("Exploration branch (explore/<slug>) to commit the edits to instead of opening a new pull request. GitHub: one commit on that branch. Local: written to the working tree, which must have that branch checked out. Never main."),
10615
12965
  pr: z.union([z.number().int().positive(), z.string().min(1)]).optional().describe('A branch already in review (12 or "#12") to add the edits to, as "Edit tokens in this branch" in Studio: one commit there and a comment for the reviewers. Its author or someone with write access; needs GITHUB_TOKEN.'),
10616
12966
  jira: z.string().optional().describe("Jira key like DS-42, as in Studio's Create branch: first in the branch name and title so Jira links them."),
10617
- product: z.string().optional().describe("Product key or name (bridge_list_products) the change is for. Default: the product of the current folder or repo. Ask the person when it is not obvious.")
12967
+ product: z.string().optional().describe("Product key or name (bridge_list_products) the change is for. Default: the product of the current folder or repo. Ask the person when it is not obvious."),
12968
+ changes: ChangesArg
10618
12969
  },
10619
12970
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
10620
12971
  },
10621
- run((a) => proposeTokenChange(ctx, a))
12972
+ run((a) => proposeTokenChange(ctx, { ...a, source: "mcp" }))
10622
12973
  );
10623
12974
  const Hue = z.union([z.string(), z.number()]);
10624
12975
  const BrandArgs = {
@@ -10652,11 +13003,11 @@ function createServer(ctx) {
10652
13003
  "bridge_propose_brand",
10653
13004
  {
10654
13005
  title: "Propose a new brand",
10655
- description: 'Propose a new brand as a reviewable change. Same plan as bridge_plan_brand; blocked (nothing written) when an input is invalid or a contrast pair fails. Local repo: commits to a new local bridge/brand-<slug> branch without touching your working tree (never pushes). GitHub with token: new branch + draft PR labelled "source: mcp", "tier: 3" and "tokens". Never writes to main. Tell the user a new brand is Tier 3: it needs design and tech owner approval.',
10656
- inputSchema: { ...BrandArgs, dryRun: z.boolean().optional().describe("Return the plan and file contents without writing.") },
13006
+ description: 'Propose a new brand as a reviewable change. Same plan as bridge_plan_brand; blocked (nothing written) when an input is invalid or a contrast pair fails. Local repo: commits to a new local bridge/brand-<slug> branch without touching your working tree (never pushes). GitHub with token: new branch + draft PR labelled "source: mcp", "tier: 3" and "tokens". Never writes to main. Suggest mode (the default unless bridge.config.json says "changes": "direct"): one suggestion issue with the brand files instead, applied by a developer once approved. Tell the user a new brand is Tier 3: it needs design and tech owner approval.',
13007
+ inputSchema: { ...BrandArgs, dryRun: z.boolean().optional().describe("Return the plan and file contents without writing."), changes: ChangesArg },
10657
13008
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
10658
13009
  },
10659
- run((a) => proposeBrand(ctx, a))
13010
+ run((a) => proposeBrand(ctx, { ...a, source: "mcp" }))
10660
13011
  );
10661
13012
  server.registerTool(
10662
13013
  "bridge_list_reviews",
@@ -10748,17 +13099,18 @@ function createServer(ctx) {
10748
13099
  "bridge_propose_revert",
10749
13100
  {
10750
13101
  title: "Propose a revert",
10751
- description: "Plans the revert (or restore) like bridge_plan_revert, then: local repo, commits to a new local bridge/* branch without touching your working tree (never pushes); GitHub with token, new bridge/* branch and a PR labelled revert and its tier (draft for Tier 2 and 3). Blocked when it would add validation errors or break a contrast pair. Never writes to main. Show the user the plan and its conflicts first.",
13102
+ description: 'Plans the revert (or restore) like bridge_plan_revert, then: local repo, commits to a new local bridge/* branch without touching your working tree (never pushes); GitHub with token, new bridge/* branch and a PR labelled revert and its tier (draft for Tier 2 and 3). Blocked when it would add validation errors or break a contrast pair. Never writes to main. Suggest mode (the default unless bridge.config.json says "changes": "direct"): one suggestion issue with the files instead, applied by a developer once approved. Show the user the plan and its conflicts first.',
10752
13103
  inputSchema: {
10753
13104
  target: z.string().optional(),
10754
13105
  restore: RestoreArg.optional(),
10755
13106
  resolutions: ResolutionsArg.optional(),
10756
13107
  restoreConflicts: z.boolean().optional(),
10757
- dryRun: z.boolean().optional()
13108
+ dryRun: z.boolean().optional(),
13109
+ changes: ChangesArg
10758
13110
  },
10759
13111
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
10760
13112
  },
10761
- run((a) => proposeRevert(ctx, a))
13113
+ run((a) => proposeRevert(ctx, { ...a, source: "mcp" }))
10762
13114
  );
10763
13115
  server.registerTool(
10764
13116
  "bridge_scaffold_component",
@@ -10892,7 +13244,7 @@ function createServer(ctx) {
10892
13244
  ]
10893
13245
  };
10894
13246
  } catch (e) {
10895
- return fail(e);
13247
+ return fail2(e);
10896
13248
  }
10897
13249
  }
10898
13250
  );
@@ -10989,8 +13341,8 @@ function createServer(ctx) {
10989
13341
  "bridge_open_library_branch",
10990
13342
  {
10991
13343
  title: "Open a library branch for a missing piece",
10992
- description: 'Turn one recorded missing piece into a library branch linked to the product branch, as Bridge Studio does: a token piece opens a token change (same gate and body as bridge_propose_token_change, a draft for design and tech review) and the product branch takes its preview; a variant opens a library exploration with the brief to build it (build it there, then bridge_use_library_preview); a new component opens a component request. Both sides link: the library side says "Needed by" the product branch (label needed-by-product), the product branch records it. Needs GITHUB_TOKEN (contents, pull requests and issues: write). dryRun shows what it would open. Only when the person asked.',
10993
- inputSchema: { pr: Pr, piece: Piece, usePreview: z.boolean().optional().describe("Merge the new library branch into the product branch when it already changes something (default true)."), dryRun: z.boolean().optional() },
13344
+ description: 'Turn one recorded missing piece into a library branch linked to the product branch, as Bridge Studio does: a token piece opens a token change (same gate and body as bridge_propose_token_change, a draft for design and tech review) and the product branch takes its preview; a variant opens a library exploration with the brief to build it (build it there, then bridge_use_library_preview); a new component opens a component request. Both sides link: the library side says "Needed by" the product branch (label needed-by-product), the product branch records it. Suggest mode (the default unless bridge.config.json says "changes": "direct"): a token piece becomes a suggestion and a variant a component request, no branch; needs Issues write. Direct mode needs GITHUB_TOKEN (contents, pull requests and issues: write). dryRun shows what it would open. Only when the person asked.',
13345
+ inputSchema: { pr: Pr, piece: Piece, usePreview: z.boolean().optional().describe("Merge the new library branch into the product branch when it already changes something (default true)."), dryRun: z.boolean().optional(), changes: ChangesArg },
10994
13346
  annotations: WRITE
10995
13347
  },
10996
13348
  run((a) => openLibraryBranchOp(ctx, { ...a, source: "mcp" }))
@@ -11025,6 +13377,46 @@ function createServer(ctx) {
11025
13377
  },
11026
13378
  run((a) => switchToPublishedOp(ctx, a))
11027
13379
  );
13380
+ server.registerTool(
13381
+ "bridge_figma_changes",
13382
+ {
13383
+ title: "Component changes sent from Figma",
13384
+ description: "The library branches the DS Bridge Figma plugin opened for components changed in Figma (label from-figma), with where each stands: an exact token change (no AI, contrast gated), or a branch carrying the spec, waiting for Claude AI to draft the code, then drafted by Claude AI and reviewed by a person.",
13385
+ inputSchema: { state: z.enum(["open", "closed", "all"]).optional().describe("Default open.") },
13386
+ annotations: READ
13387
+ },
13388
+ run((a) => figmaChangesOp(ctx, a))
13389
+ );
13390
+ server.registerTool(
13391
+ "bridge_get_figma_change",
13392
+ {
13393
+ title: "Get a component change from Figma, to draft it",
13394
+ description: "One library branch sent from Figma: its spec (variant matrix, properties, bound variables per variant, layers, what code gave Figma before, the exact token swaps) and the brief to draft the code on that branch. Check out the branch, write the TSX, CSS module, examples, docs and changeset, run npm run specs and the tests, push, then call bridge_mark_figma_change_drafted.",
13395
+ inputSchema: { pr: Pr },
13396
+ annotations: READ
13397
+ },
13398
+ run((a) => getFigmaChangeOp(ctx, a))
13399
+ );
13400
+ server.registerTool(
13401
+ "bridge_mark_figma_change_drafted",
13402
+ {
13403
+ title: "Mark a Figma change drafted by Claude AI",
13404
+ description: "After you pushed the code for a component change from Figma to its branch: label it drafted-by-claude (no longer waiting-for-claude) and say so in its description, for a person to review. Refused while the branch only carries the spec. Needs GITHUB_TOKEN (pull requests: write).",
13405
+ inputSchema: { pr: Pr },
13406
+ annotations: WRITE
13407
+ },
13408
+ run((a) => markFigmaDraftedOp(ctx, a))
13409
+ );
13410
+ server.registerTool(
13411
+ "bridge_push_figma_component",
13412
+ {
13413
+ title: "Send a component change from Figma",
13414
+ description: 'What the DS Bridge plugin Push tab does, from a structured diff of a Figma component (the v 1 spec shape: component, figmaName, coded, kind, title, summary, changes, rebinds, needsCode, unknown, variantAxes, properties, bindings, anatomy). An exact token swap on a component token used nowhere else opens a token change (contrast gated, reviewed like any token change); anything that needs code opens a draft branch with the spec, waiting for Claude AI; detached layers, raw values and variables the library lacks are listed with what to do and never sent. Suggest mode (the default unless bridge.config.json says "changes": "direct"): a token suggestion or a component spec suggestion instead of a branch, Issues write only. dryRun shows the route. Direct mode needs GITHUB_TOKEN (contents and pull requests: write). Only when the person asked.',
13415
+ inputSchema: { diff: z.record(z.string(), z.unknown()).describe("The structured component diff (v 1)."), dryRun: z.boolean().optional(), changes: ChangesArg },
13416
+ annotations: WRITE
13417
+ },
13418
+ run((a) => pushFigmaComponentOp(ctx, { diff: a.diff, dryRun: a.dryRun, source: "mcp", changes: a.changes }))
13419
+ );
11028
13420
  server.registerTool(
11029
13421
  "bridge_list_products",
11030
13422
  {
@@ -11214,7 +13606,7 @@ function createServer(ctx) {
11214
13606
  "bridge_save_docs",
11215
13607
  {
11216
13608
  title: "Save component docs",
11217
- description: `Save a component's usage guidance (docs.json sections: summary, whenToUse, whenNotToUse, dos, donts, accessibility, contentGuidelines, anatomy, preview) the way the Library edit mode does: merged into the docs on the branch, cleaned and checked (a summary, no empty items, no em dash). Without pr: a new branch sent for review (docs only: one approval, patch release note), with an optional Jira key and product. With pr: one commit on that open branch. dryRun returns the file. Needs GITHUB_TOKEN (contents and pull requests: write). Prose you drafted carries "draftedBy": "claude" and "needsReview": true so a reviewer accepts it in Studio.`,
13609
+ description: `Save a component's usage guidance (docs.json sections: summary, whenToUse, whenNotToUse, dos, donts, accessibility, contentGuidelines, anatomy, preview) the way the Library edit mode does: merged into the docs on the branch, cleaned and checked (a summary, no empty items, no em dash). Without pr: a new branch sent for review (docs only: one approval, patch release note), with an optional Jira key and product. With pr: one commit on that open branch. Suggest mode (the default unless bridge.config.json says "changes": "direct"), without pr: a docs suggestion issue instead of a branch (Issues write only). dryRun returns the file. Direct mode needs GITHUB_TOKEN (contents and pull requests: write). Prose you drafted carries "draftedBy": "claude" and "needsReview": true so a reviewer accepts it in Studio.`,
11218
13610
  inputSchema: {
11219
13611
  component: z.string().min(1).describe("Component name, e.g. Button."),
11220
13612
  docs: z.record(z.string(), z.unknown()).describe('The sections to set, in docs.json shapes: summary a string; whenToUse, whenNotToUse, accessibility, contentGuidelines lists of strings; dos and donts lists of {"title","text"}.'),
@@ -11223,11 +13615,100 @@ function createServer(ctx) {
11223
13615
  notes: z.string().optional().describe("Why, for the reviewers."),
11224
13616
  jira: z.string().optional().describe("Jira key like DS-42: first in the branch name and title so Jira links them."),
11225
13617
  product: z.string().optional().describe("Product key or name (bridge_list_products). Default: the product of the current folder or repo."),
11226
- dryRun: z.boolean().optional()
13618
+ dryRun: z.boolean().optional(),
13619
+ changes: ChangesArg
13620
+ },
13621
+ annotations: WRITE
13622
+ },
13623
+ run((a) => saveDocs(ctx, { ...a, source: "mcp" }))
13624
+ );
13625
+ const SuggestionNumber = z.union([z.number().int().positive(), z.string().min(1)]).describe('The suggestion issue number (42 or "#42"), or its link.');
13626
+ server.registerTool(
13627
+ "bridge_list_suggestions",
13628
+ {
13629
+ title: "List suggestions",
13630
+ description: "Suggestions in the repo: GitHub issues labeled bridge-suggestion, each an exact change (tokens, docs, a component spec, a brand, a revert, settings) that the owners approve and a developer applies. Each row has the number, title, kind, tier, status (needs-review, changes-requested, approved, applying, applied, declined, outdated) and linked pull requests. Statuses here come from labels; bridge_get_suggestion counts the approvals.",
13631
+ inputSchema: {
13632
+ status: z.enum(["needs-review", "changes-requested", "approved", "applying", "applied", "declined", "outdated", "all"]).optional().describe("Default all open ones."),
13633
+ state: z.enum(["open", "closed", "all"]).optional().describe("Issue state. Default: open, or closed for applied and declined."),
13634
+ kind: z.enum(["token-change", "docs", "component-spec", "brand", "revert", "config"]).optional(),
13635
+ limit: z.number().int().min(1).max(100).optional().describe("Default 100.")
13636
+ },
13637
+ annotations: READ
13638
+ },
13639
+ run((a) => listSuggestionsOp(ctx, a))
13640
+ );
13641
+ server.registerTool(
13642
+ "bridge_get_suggestion",
13643
+ {
13644
+ title: "Get a suggestion",
13645
+ description: "One suggestion: the change (tokens before and after, docs, component spec, files), the checks when suggested, links, its version (payloadSha: what approvals bind to), who approved it, which approvals are missing for its tier, reviews that no longer count (an older version, or not an owner), whether the machine-readable block was edited by hand, linked pull requests, and for a component spec the brief to implement it.",
13646
+ inputSchema: { number: SuggestionNumber },
13647
+ annotations: READ
13648
+ },
13649
+ run((a) => getSuggestionOp(ctx, a))
13650
+ );
13651
+ server.registerTool(
13652
+ "bridge_create_suggestion",
13653
+ {
13654
+ title: "Create a suggestion",
13655
+ description: "File any change as a suggestion from its payload (schema bridge.suggestion/1: kind, title, summary, and tokens edits, docs, componentSpec or files). Missing schema, source, date, base commit and checks are filled. The paths are checked against what the kind may change and the guardrail files before anything is sent. For token, docs, brand, revert and Figma changes prefer their own tools, which plan and check the change first. Needs GITHUB_TOKEN with Issues write. dryRun returns the issue. Only when the person asked.",
13656
+ inputSchema: {
13657
+ payload: z.record(z.string(), z.unknown()).describe("The suggestion payload (bridge.suggestion/1)."),
13658
+ labels: z.array(z.string()).optional().describe("More labels for the issue."),
13659
+ dryRun: z.boolean().optional().describe("Return the issue title, body and labels without creating it.")
13660
+ },
13661
+ annotations: WRITE
13662
+ },
13663
+ run((a) => createSuggestionOp(ctx, a))
13664
+ );
13665
+ server.registerTool(
13666
+ "bridge_review_suggestion",
13667
+ {
13668
+ title: "Review a suggestion",
13669
+ description: "Approve, request changes on, comment on or decline a suggestion, as the person whose GitHub token the server has. Approvals and requested changes count only from the owners in bridge.config.json (read from the default branch) and only on the version reviewed: pass payloadSha from bridge_get_suggestion so a newer version is never approved by mistake. Request changes and decline need a message. Decline closes the issue as not planned. Needs GITHUB_TOKEN (Issues: read and write). Only when the person asked, with the decision they asked for.",
13670
+ inputSchema: {
13671
+ number: SuggestionNumber,
13672
+ event: z.enum(["approve", "request-changes", "comment", "decline"]),
13673
+ message: z.string().optional().describe("What should change, why it is declined, or the comment. Plain sentences, no em dash."),
13674
+ role: z.enum(["design", "tech", "approval"]).optional().describe("The role you approve for, shown in the comment. Informative: what counts is who you are in the owner lists."),
13675
+ payloadSha: z.string().optional().describe("The version you reviewed (payloadSha or version from bridge_get_suggestion).")
13676
+ },
13677
+ annotations: WRITE
13678
+ },
13679
+ run((a) => reviewSuggestionOp(ctx, a))
13680
+ );
13681
+ server.registerTool(
13682
+ "bridge_apply_suggestion",
13683
+ {
13684
+ title: "Apply a suggestion",
13685
+ description: `Apply a Bridge suggestion to today's code ("Apply Bridge suggestion #42"). Bridge checks the paths, the approvals of the owners on its current version, re-applies token edits on the current tokens, merges files, re-runs validation, contrast and the tier, and stops on a conflict, a missing approval or a check that would fail. Local repo: mode "dry-run" (the plan and the diff, nothing written; run it first), "branch" (default: one commit on a new local branch, working tree untouched, never pushes), "write" (into the working tree, refused over uncommitted changes) or "patch" (a git format-patch text). Remote repo: the plan and the files to write yourself. Once a branch or files exist, it records on the issue that it is being applied (needs Issues write). Then push and open a pull request whose body says "Closes #<n>", and call bridge_link_suggestion. acceptEdited and forceUnapproved only when the person asked.`,
13686
+ inputSchema: {
13687
+ number: SuggestionNumber,
13688
+ mode: z.enum(["dry-run", "branch", "write", "patch"]).optional().describe("Default branch. Run dry-run first."),
13689
+ acceptEdited: z.boolean().optional().describe("Apply even though the machine-readable block was edited by hand after Bridge wrote it (check it first)."),
13690
+ forceUnapproved: z.boolean().optional().describe("Apply without every approval, or with a tier that went up. Recorded on the issue."),
13691
+ base: z.string().optional().describe("Local mode, branch and patch: the ref to build on. Default HEAD."),
13692
+ branch: z.string().optional().describe("Local mode, branch: the branch name. Default <branchPrefix>suggestion-<n>-<slug>.")
13693
+ },
13694
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
13695
+ },
13696
+ run((a) => applySuggestionOp(ctx, a))
13697
+ );
13698
+ server.registerTool(
13699
+ "bridge_link_suggestion",
13700
+ {
13701
+ title: "Link the pull request that applies a suggestion",
13702
+ description: 'Record the pull request (or branch, or commit) that applies a suggestion: a comment on the issue and the "suggestion: applying" label. Warns when the pull request does not say "Closes #<n>": add it so the suggestion closes when it merges. Needs GITHUB_TOKEN (Issues: read and write).',
13703
+ inputSchema: {
13704
+ number: SuggestionNumber,
13705
+ pr: z.number().int().positive().optional().describe("The pull request number."),
13706
+ branch: z.string().optional(),
13707
+ commit: z.string().optional()
11227
13708
  },
11228
13709
  annotations: WRITE
11229
13710
  },
11230
- run((a) => saveDocs(ctx, a))
13711
+ run((a) => linkSuggestionOp(ctx, a))
11231
13712
  );
11232
13713
  server.registerResource("resolved-tokens", "bridge://tokens/resolved", { title: "Resolved tokens", description: "Every token resolved in the default mode, plus only the values that differ in each other mode combination.", mimeType: "application/json" }, async (uri) => {
11233
13714
  const set = await ctx.tokens();
@@ -11444,6 +13925,7 @@ export {
11444
13925
  auditSnippet,
11445
13926
  branches_exports,
11446
13927
  explorations_exports,
13928
+ suggestions_exports,
11447
13929
  ops_exports,
11448
13930
  history_exports,
11449
13931
  brand_exports,
@@ -11451,6 +13933,7 @@ export {
11451
13933
  drafts_exports,
11452
13934
  studio_exports,
11453
13935
  pieces_exports,
13936
+ figma_exports,
11454
13937
  VERSION,
11455
13938
  createServer,
11456
13939
  serveStdio,