@bridge-ds/mcp 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,16 +14,19 @@ import {
14
14
  __export,
15
15
  aliasOf,
16
16
  approverEntries,
17
+ approverLists,
17
18
  assertSafeBranch,
18
19
  baseBranchOf,
19
20
  branchExists,
20
21
  canShip,
21
22
  changeTable,
23
+ changesMode,
22
24
  checkLayeredPair,
23
25
  classifyLayered,
24
26
  classifyRisk,
25
27
  comboKey,
26
28
  commitToNewLocalBranch,
29
+ commitTree,
27
30
  composeProposalBody,
28
31
  contrastDelta,
29
32
  contrastDeltaTable,
@@ -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;
@@ -5108,6 +5215,15 @@ var SUGGESTION_STATE_LABELS = {
5108
5215
  approved: "suggestion: approved",
5109
5216
  applying: "suggestion: applying"
5110
5217
  };
5218
+ var SUGGESTION_STATUS_LABEL = {
5219
+ "needs-review": "Needs review",
5220
+ "changes-requested": "Changes requested",
5221
+ approved: "Approved, ready to apply",
5222
+ applying: "Being applied",
5223
+ applied: "Applied",
5224
+ declined: "Declined",
5225
+ outdated: "Outdated"
5226
+ };
5111
5227
  var SUGGESTION_LIMITS = {
5112
5228
  body: 65536,
5113
5229
  /** The readable part of the body (tables are cut with "and N more"). */
@@ -5336,6 +5452,13 @@ function suggestionLabels(p, opts = {}) {
5336
5452
  ])
5337
5453
  ];
5338
5454
  }
5455
+ function suggestionKindFromLabels(labels) {
5456
+ for (const l of labels) {
5457
+ const m = /^suggestion: (.+)$/.exec(l);
5458
+ if (m && SUGGESTION_KINDS.includes(m[1])) return m[1];
5459
+ }
5460
+ return void 0;
5461
+ }
5339
5462
  function suggestionStateFromLabels(labels) {
5340
5463
  return Object.entries(SUGGESTION_STATE_LABELS).find(([, l]) => labels.includes(l))?.[0];
5341
5464
  }
@@ -5346,6 +5469,9 @@ function suggestionStatus(i) {
5346
5469
  if (i.outdated) return "outdated";
5347
5470
  return i.review ?? "needs-review";
5348
5471
  }
5472
+ function suggestionStateLabel(status) {
5473
+ return status in SUGGESTION_STATE_LABELS ? SUGGESTION_STATE_LABELS[status] : null;
5474
+ }
5349
5475
  var bridgeMcpInstallCommand = (repo) => `claude mcp add bridge -s user -- npx -y @bridge-ds/mcp --repo ${repo?.trim() || "<owner/name>"}`;
5350
5476
  var MARK3 = "bridge-suggestion";
5351
5477
  var PART_SCHEMA = "bridge.suggestion-part/1";
@@ -5554,6 +5680,72 @@ ${payloadBlock(skeletonJson, sha, n)}
5554
5680
  if (body.length > SUGGESTION_LIMITS.body || partBodies.some((b) => b.length > SUGGESTION_LIMITS.body)) throw new SuggestionError("too-large", SUGGESTION_TOO_LARGE);
5555
5681
  return { ...base, body, parts: partBodies };
5556
5682
  }
5683
+ function fencedAfter(text, from) {
5684
+ const m = /```json\r?\n([\s\S]*?)\r?\n```/.exec(text.slice(from));
5685
+ return m?.[1];
5686
+ }
5687
+ var fail = (reason, message) => ({ ok: false, reason, message });
5688
+ function parseSuggestionIssue(body, opts = {}) {
5689
+ const text = body ?? "";
5690
+ const markers = [...text.matchAll(new RegExp(`^<!-- ${MARK3}:v(\\d+) sha256=([0-9a-f]{64})(?: parts=(\\d+))? -->$`, "gm"))];
5691
+ const marker = markers[markers.length - 1];
5692
+ if (!marker) return fail("no-block", "This issue has no Bridge suggestion block.");
5693
+ if (marker[1] !== "1") return fail("unsupported-version", `This suggestion uses version ${marker[1]} of the format. Update Bridge to read it.`);
5694
+ const json = fencedAfter(text, marker.index + marker[0].length);
5695
+ if (json === void 0) return fail("invalid", "The suggestion block has no JSON. Someone may have edited it by hand: ask the author to send it again.");
5696
+ let raw;
5697
+ try {
5698
+ raw = JSON.parse(json);
5699
+ } catch {
5700
+ return fail("invalid", "The suggestion JSON does not parse. Someone may have edited it by hand: ask the author to send it again.");
5701
+ }
5702
+ const schema = raw?.schema;
5703
+ if (typeof schema === "string" && schema !== SUGGESTION_SCHEMA) {
5704
+ const v = /^bridge\.suggestion\/(\d+)$/.exec(schema);
5705
+ return v ? fail("unsupported-version", `This suggestion uses ${schema}. Update Bridge to read it.`) : fail("invalid", `"${schema}" is not a Bridge suggestion schema.`);
5706
+ }
5707
+ const problems = suggestionShapeProblems(raw);
5708
+ if (problems.length) return fail("invalid", `The suggestion block is not valid: ${problems.join(" ")}`);
5709
+ const payload = raw;
5710
+ const markedSha = marker[2];
5711
+ const expected = marker[3] ? Number(marker[3]) : 0;
5712
+ let edited = false;
5713
+ if (expected > 0 || payload.files?.some((f) => f.part !== void 0)) {
5714
+ const want = Math.max(expected, ...(payload.files ?? []).map((f) => f.part ?? 0));
5715
+ const found = /* @__PURE__ */ new Map();
5716
+ const author = opts.author?.toLowerCase();
5717
+ for (const c of opts.comments ?? []) {
5718
+ if (author && c.author && c.author.toLowerCase() !== author) continue;
5719
+ const m = new RegExp(`^<!-- ${MARK3}:v1 part (\\d+)/(\\d+) sha256=([0-9a-f]{64}) -->$`, "m").exec(c.body);
5720
+ if (!m) continue;
5721
+ const i = Number(m[1]);
5722
+ if (found.has(i)) continue;
5723
+ const pj = fencedAfter(c.body, m.index + m[0].length);
5724
+ if (pj === void 0) continue;
5725
+ if (sha256Hex(pj) !== m[3]) edited = true;
5726
+ try {
5727
+ const part = JSON.parse(pj);
5728
+ if (part.schema !== PART_SCHEMA || !Array.isArray(part.files)) continue;
5729
+ found.set(i, part.files.filter((x) => x && typeof x.i === "number" && typeof x.text === "string"));
5730
+ } catch {
5731
+ continue;
5732
+ }
5733
+ }
5734
+ for (let i = 1; i <= want; i++) {
5735
+ if (!found.has(i)) return fail("missing-part", `This suggestion is incomplete: part ${i} of ${want} is missing. It is still being written, or a comment was deleted: ask the author to send it again.`);
5736
+ }
5737
+ const files = (payload.files ?? []).map((f, idx) => {
5738
+ if (f.part === void 0) return f;
5739
+ let content = "";
5740
+ for (let i = 1; i <= want; i++) for (const ch of found.get(i)) if (ch.i === idx && ch.path === f.path) content += ch.text;
5741
+ const { part: _part, ...rest } = f;
5742
+ return { ...rest, content };
5743
+ });
5744
+ payload.files = files;
5745
+ }
5746
+ const sha = suggestionPayloadSha(payload);
5747
+ return { ok: true, payload, sha, markedSha, edited: edited || sha !== markedSha, parts: expected };
5748
+ }
5557
5749
  function suggestionShapeProblems(raw) {
5558
5750
  const out = [];
5559
5751
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ["The payload must be a JSON object."];
@@ -5593,6 +5785,63 @@ function suggestionShapeProblems(raw) {
5593
5785
  }
5594
5786
  return out;
5595
5787
  }
5788
+ function unsafePathProblem(path) {
5789
+ if (typeof path !== "string" || !path.length) return "A path is empty.";
5790
+ if (/[\0-\x1f\x7f]/.test(path)) return `"${path.replace(/[\0-\x1f\x7f]/g, "?")}" has control characters.`;
5791
+ if (path.includes("\\")) return `"${path}" uses backslashes: paths use forward slashes.`;
5792
+ if (path.startsWith("/") || /^[A-Za-z]:/.test(path) || path.startsWith("~")) return `"${path}" is absolute: paths are relative to the repository root.`;
5793
+ const segs = path.split("/");
5794
+ if (segs.some((s) => s === "" || s === "." || s === "..")) return `"${path}" has an empty, "." or ".." part: paths stay inside the repository.`;
5795
+ if (segs.some((s) => s.toLowerCase() === ".git")) return `"${path}" is inside .git.`;
5796
+ return null;
5797
+ }
5798
+ var under = (path, root) => {
5799
+ const r = root.replace(/\/+$/, "");
5800
+ return !r || r === "." || path === r || path.startsWith(`${r}/`);
5801
+ };
5802
+ function isGuardrailPath(path, guardrails) {
5803
+ return guardrails.some((g) => g.endsWith("/") ? path.startsWith(g) || path === g.slice(0, -1) : path === g);
5804
+ }
5805
+ function suggestionAllowedRoots(kind, config) {
5806
+ const tokenRoot2 = tokensPath(config) || ".";
5807
+ const changesets = config.changesets?.dir ? [config.changesets.dir] : [];
5808
+ switch (kind) {
5809
+ case "token-change":
5810
+ return [tokenRoot2, ...changesets];
5811
+ case "docs":
5812
+ return [...config.components?.dir ? [config.components.dir] : [], ...changesets];
5813
+ case "component-spec":
5814
+ return [".bridge/figma"];
5815
+ case "brand":
5816
+ return [tokenRoot2, ...changesets, CONFIG_FILE];
5817
+ case "config":
5818
+ return [CONFIG_FILE];
5819
+ case "revert":
5820
+ return void 0;
5821
+ }
5822
+ }
5823
+ function suggestionPathProblems(p, config) {
5824
+ const out = [];
5825
+ const roots = suggestionAllowedRoots(p.kind, config);
5826
+ const guardrails = config.governance.guardrails;
5827
+ const check = (path) => {
5828
+ const bad = unsafePathProblem(path);
5829
+ if (bad) return out.push(bad);
5830
+ if (roots && !roots.some((r) => under(path, r))) return out.push(`"${path}" is outside what a ${p.kind} suggestion may change (${roots.join(", ")}).`);
5831
+ const configAllowed = path === CONFIG_FILE && (p.kind === "config" || p.kind === "brand");
5832
+ if (!configAllowed && isGuardrailPath(path, guardrails)) return out.push(`"${path}" is a guardrail file: change it in a pull request, not a suggestion.`);
5833
+ return 0;
5834
+ };
5835
+ for (const f of p.files ?? []) check(f.path);
5836
+ for (const d of p.docs ?? []) check(d.path);
5837
+ if (p.componentSpec) check(p.componentSpec.specPath);
5838
+ for (const e of p.tokens?.edits ?? []) {
5839
+ if (e.file === void 0) continue;
5840
+ const bad = unsafePathProblem(e.file);
5841
+ if (bad) out.push(bad);
5842
+ }
5843
+ return [...new Set(out)];
5844
+ }
5596
5845
 
5597
5846
  // ../core/src/suggestions-github.ts
5598
5847
  async function createSuggestionOnGitHub(gh, payload, opts = {}) {
@@ -5611,6 +5860,97 @@ async function createSuggestionOnGitHub(gh, payload, opts = {}) {
5611
5860
  if (created.labels.length && missing.length) warnings.push(`GitHub did not keep these labels on #${created.number}: ${missing.join(", ")}.`);
5612
5861
  return { number: created.number, url: created.url, sha: issue.sha, parts: issue.parts.length, warnings };
5613
5862
  }
5863
+ function linkedPullsOf(n, pulls) {
5864
+ return pulls.filter((p) => closingIssueNumbers(p.body).includes(n)).map((p) => ({ number: p.number, url: p.url, state: p.state === "open" ? "open" : "closed", merged: p.state === "merged" }));
5865
+ }
5866
+ async function listSuggestionsOnGitHub(gh, opts = {}) {
5867
+ const issues = await gh.listIssues({ label: SUGGESTION_LABEL, state: opts.state ?? "open", perPage: opts.perPage ?? 50 });
5868
+ const pulls = opts.pulls ?? (issues.length ? await gh.listPullRequests("all").catch(() => []) : []);
5869
+ return issues.map((i) => listItem(i, pulls));
5870
+ }
5871
+ function listItem(i, pulls) {
5872
+ const linkedPrs = linkedPullsOf(i.number, pulls);
5873
+ const cached = suggestionStateFromLabels(i.labels);
5874
+ const t = i.labels.map((l) => /^tier: ([123])$/.exec(l)?.[1]).find(Boolean);
5875
+ return {
5876
+ number: i.number,
5877
+ title: i.title,
5878
+ url: i.url,
5879
+ author: i.author,
5880
+ createdAt: i.createdAt,
5881
+ state: i.state,
5882
+ stateReason: i.stateReason,
5883
+ labels: i.labels,
5884
+ kind: suggestionKindFromLabels(i.labels),
5885
+ tier: t ? Number(t) : void 0,
5886
+ linkedPrs,
5887
+ status: suggestionStatus({ state: i.state, stateReason: i.stateReason, linkedPrs, applied: cached === "applying", review: cached === "applying" ? void 0 : cached })
5888
+ };
5889
+ }
5890
+ async function getSuggestionOnGitHub(gh, n, gov, opts = {}) {
5891
+ const [issue, comments, pulls] = await Promise.all([gh.getIssue(n), gh.listIssueComments(n), opts.pulls ? Promise.resolve(opts.pulls) : gh.listPullRequests("all").catch(() => [])]);
5892
+ const parsed = parseSuggestionIssue(issue.body, { comments, author: issue.author || void 0 });
5893
+ const linkedPrs = linkedPullsOf(n, pulls);
5894
+ const applied = comments.flatMap((c) => parseSuggestionAppliedComment(c.body) ?? []);
5895
+ const approvals = parsed.ok ? summarizeSuggestionApprovals(comments, gov, { payloadSha: parsed.sha, author: issue.author }) : void 0;
5896
+ const status = suggestionStatus({
5897
+ state: issue.state,
5898
+ stateReason: issue.stateReason,
5899
+ linkedPrs,
5900
+ applied: parsed.ok && applied.some((a) => a.payloadSha === parsed.sha),
5901
+ outdated: opts.outdated,
5902
+ review: parsed.ok && approvals ? suggestionReviewState(parsed.payload.tier.tier, approvals) : void 0
5903
+ });
5904
+ return { issue, parsed, comments, approvals, linkedPrs, applied, status };
5905
+ }
5906
+ async function syncSuggestionStateLabel(gh, n, labels, status) {
5907
+ const want = suggestionStateLabel(status);
5908
+ let ok2 = true;
5909
+ for (const l of Object.values(SUGGESTION_STATE_LABELS)) {
5910
+ if (l === want || !labels.includes(l)) continue;
5911
+ try {
5912
+ await gh.removeLabel(n, l);
5913
+ } catch {
5914
+ ok2 = false;
5915
+ }
5916
+ }
5917
+ if (want && !labels.includes(want)) ok2 = await gh.addLabels(n, [want]) && ok2;
5918
+ return ok2 ? null : `The state label on #${n} could not be updated; the status shown in Bridge is still right.`;
5919
+ }
5920
+ async function reviewSuggestionOnGitHub(gh, n, input, gov) {
5921
+ if (input.event === "request-changes" && !input.message?.trim()) throw new GitHubError(422, "Say what should change, in a sentence or two: the author reads it.");
5922
+ const before = await getSuggestionOnGitHub(gh, n, gov);
5923
+ if (!before.parsed.ok) throw new GitHubError(422, `#${n} cannot be reviewed: ${before.parsed.message}`);
5924
+ if (before.issue.state === "closed") throw new GitHubError(422, `#${n} is closed. Reopen it on GitHub to review it again.`);
5925
+ const sha = before.parsed.sha;
5926
+ if (input.payloadSha && input.payloadSha !== sha) throw new GitHubError(409, `#${n} changed since you opened it. Reload it and review the new version.`);
5927
+ const by = input.by ?? (await gh.getViewer().catch(() => ({ login: "" }))).login;
5928
+ const body = composeSuggestionReviewComment({ event: input.event, role: input.role, payloadSha: sha, at: (input.at ?? /* @__PURE__ */ new Date()).toISOString(), by: by || void 0, message: input.message });
5929
+ const posted = await gh.createIssueComment(n, body);
5930
+ const after = await getSuggestionOnGitHub(gh, n, gov);
5931
+ const status = after.status;
5932
+ const who = (after.comments.find((c) => c.id === posted.id)?.author ?? by).toLowerCase();
5933
+ const counts = input.event === "comment" || [...after.approvals?.approvers ?? [], ...after.approvals?.changesRequested ?? []].some((l) => l.toLowerCase() === who);
5934
+ const warnings = [];
5935
+ if (!counts) warnings.push("Your review is posted but does not count: only the owners listed in bridge.config.json approve suggestions.");
5936
+ if (before.parsed.edited) warnings.push(`#${n} was edited by hand after Bridge wrote it: check the machine-readable block before applying it.`);
5937
+ const labelWarning = await syncSuggestionStateLabel(gh, n, before.issue.labels, status);
5938
+ if (labelWarning) warnings.push(labelWarning);
5939
+ return { commentUrl: posted.url, payloadSha: sha, status, counts, warnings };
5940
+ }
5941
+ async function linkSuggestionPrOnGitHub(gh, n, link) {
5942
+ if (!link.pr && !link.branch && !link.commit) throw new GitHubError(422, "Say where it is applied: a pull request number, a branch or a commit.");
5943
+ const issue = await gh.getIssue(n);
5944
+ await gh.addComment(n, composeSuggestionAppliedComment({ by: link.by, branch: link.branch, commit: link.commit, pr: link.pr, payloadSha: link.payloadSha, forced: link.forced, at: (link.at ?? /* @__PURE__ */ new Date()).toISOString() }));
5945
+ const warning = await syncSuggestionStateLabel(gh, n, issue.labels, "applying");
5946
+ return { warnings: warning ? [warning] : [] };
5947
+ }
5948
+ async function declineSuggestionOnGitHub(gh, n, reason, by) {
5949
+ const issue = await gh.getIssue(n);
5950
+ await declineRequestOnGitHub(gh, n, reason, by);
5951
+ const warning = await syncSuggestionStateLabel(gh, n, issue.labels, "declined");
5952
+ return { warnings: warning ? [warning] : [] };
5953
+ }
5614
5954
 
5615
5955
  // ../core/src/docs-proposal.ts
5616
5956
  var DOCS_EDITOR_ORIGIN = {
@@ -6177,6 +6517,44 @@ Proposed through ${opts.tool ?? "Bridge MCP"}. Tier ${plan.tier.tier}.`, files:
6177
6517
  var tokenLabels = (plan, before = [], after = []) => [
6178
6518
  .../* @__PURE__ */ new Set([...before, `tier: ${plan.tier.tier}`, ...plan.tier.tier === 3 ? ["tokens"] : [], ...plan.tier.bump === "major" ? ["breaking"] : [], ...after])
6179
6519
  ];
6520
+ function tokenSuggestionPayload(plan, ctx) {
6521
+ const tokenFiles = new Set(plan.touched);
6522
+ const files = plan.files.filter((f) => !tokenFiles.has(f.path)).map((f) => ({ path: f.path, beforeSha: null, afterSha: f.content === null ? null : gitBlobSha(f.content), content: f.content }));
6523
+ return {
6524
+ schema: SUGGESTION_SCHEMA,
6525
+ kind: "token-change",
6526
+ title: plan.title,
6527
+ summary: plan.summary,
6528
+ source: ctx.source,
6529
+ ...ctx.suggestedBy ? { suggestedBy: ctx.suggestedBy.replace(/^@/, "") } : {},
6530
+ createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
6531
+ base: ctx.base,
6532
+ tier: { tier: plan.tier.tier, bump: plan.tier.bump },
6533
+ tokens: {
6534
+ edits: plan.edits,
6535
+ changes: plan.changes.map((c) => ({ kind: c.kind, path: c.path, from: c.from, file: c.file, before: c.before, after: c.after, beforeCss: c.beforeCss, afterCss: c.afterCss }))
6536
+ },
6537
+ ...files.length ? { files } : {},
6538
+ checks: {
6539
+ newErrors: plan.newErrors.length,
6540
+ contrast: plan.contrast.map((d) => ({ pair: `${d.fg} on ${d.bg}`, mode: d.modes || void 0, before: d.before ?? 0, after: d.after ?? 0, min: d.min, pass: d.passAfter })),
6541
+ blocked: plan.blocked,
6542
+ impact: plan.impact.total
6543
+ },
6544
+ ...ctx.links ? { links: ctx.links } : {},
6545
+ ...ctx.previews?.length ? { previews: ctx.previews } : {}
6546
+ };
6547
+ }
6548
+ async function suggestTokenProposalOnGitHub(gh, plan, opts) {
6549
+ if (plan.blocked) throw new Error(`Not suggested: ${plan.blocked}. Bridge never suggests a change that breaks the gate.`);
6550
+ const sha = opts.baseSha ?? await gh.getBranchSha(opts.base);
6551
+ if (!sha) throw new Error(`Branch ${opts.base} not found. Check the default branch in bridge.config.json.`);
6552
+ const links = opts.neededBy || opts.links ? { ...opts.links, ...opts.neededBy ? { neededBy: opts.neededBy } : {} } : void 0;
6553
+ const payload = tokenSuggestionPayload(plan, { base: { ref: opts.base, sha }, source: opts.source, suggestedBy: opts.suggestedBy, links, previews: opts.previews });
6554
+ const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
6555
+ const created = await createSuggestionOnGitHub(gh, payload, { labels, assignees: opts.assignees, details: plan.body });
6556
+ return { status: "suggested", number: created.number, url: created.url, sha: created.sha, labels, warnings: created.warnings };
6557
+ }
6180
6558
 
6181
6559
  // ../core/src/figma-push-github.ts
6182
6560
  var pushSource = (d) => ["figma", "studio", "cli", "mcp"].includes(d.source) ? d.source : "figma";
@@ -6289,14 +6667,277 @@ async function markFigmaChangeDraftedOnGitHub(gh, n) {
6289
6667
  return { status: figmaPushStatus({ state: pull.state, labels, route: origin.route }), files };
6290
6668
  }
6291
6669
 
6670
+ // ../core/src/suggestion-apply.ts
6671
+ var SUGGESTION_APPLY_EXIT = {
6672
+ ready: 0,
6673
+ "already-applied": 0,
6674
+ conflict: 3,
6675
+ "not-approved": 1,
6676
+ blocked: 1,
6677
+ refused: 1
6678
+ };
6679
+ var same2 = (a, b) => canonicalJson(a) === canonicalJson(b);
6680
+ var show3 = (v) => v === void 0 ? "nothing" : typeof v === "string" ? v : JSON.stringify(v);
6681
+ function tokenRoot(config) {
6682
+ const r = config.tokens.root.replace(/\/+$/, "");
6683
+ return r === "." ? "" : r;
6684
+ }
6685
+ function tokenFileOf(config, path) {
6686
+ const root = tokenRoot(config);
6687
+ if (root && !path.startsWith(`${root}/`)) return null;
6688
+ if (!selectTokenFiles(config, [path]).length) return null;
6689
+ return root ? path.slice(root.length + 1) : path;
6690
+ }
6691
+ function planTokenEdit(set, e, changes) {
6692
+ const base = { type: "token", path: e.path, edit: e, ...e.file ? { file: e.file } : {} };
6693
+ const defsAt = (path) => set.defs.filter((d) => d.path === path && (!e.file || d.file === e.file));
6694
+ const recorded = (file, path = e.path) => changes.find((c) => c.path === path && c.file === file && (e.op === "rename" ? c.kind === "renamed" : true));
6695
+ const conflict = (detail, extra = {}) => ({ op: { ...base, state: "conflict", detail, ...extra }, notes: [] });
6696
+ const done = (detail) => ({ op: { ...base, state: "already-applied", detail }, notes: [] });
6697
+ let result;
6698
+ try {
6699
+ result = applyTokenEdits(set, [e]);
6700
+ } catch (err) {
6701
+ const why = err instanceof Error ? err.message : String(err);
6702
+ switch (e.op) {
6703
+ case "rename": {
6704
+ const hasOld = defsAt(e.path).length > 0;
6705
+ const hasNew = set.defs.some((d) => d.path === e.newPath);
6706
+ if (!hasOld && hasNew) return done(`Already renamed to ${e.newPath}.`);
6707
+ return conflict(hasOld && hasNew ? `Both ${e.path} and ${e.newPath} exist now: rename it by hand.` : `${e.path} no longer exists: ${why}`);
6708
+ }
6709
+ case "remove":
6710
+ if (!defsAt(e.path).length) return done("Already removed.");
6711
+ return conflict(why);
6712
+ case "add": {
6713
+ const existing = defsAt(e.path);
6714
+ if (!existing.length) return conflict(why);
6715
+ const want = (file) => {
6716
+ const c = recorded(file);
6717
+ if (c && c.after !== void 0) return c.after;
6718
+ return coerceValue(e.value, e.type ?? existing.find((d2) => d2.file === file)?.type);
6719
+ };
6720
+ const all = existing.every((d2) => same2(d2.value, want(d2.file)));
6721
+ if (all) return done("Already added with the suggested value.");
6722
+ const d = existing.find((x) => !same2(x.value, want(x.file)));
6723
+ return conflict(`Someone added ${e.path} with another value since.`, { file: d.file, after: want(d.file), now: d.value });
6724
+ }
6725
+ default:
6726
+ return conflict(defsAt(e.path).length ? why : `${e.path} no longer exists, so its value cannot be set.`);
6727
+ }
6728
+ }
6729
+ if (!result.touched.length) return done("Already has the suggested value.");
6730
+ if (e.op === "set" || e.op === "remove" || e.op === "rename") {
6731
+ for (const d of defsAt(e.path)) {
6732
+ const c = recorded(d.file, e.op === "rename" ? e.newPath ?? e.path : e.path);
6733
+ if (!c) continue;
6734
+ const okNow = same2(d.value, c.before) || e.op === "set" && same2(d.value, c.after);
6735
+ if (!okNow) return conflict(`${e.path} changed since it was suggested (now ${show3(d.value)}, suggested from ${show3(c.before)}).`, { file: d.file, before: c.before, after: c.after, now: d.value });
6736
+ }
6737
+ }
6738
+ const verb = e.op === "set" ? "Set" : e.op === "add" ? "Add" : e.op === "remove" ? "Remove" : `Rename to ${e.newPath ?? ""}`;
6739
+ return { op: { ...base, state: "apply", detail: `${verb} in ${result.touched.join(", ")}.` }, files: result.files, notes: result.notes };
6740
+ }
6741
+ async function planFile(ws, baseSha, f) {
6742
+ const base = { type: "file", path: f.path };
6743
+ const cur = await ws.read(f.path);
6744
+ const curSha = cur?.sha ?? null;
6745
+ const theirs = f.afterSha === null ? null : f.content ?? null;
6746
+ if (curSha === f.afterSha) return { op: { ...base, state: "already-applied", detail: f.afterSha === null ? "Already deleted." : "Already has the suggested content." } };
6747
+ if (curSha === f.beforeSha) return { op: { ...base, state: "apply", detail: f.afterSha === null ? "Delete it." : f.beforeSha === null ? "Create it." : "Replace it with the suggested content." }, content: theirs };
6748
+ if (f.beforeSha === null) return { op: { ...base, state: "conflict", detail: "The suggestion creates this file, but a different one exists now." } };
6749
+ if (!cur) return { op: { ...base, state: "conflict", detail: "The suggestion changes this file, but it was deleted since." } };
6750
+ if (theirs === null) return { op: { ...base, state: "conflict", detail: "The suggestion deletes this file, but it changed since." } };
6751
+ const original = await ws.readAt(baseSha, f.path);
6752
+ if (original === null || gitBlobSha(original) !== f.beforeSha || !ws.merge) {
6753
+ return { op: { ...base, state: "conflict", detail: original === null ? "The file changed since and its version at the suggestion base cannot be read, so it cannot be merged." : "The file changed since and cannot be merged here." } };
6754
+ }
6755
+ const merged = await ws.merge({ path: f.path, ours: cur.text, base: original, theirs });
6756
+ if (!merged.clean) return { op: { ...base, state: "conflict", detail: `The file changed since and the merge has ${merged.conflicts} conflict(s).`, hunks: merged.conflicts } };
6757
+ if (merged.text === cur.text) return { op: { ...base, state: "already-applied", detail: "Already has the suggested lines." } };
6758
+ return { op: { ...base, state: "apply-merged", detail: "The file changed since: merged with the suggested lines, no conflict." }, content: merged.text };
6759
+ }
6760
+ async function planSuggestionApply(input) {
6761
+ const { parsed, config, workspace: ws } = input;
6762
+ const payload = parsed.payload;
6763
+ const notes = [];
6764
+ const out = (outcome, message, rest2 = {}) => ({
6765
+ outcome,
6766
+ exitCode: SUGGESTION_APPLY_EXIT[outcome],
6767
+ message,
6768
+ payloadSha: parsed.sha,
6769
+ payload,
6770
+ gate: null,
6771
+ ops: [],
6772
+ writes: [],
6773
+ checks: null,
6774
+ problems: [],
6775
+ notes,
6776
+ ...payload.componentSpec ? { brief: payload.componentSpec.brief } : {},
6777
+ ...rest2
6778
+ });
6779
+ const problems = suggestionPathProblems(payload, config);
6780
+ if (payload.checks.blocked) problems.push(`It was suggested with a failing check (${payload.checks.blocked}): ask the author to fix it and send it again.`);
6781
+ for (const f of payload.files ?? []) {
6782
+ if (f.afterSha !== null && typeof f.content !== "string") problems.push(`"${f.path}" has no content in the suggestion: it is incomplete, ask the author to send it again.`);
6783
+ else if (f.afterSha !== null && gitBlobSha(f.content) !== f.afterSha) problems.push(`"${f.path}" does not match the content it was suggested with: someone edited it by hand.`);
6784
+ }
6785
+ if (problems.length) return out("refused", "Not applied: the suggestion asks for something Bridge never writes.", { problems: [...new Set(problems)] });
6786
+ if (parsed.edited && !input.acceptEdited) {
6787
+ return out("refused", 'Not applied: the suggestion was edited by hand since Bridge wrote it. Read the change, then apply it with "accept edited" if it is right.', {
6788
+ problems: ["The payload no longer matches the sha Bridge recorded."]
6789
+ });
6790
+ }
6791
+ const root = tokenRoot(config);
6792
+ const tokenFiles = selectTokenFiles(config, await ws.list());
6793
+ const targets = [
6794
+ ...(payload.files ?? []).map((f) => f.path),
6795
+ ...(payload.docs ?? []).map((d) => d.path),
6796
+ ...payload.componentSpec ? [payload.componentSpec.specPath] : [],
6797
+ ...payload.tokens?.edits.length ? tokenFiles.map((f) => root ? `${root}/${f}` : f) : []
6798
+ ];
6799
+ if (ws.isSymlink) {
6800
+ const links = [];
6801
+ for (const p of new Set(targets)) if (await ws.isSymlink(p)) links.push(`"${p}" is a symbolic link (or inside one): Bridge never writes through links.`);
6802
+ if (links.length) return out("refused", "Not applied: the suggestion writes through a symbolic link.", { problems: links });
6803
+ }
6804
+ const governance = input.governance ?? { owners: config.governance.owners, rights: config.governance.rights ?? null };
6805
+ const approvals = summarizeSuggestionApprovals(input.comments, governance, { payloadSha: parsed.sha, author: input.author });
6806
+ const review = suggestionReviewState(payload.tier.tier, approvals);
6807
+ const missing = pendingApprovals(payload.tier.tier, approvals);
6808
+ const approved = review === "approved";
6809
+ const gate = { review, missing, approvals, forced: !approved && Boolean(input.forceUnapproved), edited: parsed.edited };
6810
+ const ops = [];
6811
+ const writes = /* @__PURE__ */ new Map();
6812
+ const current = /* @__PURE__ */ new Map();
6813
+ for (const f of tokenFiles) {
6814
+ const r = await ws.read(root ? `${root}/${f}` : f);
6815
+ if (r) current.set(f, r.text);
6816
+ }
6817
+ const before = new LayeredTokenSet(config, current);
6818
+ let set = before;
6819
+ const appliedEdits = [];
6820
+ for (const e of payload.tokens?.edits ?? []) {
6821
+ const r = planTokenEdit(set, e, payload.tokens.changes);
6822
+ ops.push(r.op);
6823
+ notes.push(...r.notes);
6824
+ if (r.op.state === "apply" && r.files) {
6825
+ set = new LayeredTokenSet(config, r.files);
6826
+ appliedEdits.push(e);
6827
+ }
6828
+ }
6829
+ for (const [f, text] of set.files) if (text !== before.files.get(f)) writes.set(set.repoPath(f), text);
6830
+ for (const f of payload.files ?? []) {
6831
+ if (writes.has(f.path)) {
6832
+ notes.push(`${f.path}: the token edits write this file, its copy in the suggestion is not used.`);
6833
+ continue;
6834
+ }
6835
+ const r = await planFile(ws, payload.base.sha, f);
6836
+ ops.push(r.op);
6837
+ if (r.op.state === "apply" || r.op.state === "apply-merged") writes.set(f.path, r.content ?? null);
6838
+ }
6839
+ for (const d of payload.docs ?? []) {
6840
+ const base = { type: "docs", path: d.path };
6841
+ const docs = d.after;
6842
+ let text;
6843
+ try {
6844
+ const issues = validateDocs(docs);
6845
+ if (issues.length) {
6846
+ problems.push(`${d.path}: ${issues.map((i) => i.message).join(" ")}`);
6847
+ continue;
6848
+ }
6849
+ const cur = await ws.read(d.path);
6850
+ text = docsFileText(docs, cur?.text ?? null);
6851
+ if (cur && text === cur.text) {
6852
+ ops.push({ ...base, state: "already-applied", detail: "The docs already say this." });
6853
+ continue;
6854
+ }
6855
+ ops.push({ ...base, state: "apply", detail: cur ? "Update the changed fields; other fields stay as they are now." : "Create the docs file." });
6856
+ writes.set(d.path, text);
6857
+ } catch (err) {
6858
+ problems.push(`${d.path}: the suggested docs are not a docs.json Bridge can read (${err instanceof Error ? err.message : String(err)}).`);
6859
+ }
6860
+ }
6861
+ if (payload.componentSpec) {
6862
+ const s = payload.componentSpec;
6863
+ const text = `${JSON.stringify(s.spec, null, 2)}
6864
+ `;
6865
+ const cur = await ws.read(s.specPath);
6866
+ if (cur && cur.text === text) ops.push({ type: "component-spec", path: s.specPath, state: "already-applied", detail: "The spec file is already there." });
6867
+ else {
6868
+ ops.push({ type: "component-spec", path: s.specPath, state: "apply", detail: `Write the spec; the ${s.component} code itself is written from the brief.` });
6869
+ writes.set(s.specPath, text);
6870
+ }
6871
+ }
6872
+ const afterFiles = new Map(set.files);
6873
+ for (const [p, content] of writes) {
6874
+ const tf = tokenFileOf(config, p);
6875
+ if (!tf) continue;
6876
+ if (content === null) afterFiles.delete(tf);
6877
+ else afterFiles.set(tf, content);
6878
+ }
6879
+ const after = new LayeredTokenSet(config, afterFiles);
6880
+ const tokenChanges = diffLayered(before, after);
6881
+ const issueKey4 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
6882
+ const prevErrors = new Set(validateLayered(before).map(issueKey4));
6883
+ const newErrors = tokenChanges.length ? validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey4(i))) : [];
6884
+ let pairs = [];
6885
+ if (config.contrast && tokenChanges.length) {
6886
+ try {
6887
+ pairs = parseContrastChecks((await ws.read(config.contrast.path))?.text);
6888
+ } catch {
6889
+ notes.push(`${config.contrast.path} does not parse: contrast was not checked.`);
6890
+ }
6891
+ }
6892
+ const modes = config.contrast?.modes;
6893
+ const contrast = pairs.length ? contrastDelta(runLayeredContrast(before, pairs, modes), runLayeredContrast(after, pairs, modes)) : [];
6894
+ const newFailures = contrast.filter((d) => d.passBefore && !d.passAfter);
6895
+ const otherFiles = [...writes.keys()].filter((p) => !tokenFileOf(config, p));
6896
+ const tier = classifyRisk({ tokenChanges: tokenChanges.map((c) => ({ kind: c.kind, path: c.path, ...c.kind === "changed" ? { descriptionOnly: c.fields.length > 0 && c.fields.every((x) => x === "description") } : {} })), files: otherFiles.map((path) => ({ path })), config });
6897
+ const tierIncreased = writes.size > 0 && tier.tier > payload.tier.tier;
6898
+ const checks = { tierWhenSuggested: payload.tier.tier, tier, tierIncreased, newErrors, newFailures, contrast, tokenChanges };
6899
+ const rest = { gate, ops, checks, problems };
6900
+ if (problems.length) return out("refused", "Not applied: part of the suggestion is not valid.", rest);
6901
+ const conflicts = ops.filter((o) => o.state === "conflict");
6902
+ if (conflicts.length) {
6903
+ return out("conflict", `Not applied: ${conflicts.length} change(s) conflict with the code now. Ask the author to refresh the suggestion on today's code, or apply those parts by hand.`, rest);
6904
+ }
6905
+ if (!writes.size) return out("already-applied", "Already applied: the code has every change of this suggestion.", rest);
6906
+ if (newErrors.length || newFailures.length) {
6907
+ const why = [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ");
6908
+ return out("blocked", `Not applied: on today's code it would add ${why}. Ask the author to refresh the suggestion.`, rest);
6909
+ }
6910
+ if (tierIncreased && !input.forceUnapproved) {
6911
+ return out("blocked", `Not applied: on today's code it is a tier ${tier.tier} change, it was approved as tier ${payload.tier.tier}. It needs a new approval.`, rest);
6912
+ }
6913
+ if (!approved && !input.forceUnapproved) {
6914
+ const who = review === "changes-requested" ? `changes requested by ${approvals.changesRequested.map((l) => `@${l}`).join(", ")}` : `missing ${missing.map((m) => m === "approval" ? "an approval" : `${m} approval`).join(" and ")}`;
6915
+ return out("not-approved", `Not applied: it is not approved yet (${who}). Ask the owners to review it on the issue.`, rest);
6916
+ }
6917
+ const forced = gate.forced || tierIncreased && Boolean(input.forceUnapproved);
6918
+ return out("ready", `Ready: ${writes.size} file(s) to write${forced ? ", without every approval" : ""}.`, {
6919
+ ...rest,
6920
+ gate: { ...gate, forced },
6921
+ writes: [...writes].map(([path, content]) => ({ path, content })).sort((a, b) => a.path.localeCompare(b.path))
6922
+ });
6923
+ }
6924
+ function suggestionAppliedComment(plan, where2) {
6925
+ return composeSuggestionAppliedComment({ ...where2, payloadSha: plan.payloadSha, ...plan.gate?.forced ? { forced: true } : {} });
6926
+ }
6927
+ function suggestionApplyTable(plan) {
6928
+ const label = { "already-applied": "already applied", apply: "apply", "apply-merged": "apply (merged)", conflict: "**conflict**" };
6929
+ const cell4 = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
6930
+ return ["| Change | Result | Detail |", "| --- | --- | --- |", ...plan.ops.map((o) => `| \`${cell4(o.path)}\` | ${label[o.state]} | ${cell4(o.detail)} |`)].join("\n");
6931
+ }
6932
+
6292
6933
  // ../core/src/node/git-history.ts
6293
6934
  var US = "";
6294
6935
  var RS = "";
6295
6936
  function localGitHistoryBackend(root, config) {
6296
6937
  const trees = /* @__PURE__ */ new Map();
6297
6938
  const blobs = /* @__PURE__ */ new Map();
6298
- const tokenRoot = config.tokens.root.replace(/\/$/, "");
6299
- const prefix = tokenRoot === "." || !tokenRoot ? "" : `${tokenRoot}/`;
6939
+ const tokenRoot2 = config.tokens.root.replace(/\/$/, "");
6940
+ const prefix = tokenRoot2 === "." || !tokenRoot2 ? "" : `${tokenRoot2}/`;
6300
6941
  const lsTree = (sha, path) => {
6301
6942
  const k = `${sha}:${path}`;
6302
6943
  let t = trees.get(k);
@@ -6333,7 +6974,7 @@ function localGitHistoryBackend(root, config) {
6333
6974
  });
6334
6975
  },
6335
6976
  async tokenFiles(sha) {
6336
- const entries = await lsTree(sha, tokenRoot === "." ? "" : tokenRoot);
6977
+ const entries = await lsTree(sha, tokenRoot2 === "." ? "" : tokenRoot2);
6337
6978
  const picked = new Set(selectTokenFiles(config, entries.map((e) => e.path)));
6338
6979
  const wanted = entries.filter((e) => picked.has(prefix ? e.path.slice(prefix.length) : e.path));
6339
6980
  return new Map(await Promise.all(wanted.map(async (e) => [prefix ? e.path.slice(prefix.length) : e.path, await blob(e.sha)])));
@@ -6963,14 +7604,14 @@ function scanClassString(text) {
6963
7604
  const out = [];
6964
7605
  for (const m of text.matchAll(/[^\s"'`]+/g)) {
6965
7606
  const raw = m[0];
6966
- let cut = -1;
7607
+ let cut2 = -1;
6967
7608
  let depth = 0;
6968
7609
  for (let i = 0; i < raw.length; i++) {
6969
7610
  if (raw[i] === "[") depth++;
6970
7611
  else if (raw[i] === "]") depth--;
6971
- else if (raw[i] === ":" && depth === 0) cut = i;
7612
+ else if (raw[i] === ":" && depth === 0) cut2 = i;
6972
7613
  }
6973
- const cls = raw.slice(cut + 1).replace(/^!/, "");
7614
+ const cls = raw.slice(cut2 + 1).replace(/^!/, "");
6974
7615
  const index = m.index + raw.length - cls.length;
6975
7616
  const arb = /^-?([a-z][a-z-]*?)-\[(.+)\]$/.exec(cls);
6976
7617
  if (arb) {
@@ -7458,16 +8099,16 @@ function applyAllowlist(entries, findings, components) {
7458
8099
  return { findings: keep(findings), components: keep(components), ignored, used };
7459
8100
  }
7460
8101
  function parseAllowlist(json) {
7461
- const fail2 = (msg) => {
8102
+ const fail3 = (msg) => {
7462
8103
  throw new Error(`allowlist: ${msg}`);
7463
8104
  };
7464
- if (!json || typeof json !== "object" || !Array.isArray(json.entries)) fail2('expected { "entries": [...] }');
8105
+ if (!json || typeof json !== "object" || !Array.isArray(json.entries)) fail3('expected { "entries": [...] }');
7465
8106
  return json.entries.map((e, i) => {
7466
8107
  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`);
8108
+ if (typeof x.file !== "string" || !x.file) fail3(`entry ${i} needs "file"`);
8109
+ if (typeof x.line !== "number" || !Number.isInteger(x.line) || x.line < 1) fail3(`entry ${i} (${x.file}) needs a "line" number`);
8110
+ if (typeof x.reason !== "string" || !x.reason.trim()) fail3(`entry ${i} (${x.file}:${x.line}) needs a "reason"`);
8111
+ if (x.what !== void 0 && typeof x.what !== "string") fail3(`entry ${i} (${x.file}:${x.line}): "what" must be a string`);
7471
8112
  return { file: x.file, line: x.line, ...x.what !== void 0 ? { what: x.what } : {}, reason: x.reason.trim() };
7472
8113
  });
7473
8114
  }
@@ -8153,9 +8794,9 @@ var DesignSystemIndex = class {
8153
8794
  return generic ? this.make(generic, 1, 0.3) : void 0;
8154
8795
  }
8155
8796
  case "shadow": {
8156
- const norm3 = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
8797
+ const norm4 = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
8157
8798
  const cands = this.entries(["shadow"]);
8158
- const exact = cands.find((e) => norm3(e.css) === norm3(value));
8799
+ const exact = cands.find((e) => norm4(e.css) === norm4(value));
8159
8800
  if (exact) return this.make(exact, 0, 1);
8160
8801
  const t = shadowNums(value);
8161
8802
  const scored = cands.map((e) => {
@@ -8222,6 +8863,194 @@ function loadAllowlist(path) {
8222
8863
  return parseAllowlist(JSON.parse(readFileSync(path, "utf8")));
8223
8864
  }
8224
8865
 
8866
+ // ../core/src/node/suggestion-apply-node.ts
8867
+ import { execFile } from "node:child_process";
8868
+ import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
8869
+ import { tmpdir } from "node:os";
8870
+ import { dirname as dirname2, join as join2 } from "node:path";
8871
+ function gitCode(root, args) {
8872
+ return new Promise((resolve4) => {
8873
+ execFile("git", args, { cwd: root, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
8874
+ const code2 = err ? typeof err.code === "number" ? err.code : -1 : 0;
8875
+ resolve4({ code: code2, stdout, stderr });
8876
+ });
8877
+ });
8878
+ }
8879
+ async function treeEntry(root, ref, path) {
8880
+ const r = await gitCode(root, ["ls-tree", ref, "--", path]);
8881
+ const m = /^(\d+) (\w+) ([0-9a-f]+)\t/.exec(r.stdout);
8882
+ return r.code === 0 && m ? { mode: m[1], type: m[2], sha: m[3] } : null;
8883
+ }
8884
+ var prefixes = (path) => path.split("/").map((_, i, segs) => segs.slice(0, i + 1).join("/"));
8885
+ async function mergeText(root, input) {
8886
+ const dir = await mkdtemp(join2(tmpdir(), "bridge-merge-"));
8887
+ try {
8888
+ const [o, b, t] = ["current", "base", "suggested"].map((n) => join2(dir, n));
8889
+ await Promise.all([writeFile(o, input.ours), writeFile(b, input.base), writeFile(t, input.theirs)]);
8890
+ const r = await gitCode(root, ["merge-file", "-p", "-L", "current", "-L", "base", "-L", "suggested", o, b, t]);
8891
+ if (r.code < 0 || r.code > 127) throw new Error(`git merge-file failed: ${r.stderr.trim() || `exit ${r.code}`}`);
8892
+ return { clean: r.code === 0, text: r.stdout, conflicts: r.code };
8893
+ } finally {
8894
+ await rm(dir, { recursive: true, force: true });
8895
+ }
8896
+ }
8897
+ async function fileDiff(path, before, after) {
8898
+ if (before === after) return "";
8899
+ const dir = await mkdtemp(join2(tmpdir(), "bridge-diff-"));
8900
+ try {
8901
+ const side = async (name, text) => {
8902
+ if (text === null) return "/dev/null";
8903
+ const p = join2(dir, name, path);
8904
+ await mkdir(dirname2(p), { recursive: true });
8905
+ await writeFile(p, text);
8906
+ return `${name}/${path}`;
8907
+ };
8908
+ const a = await side("a", before);
8909
+ const b = await side("b", after);
8910
+ const r = await new Promise((resolve4) => execFile("git", ["diff", "--no-index", "--no-color", "--src-prefix=", "--dst-prefix=", "--", a, b], { cwd: dir, maxBuffer: 32 * 1024 * 1024 }, (_e, stdout) => resolve4({ stdout })));
8911
+ return r.stdout;
8912
+ } finally {
8913
+ await rm(dir, { recursive: true, force: true });
8914
+ }
8915
+ }
8916
+ function gitWorkspace(root, opts = {}) {
8917
+ const { ref } = opts;
8918
+ let fetched = false;
8919
+ return {
8920
+ async read(path) {
8921
+ if (ref) {
8922
+ const e = await treeEntry(root, ref, path);
8923
+ if (!e || e.type !== "blob" || e.mode === "120000") return null;
8924
+ return { text: await git(root, ["cat-file", "blob", e.sha]), sha: e.sha };
8925
+ }
8926
+ try {
8927
+ const st = await lstat(join2(root, path));
8928
+ if (!st.isFile()) return null;
8929
+ const text = await readFile(join2(root, path), "utf8");
8930
+ return { text, sha: gitBlobSha(text) };
8931
+ } catch {
8932
+ return null;
8933
+ }
8934
+ },
8935
+ async readAt(at, path) {
8936
+ if ((await gitCode(root, ["cat-file", "-e", `${at}^{commit}`])).code !== 0 && !fetched) {
8937
+ fetched = true;
8938
+ await gitCode(root, ["fetch", "--no-tags", "--quiet", "origin", at]);
8939
+ }
8940
+ const e = await treeEntry(root, at, path);
8941
+ if (e?.type === "blob") return git(root, ["cat-file", "blob", e.sha]);
8942
+ return opts.readBase ? opts.readBase(path, at) : null;
8943
+ },
8944
+ async list() {
8945
+ if (ref) return listFilesAt(root, ref);
8946
+ const out = await git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
8947
+ const all = [...new Set(out.split("\0").filter(Boolean))];
8948
+ const present = await Promise.all(all.map((p) => lstat(join2(root, p)).then((s) => s.isFile(), () => false)));
8949
+ return all.filter((_, i) => present[i]);
8950
+ },
8951
+ async isSymlink(path) {
8952
+ for (const p of prefixes(path)) {
8953
+ if (ref) {
8954
+ const e = await treeEntry(root, ref, p);
8955
+ if (!e) return false;
8956
+ if (e.mode === "120000") return true;
8957
+ } else {
8958
+ try {
8959
+ if ((await lstat(join2(root, p))).isSymbolicLink()) return true;
8960
+ } catch {
8961
+ return false;
8962
+ }
8963
+ }
8964
+ }
8965
+ return false;
8966
+ },
8967
+ merge: (input) => mergeText(root, input)
8968
+ };
8969
+ }
8970
+ var COMMIT_TYPE = { "token-change": "feat(tokens)", docs: "docs", "component-spec": "feat", brand: "feat(tokens)", revert: "revert", config: "chore(config)" };
8971
+ function commitMessage(plan, n) {
8972
+ const p = plan.payload;
8973
+ const type = `${COMMIT_TYPE[p.kind] ?? "chore"}${p.tier.bump === "major" ? "!" : ""}`;
8974
+ return [`${type}: ${p.title.trim()}`, "", p.summary.trim(), "", `Applies Bridge suggestion #${n} (version ${plan.payloadSha.slice(0, 7)}).`].filter((l, i, a) => !(l === "" && a[i - 1] === "")).join("\n");
8975
+ }
8976
+ async function readConfigAt(ws) {
8977
+ const text = (await ws.read(CONFIG_FILE))?.text;
8978
+ if (!text) throw new Error(`No ${CONFIG_FILE} here: run \`bridge init\` first, or apply from the repository root.`);
8979
+ return loadConfig(text);
8980
+ }
8981
+ async function applySuggestionLocally(input) {
8982
+ const mode = input.mode ?? "branch";
8983
+ const base = input.base ?? "HEAD";
8984
+ const { root } = input;
8985
+ const wsOpts = { readBase: input.readBase };
8986
+ const ws = gitWorkspace(root, mode === "write" ? wsOpts : { ...wsOpts, ref: base });
8987
+ const config = input.config ?? await readConfigAt(ws);
8988
+ const planFor = (workspace) => planSuggestionApply({ ...input, config, workspace });
8989
+ const plan = await planFor(ws);
8990
+ const at = (input.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
8991
+ const result = (status, rest = {}) => ({ status, exitCode: plan.exitCode, message: plan.message, plan, ...rest });
8992
+ if (plan.outcome !== "ready") return result(plan.outcome);
8993
+ const readNow = async (path) => (await ws.read(path))?.text ?? null;
8994
+ const diffOf = async () => (await Promise.all(plan.writes.map(async (w) => fileDiff(w.path, await readNow(w.path), w.content)))).join("");
8995
+ if (input.dryRun) return result("dry-run", { message: `Dry run: ${plan.message.replace(/^Ready: /, "")} Nothing written.`, diff: await diffOf() });
8996
+ if (mode === "patch") {
8997
+ const { sha } = await commitTree(root, { base, message: commitMessage(plan, input.number), files: plan.writes });
8998
+ const patch = await git(root, ["format-patch", "-1", "--stdout", sha]);
8999
+ return result("patch", { message: "Patch ready: apply it with `git am`.", patch, commit: sha });
9000
+ }
9001
+ if (mode === "write") {
9002
+ const paths = plan.writes.map((w) => w.path);
9003
+ const dirty = (await git(root, ["status", "--porcelain", "-z", "--", ...paths])).split("\0").filter((l) => l.length > 3).map((l) => l.slice(3));
9004
+ if (dirty.length) {
9005
+ return result("refused", {
9006
+ exitCode: 1,
9007
+ message: `Not written: these files have uncommitted changes: ${dirty.join(", ")}. Commit or stash them, or apply on a branch instead.`
9008
+ });
9009
+ }
9010
+ const diff = await diffOf();
9011
+ for (const w of plan.writes) {
9012
+ const full = join2(root, w.path);
9013
+ if (w.content === null) await rm(full, { force: true });
9014
+ else {
9015
+ await mkdir(dirname2(full), { recursive: true });
9016
+ await writeFile(full, w.content);
9017
+ }
9018
+ }
9019
+ return result("written", {
9020
+ message: `Written: ${paths.length} file(s) in the working tree. Review them, commit, and say "Closes #${input.number}" in the pull request.`,
9021
+ written: paths,
9022
+ diff,
9023
+ appliedComment: suggestionAppliedComment(plan, { by: input.by, at })
9024
+ });
9025
+ }
9026
+ const prefix = config.governance.branchPrefix;
9027
+ const wanted = input.branch ?? `${prefix}suggestion-${input.number}-${slugify(plan.payload.title, 40)}`;
9028
+ let branch = wanted;
9029
+ for (let i = 2; await branchExists(root, branch); i++) {
9030
+ const there = await planFor(gitWorkspace(root, { ...wsOpts, ref: branch }));
9031
+ if (there.outcome === "already-applied") {
9032
+ const commit = (await git(root, ["rev-parse", `refs/heads/${branch}`])).trim();
9033
+ return result("branch-exists", {
9034
+ exitCode: 0,
9035
+ message: `Already on ${branch}: that branch has every change of this suggestion. Push it and open a pull request that says "Closes #${input.number}".`,
9036
+ branch,
9037
+ commit,
9038
+ appliedComment: suggestionAppliedComment(plan, { by: input.by, branch, commit, at })
9039
+ });
9040
+ }
9041
+ branch = `${wanted}-${i}`;
9042
+ }
9043
+ const protectedBranches = [.../* @__PURE__ */ new Set([...protectedBranchesOf(config), baseBranchOf(config)])];
9044
+ const res = await commitToNewLocalBranch(root, { base, branch, message: commitMessage(plan, input.number), files: plan.writes, protectedBranches, branchPrefix: prefix });
9045
+ return result("branch-created", {
9046
+ message: `Applied on ${branch}, your working tree is untouched. Push it and open a pull request that says "Closes #${input.number}".`,
9047
+ branch,
9048
+ commit: res.sha,
9049
+ diff: res.diff,
9050
+ appliedComment: suggestionAppliedComment(plan, { by: input.by, branch, commit: res.sha, at })
9051
+ });
9052
+ }
9053
+
8225
9054
  // src/config.ts
8226
9055
  var SCHEMA_URL = "https://raw.githubusercontent.com/peterbono/ai-design-system-demo/main/packages/core/bridge.config.schema.json";
8227
9056
  var TIER_NAMES = {
@@ -8381,10 +9210,10 @@ var core = {
8381
9210
  };
8382
9211
 
8383
9212
  // src/source.ts
8384
- import { readdir, readFile } from "node:fs/promises";
8385
- import { join as join2 } from "node:path";
9213
+ import { readdir, readFile as readFile2 } from "node:fs/promises";
9214
+ import { join as join3 } from "node:path";
8386
9215
  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(/\/$/, "")}/`);
9216
+ var under2 = (prefix) => (f) => !prefix || prefix === "." || f === prefix || f.startsWith(`${prefix.replace(/\/$/, "")}/`);
8388
9217
  var LocalSource = class {
8389
9218
  constructor(root) {
8390
9219
  this.root = root;
@@ -8400,7 +9229,7 @@ var LocalSource = class {
8400
9229
  const walk2 = async (rel) => {
8401
9230
  let entries;
8402
9231
  try {
8403
- entries = await readdir(join2(this.root, rel), { withFileTypes: true });
9232
+ entries = await readdir(join3(this.root, rel), { withFileTypes: true });
8404
9233
  } catch {
8405
9234
  return;
8406
9235
  }
@@ -8416,7 +9245,7 @@ var LocalSource = class {
8416
9245
  }
8417
9246
  async read(path) {
8418
9247
  try {
8419
- return await readFile(join2(this.root, path), "utf8");
9248
+ return await readFile2(join3(this.root, path), "utf8");
8420
9249
  } catch {
8421
9250
  return null;
8422
9251
  }
@@ -8436,7 +9265,7 @@ var GitRefSource = class {
8436
9265
  }
8437
9266
  async list(prefix = "") {
8438
9267
  this.files ??= listFilesAt(this.root, this.ref);
8439
- return (await this.files).filter(under(prefix));
9268
+ return (await this.files).filter(under2(prefix));
8440
9269
  }
8441
9270
  read(path) {
8442
9271
  return showFile(this.root, this.ref, path);
@@ -8457,7 +9286,7 @@ var GitHubSource = class {
8457
9286
  }
8458
9287
  async list(prefix = "") {
8459
9288
  this.tree ??= this.client.listFiles(this.ref);
8460
- return (await this.tree).filter(under(prefix));
9289
+ return (await this.tree).filter(under2(prefix));
8461
9290
  }
8462
9291
  read(path) {
8463
9292
  let p = this.cache.get(path);
@@ -8479,7 +9308,7 @@ function client(repo, o, rules) {
8479
9308
  ...o.rawUrl ? { rawUrl: o.rawUrl } : {},
8480
9309
  protectedBranches: rules.protectedBranches,
8481
9310
  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"
9311
+ tokenHint: "set GITHUB_TOKEN (or BRIDGE_GITHUB_TOKEN) in the environment of the Bridge MCP server: read access to this repository is enough to read, suggestions and requests need Issues: read and write, and direct changes (branches and pull requests) need Contents and Pull requests: read and write"
8483
9312
  });
8484
9313
  }
8485
9314
  var BridgeContext = class _BridgeContext {
@@ -8901,8 +9730,8 @@ __export(ops_exports, {
8901
9730
  visualImpact: () => visualImpact
8902
9731
  });
8903
9732
  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";
9733
+ import { mkdir as mkdir3, writeFile as writeFile3, access } from "node:fs/promises";
9734
+ import { dirname as dirname4, isAbsolute, join as join5, resolve as resolve3 } from "node:path";
8906
9735
 
8907
9736
  // ../core/src/audit/missing-pieces.ts
8908
9737
  var lines = (text) => text.split(/\r?\n/);
@@ -8974,7 +9803,7 @@ function aspectOf(values) {
8974
9803
  for (const a of ["tone", "shape", "size"]) if (values.some((v) => ASPECT[v.category] === a)) return a;
8975
9804
  return "style";
8976
9805
  }
8977
- var norm2 = (f) => f.category === "color" ? f.value.toLowerCase() : f.value.replace(/\s+/g, "");
9806
+ var norm3 = (f) => f.category === "color" ? f.value.toLowerCase() : f.value.replace(/\s+/g, "");
8978
9807
  function nearestOf(s) {
8979
9808
  if (!s) return void 0;
8980
9809
  return { token: s.token, cssVar: s.cssVar, value: s.value, level: s.level, label: tokenLabel(s.token) };
@@ -9061,7 +9890,7 @@ function missingPieces(input) {
9061
9890
  variants.set(key2, v);
9062
9891
  continue;
9063
9892
  }
9064
- const key = `${finding.category}|${norm2(finding)}`;
9893
+ const key = `${finding.category}|${norm3(finding)}`;
9065
9894
  const t = tokens.get(key) ?? { category: finding.category, value: finding.value, name, hits: [] };
9066
9895
  if (!t.name && name) t.name = name;
9067
9896
  t.hits.push(hit);
@@ -9085,7 +9914,7 @@ function missingPieces(input) {
9085
9914
  const values = [];
9086
9915
  for (const h of v.hits) {
9087
9916
  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);
9917
+ if (!values.some((x) => x.category === pv.category && x.property === pv.property && norm3(x) === norm3(pv))) values.push(pv);
9089
9918
  }
9090
9919
  const aspect = aspectOf(values);
9091
9920
  const bg = v.hits.find((h) => /background/.test(h.finding.property ?? ""))?.finding.suggestion;
@@ -9281,8 +10110,8 @@ __export(explorations_exports, {
9281
10110
  toExplorationBranch: () => toExplorationBranch,
9282
10111
  writeToExploration: () => writeToExploration
9283
10112
  });
9284
- import { mkdir, rm, writeFile } from "node:fs/promises";
9285
- import { dirname as dirname2, join as join3 } from "node:path";
10113
+ import { mkdir as mkdir2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
10114
+ import { dirname as dirname3, join as join4 } from "node:path";
9286
10115
 
9287
10116
  // src/branches.ts
9288
10117
  var branches_exports = {};
@@ -9438,19 +10267,19 @@ async function whatNeedsMe(ctx, a = {}) {
9438
10267
  const pulls = await gh.listPullRequests("open");
9439
10268
  const queue = reviewQueue(pulls.map((p) => ({ ...p, state: p.state, head: { ref: p.headRef }, user: { login: p.author }, labels: p.labels.map((name) => ({ name })) }))).slice(0, a.limit ?? 20);
9440
10269
  const who = await people(gh, config);
9441
- const same2 = (x, y) => Boolean(y && x.toLowerCase() === y.toLowerCase());
10270
+ const same3 = (x, y) => Boolean(y && x.toLowerCase() === y.toLowerCase());
9442
10271
  const rows = await Promise.all(
9443
10272
  queue.map(async (p) => {
9444
10273
  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;
10274
+ if (!f) return { pr: p.number, title: p.title, url: p.url, author: p.author, status: "unknown", statusLabel: STATUS_WORDS.unknown, yours: same3(p.author, viewer), needsYou: null };
10275
+ const mine = same3(p.author, viewer);
10276
+ const inDesign = viewer ? who.lists.design.some((x) => same3(x, viewer)) : false;
10277
+ const inTech = viewer ? who.lists.tech.some((x) => same3(x, viewer)) : false;
9449
10278
  let needsYou = null;
9450
10279
  if (viewer && !mine) {
9451
10280
  if (f.status === "needs-design" && inDesign) needsYou = "Your design approval";
9452
10281
  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";
10282
+ else if (f.status === "needs-approval" && !f.approvals.approvers.some((x) => same3(x, viewer))) needsYou = "Your approval";
9454
10283
  else if (f.status === "ready" && f.gate.canShip) needsYou = "Ready for you to merge";
9455
10284
  }
9456
10285
  if (viewer && mine) {
@@ -9907,11 +10736,11 @@ async function writeToExploration(ctx, branch, input) {
9907
10736
  };
9908
10737
  }
9909
10738
  for (const f of input.files) {
9910
- const abs = join3(ctx.root, f.path);
9911
- if (f.content === null) await rm(abs, { force: true });
10739
+ const abs = join4(ctx.root, f.path);
10740
+ if (f.content === null) await rm2(abs, { force: true });
9912
10741
  else {
9913
- await mkdir(dirname2(abs), { recursive: true });
9914
- await writeFile(abs, f.content);
10742
+ await mkdir2(dirname3(abs), { recursive: true });
10743
+ await writeFile2(abs, f.content);
9915
10744
  }
9916
10745
  }
9917
10746
  const paths = input.files.map((f) => f.path);
@@ -9922,6 +10751,379 @@ async function writeToExploration(ctx, branch, input) {
9922
10751
  };
9923
10752
  }
9924
10753
 
10754
+ // src/suggestions.ts
10755
+ var suggestions_exports = {};
10756
+ __export(suggestions_exports, {
10757
+ SUGGEST_RIGHTS: () => SUGGEST_RIGHTS,
10758
+ appliedNext: () => appliedNext,
10759
+ applySuggestionOp: () => applySuggestionOp,
10760
+ changesFor: () => changesFor,
10761
+ createSuggestionOp: () => createSuggestionOp,
10762
+ fileSuggestion: () => fileSuggestion,
10763
+ filesSuggestionPayload: () => filesSuggestionPayload,
10764
+ getSuggestionOp: () => getSuggestionOp,
10765
+ githubWorkspace: () => githubWorkspace,
10766
+ linkSuggestionOp: () => linkSuggestionOp,
10767
+ listSuggestionsOp: () => listSuggestionsOp,
10768
+ noSuggestionAccess: () => noSuggestionAccess,
10769
+ reviewSuggestionOp: () => reviewSuggestionOp,
10770
+ suggestionBase: () => suggestionBase,
10771
+ suggestionGovernance: () => suggestionGovernance,
10772
+ suggestionNumber: () => suggestionNumber
10773
+ });
10774
+ var SUGGEST_RIGHTS = "Issues: read and write (Contents: read is enough)";
10775
+ async function changesFor(ctx, explicit) {
10776
+ if (explicit === "suggest" || explicit === "direct") return explicit;
10777
+ const { config } = await ctx.config();
10778
+ return changesMode(config);
10779
+ }
10780
+ var appliedNext = (n) => `The owners review it on the issue (bridge_review_suggestion, or Bridge Studio). Once approved, a developer applies it: \`bridge apply ${n}\` in the repo, or in Claude Code with the Bridge MCP server, "Apply Bridge suggestion #${n}" (bridge_apply_suggestion).`;
10781
+ function noSuggestionAccess(ctx, gh) {
10782
+ const local = ctx.mode === "local" && ctx.isGit ? ', or pass changes "direct" (the CLI: --direct) to commit it on a new local branch instead' : "";
10783
+ if (!gh) return `Nothing sent: a suggestion is a GitHub issue and no GitHub repo is known. Set "repo" in ${CONFIG_FILE} or add an origin remote${local}.`;
10784
+ return `Nothing sent: a suggestion is a GitHub issue, which needs a GitHub token with ${SUGGEST_RIGHTS} on ${gh.fullName}. Set GITHUB_TOKEN (or BRIDGE_GITHUB_TOKEN)${local}.`;
10785
+ }
10786
+ async function suggestionBase(ctx, gh, config) {
10787
+ const ref = ctx.ref ?? baseBranchOf(config);
10788
+ if (ctx.mode === "local" && ctx.isGit && ctx.root) {
10789
+ const sha2 = (await git(ctx.root, ["rev-parse", "HEAD"]).catch(() => "")).trim();
10790
+ if (/^[0-9a-f]{40}$/.test(sha2)) return { ref: baseBranchOf(config), sha: sha2 };
10791
+ }
10792
+ const sha = await gh.getBranchSha(ref);
10793
+ if (!sha) throw new Error(`Branch ${ref} not found on ${gh.fullName}. Check the default branch in ${CONFIG_FILE}.`);
10794
+ return { ref, sha };
10795
+ }
10796
+ async function filesSuggestionPayload(ctx, input) {
10797
+ const files = await Promise.all(
10798
+ input.files.map(async (f) => {
10799
+ const now = await ctx.source.read(f.path);
10800
+ return { path: f.path, beforeSha: now === null ? null : gitBlobSha(now), afterSha: f.content === null ? null : gitBlobSha(f.content), content: f.content };
10801
+ })
10802
+ );
10803
+ return {
10804
+ schema: SUGGESTION_SCHEMA,
10805
+ kind: input.kind,
10806
+ title: input.title,
10807
+ summary: input.summary.trim() || input.title,
10808
+ source: input.source,
10809
+ ...input.suggestedBy ? { suggestedBy: input.suggestedBy.replace(/^@/, "") } : {},
10810
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
10811
+ base: input.base,
10812
+ tier: input.tier,
10813
+ files,
10814
+ checks: { newErrors: 0, contrast: [], blocked: null, impact: 0, ...input.checks }
10815
+ };
10816
+ }
10817
+ async function fileSuggestion(ctx, build, opts = {}) {
10818
+ const { config } = await ctx.config();
10819
+ const gh = await ctx.github();
10820
+ if (opts.dryRun) {
10821
+ const base2 = gh ? await suggestionBase(ctx, gh, config).catch(() => ({ ref: baseBranchOf(config), sha: "0000000" })) : { ref: baseBranchOf(config), sha: "0000000" };
10822
+ const payload2 = await build(base2, null);
10823
+ const issue = composeSuggestionIssue(payload2, { labels: [...config.governance.labels, ...opts.labels ?? []], details: opts.details, repo: gh?.fullName });
10824
+ return { status: "dry-run", suggestion: { title: issue.title, labels: issue.labels, body: issue.body, parts: issue.parts.length } };
10825
+ }
10826
+ if (!gh || !ctx.canWriteRemote) return { status: "preview", message: noSuggestionAccess(ctx, gh) };
10827
+ const base = await suggestionBase(ctx, gh, config);
10828
+ const viewer = await viewerOf(ctx, gh);
10829
+ const payload = await build(base, viewer);
10830
+ const problems = suggestionPathProblems(payload, config);
10831
+ if (problems.length) throw new Error(`Not suggested: ${problems.join(" ")}`);
10832
+ const labels = [.../* @__PURE__ */ new Set([...config.governance.labels, ...opts.labels ?? []])];
10833
+ const created = await createSuggestionOnGitHub(gh, payload, { labels, details: opts.details, assignees: config.integrations?.requests?.assignees ?? [] });
10834
+ return { status: "suggested", number: created.number, url: created.url, sha: created.sha, labels, warnings: created.warnings, next: appliedNext(created.number) };
10835
+ }
10836
+ function suggestionNumber(input) {
10837
+ if (typeof input === "number" && Number.isInteger(input) && input > 0) return input;
10838
+ const m = /^#?(\d+)$/.exec(String(input).trim()) ?? /\/issues\/(\d+)/.exec(String(input));
10839
+ if (!m) throw new Error(`"${input}" is not a suggestion number. Pass the issue number (42 or "#42") or its link.`);
10840
+ return Number(m[1]);
10841
+ }
10842
+ async function suggestionGovernance(ctx, gh) {
10843
+ const { config: here } = await ctx.config();
10844
+ let config = here;
10845
+ try {
10846
+ const text = await gh.readFile(CONFIG_FILE, baseBranchOf(here));
10847
+ if (text) config = loadConfig(text);
10848
+ } catch {
10849
+ }
10850
+ const { owners, rights } = config.governance;
10851
+ const hasTeams = [...owners.design, ...owners.tech].some((o) => o.includes("/"));
10852
+ const teams = hasTeams ? await new TeamDirectory(gh).resolve(owners).catch(() => void 0) : void 0;
10853
+ return { config, gov: { owners, rights: rights ?? null, ...teams ? { teams } : {} } };
10854
+ }
10855
+ var OPEN_STATUSES = ["needs-review", "changes-requested", "approved", "applying", "outdated"];
10856
+ async function listSuggestionsOp(ctx, a = {}) {
10857
+ const gh = await reader(ctx);
10858
+ const status = a.status ?? "all";
10859
+ const state = a.state ?? (status === "all" ? "open" : OPEN_STATUSES.includes(status) ? "open" : "closed");
10860
+ let rows = await listSuggestionsOnGitHub(gh, { state, perPage: 100 });
10861
+ if (status !== "all") rows = rows.filter((r) => r.status === status);
10862
+ if (a.kind) rows = rows.filter((r) => r.kind === a.kind);
10863
+ const total = rows.length;
10864
+ rows = rows.slice(0, a.limit ?? 100);
10865
+ const approved = rows.filter((r) => r.status === "approved");
10866
+ return {
10867
+ repo: gh.fullName,
10868
+ label: SUGGESTION_LABEL,
10869
+ state,
10870
+ status,
10871
+ total,
10872
+ suggestions: rows.map((r) => ({
10873
+ number: r.number,
10874
+ title: r.title,
10875
+ kind: r.kind ?? null,
10876
+ tier: r.tier ?? null,
10877
+ status: r.status,
10878
+ said: SUGGESTION_STATUS_LABEL[r.status],
10879
+ author: r.author,
10880
+ url: r.url,
10881
+ linkedPrs: r.linkedPrs
10882
+ })),
10883
+ next: approved.length ? `Ready to apply: ${approved.map((r) => `#${r.number}`).join(", ")}. Apply one with bridge_apply_suggestion { number } (dry run first), or \`bridge apply <n>\`.` : rows.length ? "Read one with bridge_get_suggestion { number }: its change, checks and who still has to approve it. Statuses come from labels here; bridge_get_suggestion counts the approvals." : "No suggestion matches."
10884
+ };
10885
+ }
10886
+ var roleText = (m) => m === "approval" ? "one approval" : `${m} approval`;
10887
+ async function getSuggestionOp(ctx, a) {
10888
+ const n = suggestionNumber(a.number);
10889
+ const gh = await reader(ctx);
10890
+ const { gov } = await suggestionGovernance(ctx, gh);
10891
+ const d = await getSuggestionOnGitHub(gh, n, gov);
10892
+ const base = { number: n, url: d.issue.url, title: d.issue.title, state: d.issue.state, status: d.status, said: SUGGESTION_STATUS_LABEL[d.status], author: d.issue.author, labels: d.issue.labels, linkedPrs: d.linkedPrs };
10893
+ if (!d.parsed.ok) {
10894
+ if (!d.issue.labels.includes(SUGGESTION_LABEL) && d.parsed.reason === "no-block") throw new Error(`Issue #${n} is not a Bridge suggestion: it has no "${SUGGESTION_LABEL}" label and no suggestion block.`);
10895
+ return { ...base, problem: d.parsed.message, next: "Ask the author to send it again from Bridge." };
10896
+ }
10897
+ const p = d.parsed.payload;
10898
+ const approvals = d.approvals;
10899
+ const missing = pendingApprovals(p.tier.tier, approvals);
10900
+ return {
10901
+ ...base,
10902
+ kind: p.kind,
10903
+ summary: p.summary,
10904
+ source: p.source,
10905
+ ...p.suggestedBy ? { suggestedBy: p.suggestedBy } : {},
10906
+ createdAt: p.createdAt,
10907
+ base: p.base,
10908
+ tier: p.tier.tier,
10909
+ bump: p.tier.bump,
10910
+ version: d.parsed.sha.slice(0, 7),
10911
+ payloadSha: d.parsed.sha,
10912
+ ...d.parsed.edited ? { edited: "The machine-readable block was edited by hand after Bridge wrote it. Applying it needs acceptEdited (the CLI: --accept-edited) once you checked it." } : {},
10913
+ change: {
10914
+ ...p.tokens ? { tokens: p.tokens.changes.length ? p.tokens.changes.map((c) => ({ kind: c.kind, path: c.path, ...c.from ? { from: c.from } : {}, file: c.file, before: c.beforeCss ?? c.before, after: c.afterCss ?? c.after })) : p.tokens.edits } : {},
10915
+ ...p.docs ? { docs: p.docs.map((x) => ({ component: x.component, path: x.path })) } : {},
10916
+ ...p.componentSpec ? { componentSpec: { component: p.componentSpec.component, specPath: p.componentSpec.specPath } } : {},
10917
+ ...p.files?.length ? { files: p.files.map((f) => ({ path: f.path, change: f.beforeSha === null ? "new" : f.afterSha === null ? "deleted" : "changed" })) } : {}
10918
+ },
10919
+ checks: p.checks,
10920
+ ...p.links ? { links: p.links } : {},
10921
+ ...p.previews?.length ? { previews: p.previews } : {},
10922
+ approvals: {
10923
+ approvedBy: approvals.approvers,
10924
+ missing: missing.map(roleText),
10925
+ changesRequestedBy: approvals.changesRequested,
10926
+ ...approvals.stale.length ? { staleReviews: approvals.stale } : {},
10927
+ ...approvals.ignored.length ? { notCounted: approvals.ignored } : {}
10928
+ },
10929
+ applied: d.applied,
10930
+ ...p.componentSpec ? { brief: p.componentSpec.brief } : {},
10931
+ next: d.status === "approved" ? `Approved. Apply it: bridge_apply_suggestion { number: ${n}, mode: "dry-run" } first, then mode "branch" (or \`bridge apply ${n}\`). The pull request must say "Closes #${n}".` : d.status === "needs-review" ? `Waiting for ${missing.map(roleText).join(" and ") || "a review"} from the owners in ${CONFIG_FILE}: bridge_review_suggestion { number: ${n}, event: "approve" }.` : d.status === "changes-requested" ? "Changes requested: the author sends a new version (approvals then start again)." : d.status === "applying" ? `Being applied${d.linkedPrs.length ? ` in ${d.linkedPrs.map((x) => `#${x.number}`).join(", ")}` : ""}. It closes when the pull request merges.` : d.status === "outdated" ? "Outdated: the code changed since it was suggested. Ask the author to refresh it." : `${SUGGESTION_STATUS_LABEL[d.status]}: nothing to do.`
10932
+ };
10933
+ }
10934
+ async function reviewSuggestionOp(ctx, a) {
10935
+ const n = suggestionNumber(a.number);
10936
+ const gh = await writer(ctx, "Reviewing a suggestion", "Issues: read and write");
10937
+ const { gov } = await suggestionGovernance(ctx, gh);
10938
+ const viewer = await viewerOf(ctx, gh);
10939
+ if (a.event === "decline") {
10940
+ if (!a.message?.trim()) throw new Error("Say why it is declined, in a sentence: the author reads it.");
10941
+ if (a.payloadSha) {
10942
+ const d = await getSuggestionOnGitHub(gh, n, gov);
10943
+ if (d.parsed.ok && !d.parsed.sha.startsWith(a.payloadSha)) throw new Error(`#${n} changed since you read it. Read it again (bridge_get_suggestion) before declining.`);
10944
+ }
10945
+ const r2 = await declineSuggestionOnGitHub(gh, n, a.message.trim(), viewer);
10946
+ return { number: n, status: "declined", said: SUGGESTION_STATUS_LABEL.declined, ...r2.warnings.length ? { warnings: r2.warnings } : {}, next: "Closed as not planned, with your reason. Reopen it on GitHub to look at it again." };
10947
+ }
10948
+ if (a.event !== "comment" && viewer) {
10949
+ const lists = approverLists(gov.owners, gov.rights ?? null, gov.teams ?? void 0);
10950
+ const owners = [...lists.design, ...lists.tech].map((l) => l.toLowerCase().replace(/^@/, ""));
10951
+ if (owners.length && !owners.includes(viewer.toLowerCase())) {
10952
+ throw new Error(`Only the owners in ${CONFIG_FILE} approve or request changes on suggestions, and @${viewer} is not one. Comment instead (event "comment"), or ask an owner.`);
10953
+ }
10954
+ }
10955
+ const payloadSha = a.payloadSha && /^[0-9a-f]{64}$/.test(a.payloadSha) ? a.payloadSha : void 0;
10956
+ if (a.payloadSha && !payloadSha) {
10957
+ const d = await getSuggestionOnGitHub(gh, n, gov);
10958
+ if (d.parsed.ok && !d.parsed.sha.startsWith(a.payloadSha)) throw new Error(`#${n} changed since you read it. Read it again (bridge_get_suggestion) and review the new version.`);
10959
+ }
10960
+ const r = await reviewSuggestionOnGitHub(gh, n, { event: a.event, role: a.role, message: a.message, payloadSha, ...viewer ? { by: viewer } : {} }, gov);
10961
+ return {
10962
+ number: n,
10963
+ event: a.event,
10964
+ counts: r.counts,
10965
+ version: r.payloadSha.slice(0, 7),
10966
+ status: r.status,
10967
+ said: SUGGESTION_STATUS_LABEL[r.status],
10968
+ comment: r.commentUrl,
10969
+ ...r.warnings.length ? { warnings: r.warnings } : {},
10970
+ next: r.status === "approved" ? `Approved: a developer applies it with \`bridge apply ${n}\` or bridge_apply_suggestion.` : r.status === "changes-requested" ? "The author sends a new version; approvals start again on it." : "Waiting for the other approvals."
10971
+ };
10972
+ }
10973
+ function githubWorkspace(gh, ref) {
10974
+ let files;
10975
+ return {
10976
+ read: async (path) => await gh.getFile(path, ref) ?? null,
10977
+ readAt: async (sha, path) => await gh.readFile(path, sha).catch(() => void 0) ?? null,
10978
+ list: () => files ??= gh.listFiles(ref)
10979
+ };
10980
+ }
10981
+ var MAX_DIFF = 12e3;
10982
+ var cut = (s) => s && s.length > MAX_DIFF ? `${s.slice(0, MAX_DIFF)}
10983
+ ... (truncated)` : s;
10984
+ function planFacts(plan) {
10985
+ return {
10986
+ outcome: plan.outcome,
10987
+ exitCode: plan.exitCode,
10988
+ version: plan.payloadSha.slice(0, 7),
10989
+ table: suggestionApplyTable(plan),
10990
+ ...plan.problems.length ? { problems: plan.problems } : {},
10991
+ ...plan.notes.length ? { notes: plan.notes } : {},
10992
+ ...plan.gate ? { approvals: { review: plan.gate.review, missing: plan.gate.missing.map(roleText), forced: plan.gate.forced, edited: plan.gate.edited } } : {},
10993
+ ...plan.checks ? {
10994
+ checks: {
10995
+ tier: plan.checks.tier.tier,
10996
+ tierWhenSuggested: plan.checks.tierWhenSuggested,
10997
+ tierIncreased: plan.checks.tierIncreased,
10998
+ newErrors: plan.checks.newErrors.length,
10999
+ newContrastFailures: plan.checks.newFailures.map((f) => `${f.fg} on ${f.bg}${f.modes ? ` (${f.modes})` : ""}`)
11000
+ }
11001
+ } : {},
11002
+ ...plan.brief ? { brief: plan.brief } : {}
11003
+ };
11004
+ }
11005
+ async function applySuggestionOp(ctx, a) {
11006
+ const n = suggestionNumber(a.number);
11007
+ const gh = await reader(ctx);
11008
+ const { config, gov } = await suggestionGovernance(ctx, gh);
11009
+ const d = await getSuggestionOnGitHub(gh, n, gov);
11010
+ if (!d.parsed.ok) {
11011
+ if (!d.issue.labels.includes(SUGGESTION_LABEL) && d.parsed.reason === "no-block") throw new Error(`Issue #${n} is not a Bridge suggestion: it has no "${SUGGESTION_LABEL}" label and no suggestion block.`);
11012
+ return { number: n, status: "refused", outcome: "refused", exitCode: 1, message: `Not applied: ${d.parsed.message}` };
11013
+ }
11014
+ if (d.status === "declined") return { number: n, status: "refused", outcome: "refused", exitCode: 1, message: `Not applied: #${n} was declined. Reopen it on GitHub first if it should go in after all.` };
11015
+ const parsed = d.parsed;
11016
+ const dryRun = a.dryRun || a.mode === "dry-run";
11017
+ const mode = a.mode && a.mode !== "dry-run" ? a.mode : "branch";
11018
+ const common = { parsed, comments: d.comments, governance: gov, author: d.issue.author || null, acceptEdited: a.acceptEdited, forceUnapproved: a.forceUnapproved };
11019
+ if (ctx.mode !== "local" || !ctx.isGit || !ctx.root) {
11020
+ const ref = ctx.ref ?? baseBranchOf(config);
11021
+ const plan = await planSuggestionApply({ ...common, config, workspace: githubWorkspace(gh, ref) });
11022
+ return {
11023
+ number: n,
11024
+ status: plan.outcome === "ready" ? "planned" : plan.outcome,
11025
+ message: plan.message,
11026
+ ...planFacts(plan),
11027
+ ...plan.outcome === "ready" ? { files: plan.writes } : {},
11028
+ next: plan.outcome === "ready" ? `Bridge reads ${gh.fullName} over the API and writes nothing here. Write these files on a new branch, commit, push, and open a pull request whose body says "Closes #${n}", then bridge_link_suggestion { number: ${n}, pr }.` : plan.message
11029
+ };
11030
+ }
11031
+ const viewer = await viewerOf(ctx, gh);
11032
+ const r = await applySuggestionLocally({
11033
+ ...common,
11034
+ root: ctx.root,
11035
+ number: n,
11036
+ mode,
11037
+ dryRun,
11038
+ ...a.base ? { base: a.base } : {},
11039
+ ...a.branch ? { branch: a.branch } : {},
11040
+ ...viewer ? { by: viewer } : {},
11041
+ readBase: async (path, sha) => await gh.readFile(path, sha).catch(() => void 0) ?? null
11042
+ });
11043
+ let recorded = false;
11044
+ const warnings = [];
11045
+ if (r.appliedComment && !dryRun && (r.status === "branch-created" || r.status === "written" || r.status === "branch-exists")) {
11046
+ if (ctx.canWriteRemote) {
11047
+ try {
11048
+ await gh.addComment(n, r.appliedComment);
11049
+ recorded = true;
11050
+ const w = await syncSuggestionStateLabel(gh, n, d.issue.labels, "applying");
11051
+ if (w) warnings.push(w);
11052
+ } catch (e) {
11053
+ warnings.push(`#${n} does not say it is being applied yet (${e.message}). It will once the pull request says "Closes #${n}".`);
11054
+ }
11055
+ } else warnings.push(`#${n} does not say it is being applied: that needs a GitHub token with Issues write. It will once the pull request says "Closes #${n}".`);
11056
+ }
11057
+ const next = r.status === "dry-run" ? `Nothing written. Apply it: bridge_apply_suggestion { number: ${n}, mode: "branch" } (or "write" for the working tree, "patch" for a patch).` : r.status === "branch-created" || r.status === "branch-exists" ? `Push ${r.branch} (git push -u origin ${r.branch}) and open a pull request whose body says "Closes #${n}", then bridge_link_suggestion { number: ${n}, pr }.` : r.status === "written" ? `Review the files, commit them on a branch, push, and open a pull request whose body says "Closes #${n}", then bridge_link_suggestion { number: ${n}, pr }.` : r.status === "patch" ? `Apply the patch with \`git am\` on a branch, then open a pull request whose body says "Closes #${n}".` : r.message;
11058
+ return {
11059
+ number: n,
11060
+ status: r.status,
11061
+ mode: dryRun ? "dry-run" : mode,
11062
+ message: r.message,
11063
+ ...planFacts(r.plan),
11064
+ exitCode: r.exitCode,
11065
+ ...r.branch ? { branch: r.branch } : {},
11066
+ ...r.commit ? { commit: r.commit } : {},
11067
+ ...r.written ? { written: r.written } : {},
11068
+ ...r.patch ? { patch: r.patch } : {},
11069
+ ...r.diff ? { diff: cut(r.diff) } : {},
11070
+ recorded,
11071
+ ...warnings.length ? { warnings } : {},
11072
+ next
11073
+ };
11074
+ }
11075
+ async function linkSuggestionOp(ctx, a) {
11076
+ const n = suggestionNumber(a.number);
11077
+ const gh = await writer(ctx, "Linking a suggestion", "Issues: read and write");
11078
+ if (!a.pr && !a.branch && !a.commit) throw new Error("Say where it is applied: pr (the pull request number), branch or commit.");
11079
+ const { gov } = await suggestionGovernance(ctx, gh);
11080
+ const d = await getSuggestionOnGitHub(gh, n, gov);
11081
+ if (!d.parsed.ok) throw new Error(`#${n} cannot be linked: ${d.parsed.message}`);
11082
+ const warnings = [];
11083
+ let branch = a.branch;
11084
+ if (a.pr) {
11085
+ const pr = await gh.getPullRequest(a.pr);
11086
+ branch ??= pr.headRef;
11087
+ if (!closingIssueNumbers(pr.body).includes(n)) warnings.push(`#${a.pr} does not say "Closes #${n}": add it to the pull request description, so the suggestion closes when it merges.`);
11088
+ }
11089
+ const viewer = await viewerOf(ctx, gh);
11090
+ const r = await linkSuggestionPrOnGitHub(gh, n, { pr: a.pr, branch, commit: a.commit, by: viewer ?? void 0, payloadSha: d.parsed.sha, forced: a.forced });
11091
+ warnings.push(...r.warnings);
11092
+ return { number: n, status: "applying", said: SUGGESTION_STATUS_LABEL.applying, ...a.pr ? { pr: a.pr } : {}, ...branch ? { branch } : {}, ...warnings.length ? { warnings } : {}, next: "It closes when the pull request merges." };
11093
+ }
11094
+ async function createSuggestionOp(ctx, a) {
11095
+ const { config } = await ctx.config();
11096
+ const gh = await ctx.github();
11097
+ const raw = a.payload ?? {};
11098
+ if (!SUGGESTION_KINDS.includes(raw.kind)) throw new Error(`"kind" must be one of ${SUGGESTION_KINDS.join(", ")}.`);
11099
+ const needsBase = !raw.base;
11100
+ const base = needsBase ? gh ? await suggestionBase(ctx, gh, config).catch(() => null) : null : raw.base;
11101
+ if (!base) throw new Error(`No base commit: pass "base" { "ref", "sha" }, or set "repo" in ${CONFIG_FILE} so Bridge reads it.`);
11102
+ const viewer = gh && !a.dryRun ? await viewerOf(ctx, gh) : null;
11103
+ const payload = {
11104
+ schema: SUGGESTION_SCHEMA,
11105
+ source: a.source ?? "mcp",
11106
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
11107
+ checks: { newErrors: 0, contrast: [], blocked: null, impact: 0 },
11108
+ ...viewer ? { suggestedBy: viewer } : {},
11109
+ ...raw,
11110
+ base
11111
+ };
11112
+ const shape2 = suggestionShapeProblems(payload);
11113
+ if (shape2.length) throw new Error(`The payload is not a suggestion: ${shape2.join(" ")}`);
11114
+ if (payload.checks.blocked) throw new Error(`Not suggested: ${payload.checks.blocked}. Bridge never suggests a change that breaks the gate.`);
11115
+ const paths = suggestionPathProblems(payload, config);
11116
+ if (paths.length) throw new Error(`Not suggested: ${paths.join(" ")}`);
11117
+ const labels = [.../* @__PURE__ */ new Set([...config.governance.labels, ...a.labels ?? []])];
11118
+ if (a.dryRun) {
11119
+ const issue = composeSuggestionIssue(payload, { labels, repo: gh?.fullName });
11120
+ return { status: "dry-run", title: issue.title, labels: issue.labels, body: issue.body, parts: issue.parts.length, version: issue.sha.slice(0, 7) };
11121
+ }
11122
+ if (!gh || !ctx.canWriteRemote) return { status: "preview", message: noSuggestionAccess(ctx, gh), payload };
11123
+ const created = await createSuggestionOnGitHub(gh, payload, { labels, repo: gh.fullName, assignees: config.integrations?.requests?.assignees ?? [] });
11124
+ return { status: "suggested", number: created.number, url: created.url, version: created.sha.slice(0, 7), ...created.warnings.length ? { warnings: created.warnings } : {}, next: appliedNext(created.number) };
11125
+ }
11126
+
9925
11127
  // src/ops.ts
9926
11128
  var prefixPath = (set, f) => set.repoPath(f);
9927
11129
  function normalizeTokenPath(set, input) {
@@ -9941,6 +11143,7 @@ async function getConfig(ctx) {
9941
11143
  source: ctx.describe(),
9942
11144
  configOrigin: origin === "file" ? "bridge.config.json" : "detected (no bridge.config.json; run `bridge init` to pin it)",
9943
11145
  write: ctx.mode === "local" ? ctx.isGit ? "local branches only (never pushes)" : "none (not a git repo)" : ctx.canWriteRemote ? "pull requests on new bridge/* branches" : "read-only (set GITHUB_TOKEN to open PRs)",
11146
+ changes: changesMode(config) === "suggest" ? `suggest: token, docs, brand, revert and Figma changes are filed as suggestions (GitHub issues a developer applies with bridge apply or bridge_apply_suggestion)${ctx.canWriteRemote ? "" : "; filing one needs GITHUB_TOKEN with Issues write"}. Pass changes "direct" to a tool to open a branch instead.` : 'direct: changes open branches and pull requests. Pass changes "suggest" to a tool to file a suggestion instead.',
9944
11147
  repo: gh ? gh.fullName : null,
9945
11148
  tokenFiles: set.fileNames.length,
9946
11149
  tokens: set.allPaths().length,
@@ -10268,6 +11471,25 @@ async function proposeTokenChange(ctx, a) {
10268
11471
  if (newErrors.length || newFailures.length) {
10269
11472
  return { status: "blocked", why: [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and "), newErrors, ...report, hint: "Fix the edits (or add edits for the aliases they break). Bridge never opens a PR that breaks the gate." };
10270
11473
  }
11474
+ if (!target && await changesFor(ctx, a.changes) === "suggest") {
11475
+ const source = a.source ?? "mcp";
11476
+ const shown = { ...report, changesMode: "suggest", files: files.map((f) => f.path) };
11477
+ const gh2 = await ctx.github();
11478
+ if (a.dryRun) return { status: "dry-run", ...shown, suggestion: { title: suggestionIssueTitle({ kind: "token-change", title: a.title }), body }, fileContents: files };
11479
+ if (!gh2 || !ctx.canWriteRemote) return { status: "preview", message: noSuggestionAccess(ctx, gh2), ...shown, fileContents: files };
11480
+ const base2 = await suggestionBase(ctx, gh2, config);
11481
+ const viewer = await viewerOf(ctx, gh2);
11482
+ const s = await suggestTokenProposalOnGitHub(gh2, plan, {
11483
+ base: base2.ref,
11484
+ baseSha: base2.sha,
11485
+ source,
11486
+ ...viewer ? { suggestedBy: viewer } : {},
11487
+ labels: config.governance.labels,
11488
+ labelsAfter: marks.labels,
11489
+ assignees: config.integrations?.requests?.assignees ?? []
11490
+ });
11491
+ return { status: "suggested", ...shown, number: s.number, url: s.url, version: s.sha.slice(0, 7), labels: s.labels, ...s.warnings.length ? { warnings: s.warnings } : {}, product: product.name, next: appliedNext(s.number) };
11492
+ }
10271
11493
  let branch = target ? target.branch : branchWithJiraKey(`${config.governance.branchPrefix}${slugTitle}`, jira);
10272
11494
  const preview = { ...report, branch, files: files.map((f) => f.path), prBody: body };
10273
11495
  if (a.dryRun) return { status: "dry-run", ...preview, fileContents: files };
@@ -10419,8 +11641,8 @@ async function getReview(ctx, a) {
10419
11641
  const delta2 = pairs.length ? core.contrastDelta(core.runContrast(before, pairs, config.contrast?.modes), core.runContrast(after, pairs, config.contrast?.modes)) : [];
10420
11642
  const prevErrors = new Set(core.validate(before).map(issueKey3));
10421
11643
  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"}/`));
11644
+ const tokenRoot2 = config.tokens.root.replace(/\/$/, "");
11645
+ const otherFiles = changedFiles.filter((f) => !f.startsWith(`${tokenRoot2}/`) && !f.startsWith(`${config.changesets?.dir ?? ".changeset"}/`));
10424
11646
  const tier = core.classify(changes, otherFiles, config);
10425
11647
  const imp = core.impact(before, after, changes, 20);
10426
11648
  const labelTier = Array.isArray(meta.labels) ? meta.labels.map((l) => /^tier:\s*(\d)/i.exec(l)?.[1]).find(Boolean) : void 0;
@@ -10477,12 +11699,12 @@ async function scaffoldComponentOp(ctx, a) {
10477
11699
  const write = ctx.mode === "local" && a.write !== false;
10478
11700
  if (write) {
10479
11701
  for (const f of res.files) {
10480
- const abs = join4(ctx.root, f.path);
11702
+ const abs = join5(ctx.root, f.path);
10481
11703
  if (await access(abs).then(() => true, () => false)) throw new Error(`${f.path} exists; not overwriting.`);
10482
11704
  }
10483
11705
  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);
11706
+ await mkdir3(dirname4(join5(ctx.root, f.path)), { recursive: true });
11707
+ await writeFile3(join5(ctx.root, f.path), f.content);
10486
11708
  }
10487
11709
  }
10488
11710
  return { written: write, tier: 2, files: write ? res.files.map((f) => f.path) : res.files, nextSteps: res.notes };
@@ -10494,8 +11716,8 @@ async function auditUi(ctx, code2) {
10494
11716
  async function visualImpact(ctx, a, run2) {
10495
11717
  if (ctx.mode !== "local" || !ctx.root) throw new Error("Visual impact needs a local checkout (start Bridge with --root): it builds Storybook and the app at both refs.");
10496
11718
  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;
11719
+ const out = a.out ? resolve3(ctx.root, a.out) : join5(ctx.root, "visual-impact");
11720
+ const runImpact = run2 ?? (await import("./src-ABI7LARD.js")).runImpact;
10499
11721
  const r = await runImpact({
10500
11722
  root: ctx.root,
10501
11723
  base: a.base ?? "main",
@@ -10528,7 +11750,7 @@ async function visualImpact(ctx, a, run2) {
10528
11750
  status: s.status,
10529
11751
  percent: s.percent,
10530
11752
  boxes: s.boxes.length,
10531
- ...s.images.composite ? { composite: join4(r.outDir, s.images.composite) } : {},
11753
+ ...s.images.composite ? { composite: join5(r.outDir, s.images.composite) } : {},
10532
11754
  ...s.error ? { error: s.error } : {}
10533
11755
  })),
10534
11756
  outDir: r.outDir,
@@ -10772,6 +11994,17 @@ async function proposeRevert(ctx, a) {
10772
11994
  const plan = await buildPlan(ctx, a, ctx.mode === "local" ? "the bridge CLI" : "Bridge MCP");
10773
11995
  const report = planReport(plan);
10774
11996
  if (plan.status !== "ready") return { ...report, hint: plan.status === "blocked" ? "Bridge never opens a change that breaks the gate." : "Nothing to propose." };
11997
+ if (await changesFor(ctx, a.changes) === "suggest") {
11998
+ const summary2 = plan.commitMessage.split("\n").slice(1).join("\n").trim() || plan.prTitle;
11999
+ const r = await fileSuggestion(
12000
+ ctx,
12001
+ (base2, by) => filesSuggestionPayload(ctx, { kind: "revert", title: plan.prTitle, summary: summary2, files: plan.files, tier: { tier: plan.tier.tier, bump: plan.tier.bump }, base: base2, source: a.source ?? "mcp", suggestedBy: by, checks: { newErrors: plan.newErrors.length } }),
12002
+ { labels: [plan.kind === "revert" ? "revert" : "restore", ...plan.tier.tier === 3 ? ["tokens"] : [], ...plan.tier.bump === "major" ? ["breaking"] : []], details: plan.prBody, dryRun: a.dryRun }
12003
+ );
12004
+ if (r.status === "dry-run") return { ...report, status: "dry-run", changesMode: "suggest", suggestion: r.suggestion, fileContents: plan.files };
12005
+ if (r.status === "preview") return { ...report, status: "preview", message: r.message, changesMode: "suggest", fileContents: plan.files };
12006
+ return { ...report, status: "suggested", changesMode: "suggest", number: r.number, url: r.url, version: r.sha.slice(0, 7), labels: r.labels, ...r.warnings.length ? { warnings: r.warnings } : {}, next: r.next };
12007
+ }
10775
12008
  if (a.dryRun) return { ...report, status: "dry-run", prBody: plan.prBody, fileContents: plan.files };
10776
12009
  const prefix = config.governance.branchPrefix;
10777
12010
  let branch = plan.branch;
@@ -10891,6 +12124,17 @@ async function proposeBrand(ctx, a) {
10891
12124
  const prefix = config.governance.branchPrefix;
10892
12125
  const stem2 = `${prefix}brand-${plan.slug}`;
10893
12126
  let branch = stem2;
12127
+ if (await changesFor(ctx, a.changes) === "suggest") {
12128
+ const summary2 = a.notes?.trim() || `New brand "${plan.slug}", started from ${plan.from}. ${report.contrast.line} (${plan.contrast.themes.join(" and ")}).`;
12129
+ const r = await fileSuggestion(
12130
+ ctx,
12131
+ (base2, by) => filesSuggestionPayload(ctx, { kind: "brand", title: plan.title, summary: summary2, files, tier: { tier: plan.risk.tier, bump: plan.risk.bump }, base: base2, source: a.source ?? "mcp", suggestedBy: by }),
12132
+ { labels: ["tokens"], details: report.prBody, dryRun: a.dryRun }
12133
+ );
12134
+ if (r.status === "dry-run") return { status: "dry-run", ...report, changesMode: "suggest", suggestion: r.suggestion, fileContents: files };
12135
+ if (r.status === "preview") return { status: "preview", message: r.message, ...report, changesMode: "suggest", fileContents: files };
12136
+ return { status: "suggested", ...report, changesMode: "suggest", number: r.number, url: r.url, version: r.sha.slice(0, 7), labels: r.labels, ...r.warnings.length ? { warnings: r.warnings } : {}, next: r.next };
12137
+ }
10894
12138
  if (a.dryRun) return { status: "dry-run", ...report, branch, fileContents: files };
10895
12139
  const message = `feat(tokens): add the ${plan.name} brand
10896
12140
 
@@ -11054,7 +12298,7 @@ async function createRequest(ctx, a) {
11054
12298
  ${marks.line}`, labels: [...composed.labels, ...marks.labels] } : composed;
11055
12299
  if (a.dryRun) return { status: "dry-run", issue };
11056
12300
  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.");
12301
+ if (!ctx.canWriteRemote) throw new Error("Creating a request needs GITHUB_TOKEN with Issues: read and write on this repository (Contents: read is enough, like for suggestions). Without it Bridge is read-only.");
11058
12302
  const created = await gh.createIssue(issue);
11059
12303
  return {
11060
12304
  status: "created",
@@ -11162,7 +12406,8 @@ async function saveDocs(ctx, a) {
11162
12406
  return docsFileText(cleanDocs(merged), current);
11163
12407
  };
11164
12408
  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;
12409
+ const mode = a.pr === void 0 ? await changesFor(ctx, a.changes) : "direct";
12410
+ const open = a.pr === void 0 && gh && mode === "direct" ? await findOpenDocsProposalOnGitHub(gh, { name, slug: docsSlug(name) }, ctx.ref ?? baseBranchOf(config)).catch(() => null) : null;
11166
12411
  const target = a.pr ?? open?.number;
11167
12412
  if (target !== void 0) {
11168
12413
  const n = branchNumber(target);
@@ -11198,7 +12443,36 @@ async function saveDocs(ctx, a) {
11198
12443
  path,
11199
12444
  content: text,
11200
12445
  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.` }
12446
+ changesMode: mode,
12447
+ ...a.dryRun ? {} : { said: mode === "suggest" ? noSuggestionAccess(ctx, gh) : `Nothing sent: saving docs for review needs a GitHub token with ${RIGHTS.docs}. Set GITHUB_TOKEN, or write the file yourself on a branch.` }
12448
+ };
12449
+ }
12450
+ if (mode === "suggest") {
12451
+ const base = await suggestionBase(ctx, gh, config);
12452
+ const viewer = await viewerOf(ctx, gh);
12453
+ const r2 = await proposeDocsOnGitHub(gh, [edit], {
12454
+ base: base.ref,
12455
+ baseSha: base.sha,
12456
+ mode: "suggest",
12457
+ title: a.title,
12458
+ notes,
12459
+ jira,
12460
+ origin: DOCS_MCP_ORIGIN,
12461
+ extraLabels: marks.labels,
12462
+ source: a.source ?? "mcp",
12463
+ ...viewer ? { suggestedBy: viewer } : {}
12464
+ });
12465
+ return {
12466
+ status: "suggested",
12467
+ number: r2.number,
12468
+ url: r2.url,
12469
+ title: r2.title,
12470
+ path,
12471
+ product: product.name,
12472
+ changesMode: mode,
12473
+ ..."warnings" in r2 && r2.warnings.length ? { warnings: r2.warnings } : {},
12474
+ said: `Suggested the ${name} docs as #${r2.number}. Docs only: one approval, then a developer applies it.`,
12475
+ next: appliedNext(r2.number)
11202
12476
  };
11203
12477
  }
11204
12478
  const r = await proposeDocsOnGitHub(gh.withBranchPrefix("docs/"), [edit], {
@@ -11305,13 +12579,18 @@ async function missingPiecesOp(ctx, a) {
11305
12579
  next: r.pieces.length ? `${a.record ? "" : "Record them (record: true) so Studio shows them, then "}open a library branch for each with bridge_open_library_branch { pr: ${n}, piece: "<id>" }. Fixes are values and components the library already has: use them in the product code instead.` : "Nothing is missing from the library. Fix the values that equal a token, if any."
11306
12580
  };
11307
12581
  }
11308
- function tokenWriter(ctx, gh, source) {
11309
- return async (plan) => {
12582
+ function tokenWriter(ctx, gh, source, viewer) {
12583
+ return async (plan, how) => {
11310
12584
  const { config } = await ctx.config();
11311
12585
  const [before, pairs] = await Promise.all([ctx.tokens(), core.readContrastPairs(ctx.source, config).catch(() => [])]);
11312
12586
  const p = planTokenProposal({ before, pairs, edits: plan.edits, title: plan.title, summary: plan.summary, config, tool: source === "mcp" ? "Bridge MCP" : "the bridge CLI" });
11313
12587
  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).`);
12588
+ if (p.blocked) throw new Error(`The library ${how.mode === "suggest" ? "suggestion was not sent" : "branch was not opened"}: ${p.blocked}. Change the value (bridge_propose_token_change with dryRun shows why).`);
12589
+ if (how.mode === "suggest") {
12590
+ const base = await suggestionBase(ctx, gh, config);
12591
+ const s = await suggestTokenProposalOnGitHub(gh, p, { base: base.ref, baseSha: base.sha, source, ...viewer ? { suggestedBy: viewer } : {}, labels: config.governance.labels, labelsAfter: [NEEDED_BY_LABEL], neededBy: plan.neededBy });
12592
+ return { status: "suggested", number: s.number, url: s.url };
12593
+ }
11315
12594
  const opened = await writeTokenProposalOnGitHub(gh, p, { base: baseBranchOf(config), branchPrefix: config.governance.branchPrefix, labels: config.governance.labels, labelsAfter: [NEEDED_BY_LABEL], tool: source === "mcp" ? "Bridge MCP (source: mcp)" : "the bridge CLI" });
11316
12595
  return { number: opened.pr, url: opened.url, branch: opened.branch };
11317
12596
  };
@@ -11319,7 +12598,8 @@ function tokenWriter(ctx, gh, source) {
11319
12598
  async function openLibraryBranchOp(ctx, a) {
11320
12599
  const n = branchNumber(a.pr);
11321
12600
  const source = a.source ?? "mcp";
11322
- const gh = a.dryRun ? await reader(ctx) : await writer(ctx, "Opening a library branch", NEED);
12601
+ const mode = await changesFor(ctx, a.changes);
12602
+ const gh = a.dryRun ? await reader(ctx) : mode === "suggest" ? await writer(ctx, "Suggesting a library change", SUGGEST_RIGHTS) : await writer(ctx, "Opening a library branch", NEED);
11323
12603
  const { config } = await ctx.config();
11324
12604
  const viewer = await viewerOf(ctx, gh);
11325
12605
  const s = settingsOf(config);
@@ -11329,7 +12609,8 @@ async function openLibraryBranchOp(ctx, a) {
11329
12609
  base: s.base,
11330
12610
  products: productsOf(config),
11331
12611
  exploration: { packages: s.packages, apps: s.apps },
11332
- writeTokens: tokenWriter(ctx, gh, source),
12612
+ writeTokens: tokenWriter(ctx, gh, source, viewer),
12613
+ mode,
11333
12614
  ...viewer ? { openedBy: viewer } : {},
11334
12615
  ...source === "mcp" ? { agent: "claude" } : {},
11335
12616
  source,
@@ -11337,8 +12618,8 @@ async function openLibraryBranchOp(ctx, a) {
11337
12618
  dryRun: a.dryRun
11338
12619
  });
11339
12620
  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.";
12621
+ const what = plan.kind === "token-change" ? mode === "suggest" ? "token suggestion" : "token change" : plan.kind === "exploration" ? "library exploration" : "component request";
12622
+ const next = r.status === "dry-run" ? `Run it without dryRun to open the ${what}.` : r.status === "suggested" && r.link ? appliedNext(r.link.number) : plan.kind === "exploration" ? `Build it on ${r.link?.branch}: ${plan.brief} Then bridge_use_library_preview { pr: ${n}, piece: "${a.piece}" } so the product branch shows it.` : plan.kind === "request" ? "A person or Claude builds the component from the request (bridge_get_request). The product branch keeps its own version until it ships." : r.preview?.status === "conflict" ? r.preview.said : "Design and tech owners review the token change. Once it ships, bridge_switch_to_published moves the product branch to the real version.";
11342
12623
  return {
11343
12624
  status: r.status,
11344
12625
  pr: n,
@@ -11348,6 +12629,7 @@ async function openLibraryBranchOp(ctx, a) {
11348
12629
  ...plan.kind === "token-change" ? { edits: plan.edits } : {},
11349
12630
  ...plan.kind === "exploration" ? { exploration: plan.exploration.name, brief: plan.brief } : {},
11350
12631
  ...plan.kind === "request" ? { request: plan.request.title } : {},
12632
+ changesMode: mode,
11351
12633
  neededBy: `${plan.neededBy.title} (#${plan.neededBy.pr}) in ${plan.neededBy.productName}`,
11352
12634
  ...r.link ? { library: { kind: r.link.kind, number: r.link.number, url: r.link.url, ...r.link.branch ? { branch: r.link.branch } : {} } } : {},
11353
12635
  ...r.preview ? { preview: r.preview } : {},
@@ -11453,7 +12735,10 @@ async function pushFigmaComponentOp(ctx, a) {
11453
12735
  const { config } = await ctx.config();
11454
12736
  const set = await ctx.tokens();
11455
12737
  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);
12738
+ const mode = await changesFor(ctx, a.changes);
12739
+ const gh = a.dryRun ? await reader(ctx) : mode === "suggest" ? await writer(ctx, "Suggesting a component change from Figma", SUGGEST_RIGHTS) : await writer(ctx, "Sending a component change from Figma", NEED2);
12740
+ const viewer = a.dryRun ? null : await viewerOf(ctx, gh);
12741
+ const base = a.dryRun || mode !== "suggest" ? null : await suggestionBase(ctx, gh, config);
11457
12742
  const pairs = await core.readContrastPairs(ctx.source, config).catch(() => []);
11458
12743
  const aliasOfPath = (p) => aliasOf(set.defsOf(p)[0]?.value) ?? void 0;
11459
12744
  const r = await openComponentPushOnGitHub(gh, {
@@ -11464,11 +12749,19 @@ async function pushFigmaComponentOp(ctx, a) {
11464
12749
  labels: [...config.governance.labels, `source: ${source}`],
11465
12750
  tool,
11466
12751
  dryRun: a.dryRun,
11467
- writeTokens: async ({ edits, title, summary: summary2, bodyExtra }) => {
12752
+ mode,
12753
+ ...viewer ? { suggestedBy: viewer } : {},
12754
+ ...base ? { base: base.ref, baseSha: base.sha } : {},
12755
+ writeTokens: async ({ edits, title, summary: summary2, bodyExtra, mode: how, figma }) => {
11468
12756
  const all = [...pairs, ...componentContrastPairs(edits, pairs, { aliasOf: aliasOfPath, paths: set.allPaths() })];
11469
12757
  const p = planTokenProposal({ before: set, pairs: all, edits, title, summary: summary2, config, tool, bodyExtra });
11470
12758
  if (!p) throw new Error("The library already paints it this way: nothing to send.");
11471
12759
  if (p.blocked) throw new Error(`Not sent: ${p.blocked}. Pick another variable.`);
12760
+ if (how === "suggest") {
12761
+ const b = base ?? await suggestionBase(ctx, gh, config);
12762
+ const s = await suggestTokenProposalOnGitHub(gh, p, { base: b.ref, baseSha: b.sha, source, ...viewer ? { suggestedBy: viewer } : {}, labels: config.governance.labels, labelsAfter: [`source: ${source}`], links: { figma } });
12763
+ return { status: "suggested", number: s.number, url: s.url };
12764
+ }
11472
12765
  const opened = await writeTokenProposalOnGitHub(gh, p, { base: baseBranchOf(config), branchPrefix: config.governance.branchPrefix, labels: config.governance.labels, labelsAfter: [`source: ${source}`], tool: `${tool} (source: ${source})` });
11473
12766
  return { number: opened.pr, url: opened.url, branch: opened.branch };
11474
12767
  }
@@ -11482,20 +12775,22 @@ async function pushFigmaComponentOp(ctx, a) {
11482
12775
  ...plan.edits.length ? { edits: plan.edits } : {},
11483
12776
  ...plan.reasons.length ? { reasons: plan.reasons } : {},
11484
12777
  ...plan.issues.length ? { fixInFigma: plan.issues } : {},
12778
+ changesMode: mode,
11485
12779
  ...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."
12780
+ ...r.suggestion ? { suggestion: r.suggestion } : {},
12781
+ next: r.status === "dry-run" ? `Run it without dryRun to ${mode === "suggest" ? "file the suggestion" : "open the library branch"}.` : r.status === "not-sent" ? "Nothing was sent: fix these in Figma, then push again." : r.suggestion ? `${r.suggestion.route === "claude-draft" ? "A component spec suggestion: Claude AI drafts the code from it once a developer applies it (bridge_apply_suggestion writes the spec file and returns the brief). " : ""}${appliedNext(r.suggestion.number)}` : plan.route === "claude-draft" ? `Draft the code: bridge_get_figma_change { pr: ${r.link.number} }.` : "Design and tech owners review the token change. Once it ships, a pull in the DS Bridge plugin rebuilds the component."
11487
12782
  };
11488
12783
  }
11489
12784
 
11490
12785
  // src/server.ts
11491
- var VERSION = true ? "0.6.0" : "dev";
12786
+ var VERSION = true ? "0.7.0" : "dev";
11492
12787
  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 });
12788
+ var fail2 = (e) => ({ content: [{ type: "text", text: `Error: ${e.message ?? String(e)}` }], isError: true });
11494
12789
  var run = (fn) => async (a) => {
11495
12790
  try {
11496
12791
  return ok(await fn(a));
11497
12792
  } catch (e) {
11498
- return fail(e);
12793
+ return fail2(e);
11499
12794
  }
11500
12795
  };
11501
12796
  var ModeArg = z.record(z.string(), z.string()).describe('Mode values, e.g. {"theme":"dark","brand":"ocean"}. Omitted modes use their default. See bridge_get_config for the modes this repo has.');
@@ -11510,6 +12805,7 @@ var EditArg = z.object({
11510
12805
  mode: ModeArg.optional().describe('Pick the file that defines the token for this mode, e.g. {"theme":"dark"}. Alternative to `file`.')
11511
12806
  });
11512
12807
  var RO = { readOnlyHint: true, openWorldHint: false };
12808
+ var ChangesArg = z.enum(["suggest", "direct"]).optional().describe('How this change reaches the code: "suggest" files a suggestion (a GitHub issue the owners approve and a developer applies, Issues write only), "direct" opens a branch and a pull request. Default: "changes" in bridge.config.json, else "suggest". Only set it when the person asked.');
11513
12809
  function createServer(ctx) {
11514
12810
  const server = new McpServer(
11515
12811
  { name: "bridge", version: VERSION },
@@ -11517,6 +12813,7 @@ function createServer(ctx) {
11517
12813
  instructions: [
11518
12814
  "Bridge exposes a design system repo (DTCG tokens, components, docs) to you.",
11519
12815
  "Every write goes through a branch and a pull request, never straight to the default branch: the library only changes through reviewed pull requests, and on a protected repo GitHub refuses direct pushes anyway.",
12816
+ 'How changes reach the code is the "changes" setting of bridge.config.json (bridge_get_config says which): "suggest" (the default) files token, docs, brand, revert, missing piece and Figma changes as suggestions, GitHub issues with the exact change that the owners approve (bridge_review_suggestion) and a developer applies (bridge_apply_suggestion, or `bridge apply <n>`); "direct" opens branches and pull requests. Writing tools take "changes" to pick one for a call; only pass it when the person asked. When the person says "Apply Bridge suggestion #n", call bridge_apply_suggestion with mode "dry-run" first, show the result, then apply it on a branch.',
11520
12817
  "Read before you write: bridge_get_guidelines for the repo rules, bridge_search_tokens / bridge_get_token before using or proposing a token.",
11521
12818
  "Never hardcode a value in UI code when a token exists; never invent a token name (check it with bridge_get_token).",
11522
12819
  "bridge_audit measures how a product codebase adopts the design system (hardcoded values, foreign UI components) and suggests the tokens and components to use.",
@@ -11658,7 +12955,7 @@ function createServer(ctx) {
11658
12955
  "bridge_propose_token_change",
11659
12956
  {
11660
12957
  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.',
12958
+ description: 'Propose token edits as a reviewable change. Applies edits with minimal JSON diffs, validates, runs the contrast gate before/after, computes the risk tier and semver bump, writes a changeset and a designer-friendly PR body. Blocked (nothing written) if it adds validation errors or breaks a contrast pair. Suggest mode (the default unless bridge.config.json says "changes": "direct"): files a suggestion, a GitHub issue with the exact edits, the checks and the tier, status "suggested" with its number; owners approve it and a developer applies it (bridge_apply_suggestion). Direct mode: local repo, commits to a new local bridge/* branch without touching your working tree (never pushes); GitHub with token, new branch + draft PR labelled "source: mcp" and its tier. With branch or pr it writes to that branch whatever the mode. Never writes to main. Use dryRun to preview. Tell the user token changes are Tier 3.',
11662
12959
  inputSchema: {
11663
12960
  title: z.string().min(3).describe('Short imperative title, e.g. "Darken muted text in dark theme". Becomes the branch name and PR title.'),
11664
12961
  summary: z.string().optional().describe("Why: the problem and the intent, for the reviewers. Plain sentences, no em dash."),
@@ -11667,11 +12964,12 @@ function createServer(ctx) {
11667
12964
  branch: z.string().optional().describe("Exploration branch (explore/<slug>) to commit the edits to instead of opening a new pull request. GitHub: one commit on that branch. Local: written to the working tree, which must have that branch checked out. Never main."),
11668
12965
  pr: z.union([z.number().int().positive(), z.string().min(1)]).optional().describe('A branch already in review (12 or "#12") to add the edits to, as "Edit tokens in this branch" in Studio: one commit there and a comment for the reviewers. Its author or someone with write access; needs GITHUB_TOKEN.'),
11669
12966
  jira: z.string().optional().describe("Jira key like DS-42, as in Studio's Create branch: first in the branch name and title so Jira links them."),
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.")
12967
+ product: z.string().optional().describe("Product key or name (bridge_list_products) the change is for. Default: the product of the current folder or repo. Ask the person when it is not obvious."),
12968
+ changes: ChangesArg
11671
12969
  },
11672
12970
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
11673
12971
  },
11674
- run((a) => proposeTokenChange(ctx, a))
12972
+ run((a) => proposeTokenChange(ctx, { ...a, source: "mcp" }))
11675
12973
  );
11676
12974
  const Hue = z.union([z.string(), z.number()]);
11677
12975
  const BrandArgs = {
@@ -11705,11 +13003,11 @@ function createServer(ctx) {
11705
13003
  "bridge_propose_brand",
11706
13004
  {
11707
13005
  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.") },
13006
+ description: 'Propose a new brand as a reviewable change. Same plan as bridge_plan_brand; blocked (nothing written) when an input is invalid or a contrast pair fails. Local repo: commits to a new local bridge/brand-<slug> branch without touching your working tree (never pushes). GitHub with token: new branch + draft PR labelled "source: mcp", "tier: 3" and "tokens". Never writes to main. Suggest mode (the default unless bridge.config.json says "changes": "direct"): one suggestion issue with the brand files instead, applied by a developer once approved. Tell the user a new brand is Tier 3: it needs design and tech owner approval.',
13007
+ inputSchema: { ...BrandArgs, dryRun: z.boolean().optional().describe("Return the plan and file contents without writing."), changes: ChangesArg },
11710
13008
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
11711
13009
  },
11712
- run((a) => proposeBrand(ctx, a))
13010
+ run((a) => proposeBrand(ctx, { ...a, source: "mcp" }))
11713
13011
  );
11714
13012
  server.registerTool(
11715
13013
  "bridge_list_reviews",
@@ -11801,17 +13099,18 @@ function createServer(ctx) {
11801
13099
  "bridge_propose_revert",
11802
13100
  {
11803
13101
  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.",
13102
+ description: 'Plans the revert (or restore) like bridge_plan_revert, then: local repo, commits to a new local bridge/* branch without touching your working tree (never pushes); GitHub with token, new bridge/* branch and a PR labelled revert and its tier (draft for Tier 2 and 3). Blocked when it would add validation errors or break a contrast pair. Never writes to main. Suggest mode (the default unless bridge.config.json says "changes": "direct"): one suggestion issue with the files instead, applied by a developer once approved. Show the user the plan and its conflicts first.',
11805
13103
  inputSchema: {
11806
13104
  target: z.string().optional(),
11807
13105
  restore: RestoreArg.optional(),
11808
13106
  resolutions: ResolutionsArg.optional(),
11809
13107
  restoreConflicts: z.boolean().optional(),
11810
- dryRun: z.boolean().optional()
13108
+ dryRun: z.boolean().optional(),
13109
+ changes: ChangesArg
11811
13110
  },
11812
13111
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
11813
13112
  },
11814
- run((a) => proposeRevert(ctx, a))
13113
+ run((a) => proposeRevert(ctx, { ...a, source: "mcp" }))
11815
13114
  );
11816
13115
  server.registerTool(
11817
13116
  "bridge_scaffold_component",
@@ -11945,7 +13244,7 @@ function createServer(ctx) {
11945
13244
  ]
11946
13245
  };
11947
13246
  } catch (e) {
11948
- return fail(e);
13247
+ return fail2(e);
11949
13248
  }
11950
13249
  }
11951
13250
  );
@@ -12042,8 +13341,8 @@ function createServer(ctx) {
12042
13341
  "bridge_open_library_branch",
12043
13342
  {
12044
13343
  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() },
13344
+ description: 'Turn one recorded missing piece into a library branch linked to the product branch, as Bridge Studio does: a token piece opens a token change (same gate and body as bridge_propose_token_change, a draft for design and tech review) and the product branch takes its preview; a variant opens a library exploration with the brief to build it (build it there, then bridge_use_library_preview); a new component opens a component request. Both sides link: the library side says "Needed by" the product branch (label needed-by-product), the product branch records it. Suggest mode (the default unless bridge.config.json says "changes": "direct"): a token piece becomes a suggestion and a variant a component request, no branch; needs Issues write. Direct mode needs GITHUB_TOKEN (contents, pull requests and issues: write). dryRun shows what it would open. Only when the person asked.',
13345
+ inputSchema: { pr: Pr, piece: Piece, usePreview: z.boolean().optional().describe("Merge the new library branch into the product branch when it already changes something (default true)."), dryRun: z.boolean().optional(), changes: ChangesArg },
12047
13346
  annotations: WRITE
12048
13347
  },
12049
13348
  run((a) => openLibraryBranchOp(ctx, { ...a, source: "mcp" }))
@@ -12112,11 +13411,11 @@ function createServer(ctx) {
12112
13411
  "bridge_push_figma_component",
12113
13412
  {
12114
13413
  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() },
13414
+ description: 'What the DS Bridge plugin Push tab does, from a structured diff of a Figma component (the v 1 spec shape: component, figmaName, coded, kind, title, summary, changes, rebinds, needsCode, unknown, variantAxes, properties, bindings, anatomy). An exact token swap on a component token used nowhere else opens a token change (contrast gated, reviewed like any token change); anything that needs code opens a draft branch with the spec, waiting for Claude AI; detached layers, raw values and variables the library lacks are listed with what to do and never sent. Suggest mode (the default unless bridge.config.json says "changes": "direct"): a token suggestion or a component spec suggestion instead of a branch, Issues write only. dryRun shows the route. Direct mode needs GITHUB_TOKEN (contents and pull requests: write). Only when the person asked.',
13415
+ inputSchema: { diff: z.record(z.string(), z.unknown()).describe("The structured component diff (v 1)."), dryRun: z.boolean().optional(), changes: ChangesArg },
12117
13416
  annotations: WRITE
12118
13417
  },
12119
- run((a) => pushFigmaComponentOp(ctx, { diff: a.diff, dryRun: a.dryRun, source: "mcp" }))
13418
+ run((a) => pushFigmaComponentOp(ctx, { diff: a.diff, dryRun: a.dryRun, source: "mcp", changes: a.changes }))
12120
13419
  );
12121
13420
  server.registerTool(
12122
13421
  "bridge_list_products",
@@ -12307,7 +13606,7 @@ function createServer(ctx) {
12307
13606
  "bridge_save_docs",
12308
13607
  {
12309
13608
  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.`,
13609
+ description: `Save a component's usage guidance (docs.json sections: summary, whenToUse, whenNotToUse, dos, donts, accessibility, contentGuidelines, anatomy, preview) the way the Library edit mode does: merged into the docs on the branch, cleaned and checked (a summary, no empty items, no em dash). Without pr: a new branch sent for review (docs only: one approval, patch release note), with an optional Jira key and product. With pr: one commit on that open branch. Suggest mode (the default unless bridge.config.json says "changes": "direct"), without pr: a docs suggestion issue instead of a branch (Issues write only). dryRun returns the file. Direct mode needs GITHUB_TOKEN (contents and pull requests: write). Prose you drafted carries "draftedBy": "claude" and "needsReview": true so a reviewer accepts it in Studio.`,
12311
13610
  inputSchema: {
12312
13611
  component: z.string().min(1).describe("Component name, e.g. Button."),
12313
13612
  docs: z.record(z.string(), z.unknown()).describe('The sections to set, in docs.json shapes: summary a string; whenToUse, whenNotToUse, accessibility, contentGuidelines lists of strings; dos and donts lists of {"title","text"}.'),
@@ -12316,11 +13615,100 @@ function createServer(ctx) {
12316
13615
  notes: z.string().optional().describe("Why, for the reviewers."),
12317
13616
  jira: z.string().optional().describe("Jira key like DS-42: first in the branch name and title so Jira links them."),
12318
13617
  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()
13618
+ dryRun: z.boolean().optional(),
13619
+ changes: ChangesArg
13620
+ },
13621
+ annotations: WRITE
13622
+ },
13623
+ run((a) => saveDocs(ctx, { ...a, source: "mcp" }))
13624
+ );
13625
+ const SuggestionNumber = z.union([z.number().int().positive(), z.string().min(1)]).describe('The suggestion issue number (42 or "#42"), or its link.');
13626
+ server.registerTool(
13627
+ "bridge_list_suggestions",
13628
+ {
13629
+ title: "List suggestions",
13630
+ description: "Suggestions in the repo: GitHub issues labeled bridge-suggestion, each an exact change (tokens, docs, a component spec, a brand, a revert, settings) that the owners approve and a developer applies. Each row has the number, title, kind, tier, status (needs-review, changes-requested, approved, applying, applied, declined, outdated) and linked pull requests. Statuses here come from labels; bridge_get_suggestion counts the approvals.",
13631
+ inputSchema: {
13632
+ status: z.enum(["needs-review", "changes-requested", "approved", "applying", "applied", "declined", "outdated", "all"]).optional().describe("Default all open ones."),
13633
+ state: z.enum(["open", "closed", "all"]).optional().describe("Issue state. Default: open, or closed for applied and declined."),
13634
+ kind: z.enum(["token-change", "docs", "component-spec", "brand", "revert", "config"]).optional(),
13635
+ limit: z.number().int().min(1).max(100).optional().describe("Default 100.")
13636
+ },
13637
+ annotations: READ
13638
+ },
13639
+ run((a) => listSuggestionsOp(ctx, a))
13640
+ );
13641
+ server.registerTool(
13642
+ "bridge_get_suggestion",
13643
+ {
13644
+ title: "Get a suggestion",
13645
+ description: "One suggestion: the change (tokens before and after, docs, component spec, files), the checks when suggested, links, its version (payloadSha: what approvals bind to), who approved it, which approvals are missing for its tier, reviews that no longer count (an older version, or not an owner), whether the machine-readable block was edited by hand, linked pull requests, and for a component spec the brief to implement it.",
13646
+ inputSchema: { number: SuggestionNumber },
13647
+ annotations: READ
13648
+ },
13649
+ run((a) => getSuggestionOp(ctx, a))
13650
+ );
13651
+ server.registerTool(
13652
+ "bridge_create_suggestion",
13653
+ {
13654
+ title: "Create a suggestion",
13655
+ description: "File any change as a suggestion from its payload (schema bridge.suggestion/1: kind, title, summary, and tokens edits, docs, componentSpec or files). Missing schema, source, date, base commit and checks are filled. The paths are checked against what the kind may change and the guardrail files before anything is sent. For token, docs, brand, revert and Figma changes prefer their own tools, which plan and check the change first. Needs GITHUB_TOKEN with Issues write. dryRun returns the issue. Only when the person asked.",
13656
+ inputSchema: {
13657
+ payload: z.record(z.string(), z.unknown()).describe("The suggestion payload (bridge.suggestion/1)."),
13658
+ labels: z.array(z.string()).optional().describe("More labels for the issue."),
13659
+ dryRun: z.boolean().optional().describe("Return the issue title, body and labels without creating it.")
13660
+ },
13661
+ annotations: WRITE
13662
+ },
13663
+ run((a) => createSuggestionOp(ctx, a))
13664
+ );
13665
+ server.registerTool(
13666
+ "bridge_review_suggestion",
13667
+ {
13668
+ title: "Review a suggestion",
13669
+ description: "Approve, request changes on, comment on or decline a suggestion, as the person whose GitHub token the server has. Approvals and requested changes count only from the owners in bridge.config.json (read from the default branch) and only on the version reviewed: pass payloadSha from bridge_get_suggestion so a newer version is never approved by mistake. Request changes and decline need a message. Decline closes the issue as not planned. Needs GITHUB_TOKEN (Issues: read and write). Only when the person asked, with the decision they asked for.",
13670
+ inputSchema: {
13671
+ number: SuggestionNumber,
13672
+ event: z.enum(["approve", "request-changes", "comment", "decline"]),
13673
+ message: z.string().optional().describe("What should change, why it is declined, or the comment. Plain sentences, no em dash."),
13674
+ role: z.enum(["design", "tech", "approval"]).optional().describe("The role you approve for, shown in the comment. Informative: what counts is who you are in the owner lists."),
13675
+ payloadSha: z.string().optional().describe("The version you reviewed (payloadSha or version from bridge_get_suggestion).")
13676
+ },
13677
+ annotations: WRITE
13678
+ },
13679
+ run((a) => reviewSuggestionOp(ctx, a))
13680
+ );
13681
+ server.registerTool(
13682
+ "bridge_apply_suggestion",
13683
+ {
13684
+ title: "Apply a suggestion",
13685
+ description: `Apply a Bridge suggestion to today's code ("Apply Bridge suggestion #42"). Bridge checks the paths, the approvals of the owners on its current version, re-applies token edits on the current tokens, merges files, re-runs validation, contrast and the tier, and stops on a conflict, a missing approval or a check that would fail. Local repo: mode "dry-run" (the plan and the diff, nothing written; run it first), "branch" (default: one commit on a new local branch, working tree untouched, never pushes), "write" (into the working tree, refused over uncommitted changes) or "patch" (a git format-patch text). Remote repo: the plan and the files to write yourself. Once a branch or files exist, it records on the issue that it is being applied (needs Issues write). Then push and open a pull request whose body says "Closes #<n>", and call bridge_link_suggestion. acceptEdited and forceUnapproved only when the person asked.`,
13686
+ inputSchema: {
13687
+ number: SuggestionNumber,
13688
+ mode: z.enum(["dry-run", "branch", "write", "patch"]).optional().describe("Default branch. Run dry-run first."),
13689
+ acceptEdited: z.boolean().optional().describe("Apply even though the machine-readable block was edited by hand after Bridge wrote it (check it first)."),
13690
+ forceUnapproved: z.boolean().optional().describe("Apply without every approval, or with a tier that went up. Recorded on the issue."),
13691
+ base: z.string().optional().describe("Local mode, branch and patch: the ref to build on. Default HEAD."),
13692
+ branch: z.string().optional().describe("Local mode, branch: the branch name. Default <branchPrefix>suggestion-<n>-<slug>.")
13693
+ },
13694
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
13695
+ },
13696
+ run((a) => applySuggestionOp(ctx, a))
13697
+ );
13698
+ server.registerTool(
13699
+ "bridge_link_suggestion",
13700
+ {
13701
+ title: "Link the pull request that applies a suggestion",
13702
+ description: 'Record the pull request (or branch, or commit) that applies a suggestion: a comment on the issue and the "suggestion: applying" label. Warns when the pull request does not say "Closes #<n>": add it so the suggestion closes when it merges. Needs GITHUB_TOKEN (Issues: read and write).',
13703
+ inputSchema: {
13704
+ number: SuggestionNumber,
13705
+ pr: z.number().int().positive().optional().describe("The pull request number."),
13706
+ branch: z.string().optional(),
13707
+ commit: z.string().optional()
12320
13708
  },
12321
13709
  annotations: WRITE
12322
13710
  },
12323
- run((a) => saveDocs(ctx, a))
13711
+ run((a) => linkSuggestionOp(ctx, a))
12324
13712
  );
12325
13713
  server.registerResource("resolved-tokens", "bridge://tokens/resolved", { title: "Resolved tokens", description: "Every token resolved in the default mode, plus only the values that differ in each other mode combination.", mimeType: "application/json" }, async (uri) => {
12326
13714
  const set = await ctx.tokens();
@@ -12537,6 +13925,7 @@ export {
12537
13925
  auditSnippet,
12538
13926
  branches_exports,
12539
13927
  explorations_exports,
13928
+ suggestions_exports,
12540
13929
  ops_exports,
12541
13930
  history_exports,
12542
13931
  brand_exports,