@bridge-ds/mcp 0.6.0 → 0.7.1

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,
@@ -73,7 +76,7 @@ import {
73
76
  validateLayered,
74
77
  variantKeyOf,
75
78
  whoCanShipSentence
76
- } from "./chunk-E4D5KPIQ.js";
79
+ } from "./chunk-BZ3KXXMM.js";
77
80
 
78
81
  // src/lib.ts
79
82
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -103,11 +106,11 @@ function parseJson(text) {
103
106
  const skipWs = () => {
104
107
  while (i < n && isWs(text[i])) i++;
105
108
  };
106
- const fail2 = (msg) => {
109
+ const fail3 = (msg) => {
107
110
  throw new JsonParseError(`${msg} at offset ${i}`, i);
108
111
  };
109
112
  const parseString = () => {
110
- if (text[i] !== '"') fail2("Expected string");
113
+ if (text[i] !== '"') fail3("Expected string");
111
114
  const start = i;
112
115
  i++;
113
116
  while (i < n) {
@@ -122,7 +125,7 @@ function parseJson(text) {
122
125
  }
123
126
  i++;
124
127
  }
125
- return fail2("Unterminated string");
128
+ return fail3("Unterminated string");
126
129
  };
127
130
  const parseValue = () => {
128
131
  skipWs();
@@ -140,7 +143,7 @@ function parseJson(text) {
140
143
  skipWs();
141
144
  const key = parseString();
142
145
  skipWs();
143
- if (text[i] !== ":") fail2('Expected ":"');
146
+ if (text[i] !== ":") fail3('Expected ":"');
144
147
  i++;
145
148
  const value = parseValue();
146
149
  entries.push({ key, value });
@@ -153,7 +156,7 @@ function parseJson(text) {
153
156
  i++;
154
157
  break;
155
158
  }
156
- fail2('Expected "," or "}"');
159
+ fail3('Expected "," or "}"');
157
160
  }
158
161
  }
159
162
  const raw = text.slice(start, i);
@@ -176,7 +179,7 @@ function parseJson(text) {
176
179
  i++;
177
180
  break;
178
181
  }
179
- fail2('Expected "," or "]"');
182
+ fail3('Expected "," or "]"');
180
183
  }
181
184
  }
182
185
  const raw = text.slice(start, i);
@@ -195,7 +198,7 @@ function parseJson(text) {
195
198
  node = { kind: "null", value: null, raw: "null" };
196
199
  } else {
197
200
  const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i, i + 64));
198
- if (!m) return fail2("Unexpected token");
201
+ if (!m) return fail3("Unexpected token");
199
202
  i += m[0].length;
200
203
  node = { kind: "number", value: Number(m[0]), raw: m[0] };
201
204
  }
@@ -211,7 +214,7 @@ function parseJson(text) {
211
214
  const root = parseValue();
212
215
  const trailingStart = i;
213
216
  skipWs();
214
- if (i < n) fail2("Unexpected content after JSON value");
217
+ if (i < n) fail3("Unexpected content after JSON value");
215
218
  const trailing = text.slice(trailingStart);
216
219
  return { root, leading, trailing, style: detectStyle(text, root) };
217
220
  }
@@ -957,8 +960,8 @@ function encodePicks(picks) {
957
960
  var RISK_TIER = RISK_LABELS[3];
958
961
  function slugify(text, max = 40) {
959
962
  const s = text.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
960
- const cut = s.length <= max ? s : s.slice(0, max + 1).replace(/-[^-]*$/, "");
961
- return cut.replace(/-+$/g, "") || "update";
963
+ const cut2 = s.length <= max ? s : s.slice(0, max + 1).replace(/-[^-]*$/, "");
964
+ return cut2.replace(/-+$/g, "") || "update";
962
965
  }
963
966
 
964
967
  // ../core/src/changeset.ts
@@ -2505,7 +2508,7 @@ function finishPlan(p) {
2505
2508
  const prBody = composeProposalBody({ summary: p.summary, changes, contrast, impact, tier, validationErrors: newErrors.length, tool: p.tool ?? "Bridge", ...p.author ? { author: p.author } : {} });
2506
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;
2507
2510
  const files = [...result.touched.map((f) => ({ path: current.repoPath(f), content: result.files.get(f) })), ...changeset ? [changeset] : []];
2508
- 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: /, "")}
2509
2512
 
2510
2513
  ${p.source?.pr ? `Reverts #${p.source.pr.number}. ` : ""}Tier ${tier.tier}. Planned with ${p.tool ?? "Bridge"}.`;
2511
2514
  const blocked = newErrors.length || newFailures.length;
@@ -2520,7 +2523,7 @@ ${p.source?.pr ? `Reverts #${p.source.pr.number}. ` : ""}Tier ${tier.tier}. Plan
2520
2523
  tier,
2521
2524
  files,
2522
2525
  ...changeset ? { changeset } : {},
2523
- commitMessage,
2526
+ commitMessage: commitMessage2,
2524
2527
  prBody,
2525
2528
  notes
2526
2529
  };
@@ -3429,9 +3432,9 @@ var DRAFT_LINE_MAX = 120;
3429
3432
  function clipText(text, max) {
3430
3433
  const t = text.replace(/\s+/g, " ").trim().replace(/\.$/, "");
3431
3434
  if (t.length <= max) return t;
3432
- const cut = t.slice(0, max + 1);
3433
- const at = cut.lastIndexOf(" ");
3434
- 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,;:(]+$/, "");
3435
3438
  }
3436
3439
  function sanitizeDraftLine(raw) {
3437
3440
  if (typeof raw !== "string") return "";
@@ -3792,6 +3795,16 @@ function parseRequestIssue(body) {
3792
3795
  return void 0;
3793
3796
  }
3794
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
+ }
3795
3808
  function requestStatus(issue) {
3796
3809
  const prs = issue.linkedPrs ?? [];
3797
3810
  if (issue.state === "open") return prs.some((p) => p.state === "open") ? "in-progress" : "requested";
@@ -3928,9 +3941,9 @@ function serializePendingDrafts(p) {
3928
3941
  function clip(text, max) {
3929
3942
  const t = text.replace(/\s+/g, " ").trim().replace(/\.$/, "");
3930
3943
  if (t.length <= max) return t;
3931
- const cut = t.slice(0, max + 1);
3932
- const at = cut.lastIndexOf(" ");
3933
- 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,;:]+$/, "");
3934
3947
  }
3935
3948
  function sentences(text) {
3936
3949
  const t = text.replace(/\s+/g, " ").trim();
@@ -4180,6 +4193,14 @@ async function closeExplorationOnGitHub(gh, ref) {
4180
4193
  return { number: p.number, url: p.url, branch: p.headRef };
4181
4194
  }
4182
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
+
4183
4204
  // ../core/src/review-gate.ts
4184
4205
  function parseTier(labels, body) {
4185
4206
  const found = [];
@@ -4637,6 +4658,92 @@ function branchWithJiraKey(branch, key) {
4637
4658
  return i < 0 ? `${key}-${branch}` : `${branch.slice(0, i + 1)}${key}-${branch.slice(i + 1)}`;
4638
4659
  }
4639
4660
 
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
+ }
4689
+ }
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");
4698
+ }
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;
4734
+ }
4735
+ latestOld.delete(c.author);
4736
+ reviews.push({ user: { login: c.author }, state: r.event === "approve" ? "APPROVED" : "CHANGES_REQUESTED", submitted_at: at });
4737
+ });
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";
4745
+ }
4746
+
4640
4747
  // ../core/src/figma-push.ts
4641
4748
  var where = (r) => r.allVariants ? "every variant" : shortVariants(r.variants.join("; "));
4642
4749
  var cap = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
@@ -4809,6 +4916,7 @@ function figmaDraftBrief(d, ctx) {
4809
4916
  ...d.reasons?.length ? ["Why this needs code:", ...d.reasons.map((r) => `- ${r}`), ""] : [],
4810
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.`,
4811
4918
  ...d.figma?.url ? [`Design: ${d.figma.url}`] : [],
4919
+ ...suggested && d.figma?.fileKey && d.figma.nodeId ? [`Read the design straight from Figma: Figma MCP get_design_context { fileKey: "${d.figma.fileKey}", nodeId: "${d.figma.nodeId}" }${d.figma.before?.nodeId ? ` (before: node ${d.figma.before.nodeId})` : ""}, or the REST API GET https://api.figma.com/v1/files/${d.figma.fileKey}/nodes?ids=${encodeURIComponent(d.figma.nodeId)}.`] : [],
4812
4920
  ...d.images?.length ? ["Screenshot:", ...d.images.map((u) => `- ${u}`)] : [],
4813
4921
  "",
4814
4922
  "Steps:",
@@ -5108,6 +5216,16 @@ var SUGGESTION_STATE_LABELS = {
5108
5216
  approved: "suggestion: approved",
5109
5217
  applying: "suggestion: applying"
5110
5218
  };
5219
+ var SUGGESTION_STATUS_LABEL = {
5220
+ "needs-review": "Needs review",
5221
+ "changes-requested": "Changes requested",
5222
+ approved: "Approved, ready to apply",
5223
+ applying: "Being applied",
5224
+ applied: "Applied",
5225
+ declined: "Declined",
5226
+ outdated: "Outdated"
5227
+ };
5228
+ var figmaNodeUrl = (fileKey, nodeId) => `https://www.figma.com/design/${fileKey}/?node-id=${encodeURIComponent(nodeId.replace(/:/g, "-"))}`;
5111
5229
  var SUGGESTION_LIMITS = {
5112
5230
  body: 65536,
5113
5231
  /** The readable part of the body (tables are cut with "and N more"). */
@@ -5336,6 +5454,13 @@ function suggestionLabels(p, opts = {}) {
5336
5454
  ])
5337
5455
  ];
5338
5456
  }
5457
+ function suggestionKindFromLabels(labels) {
5458
+ for (const l of labels) {
5459
+ const m = /^suggestion: (.+)$/.exec(l);
5460
+ if (m && SUGGESTION_KINDS.includes(m[1])) return m[1];
5461
+ }
5462
+ return void 0;
5463
+ }
5339
5464
  function suggestionStateFromLabels(labels) {
5340
5465
  return Object.entries(SUGGESTION_STATE_LABELS).find(([, l]) => labels.includes(l))?.[0];
5341
5466
  }
@@ -5346,6 +5471,9 @@ function suggestionStatus(i) {
5346
5471
  if (i.outdated) return "outdated";
5347
5472
  return i.review ?? "needs-review";
5348
5473
  }
5474
+ function suggestionStateLabel(status) {
5475
+ return status in SUGGESTION_STATE_LABELS ? SUGGESTION_STATE_LABELS[status] : null;
5476
+ }
5349
5477
  var bridgeMcpInstallCommand = (repo) => `claude mcp add bridge -s user -- npx -y @bridge-ds/mcp --repo ${repo?.trim() || "<owner/name>"}`;
5350
5478
  var MARK3 = "bridge-suggestion";
5351
5479
  var PART_SCHEMA = "bridge.suggestion-part/1";
@@ -5404,6 +5532,19 @@ function linksSection(p) {
5404
5532
  const out = [];
5405
5533
  if (l.neededBy) out.push(`- Needed by ${l.neededBy.productName}: [#${l.neededBy.pr} ${cell3(l.neededBy.title)}](${l.neededBy.url})`);
5406
5534
  if (l.figma?.url) out.push(`- Design: [Open in Figma](${l.figma.url})`);
5535
+ const key = l.figma?.fileKey;
5536
+ const nodes = (l.figma?.nodes ?? []).filter((n) => n && typeof n.nodeId === "string");
5537
+ for (const n of nodes) {
5538
+ const url = n.url ?? (key ? figmaNodeUrl(key, n.nodeId) : void 0);
5539
+ const what = `${n.state === "before" ? "Before" : n.state === "after" ? "After" : "Node"}${n.name ? ` (${cell3(n.name)})` : ""}`;
5540
+ out.push(`- ${what}: ${url ? `[node ${n.nodeId}](${url})` : `node ${n.nodeId}`}`);
5541
+ }
5542
+ if (key && (nodes.length || l.figma?.nodeId)) {
5543
+ const ids = nodes.length ? nodes.map((n) => n.nodeId) : [l.figma.nodeId];
5544
+ out.push(
5545
+ `- Claude Code reads this design straight from Figma: the Figma MCP server (\`get_design_context\` with file key \`${key}\` and node ${ids.map((i) => `\`${i}\``).join(", ")}) or the REST API (\`GET https://api.figma.com/v1/files/${key}/nodes?ids=${ids.map(encodeURIComponent).join(",")}\`). No image hosting is needed.`
5546
+ );
5547
+ }
5407
5548
  if (l.exploration) out.push(`- Exploration: \`${l.exploration}\``);
5408
5549
  if (l.supersedes) out.push(`- Replaces suggestion #${l.supersedes}`);
5409
5550
  for (const v of p.previews ?? []) out.push(`- ${cell3(v.label)}: ${v.url}`);
@@ -5554,6 +5695,72 @@ ${payloadBlock(skeletonJson, sha, n)}
5554
5695
  if (body.length > SUGGESTION_LIMITS.body || partBodies.some((b) => b.length > SUGGESTION_LIMITS.body)) throw new SuggestionError("too-large", SUGGESTION_TOO_LARGE);
5555
5696
  return { ...base, body, parts: partBodies };
5556
5697
  }
5698
+ function fencedAfter(text, from) {
5699
+ const m = /```json\r?\n([\s\S]*?)\r?\n```/.exec(text.slice(from));
5700
+ return m?.[1];
5701
+ }
5702
+ var fail = (reason, message) => ({ ok: false, reason, message });
5703
+ function parseSuggestionIssue(body, opts = {}) {
5704
+ const text = body ?? "";
5705
+ const markers = [...text.matchAll(new RegExp(`^<!-- ${MARK3}:v(\\d+) sha256=([0-9a-f]{64})(?: parts=(\\d+))? -->$`, "gm"))];
5706
+ const marker = markers[markers.length - 1];
5707
+ if (!marker) return fail("no-block", "This issue has no Bridge suggestion block.");
5708
+ if (marker[1] !== "1") return fail("unsupported-version", `This suggestion uses version ${marker[1]} of the format. Update Bridge to read it.`);
5709
+ const json = fencedAfter(text, marker.index + marker[0].length);
5710
+ 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.");
5711
+ let raw;
5712
+ try {
5713
+ raw = JSON.parse(json);
5714
+ } catch {
5715
+ return fail("invalid", "The suggestion JSON does not parse. Someone may have edited it by hand: ask the author to send it again.");
5716
+ }
5717
+ const schema = raw?.schema;
5718
+ if (typeof schema === "string" && schema !== SUGGESTION_SCHEMA) {
5719
+ const v = /^bridge\.suggestion\/(\d+)$/.exec(schema);
5720
+ return v ? fail("unsupported-version", `This suggestion uses ${schema}. Update Bridge to read it.`) : fail("invalid", `"${schema}" is not a Bridge suggestion schema.`);
5721
+ }
5722
+ const problems = suggestionShapeProblems(raw);
5723
+ if (problems.length) return fail("invalid", `The suggestion block is not valid: ${problems.join(" ")}`);
5724
+ const payload = raw;
5725
+ const markedSha = marker[2];
5726
+ const expected = marker[3] ? Number(marker[3]) : 0;
5727
+ let edited = false;
5728
+ if (expected > 0 || payload.files?.some((f) => f.part !== void 0)) {
5729
+ const want = Math.max(expected, ...(payload.files ?? []).map((f) => f.part ?? 0));
5730
+ const found = /* @__PURE__ */ new Map();
5731
+ const author = opts.author?.toLowerCase();
5732
+ for (const c of opts.comments ?? []) {
5733
+ if (author && c.author && c.author.toLowerCase() !== author) continue;
5734
+ const m = new RegExp(`^<!-- ${MARK3}:v1 part (\\d+)/(\\d+) sha256=([0-9a-f]{64}) -->$`, "m").exec(c.body);
5735
+ if (!m) continue;
5736
+ const i = Number(m[1]);
5737
+ if (found.has(i)) continue;
5738
+ const pj = fencedAfter(c.body, m.index + m[0].length);
5739
+ if (pj === void 0) continue;
5740
+ if (sha256Hex(pj) !== m[3]) edited = true;
5741
+ try {
5742
+ const part = JSON.parse(pj);
5743
+ if (part.schema !== PART_SCHEMA || !Array.isArray(part.files)) continue;
5744
+ found.set(i, part.files.filter((x) => x && typeof x.i === "number" && typeof x.text === "string"));
5745
+ } catch {
5746
+ continue;
5747
+ }
5748
+ }
5749
+ for (let i = 1; i <= want; i++) {
5750
+ 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.`);
5751
+ }
5752
+ const files = (payload.files ?? []).map((f, idx) => {
5753
+ if (f.part === void 0) return f;
5754
+ let content = "";
5755
+ 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;
5756
+ const { part: _part, ...rest } = f;
5757
+ return { ...rest, content };
5758
+ });
5759
+ payload.files = files;
5760
+ }
5761
+ const sha = suggestionPayloadSha(payload);
5762
+ return { ok: true, payload, sha, markedSha, edited: edited || sha !== markedSha, parts: expected };
5763
+ }
5557
5764
  function suggestionShapeProblems(raw) {
5558
5765
  const out = [];
5559
5766
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ["The payload must be a JSON object."];
@@ -5576,6 +5783,8 @@ function suggestionShapeProblems(raw) {
5576
5783
  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" }.');
5577
5784
  const spec = p.componentSpec;
5578
5785
  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" }.');
5786
+ const nodes = p.links?.figma?.nodes;
5787
+ if (nodes !== void 0 && !(Array.isArray(nodes) && nodes.every((n) => n && typeof n.nodeId === "string"))) out.push('"links.figma.nodes" must be a list of { "nodeId", "url"?, "name"?, "state"? }.');
5579
5788
  if (p.files !== void 0) {
5580
5789
  if (!Array.isArray(p.files)) out.push('"files" must be a list.');
5581
5790
  else {
@@ -5593,6 +5802,63 @@ function suggestionShapeProblems(raw) {
5593
5802
  }
5594
5803
  return out;
5595
5804
  }
5805
+ function unsafePathProblem(path) {
5806
+ if (typeof path !== "string" || !path.length) return "A path is empty.";
5807
+ if (/[\0-\x1f\x7f]/.test(path)) return `"${path.replace(/[\0-\x1f\x7f]/g, "?")}" has control characters.`;
5808
+ if (path.includes("\\")) return `"${path}" uses backslashes: paths use forward slashes.`;
5809
+ if (path.startsWith("/") || /^[A-Za-z]:/.test(path) || path.startsWith("~")) return `"${path}" is absolute: paths are relative to the repository root.`;
5810
+ const segs = path.split("/");
5811
+ if (segs.some((s) => s === "" || s === "." || s === "..")) return `"${path}" has an empty, "." or ".." part: paths stay inside the repository.`;
5812
+ if (segs.some((s) => s.toLowerCase() === ".git")) return `"${path}" is inside .git.`;
5813
+ return null;
5814
+ }
5815
+ var under = (path, root) => {
5816
+ const r = root.replace(/\/+$/, "");
5817
+ return !r || r === "." || path === r || path.startsWith(`${r}/`);
5818
+ };
5819
+ function isGuardrailPath(path, guardrails) {
5820
+ return guardrails.some((g) => g.endsWith("/") ? path.startsWith(g) || path === g.slice(0, -1) : path === g);
5821
+ }
5822
+ function suggestionAllowedRoots(kind, config) {
5823
+ const tokenRoot2 = tokensPath(config) || ".";
5824
+ const changesets = config.changesets?.dir ? [config.changesets.dir] : [];
5825
+ switch (kind) {
5826
+ case "token-change":
5827
+ return [tokenRoot2, ...changesets];
5828
+ case "docs":
5829
+ return [...config.components?.dir ? [config.components.dir] : [], ...changesets];
5830
+ case "component-spec":
5831
+ return [".bridge/figma"];
5832
+ case "brand":
5833
+ return [tokenRoot2, ...changesets, CONFIG_FILE];
5834
+ case "config":
5835
+ return [CONFIG_FILE];
5836
+ case "revert":
5837
+ return void 0;
5838
+ }
5839
+ }
5840
+ function suggestionPathProblems(p, config) {
5841
+ const out = [];
5842
+ const roots = suggestionAllowedRoots(p.kind, config);
5843
+ const guardrails = config.governance.guardrails;
5844
+ const check = (path) => {
5845
+ const bad = unsafePathProblem(path);
5846
+ if (bad) return out.push(bad);
5847
+ if (roots && !roots.some((r) => under(path, r))) return out.push(`"${path}" is outside what a ${p.kind} suggestion may change (${roots.join(", ")}).`);
5848
+ const configAllowed = path === CONFIG_FILE && (p.kind === "config" || p.kind === "brand");
5849
+ if (!configAllowed && isGuardrailPath(path, guardrails)) return out.push(`"${path}" is a guardrail file: change it in a pull request, not a suggestion.`);
5850
+ return 0;
5851
+ };
5852
+ for (const f of p.files ?? []) check(f.path);
5853
+ for (const d of p.docs ?? []) check(d.path);
5854
+ if (p.componentSpec) check(p.componentSpec.specPath);
5855
+ for (const e of p.tokens?.edits ?? []) {
5856
+ if (e.file === void 0) continue;
5857
+ const bad = unsafePathProblem(e.file);
5858
+ if (bad) out.push(bad);
5859
+ }
5860
+ return [...new Set(out)];
5861
+ }
5596
5862
 
5597
5863
  // ../core/src/suggestions-github.ts
5598
5864
  async function createSuggestionOnGitHub(gh, payload, opts = {}) {
@@ -5611,6 +5877,97 @@ async function createSuggestionOnGitHub(gh, payload, opts = {}) {
5611
5877
  if (created.labels.length && missing.length) warnings.push(`GitHub did not keep these labels on #${created.number}: ${missing.join(", ")}.`);
5612
5878
  return { number: created.number, url: created.url, sha: issue.sha, parts: issue.parts.length, warnings };
5613
5879
  }
5880
+ function linkedPullsOf(n, pulls) {
5881
+ 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" }));
5882
+ }
5883
+ async function listSuggestionsOnGitHub(gh, opts = {}) {
5884
+ const issues = await gh.listIssues({ label: SUGGESTION_LABEL, state: opts.state ?? "open", perPage: opts.perPage ?? 50 });
5885
+ const pulls = opts.pulls ?? (issues.length ? await gh.listPullRequests("all").catch(() => []) : []);
5886
+ return issues.map((i) => listItem(i, pulls));
5887
+ }
5888
+ function listItem(i, pulls) {
5889
+ const linkedPrs = linkedPullsOf(i.number, pulls);
5890
+ const cached = suggestionStateFromLabels(i.labels);
5891
+ const t = i.labels.map((l) => /^tier: ([123])$/.exec(l)?.[1]).find(Boolean);
5892
+ return {
5893
+ number: i.number,
5894
+ title: i.title,
5895
+ url: i.url,
5896
+ author: i.author,
5897
+ createdAt: i.createdAt,
5898
+ state: i.state,
5899
+ stateReason: i.stateReason,
5900
+ labels: i.labels,
5901
+ kind: suggestionKindFromLabels(i.labels),
5902
+ tier: t ? Number(t) : void 0,
5903
+ linkedPrs,
5904
+ status: suggestionStatus({ state: i.state, stateReason: i.stateReason, linkedPrs, applied: cached === "applying", review: cached === "applying" ? void 0 : cached })
5905
+ };
5906
+ }
5907
+ async function getSuggestionOnGitHub(gh, n, gov, opts = {}) {
5908
+ const [issue, comments, pulls] = await Promise.all([gh.getIssue(n), gh.listIssueComments(n), opts.pulls ? Promise.resolve(opts.pulls) : gh.listPullRequests("all").catch(() => [])]);
5909
+ const parsed = parseSuggestionIssue(issue.body, { comments, author: issue.author || void 0 });
5910
+ const linkedPrs = linkedPullsOf(n, pulls);
5911
+ const applied = comments.flatMap((c) => parseSuggestionAppliedComment(c.body) ?? []);
5912
+ const approvals = parsed.ok ? summarizeSuggestionApprovals(comments, gov, { payloadSha: parsed.sha, author: issue.author }) : void 0;
5913
+ const status = suggestionStatus({
5914
+ state: issue.state,
5915
+ stateReason: issue.stateReason,
5916
+ linkedPrs,
5917
+ applied: parsed.ok && applied.some((a) => a.payloadSha === parsed.sha),
5918
+ outdated: opts.outdated,
5919
+ review: parsed.ok && approvals ? suggestionReviewState(parsed.payload.tier.tier, approvals) : void 0
5920
+ });
5921
+ return { issue, parsed, comments, approvals, linkedPrs, applied, status };
5922
+ }
5923
+ async function syncSuggestionStateLabel(gh, n, labels, status) {
5924
+ const want = suggestionStateLabel(status);
5925
+ let ok2 = true;
5926
+ for (const l of Object.values(SUGGESTION_STATE_LABELS)) {
5927
+ if (l === want || !labels.includes(l)) continue;
5928
+ try {
5929
+ await gh.removeLabel(n, l);
5930
+ } catch {
5931
+ ok2 = false;
5932
+ }
5933
+ }
5934
+ if (want && !labels.includes(want)) ok2 = await gh.addLabels(n, [want]) && ok2;
5935
+ return ok2 ? null : `The state label on #${n} could not be updated; the status shown in Bridge is still right.`;
5936
+ }
5937
+ async function reviewSuggestionOnGitHub(gh, n, input, gov) {
5938
+ 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.");
5939
+ const before = await getSuggestionOnGitHub(gh, n, gov);
5940
+ if (!before.parsed.ok) throw new GitHubError(422, `#${n} cannot be reviewed: ${before.parsed.message}`);
5941
+ if (before.issue.state === "closed") throw new GitHubError(422, `#${n} is closed. Reopen it on GitHub to review it again.`);
5942
+ const sha = before.parsed.sha;
5943
+ if (input.payloadSha && input.payloadSha !== sha) throw new GitHubError(409, `#${n} changed since you opened it. Reload it and review the new version.`);
5944
+ const by = input.by ?? (await gh.getViewer().catch(() => ({ login: "" }))).login;
5945
+ 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 });
5946
+ const posted = await gh.createIssueComment(n, body);
5947
+ const after = await getSuggestionOnGitHub(gh, n, gov);
5948
+ const status = after.status;
5949
+ const who = (after.comments.find((c) => c.id === posted.id)?.author ?? by).toLowerCase();
5950
+ const counts = input.event === "comment" || [...after.approvals?.approvers ?? [], ...after.approvals?.changesRequested ?? []].some((l) => l.toLowerCase() === who);
5951
+ const warnings = [];
5952
+ if (!counts) warnings.push("Your review is posted but does not count: only the owners listed in bridge.config.json approve suggestions.");
5953
+ if (before.parsed.edited) warnings.push(`#${n} was edited by hand after Bridge wrote it: check the machine-readable block before applying it.`);
5954
+ const labelWarning = await syncSuggestionStateLabel(gh, n, before.issue.labels, status);
5955
+ if (labelWarning) warnings.push(labelWarning);
5956
+ return { commentUrl: posted.url, payloadSha: sha, status, counts, warnings };
5957
+ }
5958
+ async function linkSuggestionPrOnGitHub(gh, n, link) {
5959
+ 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.");
5960
+ const issue = await gh.getIssue(n);
5961
+ 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() }));
5962
+ const warning = await syncSuggestionStateLabel(gh, n, issue.labels, "applying");
5963
+ return { warnings: warning ? [warning] : [] };
5964
+ }
5965
+ async function declineSuggestionOnGitHub(gh, n, reason, by) {
5966
+ const issue = await gh.getIssue(n);
5967
+ await declineRequestOnGitHub(gh, n, reason, by);
5968
+ const warning = await syncSuggestionStateLabel(gh, n, issue.labels, "declined");
5969
+ return { warnings: warning ? [warning] : [] };
5970
+ }
5614
5971
 
5615
5972
  // ../core/src/docs-proposal.ts
5616
5973
  var DOCS_EDITOR_ORIGIN = {
@@ -6177,6 +6534,44 @@ Proposed through ${opts.tool ?? "Bridge MCP"}. Tier ${plan.tier.tier}.`, files:
6177
6534
  var tokenLabels = (plan, before = [], after = []) => [
6178
6535
  .../* @__PURE__ */ new Set([...before, `tier: ${plan.tier.tier}`, ...plan.tier.tier === 3 ? ["tokens"] : [], ...plan.tier.bump === "major" ? ["breaking"] : [], ...after])
6179
6536
  ];
6537
+ function tokenSuggestionPayload(plan, ctx) {
6538
+ const tokenFiles = new Set(plan.touched);
6539
+ 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 }));
6540
+ return {
6541
+ schema: SUGGESTION_SCHEMA,
6542
+ kind: "token-change",
6543
+ title: plan.title,
6544
+ summary: plan.summary,
6545
+ source: ctx.source,
6546
+ ...ctx.suggestedBy ? { suggestedBy: ctx.suggestedBy.replace(/^@/, "") } : {},
6547
+ createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
6548
+ base: ctx.base,
6549
+ tier: { tier: plan.tier.tier, bump: plan.tier.bump },
6550
+ tokens: {
6551
+ edits: plan.edits,
6552
+ 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 }))
6553
+ },
6554
+ ...files.length ? { files } : {},
6555
+ checks: {
6556
+ newErrors: plan.newErrors.length,
6557
+ 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 })),
6558
+ blocked: plan.blocked,
6559
+ impact: plan.impact.total
6560
+ },
6561
+ ...ctx.links ? { links: ctx.links } : {},
6562
+ ...ctx.previews?.length ? { previews: ctx.previews } : {}
6563
+ };
6564
+ }
6565
+ async function suggestTokenProposalOnGitHub(gh, plan, opts) {
6566
+ if (plan.blocked) throw new Error(`Not suggested: ${plan.blocked}. Bridge never suggests a change that breaks the gate.`);
6567
+ const sha = opts.baseSha ?? await gh.getBranchSha(opts.base);
6568
+ if (!sha) throw new Error(`Branch ${opts.base} not found. Check the default branch in bridge.config.json.`);
6569
+ const links = opts.neededBy || opts.links ? { ...opts.links, ...opts.neededBy ? { neededBy: opts.neededBy } : {} } : void 0;
6570
+ const payload = tokenSuggestionPayload(plan, { base: { ref: opts.base, sha }, source: opts.source, suggestedBy: opts.suggestedBy, links, previews: opts.previews });
6571
+ const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
6572
+ const created = await createSuggestionOnGitHub(gh, payload, { labels, assignees: opts.assignees, details: plan.body });
6573
+ return { status: "suggested", number: created.number, url: created.url, sha: created.sha, labels, warnings: created.warnings };
6574
+ }
6180
6575
 
6181
6576
  // ../core/src/figma-push-github.ts
6182
6577
  var pushSource = (d) => ["figma", "studio", "cli", "mcp"].includes(d.source) ? d.source : "figma";
@@ -6186,7 +6581,8 @@ function componentSpecSuggestionPayload(d, plan, ctx) {
6186
6581
  const spec = JSON.parse(formatFigmaSpec(d, plan));
6187
6582
  const brief = figmaDraftBrief({ ...d, tokenEdits: plan.edits, reasons: plan.reasons }, { suggestion: true, specPath, componentsDir: ctx.componentsDir });
6188
6583
  const by = (ctx.suggestedBy ?? d.requestedBy)?.replace(/^@/, "");
6189
- 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;
6584
+ 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 } : {}, ...figmaNodes(d) } : void 0;
6585
+ const previews = (d.images ?? []).map((url, i) => ({ label: `${d.component} in Figma${(d.images?.length ?? 0) > 1 ? ` ${i + 1}` : ""}`, url }));
6190
6586
  return {
6191
6587
  schema: SUGGESTION_SCHEMA,
6192
6588
  kind: "component-spec",
@@ -6199,9 +6595,19 @@ function componentSpecSuggestionPayload(d, plan, ctx) {
6199
6595
  tier: specTier(d),
6200
6596
  componentSpec: { component: d.component, specPath, spec, brief },
6201
6597
  checks: { newErrors: 0, contrast: [], blocked: null, impact: 0 },
6202
- ...figma ? { links: { figma } } : {}
6598
+ ...figma ? { links: { figma } } : {},
6599
+ ...previews.length ? { previews } : {}
6203
6600
  };
6204
6601
  }
6602
+ function figmaNodes(d) {
6603
+ const f = d.figma;
6604
+ if (!f) return {};
6605
+ const url = (id, given) => given ?? (f.fileKey ? figmaNodeUrl(f.fileKey, id) : void 0);
6606
+ const nodes = [];
6607
+ if (f.before?.nodeId) nodes.push({ nodeId: f.before.nodeId, state: "before", ...url(f.before.nodeId, f.before.url) ? { url: url(f.before.nodeId, f.before.url) } : {}, ...f.before.nodeName ? { name: f.before.nodeName } : {} });
6608
+ if (f.nodeId) nodes.push({ nodeId: f.nodeId, state: "after", ...url(f.nodeId, f.url) ? { url: url(f.nodeId, f.url) } : {}, ...f.nodeName ? { name: f.nodeName } : {} });
6609
+ return nodes.length ? { nodes } : {};
6610
+ }
6205
6611
  async function openComponentPushOnGitHub(gh, input) {
6206
6612
  const d = input.diff;
6207
6613
  if (!input.plan && !input.tokens) throw new Error("Route the change first: pass the token facts or the plan.");
@@ -6222,7 +6628,7 @@ async function openComponentPushOnGitHub(gh, input) {
6222
6628
  };
6223
6629
  if (plan.route === "token-change") {
6224
6630
  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`).");
6225
- const figma = { ...origin.fileKey ? { fileKey: origin.fileKey } : {}, ...origin.nodeId ? { nodeId: origin.nodeId } : {}, ...origin.url ? { url: origin.url } : {} };
6631
+ const figma = { ...origin.fileKey ? { fileKey: origin.fileKey } : {}, ...origin.nodeId ? { nodeId: origin.nodeId } : {}, ...origin.url ? { url: origin.url } : {}, ...figmaNodes(d) };
6226
6632
  const r = await input.writeTokens({ edits: plan.edits, title: figmaBranchTitle(d), summary: d.summary.trim() || d.title, bodyExtra: [figmaOriginSection(origin)], mode, figma });
6227
6633
  await gh.addLabels(r.number, [FROM_FIGMA_LABEL]);
6228
6634
  if (r.status === "suggested") return { status: "suggested", plan, link: null, suggestion: { route: "token-change", number: r.number, url: r.url } };
@@ -6289,14 +6695,277 @@ async function markFigmaChangeDraftedOnGitHub(gh, n) {
6289
6695
  return { status: figmaPushStatus({ state: pull.state, labels, route: origin.route }), files };
6290
6696
  }
6291
6697
 
6698
+ // ../core/src/suggestion-apply.ts
6699
+ var SUGGESTION_APPLY_EXIT = {
6700
+ ready: 0,
6701
+ "already-applied": 0,
6702
+ conflict: 3,
6703
+ "not-approved": 1,
6704
+ blocked: 1,
6705
+ refused: 1
6706
+ };
6707
+ var same2 = (a, b) => canonicalJson(a) === canonicalJson(b);
6708
+ var show3 = (v) => v === void 0 ? "nothing" : typeof v === "string" ? v : JSON.stringify(v);
6709
+ function tokenRoot(config) {
6710
+ const r = config.tokens.root.replace(/\/+$/, "");
6711
+ return r === "." ? "" : r;
6712
+ }
6713
+ function tokenFileOf(config, path) {
6714
+ const root = tokenRoot(config);
6715
+ if (root && !path.startsWith(`${root}/`)) return null;
6716
+ if (!selectTokenFiles(config, [path]).length) return null;
6717
+ return root ? path.slice(root.length + 1) : path;
6718
+ }
6719
+ function planTokenEdit(set, e, changes) {
6720
+ const base = { type: "token", path: e.path, edit: e, ...e.file ? { file: e.file } : {} };
6721
+ const defsAt = (path) => set.defs.filter((d) => d.path === path && (!e.file || d.file === e.file));
6722
+ const recorded = (file, path = e.path) => changes.find((c) => c.path === path && c.file === file && (e.op === "rename" ? c.kind === "renamed" : true));
6723
+ const conflict = (detail, extra = {}) => ({ op: { ...base, state: "conflict", detail, ...extra }, notes: [] });
6724
+ const done = (detail) => ({ op: { ...base, state: "already-applied", detail }, notes: [] });
6725
+ let result;
6726
+ try {
6727
+ result = applyTokenEdits(set, [e]);
6728
+ } catch (err) {
6729
+ const why = err instanceof Error ? err.message : String(err);
6730
+ switch (e.op) {
6731
+ case "rename": {
6732
+ const hasOld = defsAt(e.path).length > 0;
6733
+ const hasNew = set.defs.some((d) => d.path === e.newPath);
6734
+ if (!hasOld && hasNew) return done(`Already renamed to ${e.newPath}.`);
6735
+ return conflict(hasOld && hasNew ? `Both ${e.path} and ${e.newPath} exist now: rename it by hand.` : `${e.path} no longer exists: ${why}`);
6736
+ }
6737
+ case "remove":
6738
+ if (!defsAt(e.path).length) return done("Already removed.");
6739
+ return conflict(why);
6740
+ case "add": {
6741
+ const existing = defsAt(e.path);
6742
+ if (!existing.length) return conflict(why);
6743
+ const want = (file) => {
6744
+ const c = recorded(file);
6745
+ if (c && c.after !== void 0) return c.after;
6746
+ return coerceValue(e.value, e.type ?? existing.find((d2) => d2.file === file)?.type);
6747
+ };
6748
+ const all = existing.every((d2) => same2(d2.value, want(d2.file)));
6749
+ if (all) return done("Already added with the suggested value.");
6750
+ const d = existing.find((x) => !same2(x.value, want(x.file)));
6751
+ return conflict(`Someone added ${e.path} with another value since.`, { file: d.file, after: want(d.file), now: d.value });
6752
+ }
6753
+ default:
6754
+ return conflict(defsAt(e.path).length ? why : `${e.path} no longer exists, so its value cannot be set.`);
6755
+ }
6756
+ }
6757
+ if (!result.touched.length) return done("Already has the suggested value.");
6758
+ if (e.op === "set" || e.op === "remove" || e.op === "rename") {
6759
+ for (const d of defsAt(e.path)) {
6760
+ const c = recorded(d.file, e.op === "rename" ? e.newPath ?? e.path : e.path);
6761
+ if (!c) continue;
6762
+ const okNow = same2(d.value, c.before) || e.op === "set" && same2(d.value, c.after);
6763
+ 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 });
6764
+ }
6765
+ }
6766
+ const verb = e.op === "set" ? "Set" : e.op === "add" ? "Add" : e.op === "remove" ? "Remove" : `Rename to ${e.newPath ?? ""}`;
6767
+ return { op: { ...base, state: "apply", detail: `${verb} in ${result.touched.join(", ")}.` }, files: result.files, notes: result.notes };
6768
+ }
6769
+ async function planFile(ws, baseSha, f) {
6770
+ const base = { type: "file", path: f.path };
6771
+ const cur = await ws.read(f.path);
6772
+ const curSha = cur?.sha ?? null;
6773
+ const theirs = f.afterSha === null ? null : f.content ?? null;
6774
+ if (curSha === f.afterSha) return { op: { ...base, state: "already-applied", detail: f.afterSha === null ? "Already deleted." : "Already has the suggested content." } };
6775
+ 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 };
6776
+ if (f.beforeSha === null) return { op: { ...base, state: "conflict", detail: "The suggestion creates this file, but a different one exists now." } };
6777
+ if (!cur) return { op: { ...base, state: "conflict", detail: "The suggestion changes this file, but it was deleted since." } };
6778
+ if (theirs === null) return { op: { ...base, state: "conflict", detail: "The suggestion deletes this file, but it changed since." } };
6779
+ const original = await ws.readAt(baseSha, f.path);
6780
+ if (original === null || gitBlobSha(original) !== f.beforeSha || !ws.merge) {
6781
+ 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." } };
6782
+ }
6783
+ const merged = await ws.merge({ path: f.path, ours: cur.text, base: original, theirs });
6784
+ if (!merged.clean) return { op: { ...base, state: "conflict", detail: `The file changed since and the merge has ${merged.conflicts} conflict(s).`, hunks: merged.conflicts } };
6785
+ if (merged.text === cur.text) return { op: { ...base, state: "already-applied", detail: "Already has the suggested lines." } };
6786
+ return { op: { ...base, state: "apply-merged", detail: "The file changed since: merged with the suggested lines, no conflict." }, content: merged.text };
6787
+ }
6788
+ async function planSuggestionApply(input) {
6789
+ const { parsed, config, workspace: ws } = input;
6790
+ const payload = parsed.payload;
6791
+ const notes = [];
6792
+ const out = (outcome, message, rest2 = {}) => ({
6793
+ outcome,
6794
+ exitCode: SUGGESTION_APPLY_EXIT[outcome],
6795
+ message,
6796
+ payloadSha: parsed.sha,
6797
+ payload,
6798
+ gate: null,
6799
+ ops: [],
6800
+ writes: [],
6801
+ checks: null,
6802
+ problems: [],
6803
+ notes,
6804
+ ...payload.componentSpec ? { brief: payload.componentSpec.brief } : {},
6805
+ ...rest2
6806
+ });
6807
+ const problems = suggestionPathProblems(payload, config);
6808
+ 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.`);
6809
+ for (const f of payload.files ?? []) {
6810
+ 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.`);
6811
+ 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.`);
6812
+ }
6813
+ if (problems.length) return out("refused", "Not applied: the suggestion asks for something Bridge never writes.", { problems: [...new Set(problems)] });
6814
+ if (parsed.edited && !input.acceptEdited) {
6815
+ 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.', {
6816
+ problems: ["The payload no longer matches the sha Bridge recorded."]
6817
+ });
6818
+ }
6819
+ const root = tokenRoot(config);
6820
+ const tokenFiles = selectTokenFiles(config, await ws.list());
6821
+ const targets = [
6822
+ ...(payload.files ?? []).map((f) => f.path),
6823
+ ...(payload.docs ?? []).map((d) => d.path),
6824
+ ...payload.componentSpec ? [payload.componentSpec.specPath] : [],
6825
+ ...payload.tokens?.edits.length ? tokenFiles.map((f) => root ? `${root}/${f}` : f) : []
6826
+ ];
6827
+ if (ws.isSymlink) {
6828
+ const links = [];
6829
+ 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.`);
6830
+ if (links.length) return out("refused", "Not applied: the suggestion writes through a symbolic link.", { problems: links });
6831
+ }
6832
+ const governance = input.governance ?? { owners: config.governance.owners, rights: config.governance.rights ?? null };
6833
+ const approvals = summarizeSuggestionApprovals(input.comments, governance, { payloadSha: parsed.sha, author: input.author });
6834
+ const review = suggestionReviewState(payload.tier.tier, approvals);
6835
+ const missing = pendingApprovals(payload.tier.tier, approvals);
6836
+ const approved = review === "approved";
6837
+ const gate = { review, missing, approvals, forced: !approved && Boolean(input.forceUnapproved), edited: parsed.edited };
6838
+ const ops = [];
6839
+ const writes = /* @__PURE__ */ new Map();
6840
+ const current = /* @__PURE__ */ new Map();
6841
+ for (const f of tokenFiles) {
6842
+ const r = await ws.read(root ? `${root}/${f}` : f);
6843
+ if (r) current.set(f, r.text);
6844
+ }
6845
+ const before = new LayeredTokenSet(config, current);
6846
+ let set = before;
6847
+ const appliedEdits = [];
6848
+ for (const e of payload.tokens?.edits ?? []) {
6849
+ const r = planTokenEdit(set, e, payload.tokens.changes);
6850
+ ops.push(r.op);
6851
+ notes.push(...r.notes);
6852
+ if (r.op.state === "apply" && r.files) {
6853
+ set = new LayeredTokenSet(config, r.files);
6854
+ appliedEdits.push(e);
6855
+ }
6856
+ }
6857
+ for (const [f, text] of set.files) if (text !== before.files.get(f)) writes.set(set.repoPath(f), text);
6858
+ for (const f of payload.files ?? []) {
6859
+ if (writes.has(f.path)) {
6860
+ notes.push(`${f.path}: the token edits write this file, its copy in the suggestion is not used.`);
6861
+ continue;
6862
+ }
6863
+ const r = await planFile(ws, payload.base.sha, f);
6864
+ ops.push(r.op);
6865
+ if (r.op.state === "apply" || r.op.state === "apply-merged") writes.set(f.path, r.content ?? null);
6866
+ }
6867
+ for (const d of payload.docs ?? []) {
6868
+ const base = { type: "docs", path: d.path };
6869
+ const docs = d.after;
6870
+ let text;
6871
+ try {
6872
+ const issues = validateDocs(docs);
6873
+ if (issues.length) {
6874
+ problems.push(`${d.path}: ${issues.map((i) => i.message).join(" ")}`);
6875
+ continue;
6876
+ }
6877
+ const cur = await ws.read(d.path);
6878
+ text = docsFileText(docs, cur?.text ?? null);
6879
+ if (cur && text === cur.text) {
6880
+ ops.push({ ...base, state: "already-applied", detail: "The docs already say this." });
6881
+ continue;
6882
+ }
6883
+ ops.push({ ...base, state: "apply", detail: cur ? "Update the changed fields; other fields stay as they are now." : "Create the docs file." });
6884
+ writes.set(d.path, text);
6885
+ } catch (err) {
6886
+ problems.push(`${d.path}: the suggested docs are not a docs.json Bridge can read (${err instanceof Error ? err.message : String(err)}).`);
6887
+ }
6888
+ }
6889
+ if (payload.componentSpec) {
6890
+ const s = payload.componentSpec;
6891
+ const text = `${JSON.stringify(s.spec, null, 2)}
6892
+ `;
6893
+ const cur = await ws.read(s.specPath);
6894
+ if (cur && cur.text === text) ops.push({ type: "component-spec", path: s.specPath, state: "already-applied", detail: "The spec file is already there." });
6895
+ else {
6896
+ ops.push({ type: "component-spec", path: s.specPath, state: "apply", detail: `Write the spec; the ${s.component} code itself is written from the brief.` });
6897
+ writes.set(s.specPath, text);
6898
+ }
6899
+ }
6900
+ const afterFiles = new Map(set.files);
6901
+ for (const [p, content] of writes) {
6902
+ const tf = tokenFileOf(config, p);
6903
+ if (!tf) continue;
6904
+ if (content === null) afterFiles.delete(tf);
6905
+ else afterFiles.set(tf, content);
6906
+ }
6907
+ const after = new LayeredTokenSet(config, afterFiles);
6908
+ const tokenChanges = diffLayered(before, after);
6909
+ const issueKey4 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
6910
+ const prevErrors = new Set(validateLayered(before).map(issueKey4));
6911
+ const newErrors = tokenChanges.length ? validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey4(i))) : [];
6912
+ let pairs = [];
6913
+ if (config.contrast && tokenChanges.length) {
6914
+ try {
6915
+ pairs = parseContrastChecks((await ws.read(config.contrast.path))?.text);
6916
+ } catch {
6917
+ notes.push(`${config.contrast.path} does not parse: contrast was not checked.`);
6918
+ }
6919
+ }
6920
+ const modes = config.contrast?.modes;
6921
+ const contrast = pairs.length ? contrastDelta(runLayeredContrast(before, pairs, modes), runLayeredContrast(after, pairs, modes)) : [];
6922
+ const newFailures = contrast.filter((d) => d.passBefore && !d.passAfter);
6923
+ const otherFiles = [...writes.keys()].filter((p) => !tokenFileOf(config, p));
6924
+ 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 });
6925
+ const tierIncreased = writes.size > 0 && tier.tier > payload.tier.tier;
6926
+ const checks = { tierWhenSuggested: payload.tier.tier, tier, tierIncreased, newErrors, newFailures, contrast, tokenChanges };
6927
+ const rest = { gate, ops, checks, problems };
6928
+ if (problems.length) return out("refused", "Not applied: part of the suggestion is not valid.", rest);
6929
+ const conflicts = ops.filter((o) => o.state === "conflict");
6930
+ if (conflicts.length) {
6931
+ 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);
6932
+ }
6933
+ if (!writes.size) return out("already-applied", "Already applied: the code has every change of this suggestion.", rest);
6934
+ if (newErrors.length || newFailures.length) {
6935
+ const why = [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ");
6936
+ return out("blocked", `Not applied: on today's code it would add ${why}. Ask the author to refresh the suggestion.`, rest);
6937
+ }
6938
+ if (tierIncreased && !input.forceUnapproved) {
6939
+ 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);
6940
+ }
6941
+ if (!approved && !input.forceUnapproved) {
6942
+ 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 ")}`;
6943
+ return out("not-approved", `Not applied: it is not approved yet (${who}). Ask the owners to review it on the issue.`, rest);
6944
+ }
6945
+ const forced = gate.forced || tierIncreased && Boolean(input.forceUnapproved);
6946
+ return out("ready", `Ready: ${writes.size} file(s) to write${forced ? ", without every approval" : ""}.`, {
6947
+ ...rest,
6948
+ gate: { ...gate, forced },
6949
+ writes: [...writes].map(([path, content]) => ({ path, content })).sort((a, b) => a.path.localeCompare(b.path))
6950
+ });
6951
+ }
6952
+ function suggestionAppliedComment(plan, where2) {
6953
+ return composeSuggestionAppliedComment({ ...where2, payloadSha: plan.payloadSha, ...plan.gate?.forced ? { forced: true } : {} });
6954
+ }
6955
+ function suggestionApplyTable(plan) {
6956
+ const label = { "already-applied": "already applied", apply: "apply", "apply-merged": "apply (merged)", conflict: "**conflict**" };
6957
+ const cell4 = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
6958
+ return ["| Change | Result | Detail |", "| --- | --- | --- |", ...plan.ops.map((o) => `| \`${cell4(o.path)}\` | ${label[o.state]} | ${cell4(o.detail)} |`)].join("\n");
6959
+ }
6960
+
6292
6961
  // ../core/src/node/git-history.ts
6293
6962
  var US = "";
6294
6963
  var RS = "";
6295
6964
  function localGitHistoryBackend(root, config) {
6296
6965
  const trees = /* @__PURE__ */ new Map();
6297
6966
  const blobs = /* @__PURE__ */ new Map();
6298
- const tokenRoot = config.tokens.root.replace(/\/$/, "");
6299
- const prefix = tokenRoot === "." || !tokenRoot ? "" : `${tokenRoot}/`;
6967
+ const tokenRoot2 = config.tokens.root.replace(/\/$/, "");
6968
+ const prefix = tokenRoot2 === "." || !tokenRoot2 ? "" : `${tokenRoot2}/`;
6300
6969
  const lsTree = (sha, path) => {
6301
6970
  const k = `${sha}:${path}`;
6302
6971
  let t = trees.get(k);
@@ -6333,7 +7002,7 @@ function localGitHistoryBackend(root, config) {
6333
7002
  });
6334
7003
  },
6335
7004
  async tokenFiles(sha) {
6336
- const entries = await lsTree(sha, tokenRoot === "." ? "" : tokenRoot);
7005
+ const entries = await lsTree(sha, tokenRoot2 === "." ? "" : tokenRoot2);
6337
7006
  const picked = new Set(selectTokenFiles(config, entries.map((e) => e.path)));
6338
7007
  const wanted = entries.filter((e) => picked.has(prefix ? e.path.slice(prefix.length) : e.path));
6339
7008
  return new Map(await Promise.all(wanted.map(async (e) => [prefix ? e.path.slice(prefix.length) : e.path, await blob(e.sha)])));
@@ -6963,14 +7632,14 @@ function scanClassString(text) {
6963
7632
  const out = [];
6964
7633
  for (const m of text.matchAll(/[^\s"'`]+/g)) {
6965
7634
  const raw = m[0];
6966
- let cut = -1;
7635
+ let cut2 = -1;
6967
7636
  let depth = 0;
6968
7637
  for (let i = 0; i < raw.length; i++) {
6969
7638
  if (raw[i] === "[") depth++;
6970
7639
  else if (raw[i] === "]") depth--;
6971
- else if (raw[i] === ":" && depth === 0) cut = i;
7640
+ else if (raw[i] === ":" && depth === 0) cut2 = i;
6972
7641
  }
6973
- const cls = raw.slice(cut + 1).replace(/^!/, "");
7642
+ const cls = raw.slice(cut2 + 1).replace(/^!/, "");
6974
7643
  const index = m.index + raw.length - cls.length;
6975
7644
  const arb = /^-?([a-z][a-z-]*?)-\[(.+)\]$/.exec(cls);
6976
7645
  if (arb) {
@@ -7458,16 +8127,16 @@ function applyAllowlist(entries, findings, components) {
7458
8127
  return { findings: keep(findings), components: keep(components), ignored, used };
7459
8128
  }
7460
8129
  function parseAllowlist(json) {
7461
- const fail2 = (msg) => {
8130
+ const fail3 = (msg) => {
7462
8131
  throw new Error(`allowlist: ${msg}`);
7463
8132
  };
7464
- if (!json || typeof json !== "object" || !Array.isArray(json.entries)) fail2('expected { "entries": [...] }');
8133
+ if (!json || typeof json !== "object" || !Array.isArray(json.entries)) fail3('expected { "entries": [...] }');
7465
8134
  return json.entries.map((e, i) => {
7466
8135
  const x = e;
7467
- if (typeof x.file !== "string" || !x.file) fail2(`entry ${i} needs "file"`);
7468
- if (typeof x.line !== "number" || !Number.isInteger(x.line) || x.line < 1) fail2(`entry ${i} (${x.file}) needs a "line" number`);
7469
- if (typeof x.reason !== "string" || !x.reason.trim()) fail2(`entry ${i} (${x.file}:${x.line}) needs a "reason"`);
7470
- if (x.what !== void 0 && typeof x.what !== "string") fail2(`entry ${i} (${x.file}:${x.line}): "what" must be a string`);
8136
+ if (typeof x.file !== "string" || !x.file) fail3(`entry ${i} needs "file"`);
8137
+ if (typeof x.line !== "number" || !Number.isInteger(x.line) || x.line < 1) fail3(`entry ${i} (${x.file}) needs a "line" number`);
8138
+ if (typeof x.reason !== "string" || !x.reason.trim()) fail3(`entry ${i} (${x.file}:${x.line}) needs a "reason"`);
8139
+ if (x.what !== void 0 && typeof x.what !== "string") fail3(`entry ${i} (${x.file}:${x.line}): "what" must be a string`);
7471
8140
  return { file: x.file, line: x.line, ...x.what !== void 0 ? { what: x.what } : {}, reason: x.reason.trim() };
7472
8141
  });
7473
8142
  }
@@ -8153,9 +8822,9 @@ var DesignSystemIndex = class {
8153
8822
  return generic ? this.make(generic, 1, 0.3) : void 0;
8154
8823
  }
8155
8824
  case "shadow": {
8156
- const norm3 = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
8825
+ const norm4 = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
8157
8826
  const cands = this.entries(["shadow"]);
8158
- const exact = cands.find((e) => norm3(e.css) === norm3(value));
8827
+ const exact = cands.find((e) => norm4(e.css) === norm4(value));
8159
8828
  if (exact) return this.make(exact, 0, 1);
8160
8829
  const t = shadowNums(value);
8161
8830
  const scored = cands.map((e) => {
@@ -8222,6 +8891,194 @@ function loadAllowlist(path) {
8222
8891
  return parseAllowlist(JSON.parse(readFileSync(path, "utf8")));
8223
8892
  }
8224
8893
 
8894
+ // ../core/src/node/suggestion-apply-node.ts
8895
+ import { execFile } from "node:child_process";
8896
+ import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
8897
+ import { tmpdir } from "node:os";
8898
+ import { dirname as dirname2, join as join2 } from "node:path";
8899
+ function gitCode(root, args) {
8900
+ return new Promise((resolve4) => {
8901
+ execFile("git", args, { cwd: root, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
8902
+ const code2 = err ? typeof err.code === "number" ? err.code : -1 : 0;
8903
+ resolve4({ code: code2, stdout, stderr });
8904
+ });
8905
+ });
8906
+ }
8907
+ async function treeEntry(root, ref, path) {
8908
+ const r = await gitCode(root, ["ls-tree", ref, "--", path]);
8909
+ const m = /^(\d+) (\w+) ([0-9a-f]+)\t/.exec(r.stdout);
8910
+ return r.code === 0 && m ? { mode: m[1], type: m[2], sha: m[3] } : null;
8911
+ }
8912
+ var prefixes = (path) => path.split("/").map((_, i, segs) => segs.slice(0, i + 1).join("/"));
8913
+ async function mergeText(root, input) {
8914
+ const dir = await mkdtemp(join2(tmpdir(), "bridge-merge-"));
8915
+ try {
8916
+ const [o, b, t] = ["current", "base", "suggested"].map((n) => join2(dir, n));
8917
+ await Promise.all([writeFile(o, input.ours), writeFile(b, input.base), writeFile(t, input.theirs)]);
8918
+ const r = await gitCode(root, ["merge-file", "-p", "-L", "current", "-L", "base", "-L", "suggested", o, b, t]);
8919
+ if (r.code < 0 || r.code > 127) throw new Error(`git merge-file failed: ${r.stderr.trim() || `exit ${r.code}`}`);
8920
+ return { clean: r.code === 0, text: r.stdout, conflicts: r.code };
8921
+ } finally {
8922
+ await rm(dir, { recursive: true, force: true });
8923
+ }
8924
+ }
8925
+ async function fileDiff(path, before, after) {
8926
+ if (before === after) return "";
8927
+ const dir = await mkdtemp(join2(tmpdir(), "bridge-diff-"));
8928
+ try {
8929
+ const side = async (name, text) => {
8930
+ if (text === null) return "/dev/null";
8931
+ const p = join2(dir, name, path);
8932
+ await mkdir(dirname2(p), { recursive: true });
8933
+ await writeFile(p, text);
8934
+ return `${name}/${path}`;
8935
+ };
8936
+ const a = await side("a", before);
8937
+ const b = await side("b", after);
8938
+ 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 })));
8939
+ return r.stdout;
8940
+ } finally {
8941
+ await rm(dir, { recursive: true, force: true });
8942
+ }
8943
+ }
8944
+ function gitWorkspace(root, opts = {}) {
8945
+ const { ref } = opts;
8946
+ let fetched = false;
8947
+ return {
8948
+ async read(path) {
8949
+ if (ref) {
8950
+ const e = await treeEntry(root, ref, path);
8951
+ if (!e || e.type !== "blob" || e.mode === "120000") return null;
8952
+ return { text: await git(root, ["cat-file", "blob", e.sha]), sha: e.sha };
8953
+ }
8954
+ try {
8955
+ const st = await lstat(join2(root, path));
8956
+ if (!st.isFile()) return null;
8957
+ const text = await readFile(join2(root, path), "utf8");
8958
+ return { text, sha: gitBlobSha(text) };
8959
+ } catch {
8960
+ return null;
8961
+ }
8962
+ },
8963
+ async readAt(at, path) {
8964
+ if ((await gitCode(root, ["cat-file", "-e", `${at}^{commit}`])).code !== 0 && !fetched) {
8965
+ fetched = true;
8966
+ await gitCode(root, ["fetch", "--no-tags", "--quiet", "origin", at]);
8967
+ }
8968
+ const e = await treeEntry(root, at, path);
8969
+ if (e?.type === "blob") return git(root, ["cat-file", "blob", e.sha]);
8970
+ return opts.readBase ? opts.readBase(path, at) : null;
8971
+ },
8972
+ async list() {
8973
+ if (ref) return listFilesAt(root, ref);
8974
+ const out = await git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
8975
+ const all = [...new Set(out.split("\0").filter(Boolean))];
8976
+ const present = await Promise.all(all.map((p) => lstat(join2(root, p)).then((s) => s.isFile(), () => false)));
8977
+ return all.filter((_, i) => present[i]);
8978
+ },
8979
+ async isSymlink(path) {
8980
+ for (const p of prefixes(path)) {
8981
+ if (ref) {
8982
+ const e = await treeEntry(root, ref, p);
8983
+ if (!e) return false;
8984
+ if (e.mode === "120000") return true;
8985
+ } else {
8986
+ try {
8987
+ if ((await lstat(join2(root, p))).isSymbolicLink()) return true;
8988
+ } catch {
8989
+ return false;
8990
+ }
8991
+ }
8992
+ }
8993
+ return false;
8994
+ },
8995
+ merge: (input) => mergeText(root, input)
8996
+ };
8997
+ }
8998
+ var COMMIT_TYPE = { "token-change": "feat(tokens)", docs: "docs", "component-spec": "feat", brand: "feat(tokens)", revert: "revert", config: "chore(config)" };
8999
+ function commitMessage(plan, n) {
9000
+ const p = plan.payload;
9001
+ const type = `${COMMIT_TYPE[p.kind] ?? "chore"}${p.tier.bump === "major" ? "!" : ""}`;
9002
+ 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");
9003
+ }
9004
+ async function readConfigAt(ws) {
9005
+ const text = (await ws.read(CONFIG_FILE))?.text;
9006
+ if (!text) throw new Error(`No ${CONFIG_FILE} here: run \`bridge init\` first, or apply from the repository root.`);
9007
+ return loadConfig(text);
9008
+ }
9009
+ async function applySuggestionLocally(input) {
9010
+ const mode = input.mode ?? "branch";
9011
+ const base = input.base ?? "HEAD";
9012
+ const { root } = input;
9013
+ const wsOpts = { readBase: input.readBase };
9014
+ const ws = gitWorkspace(root, mode === "write" ? wsOpts : { ...wsOpts, ref: base });
9015
+ const config = input.config ?? await readConfigAt(ws);
9016
+ const planFor = (workspace) => planSuggestionApply({ ...input, config, workspace });
9017
+ const plan = await planFor(ws);
9018
+ const at = (input.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
9019
+ const result = (status, rest = {}) => ({ status, exitCode: plan.exitCode, message: plan.message, plan, ...rest });
9020
+ if (plan.outcome !== "ready") return result(plan.outcome);
9021
+ const readNow = async (path) => (await ws.read(path))?.text ?? null;
9022
+ const diffOf = async () => (await Promise.all(plan.writes.map(async (w) => fileDiff(w.path, await readNow(w.path), w.content)))).join("");
9023
+ if (input.dryRun) return result("dry-run", { message: `Dry run: ${plan.message.replace(/^Ready: /, "")} Nothing written.`, diff: await diffOf() });
9024
+ if (mode === "patch") {
9025
+ const { sha } = await commitTree(root, { base, message: commitMessage(plan, input.number), files: plan.writes });
9026
+ const patch = await git(root, ["format-patch", "-1", "--stdout", sha]);
9027
+ return result("patch", { message: "Patch ready: apply it with `git am`.", patch, commit: sha });
9028
+ }
9029
+ if (mode === "write") {
9030
+ const paths = plan.writes.map((w) => w.path);
9031
+ const dirty = (await git(root, ["status", "--porcelain", "-z", "--", ...paths])).split("\0").filter((l) => l.length > 3).map((l) => l.slice(3));
9032
+ if (dirty.length) {
9033
+ return result("refused", {
9034
+ exitCode: 1,
9035
+ message: `Not written: these files have uncommitted changes: ${dirty.join(", ")}. Commit or stash them, or apply on a branch instead.`
9036
+ });
9037
+ }
9038
+ const diff = await diffOf();
9039
+ for (const w of plan.writes) {
9040
+ const full = join2(root, w.path);
9041
+ if (w.content === null) await rm(full, { force: true });
9042
+ else {
9043
+ await mkdir(dirname2(full), { recursive: true });
9044
+ await writeFile(full, w.content);
9045
+ }
9046
+ }
9047
+ return result("written", {
9048
+ message: `Written: ${paths.length} file(s) in the working tree. Review them, commit, and say "Closes #${input.number}" in the pull request.`,
9049
+ written: paths,
9050
+ diff,
9051
+ appliedComment: suggestionAppliedComment(plan, { by: input.by, at })
9052
+ });
9053
+ }
9054
+ const prefix = config.governance.branchPrefix;
9055
+ const wanted = input.branch ?? `${prefix}suggestion-${input.number}-${slugify(plan.payload.title, 40)}`;
9056
+ let branch = wanted;
9057
+ for (let i = 2; await branchExists(root, branch); i++) {
9058
+ const there = await planFor(gitWorkspace(root, { ...wsOpts, ref: branch }));
9059
+ if (there.outcome === "already-applied") {
9060
+ const commit = (await git(root, ["rev-parse", `refs/heads/${branch}`])).trim();
9061
+ return result("branch-exists", {
9062
+ exitCode: 0,
9063
+ message: `Already on ${branch}: that branch has every change of this suggestion. Push it and open a pull request that says "Closes #${input.number}".`,
9064
+ branch,
9065
+ commit,
9066
+ appliedComment: suggestionAppliedComment(plan, { by: input.by, branch, commit, at })
9067
+ });
9068
+ }
9069
+ branch = `${wanted}-${i}`;
9070
+ }
9071
+ const protectedBranches = [.../* @__PURE__ */ new Set([...protectedBranchesOf(config), baseBranchOf(config)])];
9072
+ const res = await commitToNewLocalBranch(root, { base, branch, message: commitMessage(plan, input.number), files: plan.writes, protectedBranches, branchPrefix: prefix });
9073
+ return result("branch-created", {
9074
+ message: `Applied on ${branch}, your working tree is untouched. Push it and open a pull request that says "Closes #${input.number}".`,
9075
+ branch,
9076
+ commit: res.sha,
9077
+ diff: res.diff,
9078
+ appliedComment: suggestionAppliedComment(plan, { by: input.by, branch, commit: res.sha, at })
9079
+ });
9080
+ }
9081
+
8225
9082
  // src/config.ts
8226
9083
  var SCHEMA_URL = "https://raw.githubusercontent.com/peterbono/ai-design-system-demo/main/packages/core/bridge.config.schema.json";
8227
9084
  var TIER_NAMES = {
@@ -8381,10 +9238,10 @@ var core = {
8381
9238
  };
8382
9239
 
8383
9240
  // src/source.ts
8384
- import { readdir, readFile } from "node:fs/promises";
8385
- import { join as join2 } from "node:path";
9241
+ import { readdir, readFile as readFile2 } from "node:fs/promises";
9242
+ import { join as join3 } from "node:path";
8386
9243
  var SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "coverage", "storybook-static", ".turbo", ".next", ".astro"]);
8387
- var under = (prefix) => (f) => !prefix || prefix === "." || f === prefix || f.startsWith(`${prefix.replace(/\/$/, "")}/`);
9244
+ var under2 = (prefix) => (f) => !prefix || prefix === "." || f === prefix || f.startsWith(`${prefix.replace(/\/$/, "")}/`);
8388
9245
  var LocalSource = class {
8389
9246
  constructor(root) {
8390
9247
  this.root = root;
@@ -8400,7 +9257,7 @@ var LocalSource = class {
8400
9257
  const walk2 = async (rel) => {
8401
9258
  let entries;
8402
9259
  try {
8403
- entries = await readdir(join2(this.root, rel), { withFileTypes: true });
9260
+ entries = await readdir(join3(this.root, rel), { withFileTypes: true });
8404
9261
  } catch {
8405
9262
  return;
8406
9263
  }
@@ -8416,7 +9273,7 @@ var LocalSource = class {
8416
9273
  }
8417
9274
  async read(path) {
8418
9275
  try {
8419
- return await readFile(join2(this.root, path), "utf8");
9276
+ return await readFile2(join3(this.root, path), "utf8");
8420
9277
  } catch {
8421
9278
  return null;
8422
9279
  }
@@ -8436,7 +9293,7 @@ var GitRefSource = class {
8436
9293
  }
8437
9294
  async list(prefix = "") {
8438
9295
  this.files ??= listFilesAt(this.root, this.ref);
8439
- return (await this.files).filter(under(prefix));
9296
+ return (await this.files).filter(under2(prefix));
8440
9297
  }
8441
9298
  read(path) {
8442
9299
  return showFile(this.root, this.ref, path);
@@ -8457,7 +9314,7 @@ var GitHubSource = class {
8457
9314
  }
8458
9315
  async list(prefix = "") {
8459
9316
  this.tree ??= this.client.listFiles(this.ref);
8460
- return (await this.tree).filter(under(prefix));
9317
+ return (await this.tree).filter(under2(prefix));
8461
9318
  }
8462
9319
  read(path) {
8463
9320
  let p = this.cache.get(path);
@@ -8479,7 +9336,7 @@ function client(repo, o, rules) {
8479
9336
  ...o.rawUrl ? { rawUrl: o.rawUrl } : {},
8480
9337
  protectedBranches: rules.protectedBranches,
8481
9338
  branchPrefix: rules.branchPrefix,
8482
- tokenHint: "set GITHUB_TOKEN (or BRIDGE_GITHUB_TOKEN) in the environment of the Bridge MCP server, read access to this repository is enough"
9339
+ 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"
8483
9340
  });
8484
9341
  }
8485
9342
  var BridgeContext = class _BridgeContext {
@@ -8901,8 +9758,8 @@ __export(ops_exports, {
8901
9758
  visualImpact: () => visualImpact
8902
9759
  });
8903
9760
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
8904
- import { mkdir as mkdir2, writeFile as writeFile2, access } from "node:fs/promises";
8905
- import { dirname as dirname3, isAbsolute, join as join4, resolve as resolve3 } from "node:path";
9761
+ import { mkdir as mkdir3, writeFile as writeFile3, access } from "node:fs/promises";
9762
+ import { dirname as dirname4, isAbsolute, join as join5, resolve as resolve3 } from "node:path";
8906
9763
 
8907
9764
  // ../core/src/audit/missing-pieces.ts
8908
9765
  var lines = (text) => text.split(/\r?\n/);
@@ -8974,7 +9831,7 @@ function aspectOf(values) {
8974
9831
  for (const a of ["tone", "shape", "size"]) if (values.some((v) => ASPECT[v.category] === a)) return a;
8975
9832
  return "style";
8976
9833
  }
8977
- var norm2 = (f) => f.category === "color" ? f.value.toLowerCase() : f.value.replace(/\s+/g, "");
9834
+ var norm3 = (f) => f.category === "color" ? f.value.toLowerCase() : f.value.replace(/\s+/g, "");
8978
9835
  function nearestOf(s) {
8979
9836
  if (!s) return void 0;
8980
9837
  return { token: s.token, cssVar: s.cssVar, value: s.value, level: s.level, label: tokenLabel(s.token) };
@@ -9061,7 +9918,7 @@ function missingPieces(input) {
9061
9918
  variants.set(key2, v);
9062
9919
  continue;
9063
9920
  }
9064
- const key = `${finding.category}|${norm2(finding)}`;
9921
+ const key = `${finding.category}|${norm3(finding)}`;
9065
9922
  const t = tokens.get(key) ?? { category: finding.category, value: finding.value, name, hits: [] };
9066
9923
  if (!t.name && name) t.name = name;
9067
9924
  t.hits.push(hit);
@@ -9085,7 +9942,7 @@ function missingPieces(input) {
9085
9942
  const values = [];
9086
9943
  for (const h of v.hits) {
9087
9944
  const pv = { category: h.finding.category, ...h.finding.property ? { property: h.finding.property } : {}, value: h.finding.value };
9088
- if (!values.some((x) => x.category === pv.category && x.property === pv.property && norm2(x) === norm2(pv))) values.push(pv);
9945
+ if (!values.some((x) => x.category === pv.category && x.property === pv.property && norm3(x) === norm3(pv))) values.push(pv);
9089
9946
  }
9090
9947
  const aspect = aspectOf(values);
9091
9948
  const bg = v.hits.find((h) => /background/.test(h.finding.property ?? ""))?.finding.suggestion;
@@ -9281,8 +10138,8 @@ __export(explorations_exports, {
9281
10138
  toExplorationBranch: () => toExplorationBranch,
9282
10139
  writeToExploration: () => writeToExploration
9283
10140
  });
9284
- import { mkdir, rm, writeFile } from "node:fs/promises";
9285
- import { dirname as dirname2, join as join3 } from "node:path";
10141
+ import { mkdir as mkdir2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
10142
+ import { dirname as dirname3, join as join4 } from "node:path";
9286
10143
 
9287
10144
  // src/branches.ts
9288
10145
  var branches_exports = {};
@@ -9438,19 +10295,19 @@ async function whatNeedsMe(ctx, a = {}) {
9438
10295
  const pulls = await gh.listPullRequests("open");
9439
10296
  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);
9440
10297
  const who = await people(gh, config);
9441
- const same2 = (x, y) => Boolean(y && x.toLowerCase() === y.toLowerCase());
10298
+ const same3 = (x, y) => Boolean(y && x.toLowerCase() === y.toLowerCase());
9442
10299
  const rows = await Promise.all(
9443
10300
  queue.map(async (p) => {
9444
10301
  const f = await branchFacts(ctx, gh, p.number, viewer).catch(() => null);
9445
- 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 };
9446
- const mine = same2(p.author, viewer);
9447
- const inDesign = viewer ? who.lists.design.some((x) => same2(x, viewer)) : false;
9448
- const inTech = viewer ? who.lists.tech.some((x) => same2(x, viewer)) : false;
10302
+ 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 };
10303
+ const mine = same3(p.author, viewer);
10304
+ const inDesign = viewer ? who.lists.design.some((x) => same3(x, viewer)) : false;
10305
+ const inTech = viewer ? who.lists.tech.some((x) => same3(x, viewer)) : false;
9449
10306
  let needsYou = null;
9450
10307
  if (viewer && !mine) {
9451
10308
  if (f.status === "needs-design" && inDesign) needsYou = "Your design approval";
9452
10309
  else if (f.status === "needs-tech" && inTech) needsYou = "Your tech approval";
9453
- else if (f.status === "needs-approval" && !f.approvals.approvers.some((x) => same2(x, viewer))) needsYou = "Your approval";
10310
+ else if (f.status === "needs-approval" && !f.approvals.approvers.some((x) => same3(x, viewer))) needsYou = "Your approval";
9454
10311
  else if (f.status === "ready" && f.gate.canShip) needsYou = "Ready for you to merge";
9455
10312
  }
9456
10313
  if (viewer && mine) {
@@ -9907,11 +10764,11 @@ async function writeToExploration(ctx, branch, input) {
9907
10764
  };
9908
10765
  }
9909
10766
  for (const f of input.files) {
9910
- const abs = join3(ctx.root, f.path);
9911
- if (f.content === null) await rm(abs, { force: true });
10767
+ const abs = join4(ctx.root, f.path);
10768
+ if (f.content === null) await rm2(abs, { force: true });
9912
10769
  else {
9913
- await mkdir(dirname2(abs), { recursive: true });
9914
- await writeFile(abs, f.content);
10770
+ await mkdir2(dirname3(abs), { recursive: true });
10771
+ await writeFile2(abs, f.content);
9915
10772
  }
9916
10773
  }
9917
10774
  const paths = input.files.map((f) => f.path);
@@ -9922,6 +10779,379 @@ async function writeToExploration(ctx, branch, input) {
9922
10779
  };
9923
10780
  }
9924
10781
 
10782
+ // src/suggestions.ts
10783
+ var suggestions_exports = {};
10784
+ __export(suggestions_exports, {
10785
+ SUGGEST_RIGHTS: () => SUGGEST_RIGHTS,
10786
+ appliedNext: () => appliedNext,
10787
+ applySuggestionOp: () => applySuggestionOp,
10788
+ changesFor: () => changesFor,
10789
+ createSuggestionOp: () => createSuggestionOp,
10790
+ fileSuggestion: () => fileSuggestion,
10791
+ filesSuggestionPayload: () => filesSuggestionPayload,
10792
+ getSuggestionOp: () => getSuggestionOp,
10793
+ githubWorkspace: () => githubWorkspace,
10794
+ linkSuggestionOp: () => linkSuggestionOp,
10795
+ listSuggestionsOp: () => listSuggestionsOp,
10796
+ noSuggestionAccess: () => noSuggestionAccess,
10797
+ reviewSuggestionOp: () => reviewSuggestionOp,
10798
+ suggestionBase: () => suggestionBase,
10799
+ suggestionGovernance: () => suggestionGovernance,
10800
+ suggestionNumber: () => suggestionNumber
10801
+ });
10802
+ var SUGGEST_RIGHTS = "Issues: read and write (Contents: read is enough)";
10803
+ async function changesFor(ctx, explicit) {
10804
+ if (explicit === "suggest" || explicit === "direct") return explicit;
10805
+ const { config } = await ctx.config();
10806
+ return changesMode(config);
10807
+ }
10808
+ 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).`;
10809
+ function noSuggestionAccess(ctx, gh) {
10810
+ const local = ctx.mode === "local" && ctx.isGit ? ', or pass changes "direct" (the CLI: --direct) to commit it on a new local branch instead' : "";
10811
+ 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}.`;
10812
+ 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}.`;
10813
+ }
10814
+ async function suggestionBase(ctx, gh, config) {
10815
+ const ref = ctx.ref ?? baseBranchOf(config);
10816
+ if (ctx.mode === "local" && ctx.isGit && ctx.root) {
10817
+ const sha2 = (await git(ctx.root, ["rev-parse", "HEAD"]).catch(() => "")).trim();
10818
+ if (/^[0-9a-f]{40}$/.test(sha2)) return { ref: baseBranchOf(config), sha: sha2 };
10819
+ }
10820
+ const sha = await gh.getBranchSha(ref);
10821
+ if (!sha) throw new Error(`Branch ${ref} not found on ${gh.fullName}. Check the default branch in ${CONFIG_FILE}.`);
10822
+ return { ref, sha };
10823
+ }
10824
+ async function filesSuggestionPayload(ctx, input) {
10825
+ const files = await Promise.all(
10826
+ input.files.map(async (f) => {
10827
+ const now = await ctx.source.read(f.path);
10828
+ return { path: f.path, beforeSha: now === null ? null : gitBlobSha(now), afterSha: f.content === null ? null : gitBlobSha(f.content), content: f.content };
10829
+ })
10830
+ );
10831
+ return {
10832
+ schema: SUGGESTION_SCHEMA,
10833
+ kind: input.kind,
10834
+ title: input.title,
10835
+ summary: input.summary.trim() || input.title,
10836
+ source: input.source,
10837
+ ...input.suggestedBy ? { suggestedBy: input.suggestedBy.replace(/^@/, "") } : {},
10838
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
10839
+ base: input.base,
10840
+ tier: input.tier,
10841
+ files,
10842
+ checks: { newErrors: 0, contrast: [], blocked: null, impact: 0, ...input.checks }
10843
+ };
10844
+ }
10845
+ async function fileSuggestion(ctx, build, opts = {}) {
10846
+ const { config } = await ctx.config();
10847
+ const gh = await ctx.github();
10848
+ if (opts.dryRun) {
10849
+ const base2 = gh ? await suggestionBase(ctx, gh, config).catch(() => ({ ref: baseBranchOf(config), sha: "0000000" })) : { ref: baseBranchOf(config), sha: "0000000" };
10850
+ const payload2 = await build(base2, null);
10851
+ const issue = composeSuggestionIssue(payload2, { labels: [...config.governance.labels, ...opts.labels ?? []], details: opts.details, repo: gh?.fullName });
10852
+ return { status: "dry-run", suggestion: { title: issue.title, labels: issue.labels, body: issue.body, parts: issue.parts.length } };
10853
+ }
10854
+ if (!gh || !ctx.canWriteRemote) return { status: "preview", message: noSuggestionAccess(ctx, gh) };
10855
+ const base = await suggestionBase(ctx, gh, config);
10856
+ const viewer = await viewerOf(ctx, gh);
10857
+ const payload = await build(base, viewer);
10858
+ const problems = suggestionPathProblems(payload, config);
10859
+ if (problems.length) throw new Error(`Not suggested: ${problems.join(" ")}`);
10860
+ const labels = [.../* @__PURE__ */ new Set([...config.governance.labels, ...opts.labels ?? []])];
10861
+ const created = await createSuggestionOnGitHub(gh, payload, { labels, details: opts.details, assignees: config.integrations?.requests?.assignees ?? [] });
10862
+ return { status: "suggested", number: created.number, url: created.url, sha: created.sha, labels, warnings: created.warnings, next: appliedNext(created.number) };
10863
+ }
10864
+ function suggestionNumber(input) {
10865
+ if (typeof input === "number" && Number.isInteger(input) && input > 0) return input;
10866
+ const m = /^#?(\d+)$/.exec(String(input).trim()) ?? /\/issues\/(\d+)/.exec(String(input));
10867
+ if (!m) throw new Error(`"${input}" is not a suggestion number. Pass the issue number (42 or "#42") or its link.`);
10868
+ return Number(m[1]);
10869
+ }
10870
+ async function suggestionGovernance(ctx, gh) {
10871
+ const { config: here } = await ctx.config();
10872
+ let config = here;
10873
+ try {
10874
+ const text = await gh.readFile(CONFIG_FILE, baseBranchOf(here));
10875
+ if (text) config = loadConfig(text);
10876
+ } catch {
10877
+ }
10878
+ const { owners, rights } = config.governance;
10879
+ const hasTeams = [...owners.design, ...owners.tech].some((o) => o.includes("/"));
10880
+ const teams = hasTeams ? await new TeamDirectory(gh).resolve(owners).catch(() => void 0) : void 0;
10881
+ return { config, gov: { owners, rights: rights ?? null, ...teams ? { teams } : {} } };
10882
+ }
10883
+ var OPEN_STATUSES = ["needs-review", "changes-requested", "approved", "applying", "outdated"];
10884
+ async function listSuggestionsOp(ctx, a = {}) {
10885
+ const gh = await reader(ctx);
10886
+ const status = a.status ?? "all";
10887
+ const state = a.state ?? (status === "all" ? "open" : OPEN_STATUSES.includes(status) ? "open" : "closed");
10888
+ let rows = await listSuggestionsOnGitHub(gh, { state, perPage: 100 });
10889
+ if (status !== "all") rows = rows.filter((r) => r.status === status);
10890
+ if (a.kind) rows = rows.filter((r) => r.kind === a.kind);
10891
+ const total = rows.length;
10892
+ rows = rows.slice(0, a.limit ?? 100);
10893
+ const approved = rows.filter((r) => r.status === "approved");
10894
+ return {
10895
+ repo: gh.fullName,
10896
+ label: SUGGESTION_LABEL,
10897
+ state,
10898
+ status,
10899
+ total,
10900
+ suggestions: rows.map((r) => ({
10901
+ number: r.number,
10902
+ title: r.title,
10903
+ kind: r.kind ?? null,
10904
+ tier: r.tier ?? null,
10905
+ status: r.status,
10906
+ said: SUGGESTION_STATUS_LABEL[r.status],
10907
+ author: r.author,
10908
+ url: r.url,
10909
+ linkedPrs: r.linkedPrs
10910
+ })),
10911
+ 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."
10912
+ };
10913
+ }
10914
+ var roleText = (m) => m === "approval" ? "one approval" : `${m} approval`;
10915
+ async function getSuggestionOp(ctx, a) {
10916
+ const n = suggestionNumber(a.number);
10917
+ const gh = await reader(ctx);
10918
+ const { gov } = await suggestionGovernance(ctx, gh);
10919
+ const d = await getSuggestionOnGitHub(gh, n, gov);
10920
+ 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 };
10921
+ if (!d.parsed.ok) {
10922
+ 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.`);
10923
+ return { ...base, problem: d.parsed.message, next: "Ask the author to send it again from Bridge." };
10924
+ }
10925
+ const p = d.parsed.payload;
10926
+ const approvals = d.approvals;
10927
+ const missing = pendingApprovals(p.tier.tier, approvals);
10928
+ return {
10929
+ ...base,
10930
+ kind: p.kind,
10931
+ summary: p.summary,
10932
+ source: p.source,
10933
+ ...p.suggestedBy ? { suggestedBy: p.suggestedBy } : {},
10934
+ createdAt: p.createdAt,
10935
+ base: p.base,
10936
+ tier: p.tier.tier,
10937
+ bump: p.tier.bump,
10938
+ version: d.parsed.sha.slice(0, 7),
10939
+ payloadSha: d.parsed.sha,
10940
+ ...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." } : {},
10941
+ change: {
10942
+ ...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 } : {},
10943
+ ...p.docs ? { docs: p.docs.map((x) => ({ component: x.component, path: x.path })) } : {},
10944
+ ...p.componentSpec ? { componentSpec: { component: p.componentSpec.component, specPath: p.componentSpec.specPath } } : {},
10945
+ ...p.files?.length ? { files: p.files.map((f) => ({ path: f.path, change: f.beforeSha === null ? "new" : f.afterSha === null ? "deleted" : "changed" })) } : {}
10946
+ },
10947
+ checks: p.checks,
10948
+ ...p.links ? { links: p.links } : {},
10949
+ ...p.previews?.length ? { previews: p.previews } : {},
10950
+ approvals: {
10951
+ approvedBy: approvals.approvers,
10952
+ missing: missing.map(roleText),
10953
+ changesRequestedBy: approvals.changesRequested,
10954
+ ...approvals.stale.length ? { staleReviews: approvals.stale } : {},
10955
+ ...approvals.ignored.length ? { notCounted: approvals.ignored } : {}
10956
+ },
10957
+ applied: d.applied,
10958
+ ...p.componentSpec ? { brief: p.componentSpec.brief } : {},
10959
+ 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.`
10960
+ };
10961
+ }
10962
+ async function reviewSuggestionOp(ctx, a) {
10963
+ const n = suggestionNumber(a.number);
10964
+ const gh = await writer(ctx, "Reviewing a suggestion", "Issues: read and write");
10965
+ const { gov } = await suggestionGovernance(ctx, gh);
10966
+ const viewer = await viewerOf(ctx, gh);
10967
+ if (a.event === "decline") {
10968
+ if (!a.message?.trim()) throw new Error("Say why it is declined, in a sentence: the author reads it.");
10969
+ if (a.payloadSha) {
10970
+ const d = await getSuggestionOnGitHub(gh, n, gov);
10971
+ 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.`);
10972
+ }
10973
+ const r2 = await declineSuggestionOnGitHub(gh, n, a.message.trim(), viewer);
10974
+ 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." };
10975
+ }
10976
+ if (a.event !== "comment" && viewer) {
10977
+ const lists = approverLists(gov.owners, gov.rights ?? null, gov.teams ?? void 0);
10978
+ const owners = [...lists.design, ...lists.tech].map((l) => l.toLowerCase().replace(/^@/, ""));
10979
+ if (owners.length && !owners.includes(viewer.toLowerCase())) {
10980
+ 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.`);
10981
+ }
10982
+ }
10983
+ const payloadSha = a.payloadSha && /^[0-9a-f]{64}$/.test(a.payloadSha) ? a.payloadSha : void 0;
10984
+ if (a.payloadSha && !payloadSha) {
10985
+ const d = await getSuggestionOnGitHub(gh, n, gov);
10986
+ 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.`);
10987
+ }
10988
+ const r = await reviewSuggestionOnGitHub(gh, n, { event: a.event, role: a.role, message: a.message, payloadSha, ...viewer ? { by: viewer } : {} }, gov);
10989
+ return {
10990
+ number: n,
10991
+ event: a.event,
10992
+ counts: r.counts,
10993
+ version: r.payloadSha.slice(0, 7),
10994
+ status: r.status,
10995
+ said: SUGGESTION_STATUS_LABEL[r.status],
10996
+ comment: r.commentUrl,
10997
+ ...r.warnings.length ? { warnings: r.warnings } : {},
10998
+ 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."
10999
+ };
11000
+ }
11001
+ function githubWorkspace(gh, ref) {
11002
+ let files;
11003
+ return {
11004
+ read: async (path) => await gh.getFile(path, ref) ?? null,
11005
+ readAt: async (sha, path) => await gh.readFile(path, sha).catch(() => void 0) ?? null,
11006
+ list: () => files ??= gh.listFiles(ref)
11007
+ };
11008
+ }
11009
+ var MAX_DIFF = 12e3;
11010
+ var cut = (s) => s && s.length > MAX_DIFF ? `${s.slice(0, MAX_DIFF)}
11011
+ ... (truncated)` : s;
11012
+ function planFacts(plan) {
11013
+ return {
11014
+ outcome: plan.outcome,
11015
+ exitCode: plan.exitCode,
11016
+ version: plan.payloadSha.slice(0, 7),
11017
+ table: suggestionApplyTable(plan),
11018
+ ...plan.problems.length ? { problems: plan.problems } : {},
11019
+ ...plan.notes.length ? { notes: plan.notes } : {},
11020
+ ...plan.gate ? { approvals: { review: plan.gate.review, missing: plan.gate.missing.map(roleText), forced: plan.gate.forced, edited: plan.gate.edited } } : {},
11021
+ ...plan.checks ? {
11022
+ checks: {
11023
+ tier: plan.checks.tier.tier,
11024
+ tierWhenSuggested: plan.checks.tierWhenSuggested,
11025
+ tierIncreased: plan.checks.tierIncreased,
11026
+ newErrors: plan.checks.newErrors.length,
11027
+ newContrastFailures: plan.checks.newFailures.map((f) => `${f.fg} on ${f.bg}${f.modes ? ` (${f.modes})` : ""}`)
11028
+ }
11029
+ } : {},
11030
+ ...plan.brief ? { brief: plan.brief } : {}
11031
+ };
11032
+ }
11033
+ async function applySuggestionOp(ctx, a) {
11034
+ const n = suggestionNumber(a.number);
11035
+ const gh = await reader(ctx);
11036
+ const { config, gov } = await suggestionGovernance(ctx, gh);
11037
+ const d = await getSuggestionOnGitHub(gh, n, gov);
11038
+ if (!d.parsed.ok) {
11039
+ 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.`);
11040
+ return { number: n, status: "refused", outcome: "refused", exitCode: 1, message: `Not applied: ${d.parsed.message}` };
11041
+ }
11042
+ 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.` };
11043
+ const parsed = d.parsed;
11044
+ const dryRun = a.dryRun || a.mode === "dry-run";
11045
+ const mode = a.mode && a.mode !== "dry-run" ? a.mode : "branch";
11046
+ const common = { parsed, comments: d.comments, governance: gov, author: d.issue.author || null, acceptEdited: a.acceptEdited, forceUnapproved: a.forceUnapproved };
11047
+ if (ctx.mode !== "local" || !ctx.isGit || !ctx.root) {
11048
+ const ref = ctx.ref ?? baseBranchOf(config);
11049
+ const plan = await planSuggestionApply({ ...common, config, workspace: githubWorkspace(gh, ref) });
11050
+ return {
11051
+ number: n,
11052
+ status: plan.outcome === "ready" ? "planned" : plan.outcome,
11053
+ message: plan.message,
11054
+ ...planFacts(plan),
11055
+ ...plan.outcome === "ready" ? { files: plan.writes } : {},
11056
+ 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
11057
+ };
11058
+ }
11059
+ const viewer = await viewerOf(ctx, gh);
11060
+ const r = await applySuggestionLocally({
11061
+ ...common,
11062
+ root: ctx.root,
11063
+ number: n,
11064
+ mode,
11065
+ dryRun,
11066
+ ...a.base ? { base: a.base } : {},
11067
+ ...a.branch ? { branch: a.branch } : {},
11068
+ ...viewer ? { by: viewer } : {},
11069
+ readBase: async (path, sha) => await gh.readFile(path, sha).catch(() => void 0) ?? null
11070
+ });
11071
+ let recorded = false;
11072
+ const warnings = [];
11073
+ if (r.appliedComment && !dryRun && (r.status === "branch-created" || r.status === "written" || r.status === "branch-exists")) {
11074
+ if (ctx.canWriteRemote) {
11075
+ try {
11076
+ await gh.addComment(n, r.appliedComment);
11077
+ recorded = true;
11078
+ const w = await syncSuggestionStateLabel(gh, n, d.issue.labels, "applying");
11079
+ if (w) warnings.push(w);
11080
+ } catch (e) {
11081
+ warnings.push(`#${n} does not say it is being applied yet (${e.message}). It will once the pull request says "Closes #${n}".`);
11082
+ }
11083
+ } 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}".`);
11084
+ }
11085
+ 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;
11086
+ return {
11087
+ number: n,
11088
+ status: r.status,
11089
+ mode: dryRun ? "dry-run" : mode,
11090
+ message: r.message,
11091
+ ...planFacts(r.plan),
11092
+ exitCode: r.exitCode,
11093
+ ...r.branch ? { branch: r.branch } : {},
11094
+ ...r.commit ? { commit: r.commit } : {},
11095
+ ...r.written ? { written: r.written } : {},
11096
+ ...r.patch ? { patch: r.patch } : {},
11097
+ ...r.diff ? { diff: cut(r.diff) } : {},
11098
+ recorded,
11099
+ ...warnings.length ? { warnings } : {},
11100
+ next
11101
+ };
11102
+ }
11103
+ async function linkSuggestionOp(ctx, a) {
11104
+ const n = suggestionNumber(a.number);
11105
+ const gh = await writer(ctx, "Linking a suggestion", "Issues: read and write");
11106
+ if (!a.pr && !a.branch && !a.commit) throw new Error("Say where it is applied: pr (the pull request number), branch or commit.");
11107
+ const { gov } = await suggestionGovernance(ctx, gh);
11108
+ const d = await getSuggestionOnGitHub(gh, n, gov);
11109
+ if (!d.parsed.ok) throw new Error(`#${n} cannot be linked: ${d.parsed.message}`);
11110
+ const warnings = [];
11111
+ let branch = a.branch;
11112
+ if (a.pr) {
11113
+ const pr = await gh.getPullRequest(a.pr);
11114
+ branch ??= pr.headRef;
11115
+ 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.`);
11116
+ }
11117
+ const viewer = await viewerOf(ctx, gh);
11118
+ const r = await linkSuggestionPrOnGitHub(gh, n, { pr: a.pr, branch, commit: a.commit, by: viewer ?? void 0, payloadSha: d.parsed.sha, forced: a.forced });
11119
+ warnings.push(...r.warnings);
11120
+ 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." };
11121
+ }
11122
+ async function createSuggestionOp(ctx, a) {
11123
+ const { config } = await ctx.config();
11124
+ const gh = await ctx.github();
11125
+ const raw = a.payload ?? {};
11126
+ if (!SUGGESTION_KINDS.includes(raw.kind)) throw new Error(`"kind" must be one of ${SUGGESTION_KINDS.join(", ")}.`);
11127
+ const needsBase = !raw.base;
11128
+ const base = needsBase ? gh ? await suggestionBase(ctx, gh, config).catch(() => null) : null : raw.base;
11129
+ if (!base) throw new Error(`No base commit: pass "base" { "ref", "sha" }, or set "repo" in ${CONFIG_FILE} so Bridge reads it.`);
11130
+ const viewer = gh && !a.dryRun ? await viewerOf(ctx, gh) : null;
11131
+ const payload = {
11132
+ schema: SUGGESTION_SCHEMA,
11133
+ source: a.source ?? "mcp",
11134
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
11135
+ checks: { newErrors: 0, contrast: [], blocked: null, impact: 0 },
11136
+ ...viewer ? { suggestedBy: viewer } : {},
11137
+ ...raw,
11138
+ base
11139
+ };
11140
+ const shape2 = suggestionShapeProblems(payload);
11141
+ if (shape2.length) throw new Error(`The payload is not a suggestion: ${shape2.join(" ")}`);
11142
+ if (payload.checks.blocked) throw new Error(`Not suggested: ${payload.checks.blocked}. Bridge never suggests a change that breaks the gate.`);
11143
+ const paths = suggestionPathProblems(payload, config);
11144
+ if (paths.length) throw new Error(`Not suggested: ${paths.join(" ")}`);
11145
+ const labels = [.../* @__PURE__ */ new Set([...config.governance.labels, ...a.labels ?? []])];
11146
+ if (a.dryRun) {
11147
+ const issue = composeSuggestionIssue(payload, { labels, repo: gh?.fullName });
11148
+ return { status: "dry-run", title: issue.title, labels: issue.labels, body: issue.body, parts: issue.parts.length, version: issue.sha.slice(0, 7) };
11149
+ }
11150
+ if (!gh || !ctx.canWriteRemote) return { status: "preview", message: noSuggestionAccess(ctx, gh), payload };
11151
+ const created = await createSuggestionOnGitHub(gh, payload, { labels, repo: gh.fullName, assignees: config.integrations?.requests?.assignees ?? [] });
11152
+ 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) };
11153
+ }
11154
+
9925
11155
  // src/ops.ts
9926
11156
  var prefixPath = (set, f) => set.repoPath(f);
9927
11157
  function normalizeTokenPath(set, input) {
@@ -9941,6 +11171,7 @@ async function getConfig(ctx) {
9941
11171
  source: ctx.describe(),
9942
11172
  configOrigin: origin === "file" ? "bridge.config.json" : "detected (no bridge.config.json; run `bridge init` to pin it)",
9943
11173
  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)",
11174
+ 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.',
9944
11175
  repo: gh ? gh.fullName : null,
9945
11176
  tokenFiles: set.fileNames.length,
9946
11177
  tokens: set.allPaths().length,
@@ -10268,6 +11499,25 @@ async function proposeTokenChange(ctx, a) {
10268
11499
  if (newErrors.length || newFailures.length) {
10269
11500
  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." };
10270
11501
  }
11502
+ if (!target && await changesFor(ctx, a.changes) === "suggest") {
11503
+ const source = a.source ?? "mcp";
11504
+ const shown = { ...report, changesMode: "suggest", files: files.map((f) => f.path) };
11505
+ const gh2 = await ctx.github();
11506
+ if (a.dryRun) return { status: "dry-run", ...shown, suggestion: { title: suggestionIssueTitle({ kind: "token-change", title: a.title }), body }, fileContents: files };
11507
+ if (!gh2 || !ctx.canWriteRemote) return { status: "preview", message: noSuggestionAccess(ctx, gh2), ...shown, fileContents: files };
11508
+ const base2 = await suggestionBase(ctx, gh2, config);
11509
+ const viewer = await viewerOf(ctx, gh2);
11510
+ const s = await suggestTokenProposalOnGitHub(gh2, plan, {
11511
+ base: base2.ref,
11512
+ baseSha: base2.sha,
11513
+ source,
11514
+ ...viewer ? { suggestedBy: viewer } : {},
11515
+ labels: config.governance.labels,
11516
+ labelsAfter: marks.labels,
11517
+ assignees: config.integrations?.requests?.assignees ?? []
11518
+ });
11519
+ 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) };
11520
+ }
10271
11521
  let branch = target ? target.branch : branchWithJiraKey(`${config.governance.branchPrefix}${slugTitle}`, jira);
10272
11522
  const preview = { ...report, branch, files: files.map((f) => f.path), prBody: body };
10273
11523
  if (a.dryRun) return { status: "dry-run", ...preview, fileContents: files };
@@ -10419,8 +11669,8 @@ async function getReview(ctx, a) {
10419
11669
  const delta2 = pairs.length ? core.contrastDelta(core.runContrast(before, pairs, config.contrast?.modes), core.runContrast(after, pairs, config.contrast?.modes)) : [];
10420
11670
  const prevErrors = new Set(core.validate(before).map(issueKey3));
10421
11671
  const errors = core.validate(after).filter((i) => i.level === "error");
10422
- const tokenRoot = config.tokens.root.replace(/\/$/, "");
10423
- const otherFiles = changedFiles.filter((f) => !f.startsWith(`${tokenRoot}/`) && !f.startsWith(`${config.changesets?.dir ?? ".changeset"}/`));
11672
+ const tokenRoot2 = config.tokens.root.replace(/\/$/, "");
11673
+ const otherFiles = changedFiles.filter((f) => !f.startsWith(`${tokenRoot2}/`) && !f.startsWith(`${config.changesets?.dir ?? ".changeset"}/`));
10424
11674
  const tier = core.classify(changes, otherFiles, config);
10425
11675
  const imp = core.impact(before, after, changes, 20);
10426
11676
  const labelTier = Array.isArray(meta.labels) ? meta.labels.map((l) => /^tier:\s*(\d)/i.exec(l)?.[1]).find(Boolean) : void 0;
@@ -10477,12 +11727,12 @@ async function scaffoldComponentOp(ctx, a) {
10477
11727
  const write = ctx.mode === "local" && a.write !== false;
10478
11728
  if (write) {
10479
11729
  for (const f of res.files) {
10480
- const abs = join4(ctx.root, f.path);
11730
+ const abs = join5(ctx.root, f.path);
10481
11731
  if (await access(abs).then(() => true, () => false)) throw new Error(`${f.path} exists; not overwriting.`);
10482
11732
  }
10483
11733
  for (const f of res.files) {
10484
- await mkdir2(dirname3(join4(ctx.root, f.path)), { recursive: true });
10485
- await writeFile2(join4(ctx.root, f.path), f.content);
11734
+ await mkdir3(dirname4(join5(ctx.root, f.path)), { recursive: true });
11735
+ await writeFile3(join5(ctx.root, f.path), f.content);
10486
11736
  }
10487
11737
  }
10488
11738
  return { written: write, tier: 2, files: write ? res.files.map((f) => f.path) : res.files, nextSteps: res.notes };
@@ -10494,8 +11744,8 @@ async function auditUi(ctx, code2) {
10494
11744
  async function visualImpact(ctx, a, run2) {
10495
11745
  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.");
10496
11746
  if (!ctx.isGit) throw new Error(`${ctx.root} is not a git repository.`);
10497
- const out = a.out ? resolve3(ctx.root, a.out) : join4(ctx.root, "visual-impact");
10498
- const runImpact = run2 ?? (await import("./src-F2PTXW5B.js")).runImpact;
11747
+ const out = a.out ? resolve3(ctx.root, a.out) : join5(ctx.root, "visual-impact");
11748
+ const runImpact = run2 ?? (await import("./src-ABI7LARD.js")).runImpact;
10499
11749
  const r = await runImpact({
10500
11750
  root: ctx.root,
10501
11751
  base: a.base ?? "main",
@@ -10528,7 +11778,7 @@ async function visualImpact(ctx, a, run2) {
10528
11778
  status: s.status,
10529
11779
  percent: s.percent,
10530
11780
  boxes: s.boxes.length,
10531
- ...s.images.composite ? { composite: join4(r.outDir, s.images.composite) } : {},
11781
+ ...s.images.composite ? { composite: join5(r.outDir, s.images.composite) } : {},
10532
11782
  ...s.error ? { error: s.error } : {}
10533
11783
  })),
10534
11784
  outDir: r.outDir,
@@ -10772,6 +12022,17 @@ async function proposeRevert(ctx, a) {
10772
12022
  const plan = await buildPlan(ctx, a, ctx.mode === "local" ? "the bridge CLI" : "Bridge MCP");
10773
12023
  const report = planReport(plan);
10774
12024
  if (plan.status !== "ready") return { ...report, hint: plan.status === "blocked" ? "Bridge never opens a change that breaks the gate." : "Nothing to propose." };
12025
+ if (await changesFor(ctx, a.changes) === "suggest") {
12026
+ const summary2 = plan.commitMessage.split("\n").slice(1).join("\n").trim() || plan.prTitle;
12027
+ const r = await fileSuggestion(
12028
+ ctx,
12029
+ (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 } }),
12030
+ { labels: [plan.kind === "revert" ? "revert" : "restore", ...plan.tier.tier === 3 ? ["tokens"] : [], ...plan.tier.bump === "major" ? ["breaking"] : []], details: plan.prBody, dryRun: a.dryRun }
12031
+ );
12032
+ if (r.status === "dry-run") return { ...report, status: "dry-run", changesMode: "suggest", suggestion: r.suggestion, fileContents: plan.files };
12033
+ if (r.status === "preview") return { ...report, status: "preview", message: r.message, changesMode: "suggest", fileContents: plan.files };
12034
+ 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 };
12035
+ }
10775
12036
  if (a.dryRun) return { ...report, status: "dry-run", prBody: plan.prBody, fileContents: plan.files };
10776
12037
  const prefix = config.governance.branchPrefix;
10777
12038
  let branch = plan.branch;
@@ -10891,6 +12152,17 @@ async function proposeBrand(ctx, a) {
10891
12152
  const prefix = config.governance.branchPrefix;
10892
12153
  const stem2 = `${prefix}brand-${plan.slug}`;
10893
12154
  let branch = stem2;
12155
+ if (await changesFor(ctx, a.changes) === "suggest") {
12156
+ const summary2 = a.notes?.trim() || `New brand "${plan.slug}", started from ${plan.from}. ${report.contrast.line} (${plan.contrast.themes.join(" and ")}).`;
12157
+ const r = await fileSuggestion(
12158
+ ctx,
12159
+ (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 }),
12160
+ { labels: ["tokens"], details: report.prBody, dryRun: a.dryRun }
12161
+ );
12162
+ if (r.status === "dry-run") return { status: "dry-run", ...report, changesMode: "suggest", suggestion: r.suggestion, fileContents: files };
12163
+ if (r.status === "preview") return { status: "preview", message: r.message, ...report, changesMode: "suggest", fileContents: files };
12164
+ 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 };
12165
+ }
10894
12166
  if (a.dryRun) return { status: "dry-run", ...report, branch, fileContents: files };
10895
12167
  const message = `feat(tokens): add the ${plan.name} brand
10896
12168
 
@@ -11054,7 +12326,7 @@ async function createRequest(ctx, a) {
11054
12326
  ${marks.line}`, labels: [...composed.labels, ...marks.labels] } : composed;
11055
12327
  if (a.dryRun) return { status: "dry-run", issue };
11056
12328
  const gh = await requireGitHub2(ctx);
11057
- if (!ctx.canWriteRemote) throw new Error("Creating a request needs GITHUB_TOKEN (issues: write on this repository). Without it Bridge is read-only.");
12329
+ 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.");
11058
12330
  const created = await gh.createIssue(issue);
11059
12331
  return {
11060
12332
  status: "created",
@@ -11162,7 +12434,8 @@ async function saveDocs(ctx, a) {
11162
12434
  return docsFileText(cleanDocs(merged), current);
11163
12435
  };
11164
12436
  const gh = await ctx.github();
11165
- const open = a.pr === void 0 && gh ? await findOpenDocsProposalOnGitHub(gh, { name, slug: docsSlug(name) }, ctx.ref ?? baseBranchOf(config)).catch(() => null) : null;
12437
+ const mode = a.pr === void 0 ? await changesFor(ctx, a.changes) : "direct";
12438
+ const open = a.pr === void 0 && gh && mode === "direct" ? await findOpenDocsProposalOnGitHub(gh, { name, slug: docsSlug(name) }, ctx.ref ?? baseBranchOf(config)).catch(() => null) : null;
11166
12439
  const target = a.pr ?? open?.number;
11167
12440
  if (target !== void 0) {
11168
12441
  const n = branchNumber(target);
@@ -11198,7 +12471,36 @@ async function saveDocs(ctx, a) {
11198
12471
  path,
11199
12472
  content: text,
11200
12473
  product: product.name,
11201
- ...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.` }
12474
+ changesMode: mode,
12475
+ ...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.` }
12476
+ };
12477
+ }
12478
+ if (mode === "suggest") {
12479
+ const base = await suggestionBase(ctx, gh, config);
12480
+ const viewer = await viewerOf(ctx, gh);
12481
+ const r2 = await proposeDocsOnGitHub(gh, [edit], {
12482
+ base: base.ref,
12483
+ baseSha: base.sha,
12484
+ mode: "suggest",
12485
+ title: a.title,
12486
+ notes,
12487
+ jira,
12488
+ origin: DOCS_MCP_ORIGIN,
12489
+ extraLabels: marks.labels,
12490
+ source: a.source ?? "mcp",
12491
+ ...viewer ? { suggestedBy: viewer } : {}
12492
+ });
12493
+ return {
12494
+ status: "suggested",
12495
+ number: r2.number,
12496
+ url: r2.url,
12497
+ title: r2.title,
12498
+ path,
12499
+ product: product.name,
12500
+ changesMode: mode,
12501
+ ..."warnings" in r2 && r2.warnings.length ? { warnings: r2.warnings } : {},
12502
+ said: `Suggested the ${name} docs as #${r2.number}. Docs only: one approval, then a developer applies it.`,
12503
+ next: appliedNext(r2.number)
11202
12504
  };
11203
12505
  }
11204
12506
  const r = await proposeDocsOnGitHub(gh.withBranchPrefix("docs/"), [edit], {
@@ -11305,13 +12607,18 @@ async function missingPiecesOp(ctx, a) {
11305
12607
  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."
11306
12608
  };
11307
12609
  }
11308
- function tokenWriter(ctx, gh, source) {
11309
- return async (plan) => {
12610
+ function tokenWriter(ctx, gh, source, viewer) {
12611
+ return async (plan, how) => {
11310
12612
  const { config } = await ctx.config();
11311
12613
  const [before, pairs] = await Promise.all([ctx.tokens(), core.readContrastPairs(ctx.source, config).catch(() => [])]);
11312
12614
  const p = planTokenProposal({ before, pairs, edits: plan.edits, title: plan.title, summary: plan.summary, config, tool: source === "mcp" ? "Bridge MCP" : "the bridge CLI" });
11313
12615
  if (!p) throw new Error("The token already exists with that value: nothing to add.");
11314
- 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).`);
12616
+ 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).`);
12617
+ if (how.mode === "suggest") {
12618
+ const base = await suggestionBase(ctx, gh, config);
12619
+ 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 });
12620
+ return { status: "suggested", number: s.number, url: s.url };
12621
+ }
11315
12622
  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" });
11316
12623
  return { number: opened.pr, url: opened.url, branch: opened.branch };
11317
12624
  };
@@ -11319,7 +12626,8 @@ function tokenWriter(ctx, gh, source) {
11319
12626
  async function openLibraryBranchOp(ctx, a) {
11320
12627
  const n = branchNumber(a.pr);
11321
12628
  const source = a.source ?? "mcp";
11322
- const gh = a.dryRun ? await reader(ctx) : await writer(ctx, "Opening a library branch", NEED);
12629
+ const mode = await changesFor(ctx, a.changes);
12630
+ 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);
11323
12631
  const { config } = await ctx.config();
11324
12632
  const viewer = await viewerOf(ctx, gh);
11325
12633
  const s = settingsOf(config);
@@ -11329,7 +12637,8 @@ async function openLibraryBranchOp(ctx, a) {
11329
12637
  base: s.base,
11330
12638
  products: productsOf(config),
11331
12639
  exploration: { packages: s.packages, apps: s.apps },
11332
- writeTokens: tokenWriter(ctx, gh, source),
12640
+ writeTokens: tokenWriter(ctx, gh, source, viewer),
12641
+ mode,
11333
12642
  ...viewer ? { openedBy: viewer } : {},
11334
12643
  ...source === "mcp" ? { agent: "claude" } : {},
11335
12644
  source,
@@ -11337,8 +12646,8 @@ async function openLibraryBranchOp(ctx, a) {
11337
12646
  dryRun: a.dryRun
11338
12647
  });
11339
12648
  const plan = r.plan;
11340
- const what = plan.kind === "token-change" ? "token change" : plan.kind === "exploration" ? "library exploration" : "component request";
11341
- 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.";
12649
+ const what = plan.kind === "token-change" ? mode === "suggest" ? "token suggestion" : "token change" : plan.kind === "exploration" ? "library exploration" : "component request";
12650
+ 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.";
11342
12651
  return {
11343
12652
  status: r.status,
11344
12653
  pr: n,
@@ -11348,6 +12657,7 @@ async function openLibraryBranchOp(ctx, a) {
11348
12657
  ...plan.kind === "token-change" ? { edits: plan.edits } : {},
11349
12658
  ...plan.kind === "exploration" ? { exploration: plan.exploration.name, brief: plan.brief } : {},
11350
12659
  ...plan.kind === "request" ? { request: plan.request.title } : {},
12660
+ changesMode: mode,
11351
12661
  neededBy: `${plan.neededBy.title} (#${plan.neededBy.pr}) in ${plan.neededBy.productName}`,
11352
12662
  ...r.link ? { library: { kind: r.link.kind, number: r.link.number, url: r.link.url, ...r.link.branch ? { branch: r.link.branch } : {} } } : {},
11353
12663
  ...r.preview ? { preview: r.preview } : {},
@@ -11453,7 +12763,10 @@ async function pushFigmaComponentOp(ctx, a) {
11453
12763
  const { config } = await ctx.config();
11454
12764
  const set = await ctx.tokens();
11455
12765
  const tokens = await tokenFacts(ctx, set, d.component);
11456
- const gh = a.dryRun ? await reader(ctx) : await writer(ctx, "Sending a component change from Figma", NEED2);
12766
+ const mode = await changesFor(ctx, a.changes);
12767
+ 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);
12768
+ const viewer = a.dryRun ? null : await viewerOf(ctx, gh);
12769
+ const base = a.dryRun || mode !== "suggest" ? null : await suggestionBase(ctx, gh, config);
11457
12770
  const pairs = await core.readContrastPairs(ctx.source, config).catch(() => []);
11458
12771
  const aliasOfPath = (p) => aliasOf(set.defsOf(p)[0]?.value) ?? void 0;
11459
12772
  const r = await openComponentPushOnGitHub(gh, {
@@ -11464,11 +12777,19 @@ async function pushFigmaComponentOp(ctx, a) {
11464
12777
  labels: [...config.governance.labels, `source: ${source}`],
11465
12778
  tool,
11466
12779
  dryRun: a.dryRun,
11467
- writeTokens: async ({ edits, title, summary: summary2, bodyExtra }) => {
12780
+ mode,
12781
+ ...viewer ? { suggestedBy: viewer } : {},
12782
+ ...base ? { base: base.ref, baseSha: base.sha } : {},
12783
+ writeTokens: async ({ edits, title, summary: summary2, bodyExtra, mode: how, figma }) => {
11468
12784
  const all = [...pairs, ...componentContrastPairs(edits, pairs, { aliasOf: aliasOfPath, paths: set.allPaths() })];
11469
12785
  const p = planTokenProposal({ before: set, pairs: all, edits, title, summary: summary2, config, tool, bodyExtra });
11470
12786
  if (!p) throw new Error("The library already paints it this way: nothing to send.");
11471
12787
  if (p.blocked) throw new Error(`Not sent: ${p.blocked}. Pick another variable.`);
12788
+ if (how === "suggest") {
12789
+ const b = base ?? await suggestionBase(ctx, gh, config);
12790
+ 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 } });
12791
+ return { status: "suggested", number: s.number, url: s.url };
12792
+ }
11472
12793
  const opened = await writeTokenProposalOnGitHub(gh, p, { base: baseBranchOf(config), branchPrefix: config.governance.branchPrefix, labels: config.governance.labels, labelsAfter: [`source: ${source}`], tool: `${tool} (source: ${source})` });
11473
12794
  return { number: opened.pr, url: opened.url, branch: opened.branch };
11474
12795
  }
@@ -11482,20 +12803,22 @@ async function pushFigmaComponentOp(ctx, a) {
11482
12803
  ...plan.edits.length ? { edits: plan.edits } : {},
11483
12804
  ...plan.reasons.length ? { reasons: plan.reasons } : {},
11484
12805
  ...plan.issues.length ? { fixInFigma: plan.issues } : {},
12806
+ changesMode: mode,
11485
12807
  ...r.link ? { library: r.link } : {},
11486
- next: r.status === "dry-run" ? "Run it without dryRun to open the library branch." : r.status === "not-sent" ? "Nothing was sent: fix these in Figma, then push again." : 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."
12808
+ ...r.suggestion ? { suggestion: r.suggestion } : {},
12809
+ 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."
11487
12810
  };
11488
12811
  }
11489
12812
 
11490
12813
  // src/server.ts
11491
- var VERSION = true ? "0.6.0" : "dev";
12814
+ var VERSION = true ? "0.7.1" : "dev";
11492
12815
  var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
11493
- var fail = (e) => ({ content: [{ type: "text", text: `Error: ${e.message ?? String(e)}` }], isError: true });
12816
+ var fail2 = (e) => ({ content: [{ type: "text", text: `Error: ${e.message ?? String(e)}` }], isError: true });
11494
12817
  var run = (fn) => async (a) => {
11495
12818
  try {
11496
12819
  return ok(await fn(a));
11497
12820
  } catch (e) {
11498
- return fail(e);
12821
+ return fail2(e);
11499
12822
  }
11500
12823
  };
11501
12824
  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.');
@@ -11510,6 +12833,7 @@ var EditArg = z.object({
11510
12833
  mode: ModeArg.optional().describe('Pick the file that defines the token for this mode, e.g. {"theme":"dark"}. Alternative to `file`.')
11511
12834
  });
11512
12835
  var RO = { readOnlyHint: true, openWorldHint: false };
12836
+ 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.');
11513
12837
  function createServer(ctx) {
11514
12838
  const server = new McpServer(
11515
12839
  { name: "bridge", version: VERSION },
@@ -11517,6 +12841,7 @@ function createServer(ctx) {
11517
12841
  instructions: [
11518
12842
  "Bridge exposes a design system repo (DTCG tokens, components, docs) to you.",
11519
12843
  "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.",
12844
+ '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.',
11520
12845
  "Read before you write: bridge_get_guidelines for the repo rules, bridge_search_tokens / bridge_get_token before using or proposing a token.",
11521
12846
  "Never hardcode a value in UI code when a token exists; never invent a token name (check it with bridge_get_token).",
11522
12847
  "bridge_audit measures how a product codebase adopts the design system (hardcoded values, foreign UI components) and suggests the tokens and components to use.",
@@ -11658,7 +12983,7 @@ function createServer(ctx) {
11658
12983
  "bridge_propose_token_change",
11659
12984
  {
11660
12985
  title: "Propose token change",
11661
- 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.',
12986
+ 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.',
11662
12987
  inputSchema: {
11663
12988
  title: z.string().min(3).describe('Short imperative title, e.g. "Darken muted text in dark theme". Becomes the branch name and PR title.'),
11664
12989
  summary: z.string().optional().describe("Why: the problem and the intent, for the reviewers. Plain sentences, no em dash."),
@@ -11667,11 +12992,12 @@ function createServer(ctx) {
11667
12992
  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."),
11668
12993
  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.'),
11669
12994
  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."),
11670
- 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.")
12995
+ 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."),
12996
+ changes: ChangesArg
11671
12997
  },
11672
12998
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
11673
12999
  },
11674
- run((a) => proposeTokenChange(ctx, a))
13000
+ run((a) => proposeTokenChange(ctx, { ...a, source: "mcp" }))
11675
13001
  );
11676
13002
  const Hue = z.union([z.string(), z.number()]);
11677
13003
  const BrandArgs = {
@@ -11705,11 +13031,11 @@ function createServer(ctx) {
11705
13031
  "bridge_propose_brand",
11706
13032
  {
11707
13033
  title: "Propose a new brand",
11708
- 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.',
11709
- inputSchema: { ...BrandArgs, dryRun: z.boolean().optional().describe("Return the plan and file contents without writing.") },
13034
+ 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.',
13035
+ inputSchema: { ...BrandArgs, dryRun: z.boolean().optional().describe("Return the plan and file contents without writing."), changes: ChangesArg },
11710
13036
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
11711
13037
  },
11712
- run((a) => proposeBrand(ctx, a))
13038
+ run((a) => proposeBrand(ctx, { ...a, source: "mcp" }))
11713
13039
  );
11714
13040
  server.registerTool(
11715
13041
  "bridge_list_reviews",
@@ -11801,17 +13127,18 @@ function createServer(ctx) {
11801
13127
  "bridge_propose_revert",
11802
13128
  {
11803
13129
  title: "Propose a revert",
11804
- 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.",
13130
+ 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.',
11805
13131
  inputSchema: {
11806
13132
  target: z.string().optional(),
11807
13133
  restore: RestoreArg.optional(),
11808
13134
  resolutions: ResolutionsArg.optional(),
11809
13135
  restoreConflicts: z.boolean().optional(),
11810
- dryRun: z.boolean().optional()
13136
+ dryRun: z.boolean().optional(),
13137
+ changes: ChangesArg
11811
13138
  },
11812
13139
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
11813
13140
  },
11814
- run((a) => proposeRevert(ctx, a))
13141
+ run((a) => proposeRevert(ctx, { ...a, source: "mcp" }))
11815
13142
  );
11816
13143
  server.registerTool(
11817
13144
  "bridge_scaffold_component",
@@ -11945,7 +13272,7 @@ function createServer(ctx) {
11945
13272
  ]
11946
13273
  };
11947
13274
  } catch (e) {
11948
- return fail(e);
13275
+ return fail2(e);
11949
13276
  }
11950
13277
  }
11951
13278
  );
@@ -12042,8 +13369,8 @@ function createServer(ctx) {
12042
13369
  "bridge_open_library_branch",
12043
13370
  {
12044
13371
  title: "Open a library branch for a missing piece",
12045
- 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.',
12046
- 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() },
13372
+ 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.',
13373
+ 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 },
12047
13374
  annotations: WRITE
12048
13375
  },
12049
13376
  run((a) => openLibraryBranchOp(ctx, { ...a, source: "mcp" }))
@@ -12112,11 +13439,11 @@ function createServer(ctx) {
12112
13439
  "bridge_push_figma_component",
12113
13440
  {
12114
13441
  title: "Send a component change from Figma",
12115
- 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. dryRun shows the route. Needs GITHUB_TOKEN (contents and pull requests: write). Only when the person asked.",
12116
- inputSchema: { diff: z.record(z.string(), z.unknown()).describe("The structured component diff (v 1)."), dryRun: z.boolean().optional() },
13442
+ 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.',
13443
+ inputSchema: { diff: z.record(z.string(), z.unknown()).describe("The structured component diff (v 1)."), dryRun: z.boolean().optional(), changes: ChangesArg },
12117
13444
  annotations: WRITE
12118
13445
  },
12119
- run((a) => pushFigmaComponentOp(ctx, { diff: a.diff, dryRun: a.dryRun, source: "mcp" }))
13446
+ run((a) => pushFigmaComponentOp(ctx, { diff: a.diff, dryRun: a.dryRun, source: "mcp", changes: a.changes }))
12120
13447
  );
12121
13448
  server.registerTool(
12122
13449
  "bridge_list_products",
@@ -12307,7 +13634,7 @@ function createServer(ctx) {
12307
13634
  "bridge_save_docs",
12308
13635
  {
12309
13636
  title: "Save component docs",
12310
- 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.`,
13637
+ 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.`,
12311
13638
  inputSchema: {
12312
13639
  component: z.string().min(1).describe("Component name, e.g. Button."),
12313
13640
  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"}.'),
@@ -12316,11 +13643,100 @@ function createServer(ctx) {
12316
13643
  notes: z.string().optional().describe("Why, for the reviewers."),
12317
13644
  jira: z.string().optional().describe("Jira key like DS-42: first in the branch name and title so Jira links them."),
12318
13645
  product: z.string().optional().describe("Product key or name (bridge_list_products). Default: the product of the current folder or repo."),
12319
- dryRun: z.boolean().optional()
13646
+ dryRun: z.boolean().optional(),
13647
+ changes: ChangesArg
13648
+ },
13649
+ annotations: WRITE
13650
+ },
13651
+ run((a) => saveDocs(ctx, { ...a, source: "mcp" }))
13652
+ );
13653
+ const SuggestionNumber = z.union([z.number().int().positive(), z.string().min(1)]).describe('The suggestion issue number (42 or "#42"), or its link.');
13654
+ server.registerTool(
13655
+ "bridge_list_suggestions",
13656
+ {
13657
+ title: "List suggestions",
13658
+ 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.",
13659
+ inputSchema: {
13660
+ status: z.enum(["needs-review", "changes-requested", "approved", "applying", "applied", "declined", "outdated", "all"]).optional().describe("Default all open ones."),
13661
+ state: z.enum(["open", "closed", "all"]).optional().describe("Issue state. Default: open, or closed for applied and declined."),
13662
+ kind: z.enum(["token-change", "docs", "component-spec", "brand", "revert", "config"]).optional(),
13663
+ limit: z.number().int().min(1).max(100).optional().describe("Default 100.")
13664
+ },
13665
+ annotations: READ
13666
+ },
13667
+ run((a) => listSuggestionsOp(ctx, a))
13668
+ );
13669
+ server.registerTool(
13670
+ "bridge_get_suggestion",
13671
+ {
13672
+ title: "Get a suggestion",
13673
+ 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.",
13674
+ inputSchema: { number: SuggestionNumber },
13675
+ annotations: READ
13676
+ },
13677
+ run((a) => getSuggestionOp(ctx, a))
13678
+ );
13679
+ server.registerTool(
13680
+ "bridge_create_suggestion",
13681
+ {
13682
+ title: "Create a suggestion",
13683
+ 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.",
13684
+ inputSchema: {
13685
+ payload: z.record(z.string(), z.unknown()).describe("The suggestion payload (bridge.suggestion/1)."),
13686
+ labels: z.array(z.string()).optional().describe("More labels for the issue."),
13687
+ dryRun: z.boolean().optional().describe("Return the issue title, body and labels without creating it.")
13688
+ },
13689
+ annotations: WRITE
13690
+ },
13691
+ run((a) => createSuggestionOp(ctx, a))
13692
+ );
13693
+ server.registerTool(
13694
+ "bridge_review_suggestion",
13695
+ {
13696
+ title: "Review a suggestion",
13697
+ 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.",
13698
+ inputSchema: {
13699
+ number: SuggestionNumber,
13700
+ event: z.enum(["approve", "request-changes", "comment", "decline"]),
13701
+ message: z.string().optional().describe("What should change, why it is declined, or the comment. Plain sentences, no em dash."),
13702
+ 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."),
13703
+ payloadSha: z.string().optional().describe("The version you reviewed (payloadSha or version from bridge_get_suggestion).")
13704
+ },
13705
+ annotations: WRITE
13706
+ },
13707
+ run((a) => reviewSuggestionOp(ctx, a))
13708
+ );
13709
+ server.registerTool(
13710
+ "bridge_apply_suggestion",
13711
+ {
13712
+ title: "Apply a suggestion",
13713
+ 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.`,
13714
+ inputSchema: {
13715
+ number: SuggestionNumber,
13716
+ mode: z.enum(["dry-run", "branch", "write", "patch"]).optional().describe("Default branch. Run dry-run first."),
13717
+ acceptEdited: z.boolean().optional().describe("Apply even though the machine-readable block was edited by hand after Bridge wrote it (check it first)."),
13718
+ forceUnapproved: z.boolean().optional().describe("Apply without every approval, or with a tier that went up. Recorded on the issue."),
13719
+ base: z.string().optional().describe("Local mode, branch and patch: the ref to build on. Default HEAD."),
13720
+ branch: z.string().optional().describe("Local mode, branch: the branch name. Default <branchPrefix>suggestion-<n>-<slug>.")
13721
+ },
13722
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
13723
+ },
13724
+ run((a) => applySuggestionOp(ctx, a))
13725
+ );
13726
+ server.registerTool(
13727
+ "bridge_link_suggestion",
13728
+ {
13729
+ title: "Link the pull request that applies a suggestion",
13730
+ 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).',
13731
+ inputSchema: {
13732
+ number: SuggestionNumber,
13733
+ pr: z.number().int().positive().optional().describe("The pull request number."),
13734
+ branch: z.string().optional(),
13735
+ commit: z.string().optional()
12320
13736
  },
12321
13737
  annotations: WRITE
12322
13738
  },
12323
- run((a) => saveDocs(ctx, a))
13739
+ run((a) => linkSuggestionOp(ctx, a))
12324
13740
  );
12325
13741
  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) => {
12326
13742
  const set = await ctx.tokens();
@@ -12537,6 +13953,7 @@ export {
12537
13953
  auditSnippet,
12538
13954
  branches_exports,
12539
13955
  explorations_exports,
13956
+ suggestions_exports,
12540
13957
  ops_exports,
12541
13958
  history_exports,
12542
13959
  brand_exports,