@bridge-ds/mcp 0.3.3 → 0.4.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.
@@ -1,7 +1,12 @@
1
1
  import {
2
2
  CONFIG_FILE,
3
+ DEFAULT_COMPONENT_SPECS_FILE,
4
+ DEFAULT_OVERRIDE,
3
5
  DEFAULT_PROTECTED_BRANCHES,
4
6
  DEFAULT_RIGHTS,
7
+ DEFAULT_TOKEN_PATHS,
8
+ FIGMA_REFERENCES_BRANCH,
9
+ FIGMA_REFERENCES_MANIFEST,
5
10
  LayeredTokenSet,
6
11
  ProtectedBranchError,
7
12
  RISK_LABELS,
@@ -12,6 +17,7 @@ import {
12
17
  assertSafeBranch,
13
18
  baseBranchOf,
14
19
  branchExists,
20
+ canShip,
15
21
  changeTable,
16
22
  checkLayeredPair,
17
23
  classifyLayered,
@@ -22,11 +28,15 @@ import {
22
28
  contrastDelta,
23
29
  contrastDeltaTable,
24
30
  contrastPair,
31
+ coverageSummary,
25
32
  currentBranch,
33
+ defaultProduct,
26
34
  diffLayered,
27
35
  entryLabel,
28
36
  expandOwners,
37
+ figmaCell,
29
38
  findPreviewComment,
39
+ findProduct,
30
40
  git,
31
41
  globToRegExp,
32
42
  hexToRgba,
@@ -36,9 +46,15 @@ import {
36
46
  listFilesAt,
37
47
  loadConfig,
38
48
  originRepo,
49
+ parseComponentSpecs,
39
50
  parseContrastChecks,
40
51
  previewLinks,
52
+ previewUrl,
41
53
  previewsSettled,
54
+ productForPlace,
55
+ productLabel,
56
+ productOfBranch,
57
+ productsOf,
42
58
  protectedBranchesOf,
43
59
  readPreviewComment,
44
60
  renderedColor,
@@ -49,12 +65,14 @@ import {
49
65
  selectTokenFiles,
50
66
  shipPolicy,
51
67
  showFile,
68
+ threeWayDiff,
52
69
  toConfigJson,
53
70
  tokensPath,
71
+ unreadableShipTeams,
54
72
  unreadableTeams,
55
73
  validateLayered,
56
74
  whoCanShipSentence
57
- } from "./chunk-K3HY24MZ.js";
75
+ } from "./chunk-7DBLDJIL.js";
58
76
 
59
77
  // src/lib.ts
60
78
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -62,6 +80,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
62
80
  // src/context.ts
63
81
  import { resolve as resolve2 } from "node:path";
64
82
 
83
+ // ../core/src/types.ts
84
+ var EXT_ID = "com.dsbridge.id";
85
+ var EXT_SCOPES = "com.figma.scopes";
86
+ var PRIMITIVE_MODE = "value";
87
+ var SINGLE_MODE = PRIMITIVE_MODE;
88
+
65
89
  // ../core/src/json-ast.ts
66
90
  var JsonParseError = class extends Error {
67
91
  constructor(message, offset) {
@@ -319,6 +343,24 @@ function serializeTokenFile(doc) {
319
343
  }
320
344
  var isTokenNode = (node) => node.kind === "object" && getEntry(node, "$value") !== void 0;
321
345
  var isGroupKey = (key) => !key.startsWith("$");
346
+ function flattenTokens(doc) {
347
+ const out = [];
348
+ const walk2 = (node, path, inheritedType) => {
349
+ const ownGroupType = stringEntry(node, "$type");
350
+ const groupType = ownGroupType ?? inheritedType;
351
+ for (const entry of node.entries) {
352
+ if (!isGroupKey(entry.key) || entry.value.kind !== "object") continue;
353
+ const childPath = [...path, entry.key];
354
+ if (isTokenNode(entry.value)) {
355
+ out.push(readToken(entry.value, childPath.join("."), groupType, doc.file));
356
+ } else {
357
+ walk2(entry.value, childPath, groupType);
358
+ }
359
+ }
360
+ };
361
+ walk2(doc.json.root, [], void 0);
362
+ return out;
363
+ }
322
364
  function readToken(node, path, inheritedType, file) {
323
365
  const ownType = stringEntry(node, "$type");
324
366
  const ext = getEntry(node, "$extensions")?.value;
@@ -548,6 +590,368 @@ function moveToken(doc, from, to) {
548
590
  return true;
549
591
  }
550
592
 
593
+ // ../core/src/token-set.ts
594
+ var isTokenFile = (file) => file.endsWith(".tokens.json");
595
+ var pathId = (path) => `path:${path}`;
596
+ var overrideKey = (brand, theme, path) => `${brand}|${theme}|${path}`;
597
+ function layoutOptions(config) {
598
+ const first = (list, def) => def ? [def, ...list.filter((m) => m !== def)] : list;
599
+ return {
600
+ dirs: config.tokens,
601
+ order: {
602
+ brand: first(config.modes.brands, config.modes.defaults.brand),
603
+ theme: first(config.modes.themes, config.modes.defaults.theme),
604
+ density: first(config.modes.densities, config.modes.defaults.density)
605
+ }
606
+ };
607
+ }
608
+ var FALLBACK_ORDER = { theme: ["light", "dark"], brand: [], density: [] };
609
+ var stem = (file) => file.slice(file.lastIndexOf("/") + 1).replace(/\.tokens\.json$/, "");
610
+ function ordered(list, pref) {
611
+ const rank2 = (m) => pref.indexOf(m) === -1 ? 99 : pref.indexOf(m);
612
+ return list.sort((a, b) => rank2(a.mode) - rank2(b.mode) || a.mode.localeCompare(b.mode));
613
+ }
614
+ function detectLayout(files, options = {}) {
615
+ const d = { ...DEFAULT_TOKEN_PATHS, ...options.dirs };
616
+ const inDir = (file, dir) => file.startsWith(`${dir}/`) && !file.slice(dir.length + 1).includes("/");
617
+ const layout = { primitiveFiles: [], brands: [], baseFiles: [], themes: [], overrides: [], densities: [], componentFiles: [] };
618
+ for (const file of files.filter(isTokenFile).sort()) {
619
+ const name = stem(file);
620
+ if (inDir(file, d.brands)) {
621
+ const parts = name.split(".");
622
+ if (parts.length === 2) layout.overrides.push({ brand: parts[0], theme: parts[1], file });
623
+ else layout.brands.push({ mode: name, file });
624
+ } else if (inDir(file, d.semantic)) {
625
+ if (name === d.base) layout.baseFiles.push(file);
626
+ else layout.themes.push({ mode: name, file });
627
+ } else if (inDir(file, d.density)) layout.densities.push({ mode: name, file });
628
+ else if (file.startsWith(`${d.components}/`)) layout.componentFiles.push(file);
629
+ else layout.primitiveFiles.push(file);
630
+ }
631
+ ordered(layout.brands, options.order?.brand ?? FALLBACK_ORDER.brand);
632
+ ordered(layout.themes, options.order?.theme ?? FALLBACK_ORDER.theme);
633
+ ordered(layout.densities, options.order?.density ?? FALLBACK_ORDER.density);
634
+ return layout;
635
+ }
636
+ function readScopes(ext) {
637
+ const s = ext?.[EXT_SCOPES];
638
+ return Array.isArray(s) && s.every((x) => typeof x === "string") ? s : void 0;
639
+ }
640
+ function readId(ext) {
641
+ const id = ext?.[EXT_ID];
642
+ return typeof id === "string" && id ? id : void 0;
643
+ }
644
+ var TIER_LABEL = {
645
+ primitive: "a primitive",
646
+ brand: "a brand token",
647
+ base: "a base semantic token",
648
+ semantic: "a semantic token",
649
+ density: "a density token",
650
+ component: "a component token"
651
+ };
652
+ function buildTokenSet(files, options = {}) {
653
+ const problems = [];
654
+ const docs = /* @__PURE__ */ new Map();
655
+ for (const [file, text] of Object.entries(files)) {
656
+ if (!isTokenFile(file)) continue;
657
+ try {
658
+ docs.set(file, parseTokenFile(file, text));
659
+ } catch (err) {
660
+ problems.push(`${file} is not valid JSON: ${err.message}`);
661
+ }
662
+ }
663
+ const layout = detectLayout([...docs.keys()], options);
664
+ const tokens = /* @__PURE__ */ new Map();
665
+ const byPath = /* @__PURE__ */ new Map();
666
+ const add = (t) => {
667
+ if (tokens.has(t.id)) {
668
+ problems.push(`Two tokens share the id "${t.id}" (${tokens.get(t.id).path} and ${t.path}).`);
669
+ return;
670
+ }
671
+ tokens.set(t.id, t);
672
+ byPath.set(t.path, t);
673
+ };
674
+ const single = (tier, fileList) => {
675
+ for (const file of fileList) {
676
+ for (const flat of flattenTokens(docs.get(file))) {
677
+ const prev = byPath.get(flat.path);
678
+ if (prev) {
679
+ problems.push(
680
+ prev.tier === tier ? `${flat.path} is defined twice (${prev.files[SINGLE_MODE]} and ${file}).` : `${flat.path} exists both as ${TIER_LABEL[prev.tier]} and as ${TIER_LABEL[tier]} (${file}).`
681
+ );
682
+ continue;
683
+ }
684
+ if (!flat.type) problems.push(`${flat.path} in ${file} has no $type.`);
685
+ add({
686
+ id: readId(flat.extensions) ?? pathId(flat.path),
687
+ path: flat.path,
688
+ type: flat.type ?? "unknown",
689
+ tier,
690
+ values: { [SINGLE_MODE]: flat.value },
691
+ description: flat.description,
692
+ scopes: readScopes(flat.extensions),
693
+ files: { [SINGLE_MODE]: file }
694
+ });
695
+ }
696
+ }
697
+ };
698
+ const multi = (tier, modes) => {
699
+ const merged = /* @__PURE__ */ new Map();
700
+ for (const { mode, file } of modes) {
701
+ for (const flat of flattenTokens(docs.get(file))) {
702
+ let t = merged.get(flat.path);
703
+ if (!t) {
704
+ const prev = byPath.get(flat.path);
705
+ if (prev) {
706
+ problems.push(`${flat.path} exists both as ${TIER_LABEL[prev.tier]} and as ${TIER_LABEL[tier]} (${file}).`);
707
+ continue;
708
+ }
709
+ t = {
710
+ id: readId(flat.extensions) ?? pathId(flat.path),
711
+ path: flat.path,
712
+ type: flat.type ?? "unknown",
713
+ tier,
714
+ values: {},
715
+ description: flat.description,
716
+ scopes: readScopes(flat.extensions),
717
+ files: {}
718
+ };
719
+ merged.set(flat.path, t);
720
+ if (!flat.type) problems.push(`${flat.path} in ${file} has no $type.`);
721
+ } else if (flat.type && t.type !== flat.type) {
722
+ problems.push(`${flat.path} has type ${t.type} in one mode and ${flat.type} in ${file}.`);
723
+ }
724
+ t.values[mode] = flat.value;
725
+ t.files[mode] = file;
726
+ t.description ??= flat.description;
727
+ t.scopes ??= readScopes(flat.extensions);
728
+ }
729
+ }
730
+ merged.forEach(add);
731
+ };
732
+ single("primitive", layout.primitiveFiles);
733
+ multi("brand", layout.brands);
734
+ single("base", layout.baseFiles);
735
+ multi("semantic", layout.themes);
736
+ multi("density", layout.densities);
737
+ single("component", layout.componentFiles);
738
+ const overrides = [];
739
+ const overrideIndex = /* @__PURE__ */ new Map();
740
+ for (const o of layout.overrides) {
741
+ for (const flat of flattenTokens(docs.get(o.file))) {
742
+ overrides.push({ brand: o.brand, theme: o.theme, path: flat.path, type: flat.type, value: flat.value, file: o.file });
743
+ overrideIndex.set(overrideKey(o.brand, o.theme, flat.path), flat.value);
744
+ }
745
+ }
746
+ return { tokens, byPath, docs, layout, options, overrides, overrideIndex, problems };
747
+ }
748
+
749
+ // ../core/src/token-merge.ts
750
+ var idOf = (f) => {
751
+ const id = f.extensions?.[EXT_ID];
752
+ return typeof id === "string" && id ? id : pathId(f.path);
753
+ };
754
+ function logical(f) {
755
+ const scopes = f.extensions?.[EXT_SCOPES];
756
+ return {
757
+ id: idOf(f),
758
+ path: f.path,
759
+ type: f.type ?? "unknown",
760
+ // One file holds one tier: the tier only feeds default scopes, the same on every side.
761
+ tier: "primitive",
762
+ values: { [SINGLE_MODE]: f.value },
763
+ description: f.description,
764
+ scopes: Array.isArray(scopes) ? scopes.map(String) : void 0,
765
+ files: { [SINGLE_MODE]: f.file }
766
+ };
767
+ }
768
+ function readSide(file, text, problems, label) {
769
+ const side = { doc: void 0, flat: /* @__PURE__ */ new Map(), logical: /* @__PURE__ */ new Map() };
770
+ if (text === void 0) return side;
771
+ try {
772
+ side.doc = parseTokenFile(file, text);
773
+ } catch (err) {
774
+ problems.push(`${file} (${label}) is not valid JSON: ${err.message}`);
775
+ return side;
776
+ }
777
+ for (const f of flattenTokens(side.doc)) {
778
+ const id = idOf(f);
779
+ side.flat.set(id, f);
780
+ side.logical.set(id, logical(f));
781
+ }
782
+ return side;
783
+ }
784
+ function diffFile(input, file, problems) {
785
+ const base = readSide(file, input.base[file], problems, "shared start");
786
+ const mine = readSide(file, input.mine[file], problems, "this change");
787
+ const theirs = readSide(file, input.theirs[file], problems, "latest library");
788
+ const r = threeWayDiff(base.logical, mine.logical, theirs.logical);
789
+ return { file, base, mine, theirs, local: r.local, remote: r.remote, conflicts: r.conflicts };
790
+ }
791
+ function removedSide(c) {
792
+ if (!c.base) return null;
793
+ if (!c.mine && c.theirs) return "mine";
794
+ if (!c.theirs && c.mine) return "theirs";
795
+ return null;
796
+ }
797
+ var defaultChoiceFor = (c) => removedSide(c) ? null : "mine";
798
+ function planTokenMerge(input) {
799
+ const problems = [];
800
+ const files = [...new Set(input.files)].sort();
801
+ const conflicts = [];
802
+ let mineOnly = 0;
803
+ let theirsOnly = 0;
804
+ for (const file of files) {
805
+ const d = diffFile(input, file, problems);
806
+ mineOnly += d.local.length;
807
+ theirsOnly += d.remote.length;
808
+ for (const e of d.conflicts) {
809
+ const c = { base: d.base.flat.get(e.id), mine: d.mine.flat.get(e.id), theirs: d.theirs.flat.get(e.id) };
810
+ conflicts.push({ key: `${file}#${e.id}`, file, path: (c.mine ?? c.theirs ?? c.base).path, ...c, removedOn: removedSide(c), defaultChoice: defaultChoiceFor(c) });
811
+ }
812
+ }
813
+ return { files, conflicts, mineOnly, theirsOnly, problems };
814
+ }
815
+ var choiceFor = (c, choices) => choices[c.key] ?? c.defaultChoice;
816
+ function missingChoices(plan, choices) {
817
+ return plan.conflicts.filter((c) => choiceFor(c, choices) === null);
818
+ }
819
+ function takeMine(doc, mine, existing) {
820
+ if (!mine) return existing ? removeToken(doc, existing.path) : false;
821
+ let changed = false;
822
+ if (existing && existing.path !== mine.path) changed = removeToken(doc, existing.path) || changed;
823
+ const extensions = {};
824
+ for (const k of Object.keys(existing?.path === mine.path ? existing.extensions ?? {} : {})) extensions[k] = void 0;
825
+ Object.assign(extensions, mine.extensions ?? {});
826
+ return upsertToken(doc, mine.path, { type: mine.type, value: mine.value, description: mine.description, extensions }) || changed;
827
+ }
828
+ function applyTokenMerge(input, choices) {
829
+ const plan = planTokenMerge(input);
830
+ if (plan.problems.length) throw new Error(plan.problems[0]);
831
+ const missing = missingChoices(plan, choices);
832
+ if (missing.length) throw new Error(`Pick a value for ${missing.map((c) => c.path).join(", ")} first.`);
833
+ const byKey = new Map(plan.conflicts.map((c) => [c.key, c]));
834
+ const kept = { mine: 0, theirs: 0 };
835
+ const files = {};
836
+ for (const file of plan.files) {
837
+ const d = diffFile(input, file, []);
838
+ const doc = d.theirs.doc ?? emptyTokenFile(file);
839
+ for (const e of d.local) takeMine(doc, d.mine.flat.get(e.id), d.theirs.flat.get(e.id) ?? d.base.flat.get(e.id));
840
+ for (const e of d.conflicts) {
841
+ const side = choiceFor(byKey.get(`${file}#${e.id}`), choices);
842
+ kept[side]++;
843
+ if (side === "mine") takeMine(doc, d.mine.flat.get(e.id), d.theirs.flat.get(e.id));
844
+ }
845
+ const empty = flattenTokens(doc).length === 0;
846
+ files[file] = empty && (input.theirs[file] === void 0 || input.mine[file] === void 0) ? null : serializeTokenFile(doc);
847
+ }
848
+ return { files, kept };
849
+ }
850
+
851
+ // ../core/src/token-combine.ts
852
+ var idOf2 = (f) => {
853
+ const id = f.extensions?.[EXT_ID];
854
+ return typeof id === "string" && id ? id : pathId(f.path);
855
+ };
856
+ function tokensOf(file, text, problems, label) {
857
+ const out = /* @__PURE__ */ new Map();
858
+ if (text === void 0) return out;
859
+ try {
860
+ for (const t of flattenTokens(parseTokenFile(file, text))) out.set(idOf2(t), t);
861
+ } catch (err) {
862
+ problems.push(`${file} (${label}) is not valid JSON: ${err.message}`);
863
+ }
864
+ return out;
865
+ }
866
+ var shape = (t) => t ? JSON.stringify([t.path, t.type ?? null, t.value, t.description ?? null]) : "removed";
867
+ var look = (t) => t ? JSON.stringify([t.path, t.type ?? null, t.value]) : "removed";
868
+ function changeEdits(src) {
869
+ const problems = [];
870
+ const edits = [];
871
+ for (const file of [...new Set(src.files)].sort()) {
872
+ const before = tokensOf(file, src.base[file], problems, "start");
873
+ const after = tokensOf(file, src.head[file], problems, `change ${src.number}`);
874
+ for (const id of /* @__PURE__ */ new Set([...before.keys(), ...after.keys()])) {
875
+ const a = before.get(id);
876
+ const b = after.get(id);
877
+ if (shape(a) === shape(b)) continue;
878
+ edits.push({ key: `${file}#${id}`, file, path: (b ?? a).path, change: src.number, token: b });
879
+ }
880
+ }
881
+ return { number: src.number, edits, problems };
882
+ }
883
+ function byToken(changes) {
884
+ const out = /* @__PURE__ */ new Map();
885
+ for (const c of changes) for (const e of c.edits) out.set(e.key, [...out.get(e.key) ?? [], e]);
886
+ return out;
887
+ }
888
+ function optionsOf(edits) {
889
+ const out = [];
890
+ for (const e of edits) {
891
+ const l = look(e.token);
892
+ const hit = out.find((o) => o.look === l);
893
+ if (hit) hit.option.changes.push(e.change);
894
+ else out.push({ look: l, option: { changes: [e.change], token: e.token } });
895
+ }
896
+ return out.map((o) => o.option);
897
+ }
898
+ function pickedOption(c, picks) {
899
+ const n = picks[c.key];
900
+ return n === void 0 ? null : c.options.find((o) => o.changes.includes(n)) ?? null;
901
+ }
902
+ function combineChanges(latest, changes, picks = {}, opts = {}) {
903
+ const on = opts.include ? changes.filter((c) => opts.include.includes(c.number)) : changes;
904
+ const problems = on.flatMap((c) => c.problems);
905
+ const groups = byToken(on);
906
+ const conflicts = [];
907
+ const missing = [];
908
+ const from = {};
909
+ const docs = /* @__PURE__ */ new Map();
910
+ const docOf = (file) => {
911
+ if (docs.has(file)) return docs.get(file);
912
+ const text = latest[file];
913
+ let doc;
914
+ try {
915
+ doc = text === void 0 ? emptyTokenFile(file) : parseTokenFile(file, text);
916
+ } catch (err) {
917
+ problems.push(`${file} (latest library) is not valid JSON: ${err.message}`);
918
+ return null;
919
+ }
920
+ docs.set(file, doc);
921
+ return doc;
922
+ };
923
+ for (const [key, edits] of groups) {
924
+ const options = optionsOf(edits);
925
+ let use = options[0];
926
+ if (options.length > 1) {
927
+ const conflict = { key, file: edits[0].file, path: edits[0].path, options };
928
+ conflicts.push(conflict);
929
+ use = pickedOption(conflict, picks);
930
+ if (!use) {
931
+ missing.push(conflict);
932
+ use = opts.unpicked === "first" ? options[0] : null;
933
+ }
934
+ }
935
+ if (!use) continue;
936
+ const doc = docOf(edits[0].file);
937
+ if (!doc) continue;
938
+ const existing = flattenTokens(doc).find((t) => idOf2(t) === key.slice(key.indexOf("#") + 1));
939
+ takeMine(doc, use.token, existing);
940
+ const path = use.token?.path ?? edits[0].path;
941
+ from[path] = [.../* @__PURE__ */ new Set([...from[path] ?? [], ...use.changes])];
942
+ }
943
+ const files = { ...latest };
944
+ for (const [file, doc] of docs) {
945
+ if (latest[file] === void 0 && flattenTokens(doc).length === 0) continue;
946
+ files[file] = serializeTokenFile(doc);
947
+ }
948
+ const tokens = new Set([...groups.values()].map((e) => e[0].path)).size;
949
+ return { files, included: on.map((c) => c.number), edits: groups.size, tokens, conflicts, missing, from, problems };
950
+ }
951
+ function encodePicks(picks) {
952
+ return Object.entries(picks).filter(([, n]) => Number.isSafeInteger(n) && n > 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, n]) => `${n}:${key}`).join("~");
953
+ }
954
+
551
955
  // ../core/src/pr-composer.ts
552
956
  var RISK_TIER = RISK_LABELS[3];
553
957
  function slugify(text, max = 40) {
@@ -711,6 +1115,84 @@ function applyTokenEdits(base, edits) {
711
1115
  return { files, touched: [...touched].filter((f) => files.get(f) !== base.files.get(f)).sort(), notes };
712
1116
  }
713
1117
 
1118
+ // ../core/src/protection.ts
1119
+ var PROTECTION_PARTS = ["review", "checks", "history", "deletion"];
1120
+ var protectionRulesetName = (defaultBranch) => `Protect the library (${defaultBranch})`;
1121
+ function refPattern(pattern) {
1122
+ const src = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, ".*");
1123
+ return new RegExp(`^${src}$`);
1124
+ }
1125
+ function rulesetTargetsDefault(ruleset, defaultBranch) {
1126
+ if (ruleset.target && ruleset.target !== "branch") return false;
1127
+ const ref = `refs/heads/${defaultBranch}`;
1128
+ const hit = (p) => p === "~ALL" || p === "~DEFAULT_BRANCH" || p === ref || p === defaultBranch || refPattern(p.startsWith("refs/") ? p : `refs/heads/${p}`).test(ref);
1129
+ const cond = ruleset.conditions?.ref_name;
1130
+ if (!cond) return false;
1131
+ if ((cond.exclude ?? []).some(hit)) return false;
1132
+ return (cond.include ?? []).some(hit);
1133
+ }
1134
+ var count = (v) => typeof v === "number" ? v : 0;
1135
+ function rulesetParts(ruleset) {
1136
+ const parts = /* @__PURE__ */ new Set();
1137
+ if (ruleset.enforcement !== "active") return parts;
1138
+ for (const rule of ruleset.rules ?? []) {
1139
+ if (rule.type === "deletion") parts.add("deletion");
1140
+ if (rule.type === "non_fast_forward") parts.add("history");
1141
+ if (rule.type === "pull_request" && count(rule.parameters?.required_approving_review_count) >= 1) parts.add("review");
1142
+ if (rule.type === "required_status_checks" && Array.isArray(rule.parameters?.required_status_checks) && rule.parameters.required_status_checks.length > 0) parts.add("checks");
1143
+ }
1144
+ return parts;
1145
+ }
1146
+ function classicParts(p) {
1147
+ const parts = /* @__PURE__ */ new Set();
1148
+ if (count(p.required_pull_request_reviews?.required_approving_review_count) >= 1) parts.add("review");
1149
+ if (((p.required_status_checks?.contexts?.length ?? 0) || (p.required_status_checks?.checks?.length ?? 0)) > 0) parts.add("checks");
1150
+ if (!p.allow_force_pushes?.enabled) parts.add("history");
1151
+ if (!p.allow_deletions?.enabled) parts.add("deletion");
1152
+ return parts;
1153
+ }
1154
+ var rulesetUrl = (r) => r.html_url ?? r._links?.html?.href;
1155
+ function protectionState(rulesets, branchProtection, defaultBranch) {
1156
+ const bridge = rulesets?.find((r) => r.name === protectionRulesetName(defaultBranch));
1157
+ const extra = bridge?.id !== void 0 ? { bridgeRulesetId: bridge.id } : {};
1158
+ const covered = /* @__PURE__ */ new Set();
1159
+ let url;
1160
+ for (const r of rulesets ?? []) {
1161
+ if (!rulesetTargetsDefault(r, defaultBranch)) continue;
1162
+ const parts = rulesetParts(r);
1163
+ if (parts.size && !url) url = rulesetUrl(r);
1164
+ parts.forEach((p) => covered.add(p));
1165
+ }
1166
+ if (branchProtection) classicParts(branchProtection).forEach((p) => covered.add(p));
1167
+ const missing = PROTECTION_PARTS.filter((p) => !covered.has(p));
1168
+ const withUrl = url ? { url } : {};
1169
+ if (!missing.length) return { kind: "protected", missing, ...extra, ...withUrl };
1170
+ const unreadable = rulesets == null || branchProtection === void 0;
1171
+ if (covered.size === 0) return { kind: unreadable ? "unknown" : "unprotected", missing, ...extra };
1172
+ return { kind: "partial", missing, ...extra, ...withUrl };
1173
+ }
1174
+ var PROTECTION_PART_PHRASE = {
1175
+ review: "changes can skip review",
1176
+ checks: "checks do not have to pass",
1177
+ history: "past changes can be rewritten",
1178
+ deletion: "the library can be deleted"
1179
+ };
1180
+ var joinAnd = (xs) => xs.length <= 1 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
1181
+ function protectionSentence(state) {
1182
+ switch (state.kind) {
1183
+ case "protected":
1184
+ return "Protected: every change goes through review.";
1185
+ case "unprotected":
1186
+ return "Your library can be changed without review.";
1187
+ case "partial": {
1188
+ const s = joinAnd(state.missing.map((p) => PROTECTION_PART_PHRASE[p]));
1189
+ return `Partly protected: ${s}.`;
1190
+ }
1191
+ default:
1192
+ return "Protection status unknown: GitHub did not let this token read it.";
1193
+ }
1194
+ }
1195
+
714
1196
  // ../core/src/github-client.ts
715
1197
  var GitHubError = class extends Error {
716
1198
  constructor(status, message, detail, info = {}) {
@@ -1333,7 +1815,7 @@ var GitHubClient = class _GitHubClient {
1333
1815
  }
1334
1816
  async getPullRequest(n) {
1335
1817
  const r = await this.request("GET", `${this.repoPath}/pulls/${n}`);
1336
- return { ...toSummary(r), body: r.body ?? "", mergeable: r.mergeable ?? null };
1818
+ return { ...toSummary(r), body: r.body ?? "", mergeable: r.mergeable ?? null, requestedReviewers: (r.requested_reviewers ?? []).map((x) => x.login) };
1337
1819
  }
1338
1820
  async listPullRequestFiles(n) {
1339
1821
  const r = await this.request("GET", `${this.repoPath}/pulls/${n}/files?per_page=100`);
@@ -1410,6 +1892,100 @@ var GitHubClient = class _GitHubClient {
1410
1892
  async removeLabel(issueNumber, label) {
1411
1893
  await this.request("DELETE", `${this.repoPath}/issues/${issueNumber}/labels/${encodeURIComponent(label)}`);
1412
1894
  }
1895
+ // --- branch actions shared by Studio, the MCP server and the CLI (branch-actions.ts) ---
1896
+ /** The account the token belongs to (GET /user). Needs a token. */
1897
+ async getViewer() {
1898
+ this.assertToken("Reading who you are on GitHub");
1899
+ const r = await this.request("GET", "/user");
1900
+ return { login: r.login };
1901
+ }
1902
+ /** The repository's merge settings and the viewer's permissions (unknown settings count as allowed). */
1903
+ async getMergeSettings() {
1904
+ return this.request("GET", this.repoPath);
1905
+ }
1906
+ /**
1907
+ * Merges a pull request, only at `sha` (GitHub refuses when the branch moved meanwhile). The repository's branch
1908
+ * rules still apply: GitHub refuses a merge they block, whatever Bridge says.
1909
+ */
1910
+ async mergePullRequest(n, input) {
1911
+ this.assertToken("Merging a branch");
1912
+ const r = await this.request("PUT", `${this.repoPath}/pulls/${n}/merge`, {
1913
+ merge_method: input.method,
1914
+ sha: input.sha,
1915
+ ...input.commitMessage !== void 0 && input.method !== "rebase" ? { commit_message: input.commitMessage } : {}
1916
+ });
1917
+ return { sha: r?.sha ?? "", merged: r?.merged ?? true };
1918
+ }
1919
+ /** GitHub's "Update branch": merges the base into the pull request, only when its head is still `expectedHeadSha`. */
1920
+ async updatePullRequestBranch(n, expectedHeadSha) {
1921
+ this.assertToken("Updating a branch from main");
1922
+ await this.request("PUT", `${this.repoPath}/pulls/${n}/update-branch`, { expected_head_sha: expectedHeadSha });
1923
+ }
1924
+ /** Posts a comment on an issue or pull request and returns its id and link. */
1925
+ async createIssueComment(n, body) {
1926
+ this.assertToken("Commenting");
1927
+ const r = await this.request("POST", `${this.repoPath}/issues/${n}/comments`, { body });
1928
+ return { id: r.id, url: r.html_url };
1929
+ }
1930
+ /** Replaces the text of a comment. */
1931
+ async updateIssueComment(id, body) {
1932
+ this.assertToken("Editing a comment");
1933
+ await this.request("PATCH", `${this.repoPath}/issues/comments/${id}`, { body });
1934
+ }
1935
+ /** Submits a review on a given commit (the head the reviewer saw); the body may be empty for an approval. */
1936
+ async submitReview(n, input) {
1937
+ this.assertToken("Reviewing a branch");
1938
+ const r = await this.request("POST", `${this.repoPath}/pulls/${n}/reviews`, {
1939
+ event: input.event,
1940
+ ...input.body ? { body: input.body } : {},
1941
+ ...input.commitId ? { commit_id: input.commitId } : {}
1942
+ });
1943
+ return { id: r.id, user: r.user?.login, state: r.state, body: r.body ?? "" };
1944
+ }
1945
+ /** Line comments of a pull request (review threads), oldest first. */
1946
+ async listReviewComments(n) {
1947
+ const r = await this.request(
1948
+ "GET",
1949
+ `${this.repoPath}/pulls/${n}/comments?per_page=100`
1950
+ );
1951
+ return r.map((c) => ({ id: c.id, body: c.body ?? "", author: c.user?.login ?? "unknown", path: c.path, line: c.line ?? null, inReplyTo: c.in_reply_to_id ?? null, url: c.html_url, createdAt: c.created_at }));
1952
+ }
1953
+ /** Asks these people to review a pull request. */
1954
+ async requestReviewers(n, logins) {
1955
+ this.assertToken("Asking for a review");
1956
+ await this.request("POST", `${this.repoPath}/pulls/${n}/requested_reviewers`, { reviewers: logins });
1957
+ }
1958
+ /** Takes these people off the requested reviewers. */
1959
+ async removeRequestedReviewers(n, logins) {
1960
+ this.assertToken("Changing the requested reviewers");
1961
+ await this.request("DELETE", `${this.repoPath}/pulls/${n}/requested_reviewers`, { reviewers: logins });
1962
+ }
1963
+ /** Replies in the thread of a line comment. */
1964
+ async replyToReviewComment(n, commentId, body) {
1965
+ this.assertToken("Replying to a comment");
1966
+ const r = await this.request("POST", `${this.repoPath}/pulls/${n}/comments/${commentId}/replies`, { body });
1967
+ return { id: r.id, url: r.html_url };
1968
+ }
1969
+ /**
1970
+ * What `head` adds on top of `base`, with what library sync needs: the shared start (merge base), the base
1971
+ * commit and its tree, how far behind, and the files with their previous names.
1972
+ */
1973
+ async compareDetailed(base, head) {
1974
+ const r = await this.request("GET", `${this.repoPath}/compare/${encodeRef(base)}...${encodeRef(head)}`);
1975
+ return {
1976
+ behindBy: r.behind_by,
1977
+ aheadBy: r.ahead_by,
1978
+ mergeBase: r.merge_base_commit?.sha ?? null,
1979
+ baseSha: r.base_commit?.sha ?? null,
1980
+ baseTree: r.base_commit?.commit?.tree?.sha ?? null,
1981
+ files: (r.files ?? []).map((f) => ({ filename: f.filename, status: f.status, ...f.previous_filename ? { previous_filename: f.previous_filename } : {} }))
1982
+ };
1983
+ }
1984
+ /** A tree on top of `baseTree` from entries with their own file mode (blob sha, inline content, or sha null to delete). */
1985
+ async createTreeEntries(baseTree, entries) {
1986
+ const r = await this.request("POST", `${this.repoPath}/git/trees`, { base_tree: baseTree, tree: entries.map((e) => ({ ...e, type: "blob" })) }, true);
1987
+ return r.sha;
1988
+ }
1413
1989
  };
1414
1990
  function toCommitSummary(c) {
1415
1991
  return {
@@ -1477,6 +2053,13 @@ function toIssue(r) {
1477
2053
  };
1478
2054
  }
1479
2055
 
2056
+ // ../core/src/protection-github.ts
2057
+ async function loadProtection(client2, defaultBranch) {
2058
+ const rulesets = await client2.listRulesets().then((list) => Promise.all(list.map((r) => r.enforcement === "disabled" || r.id === void 0 ? r : client2.getRuleset(r.id)))).catch(() => null);
2059
+ const classic = await client2.getBranchProtection(defaultBranch).catch(() => void 0);
2060
+ return protectionState(rulesets, classic, defaultBranch);
2061
+ }
2062
+
1480
2063
  // ../core/src/history.ts
1481
2064
  var BULK_LIMIT = 60;
1482
2065
  var UNRELEASED = "Unreleased";
@@ -1668,11 +2251,11 @@ function modeLabel(modes) {
1668
2251
  return s.charAt(0).toUpperCase() + s.slice(1);
1669
2252
  }
1670
2253
  function tokenHistory(entries, path, config) {
1671
- const names = /* @__PURE__ */ new Set([path]);
2254
+ const names2 = /* @__PURE__ */ new Set([path]);
1672
2255
  const out = [];
1673
2256
  const sorted = [...entries].sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
1674
2257
  for (const e of sorted) {
1675
- const hits = e.changes.filter((c) => names.has(c.path));
2258
+ const hits = e.changes.filter((c) => names2.has(c.path));
1676
2259
  for (const c of hits) {
1677
2260
  const modes = config ? fileModes(config, c.file) : {};
1678
2261
  out.push({
@@ -1700,7 +2283,7 @@ function tokenHistory(entries, path, config) {
1700
2283
  ...c.descriptionAfter !== void 0 ? { descriptionAfter: c.descriptionAfter } : {}
1701
2284
  });
1702
2285
  }
1703
- for (const c of hits) if (c.kind === "renamed" && c.from) names.add(c.from);
2286
+ for (const c of hits) if (c.kind === "renamed" && c.from) names2.add(c.from);
1704
2287
  }
1705
2288
  return out;
1706
2289
  }
@@ -2732,7 +3315,7 @@ ${text.trim()}
2732
3315
  ] : [],
2733
3316
  ...options.rewrite ? ["Rewrite each one from the facts and the source: keep what is true in the current text, drop what the code does not support."] : []
2734
3317
  ] : [`Write the missing prose of ${prefill.component}.docs.json for the Bridge design system.`];
2735
- const shape = keys ? [
3318
+ const shape2 = keys ? [
2736
3319
  `- ${check.length ? "Return" : "Write"} only these keys: ${keys.join(", ")}${check.length ? ` (the checked ones, ${check.join(", ")}, only when they need a fix)` : ""}. Never return anatomy, preview or any other key: code decides them.`,
2737
3320
  `- Answer with one JSON object holding only those keys, nothing else: ${docsSectionShapes(keys)}.`
2738
3321
  ] : [
@@ -2746,7 +3329,7 @@ ${text.trim()}
2746
3329
  "- Ground every sentence in the facts and the source below. Never invent props, variants, keyboard behavior or tokens that are not there.",
2747
3330
  "- Bridge voice: sentence case, plain words, verbs first, no em dashes, no marketing words.",
2748
3331
  '- Keep the keys and shapes of the example docs.json. dos and donts are { "title", "text" } objects; add an "example" only with components and props that exist.',
2749
- ...shape,
3332
+ ...shape2,
2750
3333
  "",
2751
3334
  "Facts computed from the code:",
2752
3335
  facts.join("\n\n"),
@@ -2778,6 +3361,30 @@ function formatDocsJson(d) {
2778
3361
  return `${JSON.stringify(out, null, 2)}
2779
3362
  `;
2780
3363
  }
3364
+ function patchNode(old, next) {
3365
+ if (plainEqual(toPlain(old), next)) return old;
3366
+ if (old.kind === "object" && next && typeof next === "object" && !Array.isArray(next)) {
3367
+ const n = next;
3368
+ const kept = old.entries.filter((e) => n[e.key] !== void 0).map((e) => ({ key: e.key, value: patchNode(e.value, n[e.key]) }));
3369
+ const added = Object.keys(n).filter((k) => n[k] !== void 0 && !getEntry(old, k)).map((k) => ({ key: k, value: fromPlain(n[k], void 0, old.inline) }));
3370
+ return { kind: "object", inline: old.inline, entries: [...kept, ...added] };
3371
+ }
3372
+ if (old.kind === "array" && Array.isArray(next)) {
3373
+ return { kind: "array", inline: old.inline, items: next.map((v, i) => old.items[i] ? patchNode(old.items[i], v) : fromPlain(v, old.items[0], old.inline)) };
3374
+ }
3375
+ return fromPlain(next, old);
3376
+ }
3377
+ function patchDocsJson(original, next) {
3378
+ const plain = JSON.parse(formatDocsJson(next));
3379
+ if (!original) return formatDocsJson(next);
3380
+ try {
3381
+ const doc = parseJson(original);
3382
+ if (doc.root.kind !== "object") return formatDocsJson(next);
3383
+ return serializeJson({ ...doc, root: patchNode(doc.root, plain) });
3384
+ } catch {
3385
+ return formatDocsJson(next);
3386
+ }
3387
+ }
2781
3388
 
2782
3389
  // ../core/src/change-drafts.ts
2783
3390
  function cleanDraftText(text) {
@@ -3046,6 +3653,7 @@ function explorationStatus(p) {
3046
3653
  if (p.state === "open" && labelNames(p.labels).includes(EXPLORATION_LABEL)) return "active";
3047
3654
  return "promoted";
3048
3655
  }
3656
+ var isActiveExploration = (labels) => labelNames(labels).includes(EXPLORATION_LABEL);
3049
3657
  function promotionPlan(input) {
3050
3658
  const exploration = parseExplorationBody(input.body);
3051
3659
  const jira = /^([A-Z][A-Z0-9_]*-\d+)\s+/.exec(input.title)?.[1] ?? null;
@@ -3538,6 +4146,699 @@ async function closeExplorationOnGitHub(gh, ref) {
3538
4146
  return { number: p.number, url: p.url, branch: p.headRef };
3539
4147
  }
3540
4148
 
4149
+ // ../core/src/review-gate.ts
4150
+ function parseTier(labels, body) {
4151
+ const found = [];
4152
+ for (const l of labels) {
4153
+ const m = l.name.match(/^(?:risk[\s:-]*)?tier[\s:_-]*([123])\b/i) ?? l.name.match(/^risk[\s:_-]*([123])$/i);
4154
+ if (m) found.push(Number(m[1]));
4155
+ }
4156
+ for (const m of (body ?? "").matchAll(/-\s*\[[xX]\]\s*\**\s*Tier\s*([123])\b/g)) found.push(Number(m[1]));
4157
+ for (const m of (body ?? "").matchAll(/risk tier\W{0,6}(?:tier\s*)?([123])\b/gi)) found.push(Number(m[1]));
4158
+ return found.length ? Math.max(...found) : null;
4159
+ }
4160
+ function pullState(p) {
4161
+ if (p.merged_at || p.merged) return "merged";
4162
+ if (p.state && p.state !== "open") return "closed";
4163
+ if (p.draft) return "draft";
4164
+ return "open";
4165
+ }
4166
+ function openPulls(pulls) {
4167
+ return pulls.filter((p) => {
4168
+ const s = pullState(p);
4169
+ return s === "open" || s === "draft";
4170
+ });
4171
+ }
4172
+ var OK = /* @__PURE__ */ new Set(["success", "neutral", "skipped"]);
4173
+ function summarizeCi(runs, statuses = []) {
4174
+ const failed = [];
4175
+ const pending = [];
4176
+ let passed = 0;
4177
+ for (const r of runs) {
4178
+ if (r.status !== "completed") pending.push({ name: r.name, url: r.html_url });
4179
+ else if (r.conclusion && OK.has(r.conclusion)) passed++;
4180
+ else failed.push({ name: r.name, url: r.html_url });
4181
+ }
4182
+ const seen = /* @__PURE__ */ new Set();
4183
+ for (const s of statuses) {
4184
+ if (seen.has(s.context)) continue;
4185
+ seen.add(s.context);
4186
+ if (s.state === "success") passed++;
4187
+ else if (s.state === "pending") pending.push({ name: s.context, url: s.target_url ?? void 0 });
4188
+ else failed.push({ name: s.context, url: s.target_url ?? void 0 });
4189
+ }
4190
+ const total = passed + failed.length + pending.length;
4191
+ const state = total === 0 ? "none" : failed.length ? "fail" : pending.length ? "pending" : "pass";
4192
+ return { state, total, passed, failed, pending };
4193
+ }
4194
+ function summarizeApprovals(reviews, owners, author) {
4195
+ const latest = /* @__PURE__ */ new Map();
4196
+ const sorted = [...reviews].sort((a2, b) => String(a2.submitted_at ?? "").localeCompare(String(b.submitted_at ?? "")));
4197
+ for (const r of sorted) {
4198
+ const login = r.user?.login;
4199
+ if (!login || login === author) continue;
4200
+ if (r.state === "APPROVED" || r.state === "CHANGES_REQUESTED" || r.state === "DISMISSED") latest.set(login, r.state);
4201
+ }
4202
+ const approvers = [...latest].filter(([, s]) => s === "APPROVED").map(([l]) => l);
4203
+ const changesRequested = [...latest].filter(([, s]) => s === "CHANGES_REQUESTED").map(([l]) => l);
4204
+ const lower = (xs) => xs.map((x) => x.toLowerCase().replace(/^@/, ""));
4205
+ const d = lower(owners.design);
4206
+ const t = lower(owners.tech);
4207
+ const a = lower(approvers);
4208
+ let design;
4209
+ let tech;
4210
+ if (d.length || t.length) {
4211
+ design = d.length ? a.some((x) => d.includes(x)) : a.length > 0;
4212
+ const soloBoth = (x) => a.length === 1 && d.includes(x);
4213
+ tech = t.length ? a.some((x) => t.includes(x) && !soloBoth(x)) : a.length > 1;
4214
+ } else {
4215
+ design = a.length >= 1;
4216
+ tech = a.length >= 2;
4217
+ }
4218
+ return { approvers, changesRequested, design, tech };
4219
+ }
4220
+ var normLogin = (x) => x.trim().toLowerCase().replace(/^@/, "");
4221
+ function canOverride(login, policy) {
4222
+ if (!login || !policy?.allowed.length) return false;
4223
+ return policy.allowed.map(normLogin).includes(normLogin(login));
4224
+ }
4225
+ function mergeGate(input) {
4226
+ const blocking = [];
4227
+ const missing = [];
4228
+ const approvalReasons = [];
4229
+ const lifecycle = pullState(input);
4230
+ if (lifecycle === "merged") blocking.push("This branch is already merged.");
4231
+ else if (lifecycle === "closed") blocking.push("This branch is closed.");
4232
+ if (input.exploration && lifecycle !== "merged" && lifecycle !== "closed") blocking.push("It is an exploration: promote it to review first.");
4233
+ if (input.draft) blocking.push("It is a draft. Mark it ready for review on GitHub first.");
4234
+ if (input.ci.state === "fail") blocking.push(`Tests fail: ${input.ci.failed.map((f) => f.name).join(", ")}.`);
4235
+ if (input.ci.state === "pending") blocking.push("Tests are still running.");
4236
+ if (input.approvals.changesRequested.length) blocking.push(`Changes requested by @${input.approvals.changesRequested.join(", @")}.`);
4237
+ const tier = input.tier ?? 3;
4238
+ if (tier >= 2) {
4239
+ if (!input.approvals.design) {
4240
+ approvalReasons.push("It needs a design owner approval.");
4241
+ missing.push("design approval");
4242
+ }
4243
+ if (!input.approvals.tech) {
4244
+ approvalReasons.push("It needs a tech owner approval.");
4245
+ missing.push("tech approval");
4246
+ }
4247
+ } else if (!input.approvals.approvers.length) {
4248
+ approvalReasons.push("It needs one approval.");
4249
+ missing.push("approval");
4250
+ }
4251
+ const conflict = input.mergeable === false ? [CONFLICT_REASON] : [];
4252
+ const shipper = !input.viewer || canShip(input.viewer, input.ship);
4253
+ const ship = !shipper && lifecycle !== "merged" && lifecycle !== "closed" ? [whoCanShipSentence(input.ship), ...unreadableShipTeams(input.ship).map((t) => t.status === "unreadable" ? t.message : "")] : [];
4254
+ const reasons = [...blocking, ...approvalReasons, ...ship, ...conflict];
4255
+ const allowed = reasons.length === 0;
4256
+ const overridable = !allowed && blocking.length + conflict.length + ship.length === 0 && missing.length > 0 && canOverride(input.viewer, input.override);
4257
+ return { allowed, reasons, overridable, missing, canShip: shipper };
4258
+ }
4259
+ var CONFLICT_REASON = "It conflicts with main: resolve it with Update from main on the branch.";
4260
+ var OVERRIDE_HEADING = "**Bridge owner override**";
4261
+ function overrideReasonProblem(reason, policy) {
4262
+ if (!policy.requireReason) return null;
4263
+ const n = reason.trim().length;
4264
+ if (n === 0) return `Write why this merges without the missing approvals, in at least ${policy.minReasonLength} characters.`;
4265
+ if (n < policy.minReasonLength) return `Write at least ${policy.minReasonLength} characters (${n} so far), so the history says why.`;
4266
+ return null;
4267
+ }
4268
+ function overrideComment(login, reason, missing) {
4269
+ const why = reason.trim().replace(/\s+/g, " ").replace(/[.\s]+$/, "");
4270
+ return `${OVERRIDE_HEADING}
4271
+
4272
+ Owner override by @${normLogin(login)}: ${why || "no reason given"}. Missing: ${missing.join(", ")}. Merged from Bridge.`;
4273
+ }
4274
+ function overrideRefusedComment(body, message) {
4275
+ return body.replace(/ Merged from Bridge\.$/, ` Merge refused by GitHub: ${message.trim().replace(/[.\s]+$/, "")}. Not merged.`);
4276
+ }
4277
+ function allowedMergeMethods(repo) {
4278
+ const out = [];
4279
+ if (repo?.allow_squash_merge !== false) out.push("squash");
4280
+ if (repo?.allow_merge_commit !== false) out.push("merge");
4281
+ if (repo?.allow_rebase_merge !== false) out.push("rebase");
4282
+ return out;
4283
+ }
4284
+ function hasWriteAccess(perms) {
4285
+ return Boolean(perms && (perms.admin || perms.maintain || perms.push));
4286
+ }
4287
+ var REVIEW_STATUS_ORDER = {
4288
+ "ci-failing": 0,
4289
+ conflict: 1,
4290
+ "changes-requested": 2,
4291
+ "needs-design": 3,
4292
+ "needs-tech": 4,
4293
+ "needs-approval": 5,
4294
+ ready: 6,
4295
+ "ci-running": 7,
4296
+ checking: 8,
4297
+ unknown: 9,
4298
+ draft: 10,
4299
+ merged: 11,
4300
+ closed: 12
4301
+ };
4302
+ function reviewStatusKind(input) {
4303
+ const out = (kind) => kind;
4304
+ const lifecycle = pullState(input);
4305
+ if (lifecycle === "merged") return out("merged");
4306
+ if (lifecycle === "closed") return out("closed");
4307
+ if (lifecycle === "draft") return out("draft");
4308
+ if (input.ci?.state === "fail") return out("ci-failing");
4309
+ if (input.approvals?.changesRequested.length) return out("changes-requested");
4310
+ if (input.mergeable === false) return out("conflict");
4311
+ if (input.ci?.state === "pending") return out("ci-running");
4312
+ if (!input.ci || !input.approvals) return out("unknown");
4313
+ if (input.mergeable === null) return out("checking");
4314
+ const tier = input.tier ?? 3;
4315
+ if (tier >= 2) {
4316
+ if (!input.approvals.design) return out("needs-design");
4317
+ if (!input.approvals.tech) return out("needs-tech");
4318
+ } else if (!input.approvals.approvers.length) return out("needs-approval");
4319
+ const gate = mergeGate({ tier: input.tier, ci: input.ci, approvals: input.approvals, draft: Boolean(input.draft), mergeable: input.mergeable, state: input.state ?? "open", merged_at: input.merged_at });
4320
+ return gate.allowed ? out("ready") : out("unknown");
4321
+ }
4322
+ var BOTS = /* @__PURE__ */ new Set(["github-actions[bot]", "app/github-actions", "changeset-bot[bot]"]);
4323
+ var isReleasePull = (p) => p.head.ref.startsWith("changeset-release/");
4324
+ function isAutomationPull(p) {
4325
+ return isReleasePull(p) || BOTS.has((p.user?.login ?? "").toLowerCase());
4326
+ }
4327
+ var runningExploration = (p) => p.state === "open" && isActiveExploration(p.labels ?? []);
4328
+ function reviewQueue(pulls) {
4329
+ return openPulls(pulls).filter((p) => !isAutomationPull(p) && !runningExploration(p));
4330
+ }
4331
+ function effectiveTier(labels, body, files) {
4332
+ const labelled = parseTier(labels, body);
4333
+ if (!files) return labelled;
4334
+ const computed = classifyRisk({ files: files.map((f) => ({ path: f.filename, status: f.status })) }).tier;
4335
+ return Math.max(labelled ?? 0, computed);
4336
+ }
4337
+ var norm = (x) => x.trim().toLowerCase().replace(/^@/, "");
4338
+ function reopenEligibility(x) {
4339
+ if (x.state !== "closed" || !x.viewer) return { kind: "none" };
4340
+ const isAuthor = norm(x.viewer) === norm(x.author);
4341
+ const isOwner = x.owners.some((o) => norm(o) === norm(x.viewer));
4342
+ if (!isAuthor && !isOwner) return { kind: "none" };
4343
+ if (!x.canWrite && !isAuthor) return { kind: "none" };
4344
+ if (x.branch === "exists") return { kind: "reopen" };
4345
+ if (x.branch === "gone") return { kind: "gone" };
4346
+ return { kind: "none" };
4347
+ }
4348
+ var NO_CI = { state: "pass", total: 0, passed: 0, failed: [], pending: [] };
4349
+ var ROLE_OF_MISSING = { "design approval": "design", "tech approval": "tech", approval: "approval" };
4350
+ function pendingApprovals(tier, approvals) {
4351
+ if (tier == null || !approvals) return [];
4352
+ return mergeGate({ tier, ci: NO_CI, approvals, draft: false, state: "open" }).missing.map((m) => ROLE_OF_MISSING[m]);
4353
+ }
4354
+ var uniqLogins = (xs) => xs.filter((x, i) => xs.findIndex((y) => normLogin(y) === normLogin(x)) === i);
4355
+ function eligibleReviewers(role, p) {
4356
+ const raw = role === "design" ? p.owners.design : role === "tech" ? p.owners.tech : uniqLogins([...p.owners.design, ...p.owners.tech]);
4357
+ const listed = raw.map((x) => x.replace(/^@/, ""));
4358
+ const approvers = (p.approvers ?? []).map(normLogin);
4359
+ const logins = listed.filter((l) => normLogin(l) !== normLogin(p.author) && !approvers.includes(normLogin(l)));
4360
+ return { open: listed.length === 0, logins, listed };
4361
+ }
4362
+ function reviewersToAsk(x) {
4363
+ const roles = pendingApprovals(x.tier, x.approvals);
4364
+ const all = roles.flatMap((r) => eligibleReviewers(r, { owners: x.owners, author: x.author, approvers: x.approvals.approvers }).logins);
4365
+ return uniqLogins(all).filter((l) => normLogin(l) !== normLogin(x.viewer ?? "") && normLogin(l) !== normLogin(x.author));
4366
+ }
4367
+
4368
+ // ../core/src/library-update.ts
4369
+ var plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
4370
+ var names = (files) => files.flatMap((f) => f.previous_filename ? [f.filename, f.previous_filename] : [f.filename]);
4371
+ function overlap(changeFiles, libraryFiles, isTokenFile2) {
4372
+ const lib = new Set(names(libraryFiles));
4373
+ const both = [...new Set(names(changeFiles).filter((f) => lib.has(f)))].sort();
4374
+ return { tokenFiles: both.filter(isTokenFile2), codeFiles: both.filter((f) => !isTokenFile2(f)) };
4375
+ }
4376
+ var COMPARE_FILE_LIMIT = 300;
4377
+ function conflictRoute(o, opts = {}) {
4378
+ if (opts.truncated || !o.tokenFiles.length && !o.codeFiles.length) return { kind: "code", alsoTokens: false, files: [] };
4379
+ if (o.codeFiles.length) return { kind: "code", alsoTokens: o.tokenFiles.length > 0, files: o.codeFiles };
4380
+ return { kind: "tokens", files: o.tokenFiles };
4381
+ }
4382
+ function resolutionMessage(input) {
4383
+ const lines = input.conflicts.map((c) => `- ${c.path} (${c.file}): ${choiceFor(c, input.choices) === "theirs" ? `used the ${input.library} value` : "kept this change"}`);
4384
+ const title = input.conflicts.length ? `Bring in the latest ${input.library} and resolve ${plural(input.conflicts.length, "token conflict")}` : `Bring in the latest ${input.library}`;
4385
+ const by = input.who ? `
4386
+
4387
+ Resolved in Bridge by @${input.who.replace(/^@/, "")}.` : "\n\nResolved in Bridge.";
4388
+ return `${title}${lines.length ? `
4389
+
4390
+ ${lines.join("\n")}` : ""}${by}`;
4391
+ }
4392
+ function freshness(input) {
4393
+ const behindBy = typeof input.compare?.behind_by === "number" ? input.compare.behind_by : null;
4394
+ const mergeBase = input.compare?.merge_base_commit?.sha ?? null;
4395
+ if (!input.open) return { state: "current", behindBy, mergeBase };
4396
+ if (input.mergeable === false) return { state: "conflict", behindBy, mergeBase };
4397
+ if (behindBy === null) return { state: "unknown", behindBy, mergeBase };
4398
+ return { state: behindBy > 0 ? "behind" : "current", behindBy, mergeBase };
4399
+ }
4400
+ var withoutRoot = (root, p) => root && p.startsWith(`${root}/`) ? p.slice(root.length + 1) : p;
4401
+ async function treeAt(gh, sha) {
4402
+ const t = await gh.getTree(await gh.getCommitTree(sha));
4403
+ if (t.truncated) throw new Error("This repository is too big for Bridge to read in one go. A developer resolves the conflict on GitHub.");
4404
+ return new Map(t.entries.filter((e) => e.type === "blob").map((e) => [e.path, e]));
4405
+ }
4406
+ async function textAt(gh, sha, path) {
4407
+ return await gh.readFile(path, sha) ?? "";
4408
+ }
4409
+ async function tokenFilesAt(gh, sha, root, tree) {
4410
+ const prefix = root ? `${root}/` : "";
4411
+ const paths = [...tree.keys()].filter((p) => p.startsWith(prefix) && p.endsWith(".tokens.json"));
4412
+ return Object.fromEntries(await Promise.all(paths.map(async (p) => [withoutRoot(root, p), await textAt(gh, sha, p)])));
4413
+ }
4414
+ async function configAt(gh, sha, tree) {
4415
+ if (!tree.has("bridge.config.json")) return null;
4416
+ try {
4417
+ return loadConfig(await textAt(gh, sha, "bridge.config.json"));
4418
+ } catch {
4419
+ return null;
4420
+ }
4421
+ }
4422
+ async function readLibraryConflicts(gh, compare, headSha, opts = {}) {
4423
+ const { mergeBase, baseSha: mainSha, baseTree: mainTree } = compare;
4424
+ const changeFiles = compare.files;
4425
+ if (!mergeBase || !mainSha || !mainTree) return { route: conflictRoute({ tokenFiles: [], codeFiles: [] }) };
4426
+ const library = (await gh.compareDetailed(mergeBase, mainSha)).files;
4427
+ const truncated = changeFiles.length >= COMPARE_FILE_LIMIT || library.length >= COMPARE_FILE_LIMIT;
4428
+ const guess = opts.tokensRoot ?? DEFAULT_TOKEN_PATHS.root;
4429
+ const route = conflictRoute(
4430
+ overlap(changeFiles, library, (p) => p.endsWith(".tokens.json") && p.startsWith(`${guess}/`)),
4431
+ { truncated }
4432
+ );
4433
+ if (route.kind !== "tokens") return { route };
4434
+ const [baseTree, headTree, theirsTree] = await Promise.all([treeAt(gh, mergeBase), treeAt(gh, headSha), treeAt(gh, mainSha)]);
4435
+ const config = await configAt(gh, mainSha, theirsTree) ?? await configAt(gh, mergeBase, baseTree);
4436
+ const root = config?.tokens.root ?? guess;
4437
+ const options = config ? layoutOptions(config) : {};
4438
+ const checksPath = `${root}/${config?.tokens.contrastChecks ?? DEFAULT_TOKEN_PATHS.contrastChecks}`;
4439
+ const [baseFiles, mineFiles, theirsFiles, checks] = await Promise.all([
4440
+ tokenFilesAt(gh, mergeBase, root, baseTree),
4441
+ tokenFilesAt(gh, headSha, root, headTree),
4442
+ tokenFilesAt(gh, mainSha, root, theirsTree),
4443
+ theirsTree.has(checksPath) ? textAt(gh, mainSha, checksPath) : Promise.resolve("")
4444
+ ]);
4445
+ const input = { base: baseFiles, mine: mineFiles, theirs: theirsFiles, files: route.files.map((f) => withoutRoot(root, f)) };
4446
+ return {
4447
+ route,
4448
+ tokens: {
4449
+ root,
4450
+ plan: planTokenMerge(input),
4451
+ input,
4452
+ sets: { base: buildTokenSet(baseFiles, options), mine: buildTokenSet(mineFiles, options), theirs: buildTokenSet(theirsFiles, options) },
4453
+ mineFiles,
4454
+ theirsFiles,
4455
+ pairs: parseContrastChecks(checks || opts.contrastFallback),
4456
+ options
4457
+ },
4458
+ write: { mainSha, mainTree, headSha, headTree, changeFiles }
4459
+ };
4460
+ }
4461
+ function resolvedTokenFiles(data, choices) {
4462
+ const t = data.tokens;
4463
+ const merged = applyTokenMerge(t.input, choices).files;
4464
+ const all = { ...t.theirsFiles };
4465
+ for (const f of data.write.changeFiles) {
4466
+ for (const p of [f.previous_filename, f.filename]) {
4467
+ if (!p || !p.endsWith(".tokens.json") || !p.startsWith(`${t.root}/`)) continue;
4468
+ const rel = withoutRoot(t.root, p);
4469
+ if (rel in merged) continue;
4470
+ if (t.mineFiles[rel] === void 0) delete all[rel];
4471
+ else all[rel] = t.mineFiles[rel];
4472
+ }
4473
+ }
4474
+ for (const [rel, text] of Object.entries(merged)) {
4475
+ if (text === null) delete all[rel];
4476
+ else all[rel] = text;
4477
+ }
4478
+ return { all, merged };
4479
+ }
4480
+ async function commitLibraryResolution(gh, pull, data, choices, who) {
4481
+ const t = data.tokens;
4482
+ const w = data.write;
4483
+ if (!t || !w) throw new Error("Only a token conflict can be resolved in Bridge. A developer resolves code conflicts on GitHub.");
4484
+ if (w.headSha !== pull.head.sha) throw new Error("The branch moved while you were choosing. Read it again and resolve again.");
4485
+ const { merged } = resolvedTokenFiles(data, choices);
4486
+ const shared = new Set(data.route.files);
4487
+ const tree = [];
4488
+ for (const f of w.changeFiles) {
4489
+ if (shared.has(f.filename)) continue;
4490
+ if (f.previous_filename && !shared.has(f.previous_filename)) tree.push({ path: f.previous_filename, mode: "100644", sha: null });
4491
+ if (f.status === "removed") {
4492
+ tree.push({ path: f.filename, mode: "100644", sha: null });
4493
+ continue;
4494
+ }
4495
+ const e = w.headTree.get(f.filename);
4496
+ if (!e) throw new Error(`Bridge could not read ${f.filename} in the branch. Read it again and try again.`);
4497
+ tree.push({ path: e.path, mode: e.mode, sha: e.sha });
4498
+ }
4499
+ for (const [rel, text] of Object.entries(merged)) {
4500
+ const path = t.root ? `${t.root}/${rel}` : rel;
4501
+ const mode = w.headTree.get(path)?.mode ?? "100644";
4502
+ tree.push(text === null ? { path, mode, sha: null } : { path, mode, content: text });
4503
+ }
4504
+ const newTree = await gh.createTreeEntries(w.mainTree, tree);
4505
+ const message = resolutionMessage({ library: pull.base.ref, conflicts: t.plan.conflicts, choices, who });
4506
+ const commit = await gh.createCommit(message, newTree, [w.headSha, w.mainSha]);
4507
+ await gh.updateBranch(pull.head.ref, commit);
4508
+ return commit;
4509
+ }
4510
+
4511
+ // ../core/src/branch-actions.ts
4512
+ async function mergeOnGitHub(gh, input) {
4513
+ const r = await gh.mergePullRequest(input.number, { method: input.method ?? "squash", sha: input.headSha });
4514
+ return { sha: r.sha };
4515
+ }
4516
+ var OverrideMergeError = class extends Error {
4517
+ constructor(stage, message, cause, commentUrl) {
4518
+ super(message);
4519
+ this.stage = stage;
4520
+ this.cause = cause;
4521
+ this.commentUrl = commentUrl;
4522
+ this.name = "OverrideMergeError";
4523
+ }
4524
+ stage;
4525
+ cause;
4526
+ commentUrl;
4527
+ };
4528
+ async function overrideMergeOnGitHub(gh, input) {
4529
+ const body = overrideComment(input.login, input.reason, input.missing);
4530
+ let comment;
4531
+ try {
4532
+ comment = await gh.createIssueComment(input.number, body);
4533
+ } catch (err) {
4534
+ throw new OverrideMergeError("comment", `GitHub did not take the comment, so nothing merged: ${err.message}`, err);
4535
+ }
4536
+ try {
4537
+ const r = await gh.mergePullRequest(input.number, { method: input.method, sha: input.headSha, commitMessage: body.replace(OVERRIDE_HEADING, "").trim() });
4538
+ return { sha: r.sha, commentUrl: comment.url, comment: body };
4539
+ } catch (err) {
4540
+ const msg = err.message.trim().replace(/[.\s]+$/, "");
4541
+ await gh.updateIssueComment(comment.id, overrideRefusedComment(body, msg)).catch(() => void 0);
4542
+ throw new OverrideMergeError(
4543
+ "merge",
4544
+ `GitHub refused to merge it: ${msg}. Your reason stays on the branch, marked not merged. The protection of main may need an approving review or an admin bypass.`,
4545
+ err,
4546
+ comment.url
4547
+ );
4548
+ }
4549
+ }
4550
+ async function reviewOnGitHub(gh, input) {
4551
+ const text = input.body?.trim() ?? "";
4552
+ if (input.event === "REQUEST_CHANGES" && !text) throw new Error("Add a comment that says what to change.");
4553
+ if (input.event === "COMMENT" && !text) throw new Error("Write a comment first.");
4554
+ return gh.submitReview(input.number, { event: input.event, body: text || void 0, commitId: input.headSha });
4555
+ }
4556
+ async function commentOnGitHub(gh, n, body) {
4557
+ const text = body.trim();
4558
+ if (!text) throw new Error("Write the comment first.");
4559
+ return gh.createIssueComment(n, text);
4560
+ }
4561
+ async function askOthersToReviewOnGitHub(gh, input) {
4562
+ if (!input.others.length) throw new Error("Nobody else can give the missing approval. Review it, or ask an owner in Settings to add a reviewer.");
4563
+ await gh.requestReviewers(input.number, input.others);
4564
+ if (input.viewer && input.viewerRequested) await gh.removeRequestedReviewers(input.number, [input.viewer]).catch(() => void 0);
4565
+ return input.others;
4566
+ }
4567
+ async function closeOnGitHub(gh, n) {
4568
+ await gh.updatePullRequest(n, { state: "closed" });
4569
+ }
4570
+ async function reopenOnGitHub(gh, n) {
4571
+ await gh.updatePullRequest(n, { state: "open" });
4572
+ }
4573
+ var sameLogin = (a, b) => Boolean(a && b && a.toLowerCase().replace(/^@/, "") === b.toLowerCase().replace(/^@/, ""));
4574
+ function canAddTo(p, viewer, write) {
4575
+ if (!viewer) return false;
4576
+ return write || sameLogin(p.user?.login, viewer);
4577
+ }
4578
+ function addedComment(paths, viewer, from = "the Bridge token editor") {
4579
+ const lines = paths.slice(0, 20).map((p) => `- \`${p}\``);
4580
+ if (paths.length > 20) lines.push(`- and ${paths.length - 20} more`);
4581
+ return `${viewer ? `@${viewer} added` : "Added"} token edits from ${from}:
4582
+
4583
+ ${lines.join("\n")}`;
4584
+ }
4585
+
4586
+ // ../core/src/jira.ts
4587
+ var JIRA_KEY = /^[A-Z][A-Z0-9_]{0,9}-[1-9]\d{0,6}$/;
4588
+ function parseJiraKey(input) {
4589
+ const t = (input ?? "").trim().toUpperCase();
4590
+ if (!t) return null;
4591
+ if (!JIRA_KEY.test(t)) throw new Error("Write the Jira ticket as its key, like DS-42, or leave it empty.");
4592
+ return t;
4593
+ }
4594
+ var LEADING_KEY = /^\[?([A-Z][A-Z0-9_]{0,9}-[1-9]\d{0,6})\]?[:\s]\s*/;
4595
+ var withoutJiraKey = (title) => title.trim().replace(LEADING_KEY, "");
4596
+ function titleWithJiraKey(title, key) {
4597
+ if (!key) return title;
4598
+ return `${key} ${withoutJiraKey(title)}`;
4599
+ }
4600
+ function branchWithJiraKey(branch, key) {
4601
+ if (!key || branch.includes(key)) return branch;
4602
+ const i = branch.lastIndexOf("/");
4603
+ return i < 0 ? `${key}-${branch}` : `${branch.slice(0, i + 1)}${key}-${branch.slice(i + 1)}`;
4604
+ }
4605
+
4606
+ // ../core/src/docs-proposal.ts
4607
+ var DOCS_EDITOR_ORIGIN = {
4608
+ where: "in the Bridge docs editor",
4609
+ footer: 'Proposed with the Bridge docs editor by a person (no AI involved in this text unless the docs say "draftedBy").',
4610
+ label: "source: bridge-web"
4611
+ };
4612
+ var DOCS_MCP_ORIGIN = {
4613
+ where: "with Claude through the Bridge MCP server",
4614
+ footer: 'Proposed with the Bridge MCP server on behalf of a person. Docs Claude drafted say "draftedBy": "claude" until a reviewer accepts them.',
4615
+ label: "source: mcp"
4616
+ };
4617
+ var EM_DASH = /\u2014/;
4618
+ function validateDocs(d) {
4619
+ const out = [];
4620
+ if (!d.summary.trim()) out.push({ field: "summary", message: "Add a summary: one or two sentences on what it is for." });
4621
+ const lists = ["whenToUse", "whenNotToUse", "accessibility", "contentGuidelines"];
4622
+ for (const k of lists) (d[k] ?? []).forEach((x, i) => !x.trim() && out.push({ field: `${k}.${i}`, message: "Fill or remove the empty item." }));
4623
+ for (const k of ["dos", "donts"]) d[k].forEach((g, i) => (!g.title.trim() || !g.text.trim()) && out.push({ field: `${k}.${i}`, message: "Give every item a title and a sentence." }));
4624
+ const prose = JSON.stringify([d.summary, d.whenToUse, d.whenNotToUse, d.dos, d.donts, d.accessibility, d.contentGuidelines ?? [], d.anatomy ?? {}]);
4625
+ if (EM_DASH.test(prose)) out.push({ field: "voice", message: "Replace the em dashes with a colon, a comma or two sentences." });
4626
+ return out;
4627
+ }
4628
+ function cleanDocs(d) {
4629
+ const list = (xs) => (xs ?? []).map((x) => x.trim()).filter(Boolean);
4630
+ const guide = (xs) => xs.map((g) => ({ ...g, title: g.title.trim(), text: g.text.trim() })).filter((g) => g.title || g.text);
4631
+ return {
4632
+ ...d,
4633
+ summary: d.summary.trim(),
4634
+ whenToUse: list(d.whenToUse),
4635
+ whenNotToUse: list(d.whenNotToUse),
4636
+ dos: guide(d.dos),
4637
+ donts: guide(d.donts),
4638
+ accessibility: list(d.accessibility),
4639
+ contentGuidelines: list(d.contentGuidelines),
4640
+ anatomy: Object.fromEntries(Object.entries(d.anatomy ?? {}).filter(([k, v]) => k.trim() && v.trim()))
4641
+ };
4642
+ }
4643
+ var docsFileText = (d, original) => patchDocsJson(original, cleanDocs(d));
4644
+ var slugDate = (d) => d.toISOString().slice(0, 10).replace(/-/g, "");
4645
+ function docsProposal(edits, opts) {
4646
+ const origin = opts.origin ?? DOCS_EDITOR_ORIGIN;
4647
+ if (!edits.length) throw new Error("No docs edits in the draft.");
4648
+ const names2 = edits.map((e) => e.component);
4649
+ const list = names2.length === 1 ? names2[0] : `${names2.slice(0, -1).join(", ")} and ${names2[names2.length - 1]}`;
4650
+ const title = opts.title?.trim() || `docs(${names2.length === 1 ? edits[0].slug : "components"}): usage guidance for ${list}`;
4651
+ const slug2 = `${names2.length === 1 ? edits[0].slug : `${edits[0].slug}-and-${names2.length - 1}-more`}`.toLowerCase().replace(/[^a-z0-9-]+/g, "-").slice(0, 40);
4652
+ const branch = `docs/${slug2}-${slugDate(opts.date)}-${opts.date.getTime().toString(36).slice(-4)}`;
4653
+ const csPath = `${opts.changesetDir ?? ".changeset"}/docs-${slug2}-${opts.date.getTime().toString(36)}.md`;
4654
+ const pkg = opts.packageName ?? "@bridge-ds/react";
4655
+ const changeset = `---
4656
+ "${pkg}": patch
4657
+ ---
4658
+
4659
+ Usage guidance for ${list}.
4660
+ `;
4661
+ const files = [...edits.map((e) => ({ path: e.path, content: e.after })), { path: csPath, content: changeset }];
4662
+ const risk = classifyRisk({ files: files.map((f) => ({ path: f.path, status: "modified" })) });
4663
+ const rows = edits.map((e) => {
4664
+ let state = "edited";
4665
+ try {
4666
+ const d = JSON.parse(e.after);
4667
+ const gaps = missingProse(d);
4668
+ state = gaps.length ? `still missing: ${gaps.join(", ")}` : "every section filled";
4669
+ } catch {
4670
+ state = "not valid JSON";
4671
+ }
4672
+ return `- \`${e.path}\` (${e.before === null ? "new" : "changed"}, ${state})`;
4673
+ });
4674
+ const body = [
4675
+ `## Docs change`,
4676
+ "",
4677
+ `Usage guidance for ${list}, written ${origin.where}.`,
4678
+ "",
4679
+ ...rows,
4680
+ "",
4681
+ `**Risk:** ${risk.label}. Docs only: the published components do not change. Changeset: patch.`,
4682
+ "",
4683
+ opts.notes?.trim() ? `## Notes
4684
+
4685
+ ${opts.notes.trim()}
4686
+ ` : "",
4687
+ "---",
4688
+ origin.footer
4689
+ ].filter((x, i, a) => !(x === "" && a[i - 1] === "")).join("\n");
4690
+ return { branch, title, body, files, risk, bump: "patch", labels: ["docs", origin.label, `tier-${risk.tier}`] };
4691
+ }
4692
+ async function proposeDocsOnGitHub(gh, edits, opts) {
4693
+ const p = docsProposal(edits, { title: opts.title, notes: opts.notes, date: opts.date ?? /* @__PURE__ */ new Date(), origin: opts.origin });
4694
+ const prTitle = titleWithJiraKey(p.title, opts.jira ?? null);
4695
+ const branch = branchWithJiraKey(p.branch, opts.jira ?? null);
4696
+ const mainSha = await gh.getBranchSha(opts.base);
4697
+ if (!mainSha) throw new Error(`Branch ${opts.base} not found.`);
4698
+ const tree = await gh.createTree(await gh.getCommitTree(mainSha), p.files.map((f) => ({ path: f.path, content: f.content })));
4699
+ const commit = await gh.createCommit(`${prTitle}
4700
+
4701
+ ${opts.commitNote ?? "Proposed with the Bridge docs editor."}`, tree, [mainSha]);
4702
+ await gh.createBranch(branch, commit);
4703
+ const pr = await gh.createPullRequest({ title: prTitle, head: branch, base: opts.base, body: p.body, draft: false });
4704
+ await gh.addLabels(pr.number, [...p.labels, ...opts.extraLabels ?? []]).catch(() => false);
4705
+ return { number: pr.number, url: pr.url, branch, title: prTitle };
4706
+ }
4707
+ async function commitFileOnGitHub(gh, input) {
4708
+ const cur = await gh.getFile(input.path, input.branch);
4709
+ if (!cur) throw new Error(`${input.path} is not on ${input.branch}. Check the component name and the branch.`);
4710
+ const content = input.next(cur.text);
4711
+ if (content === cur.text) return "";
4712
+ return gh.putFile({ path: input.path, content, branch: input.branch, message: input.message, sha: cur.sha });
4713
+ }
4714
+
4715
+ // ../core/src/share-branch.ts
4716
+ var packageDir = (name) => `packages/${name.replace(/^@[^/]+\//, "")}/`;
4717
+ function touchesPackages(files, packages) {
4718
+ const dirs = packages.map(packageDir);
4719
+ return files.some((f) => dirs.some((d) => f.startsWith(d)));
4720
+ }
4721
+ function shareState(x) {
4722
+ if (!touchesPackages(x.files, x.packages)) return { kind: "no-packages" };
4723
+ const c = readPreviewComment(x.comment);
4724
+ if (x.lifecycle === "merged" || x.lifecycle === "closed") {
4725
+ const kind = x.lifecycle === "merged" ? "shipped" : "closed";
4726
+ if (!c || !c.command || c.source === "none") return { kind, stillWorks: null, command: null };
4727
+ return { kind, stillWorks: c.source === "pkg.pr.new" ? true : c.source === "release" ? false : null, command: c.command };
4728
+ }
4729
+ if (!c) return { kind: "building" };
4730
+ if (c.sha7 && !x.headSha.startsWith(c.sha7)) return { kind: "building" };
4731
+ if (!c.command || c.source === "none") return { kind: "failed" };
4732
+ return { kind: "ready", command: c.command, source: c.source };
4733
+ }
4734
+ var stableCommand = (packages) => `npm i ${packages.map((p) => `${p}@latest`).join(" ")}`;
4735
+ function stateSentence(s) {
4736
+ switch (s.kind) {
4737
+ case "ready":
4738
+ return null;
4739
+ case "building":
4740
+ return "The preview version is being prepared. It is usually ready a couple of minutes after each update to the branch.";
4741
+ case "failed":
4742
+ return "The preview version could not be prepared for the latest update. Update the branch to try again, or ask a developer to run it again on GitHub.";
4743
+ case "no-packages":
4744
+ return "This branch does not touch the library tokens or components, so there is no preview version to install.";
4745
+ case "shipped":
4746
+ return s.stillWorks === true ? "This branch was merged. Its preview version still installs, and products get it with the next library release." : s.stillWorks === false ? "This branch was merged, so its preview version was removed. Products get it with the next library release." : "This branch was merged. Products get it with the next library release.";
4747
+ case "closed":
4748
+ return s.stillWorks === true ? "This branch was closed without merging. Its preview version still installs, but it will not reach the library." : s.stillWorks === false ? "This branch was closed without merging, so its preview version was removed." : "This branch was closed without merging.";
4749
+ }
4750
+ }
4751
+ var quote = (t) => `"${t.trim().replace(/"/g, "'")}"`;
4752
+ function shareMessage(x) {
4753
+ const back = stableCommand(x.packages);
4754
+ const s = x.state;
4755
+ if (s.kind === "ready") {
4756
+ const lasts = s.source === "pkg.pr.new" ? "This preview stays available. Each update to the change makes a new one: ask me for the latest command." : "This preview is replaced on each update to the change and removed when it ships or closes: ask me for the latest command if the install fails.";
4757
+ return {
4758
+ subject: `Try the design system change ${quote(x.title)} before it ships`,
4759
+ body: [
4760
+ "Hi,",
4761
+ "",
4762
+ `Could you try the design system change ${quote(x.title)} in our product before it ships?`,
4763
+ ...x.tryUrl ? ["", `See it in the demo product first: ${x.tryUrl}`] : [],
4764
+ "",
4765
+ "Install the preview version in a staging branch (not production):",
4766
+ s.command,
4767
+ "",
4768
+ lasts,
4769
+ "",
4770
+ "To go back to the stable version:",
4771
+ back,
4772
+ "",
4773
+ `Details for developers: ${x.url}`
4774
+ ].join("\n")
4775
+ };
4776
+ }
4777
+ if ((s.kind === "shipped" || s.kind === "closed") && s.command) {
4778
+ const what = s.kind === "shipped" ? `The design system change ${quote(x.title)} has shipped. You get it with the next library release.` : `The design system change ${quote(x.title)} was closed without shipping.`;
4779
+ const preview = s.stillWorks === false ? "Its preview version was removed, so reinstalling it fails." : s.stillWorks === true ? "Its preview version still installs, but it is not the released version." : "";
4780
+ return {
4781
+ subject: `Design system change ${quote(x.title)} ${s.kind === "shipped" ? "has shipped" : "was closed"}`,
4782
+ body: ["Hi,", "", [what, preview].filter(Boolean).join(" "), "", "If you installed its preview, go back to the stable version:", back, "", `Details for developers: ${x.url}`].join("\n")
4783
+ };
4784
+ }
4785
+ return null;
4786
+ }
4787
+
4788
+ // ../core/src/try-in-product.ts
4789
+ function tryInProductUrl(preview, pr, screen) {
4790
+ const url = new URL(preview);
4791
+ url.pathname = "/";
4792
+ url.search = "";
4793
+ url.searchParams.set("bridge", String(pr));
4794
+ if (screen) {
4795
+ url.searchParams.set("tenant", screen.tenant);
4796
+ url.searchParams.set("theme", screen.theme);
4797
+ }
4798
+ url.hash = screen ? screen.route : "";
4799
+ return url.href;
4800
+ }
4801
+
4802
+ // ../core/src/combine-branches.ts
4803
+ var MIN_TOGETHER = 2;
4804
+ var MAX_TOGETHER = 4;
4805
+ function onlyTokens(files, tokensRoot) {
4806
+ const root = `${tokensRoot.replace(/^\/+|\/+$/g, "")}/`;
4807
+ return files.length > 0 && files.every((f) => f.startsWith(root) && f.endsWith(".tokens.json") || /contrast\.checks\.json$/.test(f) || f.startsWith(".changeset/"));
4808
+ }
4809
+ function tokenFilesOf(files, tokensRoot) {
4810
+ const root = `${tokensRoot.replace(/^\/+|\/+$/g, "")}/`;
4811
+ return files.filter((f) => f.startsWith(root) && f.endsWith(".tokens.json")).map((f) => f.slice(root.length));
4812
+ }
4813
+ var changeList = (numbers) => numbers.join(",");
4814
+ function tryTogetherUrl(admin, numbers, picks) {
4815
+ const url = new URL(admin);
4816
+ url.pathname = "/";
4817
+ url.hash = "";
4818
+ const use = encodePicks(picks);
4819
+ url.search = `?bridge=${changeList(numbers)}${use ? `&bridge-use=${encodeURIComponent(use)}` : ""}`;
4820
+ return url.href;
4821
+ }
4822
+ var COMBINE_COMPARE_LIMIT = 300;
4823
+ async function readCombineBranch(gh, pull, opts) {
4824
+ const compare = await gh.compareDetailed(opts.base, pull.headSha);
4825
+ const files = compare.files;
4826
+ const names2 = files.flatMap((f) => f.previous_filename ? [f.filename, f.previous_filename] : [f.filename]);
4827
+ const start = compare.mergeBase;
4828
+ if (!start || files.length >= COMBINE_COMPARE_LIMIT || !onlyTokens(names2, opts.tokensRoot)) return { number: pull.number, title: pull.title, tokens: false, edits: null };
4829
+ const root = opts.tokensRoot.replace(/^\/+|\/+$/g, "");
4830
+ const tokenFiles = files.filter((f) => tokenFilesOf([f.filename], root).length);
4831
+ const source = { number: pull.number, base: {}, head: {}, files: tokenFiles.map((f) => tokenFilesOf([f.filename], root)[0]) };
4832
+ await Promise.all(
4833
+ tokenFiles.map(async (f) => {
4834
+ const rel = tokenFilesOf([f.filename], root)[0];
4835
+ source.base[rel] = f.status === "added" ? void 0 : await gh.readFile(f.previous_filename ?? f.filename, start);
4836
+ source.head[rel] = f.status === "removed" ? void 0 : await gh.readFile(f.filename, pull.headSha);
4837
+ })
4838
+ );
4839
+ return { number: pull.number, title: pull.title, tokens: true, edits: changeEdits(source) };
4840
+ }
4841
+
3541
4842
  // ../core/src/node/git-history.ts
3542
4843
  var US = "";
3543
4844
  var RS = "";
@@ -5402,9 +6703,9 @@ var DesignSystemIndex = class {
5402
6703
  return generic ? this.make(generic, 1, 0.3) : void 0;
5403
6704
  }
5404
6705
  case "shadow": {
5405
- const norm = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
6706
+ const norm2 = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
5406
6707
  const cands = this.entries(["shadow"]);
5407
- const exact = cands.find((e) => norm(e.css) === norm(value));
6708
+ const exact = cands.find((e) => norm2(e.css) === norm2(value));
5408
6709
  if (exact) return this.make(exact, 0, 1);
5409
6710
  const t = shadowNums(value);
5410
6711
  const scored = cands.map((e) => {
@@ -5830,11 +7131,11 @@ async function listComponentNames(source, config) {
5830
7131
  }
5831
7132
  var findFamily = (stories) => stories ? /title:\s*['"]([^'"/]+)\//.exec(stories)?.[1] : void 0;
5832
7133
  async function listComponents(source, config, withSummaries) {
5833
- const names = await listComponentNames(source, config);
5834
- if (!withSummaries) return names.map((n) => ({ name: n.name }));
7134
+ const names2 = await listComponentNames(source, config);
7135
+ if (!withSummaries) return names2.map((n) => ({ name: n.name }));
5835
7136
  const dir = config.components.dir;
5836
7137
  return Promise.all(
5837
- names.map(async ({ name, files }) => {
7138
+ names2.map(async ({ name, files }) => {
5838
7139
  const docsPath = `${dir}/${name}/${name}${config.components.docsSuffix}`;
5839
7140
  const storiesPath = files.find((f) => /\.stories\.\w+$/.test(f));
5840
7141
  const [docs, stories] = await Promise.all([files.includes(docsPath) ? source.read(docsPath) : null, storiesPath ? source.read(storiesPath) : null]);
@@ -6289,6 +7590,454 @@ __export(explorations_exports, {
6289
7590
  });
6290
7591
  import { mkdir, rm, writeFile } from "node:fs/promises";
6291
7592
  import { dirname as dirname2, join as join3 } from "node:path";
7593
+
7594
+ // src/branches.ts
7595
+ var branches_exports = {};
7596
+ __export(branches_exports, {
7597
+ RIGHTS: () => RIGHTS,
7598
+ addableBranch: () => addableBranch,
7599
+ askSomeoneElse: () => askSomeoneElse,
7600
+ backToDraft: () => backToDraft,
7601
+ branchFacts: () => branchFacts,
7602
+ branchNumber: () => branchNumber,
7603
+ branchStatus: () => branchStatus,
7604
+ closeBranch: () => closeBranch,
7605
+ combineBranches: () => combineBranches,
7606
+ commentOnBranch: () => commentOnBranch,
7607
+ listComments: () => listComments,
7608
+ listProducts: () => listProducts,
7609
+ mergeBranch: () => mergeBranch,
7610
+ productMarks: () => productMarks,
7611
+ reopenBranch: () => reopenBranch,
7612
+ resolveProduct: () => resolveProduct,
7613
+ reviewBranch: () => reviewBranch,
7614
+ sendForReview: () => sendForReview,
7615
+ tryBranch: () => tryBranch,
7616
+ updateFromMain: () => updateFromMain,
7617
+ whatNeedsMe: () => whatNeedsMe
7618
+ });
7619
+ import { relative as relative2 } from "node:path";
7620
+ var RIGHTS = {
7621
+ review: "Pull requests: read and write",
7622
+ comment: "Pull requests (or Issues): read and write",
7623
+ merge: "Contents and Pull requests: read and write, and the right to ship in bridge.config.json (governance.rights)",
7624
+ update: "Contents and Pull requests: read and write",
7625
+ draft: "Pull requests: read and write (you must be the author, as in Studio)",
7626
+ docs: "Contents and Pull requests: read and write"
7627
+ };
7628
+ var NO_REPO = 'No GitHub repo known: set "repo" in bridge.config.json or add an origin remote.';
7629
+ async function reader(ctx) {
7630
+ const gh = await ctx.github();
7631
+ if (!gh) throw new Error(NO_REPO);
7632
+ return gh;
7633
+ }
7634
+ async function writer(ctx, what, need) {
7635
+ const gh = await reader(ctx);
7636
+ if (!ctx.canWriteRemote) throw new Error(`${what} needs a GitHub token with ${need} on ${gh.fullName}. Set GITHUB_TOKEN (or BRIDGE_GITHUB_TOKEN) where the Bridge MCP server or the CLI runs, or do it in Bridge Studio.`);
7637
+ return gh.withBranchPrefix(void 0);
7638
+ }
7639
+ function branchNumber(input) {
7640
+ if (typeof input === "number" && Number.isInteger(input) && input > 0) return input;
7641
+ const m = /^#?(\d+)$/.exec(String(input).trim()) ?? /\/pull\/(\d+)/.exec(String(input));
7642
+ if (!m) throw new Error(`"${input}" is not a branch number. Pass the number Studio shows (#12) or the link of the branch on GitHub.`);
7643
+ return Number(m[1]);
7644
+ }
7645
+ var lifecycleOf = (state, draft) => state === "merged" ? "merged" : state === "closed" ? "closed" : draft ? "draft" : "open";
7646
+ async function people(gh, config) {
7647
+ const { owners, rights } = config.governance;
7648
+ const teams = await new TeamDirectory(gh).resolve(owners).catch(() => void 0);
7649
+ const lists = expandOwners(approverEntries(owners, rights), teams);
7650
+ return { lists, ship: shipPolicy(owners, rights, teams) };
7651
+ }
7652
+ async function viewerOf(ctx, gh) {
7653
+ if (!ctx.canWriteRemote) return null;
7654
+ return gh.getViewer().then((v) => v.login, () => null);
7655
+ }
7656
+ async function branchFacts(ctx, gh, n, viewer) {
7657
+ const { config } = await ctx.config();
7658
+ const pr = await gh.getPullRequest(n);
7659
+ const [files, reviews, runs, statuses, who, compare, repo] = await Promise.all([
7660
+ gh.listPullRequestFiles(n),
7661
+ gh.listReviews(n).catch(() => []),
7662
+ gh.listCheckRuns(pr.headSha).catch(() => []),
7663
+ gh.listCommitStatuses(pr.headSha).catch(() => []),
7664
+ people(gh, config),
7665
+ pr.state === "open" ? gh.compareDetailed(pr.baseRef, pr.headSha).catch(() => null) : Promise.resolve(null),
7666
+ viewer ? gh.getMergeSettings().catch(() => null) : Promise.resolve(null)
7667
+ ]);
7668
+ const labels = pr.labels.map((name) => ({ name }));
7669
+ const ci = summarizeCi(
7670
+ runs.map((r) => ({ name: r.name, status: r.status, conclusion: r.conclusion, ...r.url ? { html_url: r.url } : {} })),
7671
+ statuses.map((s) => ({ context: s.context, state: s.state, target_url: s.target_url }))
7672
+ );
7673
+ const approvals = summarizeApprovals(
7674
+ reviews.map((r) => ({ user: r.user ? { login: r.user } : null, state: r.state, submitted_at: r.submittedAt ?? null })),
7675
+ who.lists,
7676
+ pr.author
7677
+ );
7678
+ const tier = effectiveTier(labels, pr.body, files);
7679
+ const merged = pr.state === "merged";
7680
+ const pullLike = { state: merged ? "closed" : pr.state, draft: pr.draft, merged_at: merged ? "merged" : null };
7681
+ const exploration = isActiveExploration(pr.labels);
7682
+ const gate = mergeGate({ tier, ci, approvals, draft: pr.draft, mergeable: pr.mergeable, state: pullLike.state, merged_at: pullLike.merged_at, viewer, override: config.governance.override, exploration, ship: who.ship });
7683
+ const status = reviewStatusKind({ ...pullLike, ci, approvals, tier, mergeable: pr.mergeable });
7684
+ const fresh = freshness({ compare: compare ? { behind_by: compare.behindBy, merge_base_commit: compare.mergeBase ? { sha: compare.mergeBase } : null } : null, mergeable: pr.mergeable, open: pr.state === "open" });
7685
+ const product = productOfBranch(productsOf(config), { files: files.map((f) => f.filename), labels: pr.labels });
7686
+ return { config, pr, files, ci, approvals, tier, gate, status, fresh, compare, exploration, product, lifecycle: pullState(pullLike), write: repo ? hasWriteAccess(repo.permissions) : null, repo };
7687
+ }
7688
+ var STATUS_WORDS = {
7689
+ merged: "Merged",
7690
+ closed: "Closed",
7691
+ draft: "Draft",
7692
+ "ci-failing": "Blocked by tests",
7693
+ "changes-requested": "Changes requested",
7694
+ "ci-running": "Tests running",
7695
+ "needs-design": "Needs design approval",
7696
+ "needs-tech": "Needs tech approval",
7697
+ "needs-approval": "Needs approval",
7698
+ conflict: "Conflict with main",
7699
+ checking: "Checking",
7700
+ ready: "Ready to merge",
7701
+ unknown: "Status unknown"
7702
+ };
7703
+ async function branchStatus(ctx, a) {
7704
+ const n = branchNumber(a.pr);
7705
+ const gh = await reader(ctx);
7706
+ const viewer = await viewerOf(ctx, gh);
7707
+ const f = await branchFacts(ctx, gh, n, viewer);
7708
+ return {
7709
+ pr: n,
7710
+ title: f.pr.title,
7711
+ url: f.pr.url,
7712
+ author: f.pr.author,
7713
+ product: { key: f.product.key, name: f.product.name },
7714
+ lifecycle: f.lifecycle,
7715
+ status: { kind: f.status, label: STATUS_WORDS[f.status] },
7716
+ approvalNeeded: f.tier === 1 ? "One approval" : "Design and tech approval",
7717
+ approvals: { approvedBy: f.approvals.approvers, changesRequestedBy: f.approvals.changesRequested, design: f.approvals.design, tech: f.approvals.tech },
7718
+ tests: { state: f.ci.state, failing: f.ci.failed.map((x) => x.name), running: f.ci.pending.map((x) => x.name) },
7719
+ main: { state: f.fresh.state, behindBy: f.fresh.behindBy },
7720
+ merge: {
7721
+ allowed: f.gate.allowed,
7722
+ blockedBy: f.gate.reasons,
7723
+ ...viewer ? { you: viewer, youCanShip: f.gate.canShip, ownerOverride: f.gate.overridable } : { you: null, note: "Without a GitHub token Bridge cannot tell whether you may merge." }
7724
+ },
7725
+ next: nextStep(f, viewer)
7726
+ };
7727
+ }
7728
+ function nextStep(f, viewer) {
7729
+ if (f.lifecycle === "merged") return "Merged. It goes out with the next library release.";
7730
+ if (f.lifecycle === "closed") return "Closed without merging. bridge_reopen_branch reopens it when its work is still there.";
7731
+ if (f.exploration) return "It is an exploration: promote it with bridge_promote_exploration when the idea is validated, or close it.";
7732
+ if (f.lifecycle === "draft") return "Draft: bridge_send_for_review asks for review when it is ready.";
7733
+ if (f.fresh.state === "conflict") return "It conflicts with main: bridge_update_from_main shows the token conflicts to pick (code conflicts stay with a developer).";
7734
+ if (f.gate.allowed) return viewer ? "Ready: bridge_merge_branch merges it when the person asks." : "Ready to merge by someone who may ship.";
7735
+ if (f.gate.overridable) return `Only approvals are missing and you may use the owner override: bridge_merge_branch with a reason of at least ${(f.config.governance.override ?? DEFAULT_OVERRIDE).minReasonLength} characters.`;
7736
+ return f.gate.reasons[0] ?? "Waiting.";
7737
+ }
7738
+ async function whatNeedsMe(ctx, a = {}) {
7739
+ const gh = await reader(ctx);
7740
+ const { config } = await ctx.config();
7741
+ const viewer = await viewerOf(ctx, gh);
7742
+ const pulls = await gh.listPullRequests("open");
7743
+ 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);
7744
+ const who = await people(gh, config);
7745
+ const same2 = (x, y) => Boolean(y && x.toLowerCase() === y.toLowerCase());
7746
+ const rows = await Promise.all(
7747
+ queue.map(async (p) => {
7748
+ const f = await branchFacts(ctx, gh, p.number, viewer).catch(() => null);
7749
+ 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 };
7750
+ const mine = same2(p.author, viewer);
7751
+ const inDesign = viewer ? who.lists.design.some((x) => same2(x, viewer)) : false;
7752
+ const inTech = viewer ? who.lists.tech.some((x) => same2(x, viewer)) : false;
7753
+ let needsYou = null;
7754
+ if (viewer && !mine) {
7755
+ if (f.status === "needs-design" && inDesign) needsYou = "Your design approval";
7756
+ else if (f.status === "needs-tech" && inTech) needsYou = "Your tech approval";
7757
+ else if (f.status === "needs-approval" && !f.approvals.approvers.some((x) => same2(x, viewer))) needsYou = "Your approval";
7758
+ else if (f.status === "ready" && f.gate.canShip) needsYou = "Ready for you to merge";
7759
+ }
7760
+ if (viewer && mine) {
7761
+ if (f.status === "changes-requested") needsYou = "Changes requested on your branch";
7762
+ else if (f.status === "ci-failing") needsYou = "Tests fail on your branch";
7763
+ else if (f.status === "conflict") needsYou = "Your branch conflicts with main";
7764
+ else if (f.status === "ready" && f.gate.canShip) needsYou = "Your branch is ready to merge";
7765
+ }
7766
+ return { pr: p.number, title: p.title, url: p.url, author: p.author, product: f.product.name, status: f.status, statusLabel: STATUS_WORDS[f.status], yours: mine, needsYou };
7767
+ })
7768
+ );
7769
+ rows.sort((x, y) => Number(Boolean(y.needsYou)) - Number(Boolean(x.needsYou)) || REVIEW_STATUS_ORDER[x.status] - REVIEW_STATUS_ORDER[y.status]);
7770
+ const mineCount = rows.filter((r) => r.needsYou).length;
7771
+ return {
7772
+ viewer,
7773
+ summary: viewer ? `${mineCount ? `${mineCount} ${mineCount === 1 ? "branch needs" : "branches need"} you` : "Nothing needs you right now"}; ${rows.length} in review.` : `${rows.length} in review. Set a GitHub token so Bridge knows which ones need you.`,
7774
+ branches: rows
7775
+ };
7776
+ }
7777
+ async function listProducts(ctx) {
7778
+ const { config } = await ctx.config();
7779
+ const products = productsOf(config);
7780
+ const here = await productHere(ctx, config);
7781
+ return {
7782
+ products: products.map((p) => ({ key: p.key, name: p.name, ...p.repo ? { repo: p.repo } : {}, ...p.path ? { path: p.path } : {}, ...p.preview ? { preview: p.preview } : {}, ...p.framework ? { framework: p.framework } : {}, default: p === defaultProduct(products) })),
7783
+ current: { key: here.product.key, name: here.product.name, how: here.how },
7784
+ howToPick: 'Pass "product" (key or name) to the tools that start work. Without it Bridge uses the product of the current folder or repo, else the default product. Ask the person when it is not obvious.'
7785
+ };
7786
+ }
7787
+ async function productHere(ctx, config) {
7788
+ const products = productsOf(config);
7789
+ if (ctx.mode !== "local" || !ctx.root) return productForPlace(products, {});
7790
+ const cwd = process.cwd();
7791
+ const rel = relative2(ctx.root, cwd);
7792
+ const repo = await originRepo(cwd).catch(() => void 0);
7793
+ const ds = config.repo ? `${config.repo.owner}/${config.repo.name}` : await originRepo(ctx.root).catch(() => void 0);
7794
+ return productForPlace(products, { relPath: rel.startsWith("..") ? null : rel, repo: repo ?? null, designSystemRepo: ds ?? null });
7795
+ }
7796
+ async function resolveProduct(ctx, name) {
7797
+ const { config } = await ctx.config();
7798
+ const products = productsOf(config);
7799
+ if (name?.trim()) {
7800
+ const p = findProduct(products, name);
7801
+ if (!p) throw new Error(`Unknown product "${name}". Pick one of: ${products.map((x) => `${x.key} (${x.name})`).join(", ")}. Products are listed in bridge.config.json "products".`);
7802
+ return p;
7803
+ }
7804
+ return (await productHere(ctx, config)).product;
7805
+ }
7806
+ function productMarks(products, p) {
7807
+ if (p.key === defaultProduct(products).key) return { labels: [], line: "" };
7808
+ return { labels: [productLabel(p.key)], line: `**Product:** ${p.name}` };
7809
+ }
7810
+ async function tryBranch(ctx, a) {
7811
+ const n = branchNumber(a.pr);
7812
+ const gh = await reader(ctx);
7813
+ const { config } = await ctx.config();
7814
+ const pr = await gh.getPullRequest(n);
7815
+ const [files, comments] = await Promise.all([gh.listPullRequestFiles(n), gh.listIssueComments(n).catch(() => [])]);
7816
+ const previews = await branchPreviewUrls(gh, n, config, { branch: pr.headRef, sha: pr.headSha, comments }).catch(() => []);
7817
+ const apps = config.previews?.apps ?? {};
7818
+ const products = productsOf(config).map((p) => {
7819
+ const app = Object.entries(apps).find(([, t]) => t && t === p.preview)?.[0];
7820
+ const hit = app ? previews.find((x) => x.app === app && x.url && x.source !== "template" && x.state !== "failed") : void 0;
7821
+ const reported = hit?.url ?? null;
7822
+ const pattern = !app && p.preview ? previewUrl(p.preview, { branch: pr.headRef, pr: n, sha: pr.headSha }) : null;
7823
+ const url = reported ? app === "admin" ? tryInProductUrl(reported, n, null) : reported : pattern;
7824
+ return { key: p.key, name: p.name, url, how: reported ? "reported by Vercel" : pattern ? "from the preview pattern in bridge.config.json (it may not exist yet)" : "no preview reported yet" };
7825
+ });
7826
+ const lifecycle = lifecycleOf(pr.state, pr.draft);
7827
+ const packages = config.previews?.packages ?? [];
7828
+ const share = shareState({ lifecycle, files: files.map((f) => f.filename), packages, comment: comments.find((c) => c.body.includes("<!-- bridge-branch-preview -->"))?.body, headSha: pr.headSha });
7829
+ const tryUrl = products.find((p) => p.url && p.how === "reported by Vercel")?.url ?? null;
7830
+ const message = shareMessage({ title: pr.title, state: share, packages, tryUrl, url: pr.url });
7831
+ return {
7832
+ pr: n,
7833
+ title: pr.title,
7834
+ tryInProduct: products,
7835
+ previews: previews.map((p) => ({ app: p.app, label: p.label, url: p.url, state: p.state })),
7836
+ share: {
7837
+ state: share.kind,
7838
+ install: share.kind === "ready" ? share.command : null,
7839
+ sentence: stateSentence(share) ?? "Product teams can install this branch in their app before it is merged, and remove it any time.",
7840
+ ...message ? { message } : {}
7841
+ }
7842
+ };
7843
+ }
7844
+ async function combineBranches(ctx, a) {
7845
+ const numbers = [...new Set(a.prs.map(branchNumber))];
7846
+ if (numbers.length < MIN_TOGETHER || numbers.length > MAX_TOGETHER) throw new Error(`Pick ${MIN_TOGETHER} to ${MAX_TOGETHER} branches to try together (you gave ${numbers.length}).`);
7847
+ const gh = await reader(ctx);
7848
+ const { config } = await ctx.config();
7849
+ const base = baseBranchOf(config);
7850
+ const root = config.tokens.root;
7851
+ const pulls = await Promise.all(numbers.map((n) => gh.getPullRequest(n)));
7852
+ const branches = await Promise.all(pulls.map((p) => readCombineBranch(gh, { number: p.number, title: p.title, headSha: p.headSha }, { base, tokensRoot: root })));
7853
+ const code2 = branches.filter((b) => !b.tokens);
7854
+ if (code2.length) throw new Error(`#${code2.map((b) => b.number).join(", #")} ${code2.length === 1 ? "changes" : "change"} code, so ${code2.length === 1 ? "it" : "they"} can't be combined here. Pick branches that only change tokens.`);
7855
+ const latest = {};
7856
+ const files = (await gh.listFiles(base)).filter((f) => f.startsWith(`${root}/`) && f.endsWith(".tokens.json"));
7857
+ await Promise.all(files.map(async (f) => latest[f.slice(root.length + 1)] = await gh.readFile(f, base) ?? ""));
7858
+ const picks = a.picks ?? {};
7859
+ const combo = combineChanges(latest, branches.map((b) => b.edits), picks);
7860
+ const titles = new Map(pulls.map((p) => [p.number, p.title]));
7861
+ const admin = config.previews?.production?.admin;
7862
+ const docs = config.previews?.production?.docs;
7863
+ return {
7864
+ branches: pulls.map((p) => ({ pr: p.number, title: p.title })),
7865
+ tokens: combo.tokens,
7866
+ conflicts: combo.conflicts.map((c) => ({ key: c.key, token: c.path, file: c.file, options: c.options.map((o) => ({ use: o.changes[0], titles: o.changes.map((x) => titles.get(x) ?? `#${x}`) })), picked: picks[c.key] ?? null })),
7867
+ missingPicks: combo.missing.length,
7868
+ problems: combo.problems,
7869
+ open: {
7870
+ ...admin ? { product: tryTogetherUrl(admin, numbers, picks) } : {},
7871
+ ...docs ? { library: `${docs.replace(/\/$/, "")}/?change=${numbers.join(",")}` } : {}
7872
+ },
7873
+ next: combo.missing.length ? `Pick a value for ${combo.missing.length} token${combo.missing.length === 1 ? "" : "s"}: pass picks {"<key>": <branch number>}.` : "Open the links to see them together. Nothing was written."
7874
+ };
7875
+ }
7876
+ async function sendForReview(ctx, a) {
7877
+ const n = branchNumber(a.pr);
7878
+ const gh = await writer(ctx, "Sending a branch for review", RIGHTS.draft);
7879
+ const [pr, viewer] = await Promise.all([gh.getPullRequest(n), gh.getViewer()]);
7880
+ if (pr.author.toLowerCase() !== viewer.login.toLowerCase()) throw new Error(`Only its author (@${pr.author}) sends #${n} for review, as in Studio. Ask them, or comment with bridge_comment.`);
7881
+ if (isActiveExploration(pr.labels)) throw new Error(`#${n} is an exploration: promote it with bridge_promote_exploration instead.`);
7882
+ if (pr.state !== "open") throw new Error(`#${n} is ${pr.state}: reopen it first with bridge_reopen_branch.`);
7883
+ if (!pr.draft) return { pr: n, status: "already-in-review", said: "It is already in review. Reviewers can see it." };
7884
+ await gh.markReadyForReview(n);
7885
+ return { pr: n, status: "in-review", said: "Review requested. Reviewers can see it now.", url: pr.url };
7886
+ }
7887
+ async function backToDraft(ctx, a) {
7888
+ const n = branchNumber(a.pr);
7889
+ const gh = await writer(ctx, "Taking a branch back to draft", RIGHTS.draft);
7890
+ const [pr, viewer] = await Promise.all([gh.getPullRequest(n), gh.getViewer()]);
7891
+ if (pr.author.toLowerCase() !== viewer.login.toLowerCase()) throw new Error(`Only its author (@${pr.author}) takes #${n} back to draft, as in Studio.`);
7892
+ if (isActiveExploration(pr.labels)) throw new Error(`#${n} is an exploration: it is a draft until promoted.`);
7893
+ await gh.convertToDraft(n);
7894
+ return { pr: n, status: "draft", said: "Back to draft. Reviewers are not asked until you send it for review again." };
7895
+ }
7896
+ async function reviewBranch(ctx, a) {
7897
+ const n = branchNumber(a.pr);
7898
+ const gh = await writer(ctx, "Reviewing a branch", RIGHTS.review);
7899
+ const pr = await gh.getPullRequest(n);
7900
+ const event = { approve: "APPROVE", request_changes: "REQUEST_CHANGES", comment: "COMMENT" }[a.decision];
7901
+ const r = await reviewOnGitHub(gh, { number: n, event, body: a.body, headSha: pr.headSha });
7902
+ const said = { APPROVE: "Approved.", REQUEST_CHANGES: "Changes requested.", COMMENT: "Comment posted." }[event];
7903
+ return { pr: n, decision: a.decision, id: r.id, said, note: event === "APPROVE" ? "Recorded under your GitHub account, on the version you saw." : void 0 };
7904
+ }
7905
+ async function askSomeoneElse(ctx, a) {
7906
+ const n = branchNumber(a.pr);
7907
+ const gh = a.dryRun ? await reader(ctx) : await writer(ctx, "Asking someone else to review", RIGHTS.review);
7908
+ const viewer = await viewerOf(ctx, gh);
7909
+ const f = await branchFacts(ctx, gh, n, viewer);
7910
+ if (f.lifecycle !== "open") throw new Error(`#${n} is ${f.lifecycle === "draft" ? "a draft: send it for review first" : f.lifecycle}, so there is no review to ask for.`);
7911
+ const { lists } = await people(gh, f.config);
7912
+ const others = reviewersToAsk({ tier: f.tier, approvals: f.approvals, owners: lists, author: f.pr.author, viewer });
7913
+ if (!others.length) throw new Error("Nobody else can give the missing approval. Review it, or ask an owner in Settings to add a reviewer.");
7914
+ if (a.dryRun) return { pr: n, status: "would-ask", asks: others.map((l) => `@${l}`), said: `Would ask ${others.map((l) => `@${l}`).join(", ")} to review.` };
7915
+ await askOthersToReviewOnGitHub(gh, { number: n, others, viewer, viewerRequested: Boolean(viewer && f.pr.requestedReviewers.some((r) => r.toLowerCase() === viewer.toLowerCase())) });
7916
+ return { pr: n, status: "asked", asked: others.map((l) => `@${l}`), said: `Asked ${others.map((l) => `@${l}`).join(", ")} to review.` };
7917
+ }
7918
+ async function commentOnBranch(ctx, a) {
7919
+ const n = branchNumber(a.pr);
7920
+ const text = a.body.trim();
7921
+ if (!text) throw new Error("Write the comment first.");
7922
+ const gh = await writer(ctx, "Commenting on a branch", RIGHTS.comment);
7923
+ if (a.replyTo) {
7924
+ const r2 = await gh.replyToReviewComment(n, a.replyTo, text);
7925
+ return { pr: n, replied: a.replyTo, url: r2.url, said: "Reply posted in the thread." };
7926
+ }
7927
+ const r = await commentOnGitHub(gh, n, text);
7928
+ return { pr: n, url: r.url, said: "Comment posted." };
7929
+ }
7930
+ async function listComments(ctx, a) {
7931
+ const n = branchNumber(a.pr);
7932
+ const gh = await reader(ctx);
7933
+ const [conversation, lines] = await Promise.all([gh.listIssueComments(n), gh.listReviewComments(n).catch(() => [])]);
7934
+ return {
7935
+ pr: n,
7936
+ conversation: conversation.filter((c) => !c.body.includes("<!-- bridge-branch-preview -->")).map((c) => ({ id: c.id, author: c.author, body: c.body, at: c.updatedAt })),
7937
+ threads: lines.filter((c) => !c.inReplyTo).map((c) => ({ id: c.id, author: c.author, file: c.path, line: c.line, body: c.body, url: c.url, replies: lines.filter((r) => r.inReplyTo === c.id).map((r) => ({ id: r.id, author: r.author, body: r.body })) })),
7938
+ howToReply: "bridge_comment with replyTo: the thread id."
7939
+ };
7940
+ }
7941
+ async function mergeBranch(ctx, a) {
7942
+ const n = branchNumber(a.pr);
7943
+ const gh = await writer(ctx, "Merging a branch", RIGHTS.merge);
7944
+ const viewer = (await gh.getViewer()).login;
7945
+ const f = await branchFacts(ctx, gh, n, viewer);
7946
+ const policy = f.config.governance.override ?? DEFAULT_OVERRIDE;
7947
+ const reason = a.overrideReason?.trim();
7948
+ if (f.write === false) throw new Error(`@${viewer} cannot write to ${gh.fullName}, so GitHub refuses the merge. Ask someone with write access.`);
7949
+ if (f.gate.allowed) {
7950
+ if (a.dryRun) return { pr: n, status: "would-merge", said: "Every rule passes: it can be merged." };
7951
+ const r = await mergeOnGitHub(gh, { number: n, headSha: f.pr.headSha, method: "squash" });
7952
+ return { pr: n, status: "merged", sha: r.sha, said: "Merged. It goes out with the next library release." };
7953
+ }
7954
+ if (!reason) {
7955
+ const hint = f.gate.overridable ? ` Only approvals are missing and @${viewer} may use the owner override: pass overrideReason (at least ${policy.minReasonLength} characters), it is posted on the branch.` : "";
7956
+ throw new Error(`#${n} cannot be merged yet: ${f.gate.reasons.join(" ")}${hint}`);
7957
+ }
7958
+ if (!canOverride(viewer, policy)) throw new Error(`@${viewer} is not allowed to use the owner override (governance.override.allowed in bridge.config.json). It still needs: ${f.gate.missing.join(", ") || f.gate.reasons.join(" ")}`);
7959
+ if (!f.gate.overridable) throw new Error(`The owner override only skips missing approvals. #${n} is blocked by: ${f.gate.reasons.join(" ")}`);
7960
+ const problem = overrideReasonProblem(reason, policy);
7961
+ if (problem) throw new Error(problem);
7962
+ if (a.dryRun) return { pr: n, status: "would-override", skips: f.gate.missing, said: "The override would post your reason on the branch, then merge." };
7963
+ const method = allowedMergeMethods(f.repo)[0] ?? "squash";
7964
+ try {
7965
+ const r = await overrideMergeOnGitHub(gh, { number: n, headSha: f.pr.headSha, login: viewer, reason, missing: f.gate.missing, method });
7966
+ return { pr: n, status: "merged", override: true, sha: r.sha, reasonUrl: r.commentUrl, said: `Merged as owner override. The reason is on #${n}.` };
7967
+ } catch (err) {
7968
+ if (err instanceof OverrideMergeError) throw new Error(err.message);
7969
+ throw err;
7970
+ }
7971
+ }
7972
+ async function closeBranch(ctx, a) {
7973
+ const n = branchNumber(a.pr);
7974
+ const gh = await writer(ctx, "Closing a branch", RIGHTS.review);
7975
+ const pr = await gh.getPullRequest(n);
7976
+ if (isActiveExploration(pr.labels)) throw new Error(`#${n} is an exploration: close it with bridge_close_exploration.`);
7977
+ if (pr.state !== "open") return { pr: n, status: pr.state, said: `It is already ${pr.state}.` };
7978
+ await closeOnGitHub(gh, n);
7979
+ return { pr: n, status: "closed", said: "Closed. It can be reopened while its work is still there." };
7980
+ }
7981
+ async function reopenBranch(ctx, a) {
7982
+ const n = branchNumber(a.pr);
7983
+ const gh = await writer(ctx, "Reopening a branch", RIGHTS.review);
7984
+ const { config } = await ctx.config();
7985
+ const [pr, viewer, repo] = await Promise.all([gh.getPullRequest(n), gh.getViewer(), gh.getMergeSettings().catch(() => null)]);
7986
+ if (pr.state === "merged") throw new Error(`#${n} is merged: it cannot be reopened. Undo it with bridge_plan_revert and bridge_propose_revert.`);
7987
+ const exists = await gh.getBranchSha(pr.headRef).then((s) => s ? "exists" : "gone", () => "unknown");
7988
+ const owners = [...config.governance.owners.design, ...config.governance.owners.tech];
7989
+ const ok2 = reopenEligibility({ state: pr.state === "closed" ? "closed" : "open", viewer: viewer.login, author: pr.author, owners, canWrite: hasWriteAccess(repo?.permissions), branch: exists });
7990
+ if (pr.state === "open") return { pr: n, status: "open", said: "It is already open." };
7991
+ if (ok2.kind === "gone") throw new Error(`#${n} can't be reopened: its work was removed. Start a new branch with its token edits (bridge_propose_token_change).`);
7992
+ if (ok2.kind !== "reopen") throw new Error(`Only its author (@${pr.author}) or an owner with write access reopens #${n}, as in Studio.`);
7993
+ await reopenOnGitHub(gh, n);
7994
+ return { pr: n, status: "open", said: "Reopened. It is back in review with its earlier approvals and checks." };
7995
+ }
7996
+ async function updateFromMain(ctx, a) {
7997
+ const n = branchNumber(a.pr);
7998
+ const gh = a.dryRun ? await reader(ctx) : await writer(ctx, "Updating a branch from main", RIGHTS.update);
7999
+ const { config } = await ctx.config();
8000
+ const pr = await gh.getPullRequest(n);
8001
+ if (pr.state !== "open") throw new Error(`#${n} is ${pr.state}: only an open branch is updated from main.`);
8002
+ const compare = await gh.compareDetailed(pr.baseRef, pr.headSha);
8003
+ const fresh = freshness({ compare: { behind_by: compare.behindBy, merge_base_commit: compare.mergeBase ? { sha: compare.mergeBase } : null }, mergeable: pr.mergeable, open: true });
8004
+ if (fresh.state === "current") return { pr: n, status: "up-to-date", said: `It already has the latest ${pr.baseRef}.` };
8005
+ if (fresh.state === "behind") {
8006
+ if (a.dryRun) return { pr: n, status: "would-update", behindBy: fresh.behindBy, said: `${pr.baseRef} has ${fresh.behindBy} new update${fresh.behindBy === 1 ? "" : "s"}; nothing conflicts.` };
8007
+ await gh.updatePullRequestBranch(n, pr.headSha);
8008
+ return { pr: n, status: "updated", said: `Updated from ${pr.baseRef}. Checks run again; approvals may need renewing.` };
8009
+ }
8010
+ if (fresh.state === "unknown") throw new Error(`GitHub has not said yet whether #${n} merges cleanly. Try again in a minute.`);
8011
+ const data = await readLibraryConflicts(gh, compare, pr.headSha, { tokensRoot: config.tokens.root });
8012
+ if (data.route.kind === "code" || !data.tokens) {
8013
+ throw new Error(`#${n} conflicts with ${pr.baseRef} in code${data.route.files.length ? ` (${data.route.files.slice(0, 5).join(", ")})` : ""}. A developer resolves that on GitHub: ${pr.url}`);
8014
+ }
8015
+ const conflicts = data.tokens.plan.conflicts.map((c) => ({ key: c.key, token: c.path, file: c.file, mine: c.mine ?? null, theirs: c.theirs ?? null, default: c.defaultChoice }));
8016
+ const choices = a.choices ?? {};
8017
+ const missing = missingChoices(data.tokens.plan, choices);
8018
+ if (a.dryRun || missing.length) {
8019
+ return {
8020
+ pr: n,
8021
+ status: "token-conflicts",
8022
+ conflicts,
8023
+ missing: missing.map((c) => c.key),
8024
+ said: missing.length ? `${missing.length} token${missing.length === 1 ? "" : "s"} changed here and on ${pr.baseRef} without a default: pick "mine" or "theirs" for each key in choices.` : `${conflicts.length} token conflict${conflicts.length === 1 ? "" : "s"}; pass choices ("mine" keeps this branch, "theirs" uses ${pr.baseRef}) or run without dryRun to keep the defaults.`
8025
+ };
8026
+ }
8027
+ const viewer = await gh.getViewer().then((v) => v.login, () => null);
8028
+ const sha = await commitLibraryResolution(gh, { head: { ref: pr.headRef, sha: pr.headSha }, base: { ref: pr.baseRef } }, data, choices, viewer);
8029
+ return { pr: n, status: "resolved", commit: sha, said: `Brought in the latest ${pr.baseRef} with your token choices. Checks run again.` };
8030
+ }
8031
+ async function addableBranch(ctx, n) {
8032
+ const gh = await writer(ctx, "Adding edits to a branch", RIGHTS.update);
8033
+ const [pr, viewer, repo] = await Promise.all([gh.getPullRequest(n), gh.getViewer(), gh.getMergeSettings().catch(() => null)]);
8034
+ if (pr.state !== "open") throw new Error(`#${n} is ${pr.state}: only an open branch takes more edits. Start a new one instead.`);
8035
+ if (isActiveExploration(pr.labels)) throw new Error(`#${n} is an exploration: pass branch "${pr.headRef}" instead of pr.`);
8036
+ if (!canAddTo({ user: { login: pr.author } }, viewer.login, hasWriteAccess(repo?.permissions))) throw new Error(`Only its author (@${pr.author}) or someone with write access adds edits to #${n}.`);
8037
+ return { gh, pr, viewer: viewer.login, comment: (paths) => addedComment(paths, viewer.login, "Claude through the Bridge MCP server") };
8038
+ }
8039
+
8040
+ // src/explorations.ts
6292
8041
  var USE_CASES = new Set(EXPLORATION_USE_CASES.map((u) => u.value));
6293
8042
  var settingsOf = (config) => ({ base: baseBranchOf(config), packages: config.previews?.packages ?? [], apps: config.previews?.apps ?? {} });
6294
8043
  async function requireGitHub(ctx) {
@@ -6323,8 +8072,11 @@ async function startExploration(ctx, a) {
6323
8072
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
6324
8073
  };
6325
8074
  const gh = await ctx.github().catch(() => null);
8075
+ const product = await resolveProduct(ctx, a.product);
8076
+ const marks = productMarks(productsOf(config), product);
6326
8077
  const preview = {
6327
8078
  branch,
8079
+ product: product.name,
6328
8080
  slug: slug2,
6329
8081
  base: s.base,
6330
8082
  title: explorationPrTitle(e),
@@ -6336,6 +8088,7 @@ async function startExploration(ctx, a) {
6336
8088
  const work = `Work on it: \`git fetch origin && git switch ${branch}\`. Token edits: bridge_propose_token_change with "branch": "${branch}". Requests: bridge_create_request with "exploration": "${branch}", then bridge_get_request with "branch": "${branch}". It is never merged as is: promote it (\`bridge explore promote ${slug2}\`) or close it.`;
6337
8089
  if (gh && ctx.canWriteRemote) {
6338
8090
  const r = await startExplorationOnGitHub(gh, e, s);
8091
+ if (marks.labels.length) await gh.addLabels(r.pr, marks.labels);
6339
8092
  return {
6340
8093
  status: "started",
6341
8094
  ...preview,
@@ -6797,6 +8550,10 @@ async function getGuidelines(ctx, a) {
6797
8550
  }
6798
8551
  async function proposeTokenChange(ctx, a) {
6799
8552
  if (!a.edits?.length) throw new Error("Pass at least one edit.");
8553
+ if (a.pr !== void 0 && a.branch) throw new Error("Pass pr (a branch in review) or branch (an exploration), not both.");
8554
+ if (a.pr !== void 0) return addToBranch(ctx, { ...a, pr: a.pr });
8555
+ const jira = parseJiraKey(a.jira);
8556
+ const product = await resolveProduct(ctx, a.product);
6800
8557
  const target = a.branch ? await explorationContext(ctx, a.branch, { requireCheckout: !a.dryRun }) : null;
6801
8558
  if (target) ctx = target.ctx;
6802
8559
  const { config } = await ctx.config();
@@ -6811,7 +8568,8 @@ async function proposeTokenChange(ctx, a) {
6811
8568
  const imp = core.impact(before, after, changes);
6812
8569
  const tier = core.classify(changes, [], config);
6813
8570
  const summary2 = a.summary?.trim() || a.title;
6814
- const body = core.prBody({ summary: summary2, changes, contrast: delta2, impact: imp, tier, validationErrors: newErrors.length, tool: "Bridge MCP" });
8571
+ const marks = productMarks(productsOf(config), product);
8572
+ const body = [core.prBody({ summary: summary2, changes, contrast: delta2, impact: imp, tier, validationErrors: newErrors.length, tool: "Bridge MCP" }), marks.line].filter(Boolean).join("\n\n");
6815
8573
  const slugTitle = core.slugify(a.title);
6816
8574
  const changesetPath = `${config.changesets?.dir ?? ".changeset"}/bridge-${slugTitle}.md`;
6817
8575
  const files = [
@@ -6831,7 +8589,7 @@ async function proposeTokenChange(ctx, a) {
6831
8589
  if (newErrors.length || newFailures.length) {
6832
8590
  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." };
6833
8591
  }
6834
- let branch = target ? target.branch : `${config.governance.branchPrefix}${slugTitle}`;
8592
+ let branch = target ? target.branch : branchWithJiraKey(`${config.governance.branchPrefix}${slugTitle}`, jira);
6835
8593
  const preview = { ...report, branch, files: files.map((f) => f.path), prBody: body };
6836
8594
  if (a.dryRun) return { status: "dry-run", ...preview, fileContents: files };
6837
8595
  if (target) {
@@ -6845,7 +8603,7 @@ Exploration ${target.branch}: not merged as is. Proposed through Bridge MCP (sou
6845
8603
  if (ctx.mode === "local") {
6846
8604
  if (!ctx.isGit) return { status: "preview", message: "Not a git repo: nothing written. Apply fileContents yourself.", ...preview, fileContents: files };
6847
8605
  const protectedBranches = [.../* @__PURE__ */ new Set([...protectedBranchesOf(config), baseBranchOf(config)])];
6848
- for (let i = 2; await branchExists(ctx.root, branch); i++) branch = `${config.governance.branchPrefix}${slugTitle}-${i}`;
8606
+ for (let i = 2; await branchExists(ctx.root, branch); i++) branch = branchWithJiraKey(`${config.governance.branchPrefix}${slugTitle}-${i}`, jira);
6849
8607
  const base2 = await currentBranch(ctx.root);
6850
8608
  const message = `${tier.bump === "major" ? "feat(tokens)!" : "feat(tokens)"}: ${a.title}
6851
8609
 
@@ -6870,12 +8628,46 @@ Tier ${tier.tier}. Proposed through Bridge MCP (source: mcp).`;
6870
8628
 
6871
8629
  Proposed through Bridge MCP (source: mcp). Tier ${tier.tier}.`;
6872
8630
  const base = ctx.ref ?? baseBranchOf(config);
6873
- for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${config.governance.branchPrefix}${slugTitle}-${i}`;
8631
+ for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = branchWithJiraKey(`${config.governance.branchPrefix}${slugTitle}-${i}`, jira);
6874
8632
  const commit = await gh.commitToNewBranch({ base, branch, message: commitMsg, files });
6875
- const labels = [...config.governance.labels, `tier: ${tier.tier}`, ...tier.tier === 3 ? ["tokens"] : [], ...tier.bump === "major" ? ["breaking"] : []];
6876
- const pr = await gh.createPullRequest({ base, head: branch, title: `feat(tokens): ${a.title}`, body, draft: tier.tier >= 2 });
8633
+ const labels = [...config.governance.labels, `tier: ${tier.tier}`, ...tier.tier === 3 ? ["tokens"] : [], ...tier.bump === "major" ? ["breaking"] : [], ...marks.labels];
8634
+ const pr = await gh.createPullRequest({ base, head: branch, title: titleWithJiraKey(`feat(tokens): ${a.title}`, jira), body, draft: tier.tier >= 2 });
6877
8635
  await gh.addLabels(pr.number, labels);
6878
- return { status: "pr-opened", ...preview, branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft: tier.tier >= 2 };
8636
+ return { status: "pr-opened", ...preview, branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft: tier.tier >= 2, product: product.name };
8637
+ }
8638
+ async function addToBranch(ctx, a) {
8639
+ const n = branchNumber(a.pr);
8640
+ const target = await addableBranch(ctx, n);
8641
+ const at = await BridgeContext.create({ ...ctx.opts, repo: target.gh.fullName, ref: target.pr.headRef });
8642
+ const { config } = await at.config();
8643
+ const { before, after, touched, notes } = await withEdits(at, a.edits);
8644
+ const changes = core.diff(before, after);
8645
+ if (!changes.length) return { status: "no-change", pr: n, message: `#${n} already has these values.` };
8646
+ const prevErrors = new Set(core.validate(before).map(issueKey2));
8647
+ const newErrors = core.validate(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey2(i)));
8648
+ const pairs = await core.readContrastPairs(at.source, config).catch(() => []);
8649
+ const delta2 = pairs.length ? core.contrastDelta(core.runContrast(before, pairs, config.contrast?.modes), core.runContrast(after, pairs, config.contrast?.modes)) : [];
8650
+ const newFailures = delta2.filter((d) => d.passBefore && !d.passAfter);
8651
+ const tier = core.classify(changes, [], config);
8652
+ const summary2 = a.summary?.trim() || a.title;
8653
+ const report = { pr: n, branch: target.pr.headRef, tier: tier.tier, changes: changes.map((c) => ({ kind: c.kind, path: c.path, file: prefixPath(after, c.file), before: c.beforeCss, after: c.afterCss })), contrast: { moved: delta2.length, newFailures }, notes };
8654
+ if (newErrors.length || newFailures.length) {
8655
+ 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. Bridge never adds edits that break the gate." };
8656
+ }
8657
+ const paths = changes.map((c) => c.path);
8658
+ const files = [
8659
+ ...touched.map((f) => ({ path: prefixPath(after, f), content: after.files.get(f) })),
8660
+ ...config.changesets ? [{ path: `${config.changesets.dir ?? ".changeset"}/bridge-${core.slugify(a.title)}-${n}-${Date.now().toString(36)}.md`, content: core.changeset(config.changesets.package, tier.bump, summary2) }] : []
8661
+ ];
8662
+ if (a.dryRun) return { status: "dry-run", ...report, files: files.map((f) => f.path) };
8663
+ const message = `tokens: ${a.title}
8664
+
8665
+ ${paths.map((p) => `- ${p}`).join("\n")}
8666
+
8667
+ Added with Claude through the Bridge MCP server.`;
8668
+ const res = await target.gh.commitToBranch({ branch: target.pr.headRef, message, files });
8669
+ await target.gh.createIssueComment(n, target.comment(paths)).catch(() => void 0);
8670
+ return { status: "added", ...report, commit: res.sha, url: target.pr.url, said: `Added ${paths.length} edit${paths.length === 1 ? "" : "s"} to #${n}. Reviewers see ${paths.length === 1 ? "it" : "them"} now; GitHub may ask for approvals again.` };
6879
8671
  }
6880
8672
  async function listReviews(ctx, a) {
6881
8673
  const { config } = await ctx.config();
@@ -6971,7 +8763,7 @@ async function getReview(ctx, a) {
6971
8763
  impact: imp
6972
8764
  };
6973
8765
  }
6974
- var sameLogin = (a, b) => a.trim().toLowerCase().replace(/^@/, "") === b.trim().toLowerCase().replace(/^@/, "");
8766
+ var sameLogin2 = (a, b) => a.trim().toLowerCase().replace(/^@/, "") === b.trim().toLowerCase().replace(/^@/, "");
6975
8767
  async function reviewRoles(gh, config, approvers) {
6976
8768
  const { owners, rights } = config.governance;
6977
8769
  const teams = await new TeamDirectory(gh).resolve(owners);
@@ -6979,7 +8771,7 @@ async function reviewRoles(gh, config, approvers) {
6979
8771
  const lists = expandOwners(entries, teams);
6980
8772
  const role = (k) => ({
6981
8773
  owners: entries[k].map((e) => entryLabel(e, teams)),
6982
- approvedBy: approvers.filter((a) => lists[k].some((x) => sameLogin(x, a)))
8774
+ approvedBy: approvers.filter((a) => lists[k].some((x) => sameLogin2(x, a)))
6983
8775
  });
6984
8776
  const ship = shipPolicy(owners, rights, teams);
6985
8777
  const unreadable = unreadableTeams(teams);
@@ -6996,10 +8788,11 @@ async function reviewPr(ctx, a) {
6996
8788
  if (!ctx.canWriteRemote) throw new Error("Reviewing needs GITHUB_TOKEN. Without it Bridge is read-only.");
6997
8789
  if (!a.body.trim()) throw new Error("A review needs a body that says what to fix or why it is fine.");
6998
8790
  const event = { comment: "COMMENT", approve: "APPROVE", request_changes: "REQUEST_CHANGES" }[a.event];
6999
- const r = await gh.createReview(a.pr, { event, body: `${a.body.trim()}
8791
+ const head = (await gh.getPullRequest(a.pr)).headSha;
8792
+ const r = await reviewOnGitHub(gh, { number: a.pr, event, body: `${a.body.trim()}
7000
8793
 
7001
- _Review drafted with Bridge MCP by an AI agent on behalf of the token owner._` });
7002
- return { pr: a.pr, event, id: r.id, state: r.state, note: event === "APPROVE" ? "Approval recorded under your GitHub account. A human still merges." : void 0 };
8794
+ _Review drafted with Bridge MCP by an AI agent on behalf of the token owner._`, headSha: head });
8795
+ return { pr: a.pr, event, id: r.id, state: r.state, note: event === "APPROVE" ? "Approval recorded under your GitHub account. Merging is a separate step (bridge_merge_branch), only when the person asks." : void 0 };
7003
8796
  }
7004
8797
  async function scaffoldComponentOp(ctx, a) {
7005
8798
  const { config } = await ctx.config();
@@ -7026,7 +8819,7 @@ async function visualImpact(ctx, a, run2) {
7026
8819
  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.");
7027
8820
  if (!ctx.isGit) throw new Error(`${ctx.root} is not a git repository.`);
7028
8821
  const out = a.out ? resolve3(ctx.root, a.out) : join4(ctx.root, "visual-impact");
7029
- const runImpact = run2 ?? (await import("./src-V653Z7JC.js")).runImpact;
8822
+ const runImpact = run2 ?? (await import("./src-2YRRAUUM.js")).runImpact;
7030
8823
  const r = await runImpact({
7031
8824
  root: ctx.root,
7032
8825
  base: a.base ?? "main",
@@ -7420,8 +9213,8 @@ async function proposeBrand(ctx, a) {
7420
9213
  }
7421
9214
  const files = plan.files.map((f) => ({ path: f.path, content: f.content }));
7422
9215
  const prefix = config.governance.branchPrefix;
7423
- const stem = `${prefix}brand-${plan.slug}`;
7424
- let branch = stem;
9216
+ const stem2 = `${prefix}brand-${plan.slug}`;
9217
+ let branch = stem2;
7425
9218
  if (a.dryRun) return { status: "dry-run", ...report, branch, fileContents: files };
7426
9219
  const message = `feat(tokens): add the ${plan.name} brand
7427
9220
 
@@ -7430,7 +9223,7 @@ New brand "${plan.slug}", started from ${plan.from}. ${report.contrast.line} (${
7430
9223
  Tier 3. Proposed through Bridge MCP (source: mcp).`;
7431
9224
  if (ctx.mode === "local") {
7432
9225
  if (!ctx.isGit) return { status: "preview", message: "Not a git repo: nothing written. Apply fileContents yourself.", ...report, branch, fileContents: files };
7433
- for (let i = 2; await branchExists(ctx.root, branch); i++) branch = `${stem}-${i}`;
9226
+ for (let i = 2; await branchExists(ctx.root, branch); i++) branch = `${stem2}-${i}`;
7434
9227
  const base2 = await currentBranch(ctx.root);
7435
9228
  const protectedBranches = [.../* @__PURE__ */ new Set([...protectedBranchesOf(config), baseBranchOf(config)])];
7436
9229
  const res = await commitToNewLocalBranch(ctx.root, { base: "HEAD", branch, message, files, protectedBranches, branchPrefix: prefix });
@@ -7446,7 +9239,7 @@ Tier 3. Proposed through Bridge MCP (source: mcp).`;
7446
9239
  if (!ctx.canWriteRemote) return { status: "preview", message: "Read-only: set GITHUB_TOKEN to open this as a pull request.", ...report, branch, fileContents: files };
7447
9240
  const gh = await ctx.github();
7448
9241
  const base = ctx.ref ?? baseBranchOf(config);
7449
- for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${stem}-${i}`;
9242
+ for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${stem2}-${i}`;
7450
9243
  const commit = await gh.commitToNewBranch({ base, branch, message, files });
7451
9244
  const pr = await gh.createPullRequest({ base, head: branch, title: `feat(tokens): ${plan.title.charAt(0).toLowerCase()}${plan.title.slice(1)}`, body: report.prBody, draft: true });
7452
9245
  const labels = [.../* @__PURE__ */ new Set([...config.governance.labels, "tier: 3", "tokens"])];
@@ -7577,7 +9370,12 @@ async function createRequest(ctx, a) {
7577
9370
  ...a.exploration ? { exploration: toExplorationBranch(a.exploration) } : {}
7578
9371
  };
7579
9372
  const settings = config.integrations?.requests;
7580
- const issue = composeRequestIssue(req, { labels: settings?.labels ?? [], assignees: settings?.assignees ?? [] });
9373
+ const product = await resolveProduct(ctx, a.product);
9374
+ const marks = productMarks(productsOf(config), product);
9375
+ const composed = composeRequestIssue(req, { labels: settings?.labels ?? [], assignees: settings?.assignees ?? [] });
9376
+ const issue = marks.labels.length ? { ...composed, body: `${composed.body}
9377
+
9378
+ ${marks.line}`, labels: [...composed.labels, ...marks.labels] } : composed;
7581
9379
  if (a.dryRun) return { status: "dry-run", issue };
7582
9380
  const gh = await requireGitHub2(ctx);
7583
9381
  if (!ctx.canWriteRemote) throw new Error("Creating a request needs GITHUB_TOKEN (issues: write on this repository). Without it Bridge is read-only.");
@@ -7655,8 +9453,131 @@ async function writeDrafts(ctx, a) {
7655
9453
  };
7656
9454
  }
7657
9455
 
9456
+ // src/studio.ts
9457
+ var studio_exports = {};
9458
+ __export(studio_exports, {
9459
+ figmaCoverage: () => figmaCoverage,
9460
+ getSettings: () => getSettings,
9461
+ saveDocs: () => saveDocs
9462
+ });
9463
+ var DOC_KEYS = ["summary", "preview", "whenToUse", "whenNotToUse", "dos", "donts", "accessibility", "contentGuidelines", "anatomy"];
9464
+ async function saveDocs(ctx, a) {
9465
+ const { config } = await ctx.config();
9466
+ if (!config.components) throw new Error("No components.dir in bridge.config.json: Bridge does not know where component docs live.");
9467
+ const name = a.component.trim();
9468
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) throw new Error(`"${a.component}" is not a component name. Use the exported name, like Button (bridge_list_components lists them).`);
9469
+ const unknown = Object.keys(a.docs).filter((k) => !DOC_KEYS.includes(k));
9470
+ if (unknown.length) throw new Error(`Unknown docs sections: ${unknown.join(", ")}. Use ${DOC_KEYS.join(", ")}.`);
9471
+ const path = `${config.components.dir}/${name}/${name}${config.components.docsSuffix}`;
9472
+ const jira = parseJiraKey(a.jira);
9473
+ const product = await resolveProduct(ctx, a.product);
9474
+ const next = (current) => {
9475
+ let base = {};
9476
+ if (current) {
9477
+ try {
9478
+ base = JSON.parse(current);
9479
+ } catch {
9480
+ throw new Error(`${path} is not valid JSON on that branch. Fix it there first.`);
9481
+ }
9482
+ }
9483
+ const merged = { summary: "", whenToUse: [], whenNotToUse: [], dos: [], donts: [], accessibility: [], ...base, ...a.docs };
9484
+ const issues = validateDocs(merged);
9485
+ if (issues.length) throw new Error(`The docs are not ready: ${issues.map((i) => `${i.field}: ${i.message}`).join(" ")}`);
9486
+ return docsFileText(cleanDocs(merged), current);
9487
+ };
9488
+ const gh = await ctx.github();
9489
+ if (a.pr !== void 0) {
9490
+ const n = branchNumber(a.pr);
9491
+ if (!gh) throw new Error('No GitHub repo known: set "repo" in bridge.config.json or add an origin remote.');
9492
+ const pr = await gh.getPullRequest(n);
9493
+ if (pr.state !== "open") throw new Error(`#${n} is ${pr.state}: save the docs on a new branch instead (leave pr out).`);
9494
+ const current = (await gh.getFile(path, pr.headRef))?.text ?? null;
9495
+ if (current === null) throw new Error(`${path} is not on #${n}. Save the docs on a new branch instead (leave pr out), or check the component name.`);
9496
+ const text2 = next(current);
9497
+ if (a.dryRun) return { status: "dry-run", pr: n, path, content: text2 };
9498
+ if (!ctx.canWriteRemote) throw new Error(`Saving docs on #${n} needs a GitHub token with ${RIGHTS.docs}. Set GITHUB_TOKEN where the Bridge MCP server runs, or save it in the Library edit mode.`);
9499
+ const sha = await commitFileOnGitHub(gh.withBranchPrefix(void 0), { path, branch: pr.headRef, message: `docs(${name.toLowerCase()}): edit usage guidance with Claude (Bridge MCP)`, next });
9500
+ return { status: sha ? "committed" : "unchanged", pr: n, path, commit: sha || null, said: sha ? `Saved the ${name} docs on #${n}. Reviewers see them now.` : "The docs on that branch already say this." };
9501
+ }
9502
+ const original = await ctx.source.read(path);
9503
+ const text = next(original);
9504
+ if (original === text) return { status: "unchanged", path, said: `The ${name} docs on main already say this.` };
9505
+ const edit = { path, component: name, slug: name.toLowerCase(), before: original, after: text };
9506
+ const marks = productMarks(productsOf(config), product);
9507
+ const notes = [a.notes?.trim(), marks.line].filter(Boolean).join("\n\n");
9508
+ if (a.dryRun || !gh || !ctx.canWriteRemote) {
9509
+ return {
9510
+ status: a.dryRun ? "dry-run" : "preview",
9511
+ path,
9512
+ content: text,
9513
+ product: product.name,
9514
+ ...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.` }
9515
+ };
9516
+ }
9517
+ const r = await proposeDocsOnGitHub(gh.withBranchPrefix("docs/"), [edit], {
9518
+ base: ctx.ref ?? baseBranchOf(config),
9519
+ title: a.title,
9520
+ notes,
9521
+ jira,
9522
+ origin: DOCS_MCP_ORIGIN,
9523
+ extraLabels: marks.labels,
9524
+ commitNote: "Proposed with Claude through the Bridge MCP server."
9525
+ });
9526
+ return { status: "sent-for-review", pr: r.number, url: r.url, title: r.title, path, product: product.name, said: `Sent the ${name} docs for review as #${r.number}. Docs only: one approval ships them.` };
9527
+ }
9528
+ async function getSettings(ctx) {
9529
+ const { config } = await ctx.config();
9530
+ const { owners, rights } = config.governance;
9531
+ const override = config.governance.override ?? DEFAULT_OVERRIDE;
9532
+ const gh = await ctx.github();
9533
+ const teams = gh ? await new TeamDirectory(gh).resolve(owners).catch(() => void 0) : void 0;
9534
+ const entries = approverEntries(owners, rights);
9535
+ const ship = shipPolicy(owners, rights, teams);
9536
+ const protection = gh ? await loadProtection(gh, baseBranchOf(config)).catch(() => null) : null;
9537
+ const products = productsOf(config);
9538
+ const unreadable = teams ? unreadableTeams(teams) : [];
9539
+ return {
9540
+ repo: config.repo ? `${config.repo.owner}/${config.repo.name}` : gh?.fullName ?? null,
9541
+ main: baseBranchOf(config),
9542
+ roles: {
9543
+ designApproval: entries.design.map((e) => entryLabel(e, teams)),
9544
+ techApproval: entries.tech.map((e) => entryLabel(e, teams)),
9545
+ ship: ship ? whoCanShipSentence(ship) : "Anyone with write access can merge.",
9546
+ ...unreadable.length ? { teamsNotRead: unreadable.map((t) => t.message) } : {}
9547
+ },
9548
+ ownerOverride: override.allowed.length ? { on: true, who: override.allowed.map((l) => `@${l}`), reason: override.requireReason ? `A reason of at least ${override.minReasonLength} characters, posted on the branch.` : "No reason required." } : { on: false, who: [], reason: "Nobody can merge without the approvals." },
9549
+ protection: protection ? protectionSentence(protection) : "Not read (no GitHub repo known, or GitHub did not answer).",
9550
+ products: products.map((p) => ({ key: p.key, name: p.name, default: p === defaultProduct(products) })),
9551
+ previews: Object.entries(config.previews?.apps ?? {}).filter(([, t]) => Boolean(t)).map(([app]) => app),
9552
+ visualImpact: config.visualImpact?.mode ?? "auto",
9553
+ note: "Settings change in bridge.config.json through a reviewed branch, or in Studio Settings. Tokens and secrets are never shown."
9554
+ };
9555
+ }
9556
+ async function figmaCoverage(ctx, a = {}) {
9557
+ const text = await ctx.source.read(DEFAULT_COMPONENT_SPECS_FILE);
9558
+ if (!text) return { components: [], summary: `No ${DEFAULT_COMPONENT_SPECS_FILE} in this repo: run \`npm run specs\` (or the DS Bridge Figma plugin) to record what is in Figma.` };
9559
+ const file = parseComponentSpecs(text);
9560
+ const s = coverageSummary(file);
9561
+ const gh = await ctx.github();
9562
+ const manifest = gh ? await gh.readFile(FIGMA_REFERENCES_MANIFEST, FIGMA_REFERENCES_BRANCH).catch(() => void 0) : void 0;
9563
+ let references = null;
9564
+ try {
9565
+ const m = manifest ? JSON.parse(manifest) : null;
9566
+ if (m?.components) references = { components: Object.keys(m.components).length, variants: Object.values(m.components).reduce((n, c) => n + Object.keys(c.variants ?? {}).length, 0), sha: m.sha };
9567
+ } catch {
9568
+ references = null;
9569
+ }
9570
+ const list = (file.coverage ?? []).filter((c) => !a.component || c.name.toLowerCase() === a.component.toLowerCase());
9571
+ if (a.component && !list.length) throw new Error(`${a.component} is not in the coverage list. bridge_list_components lists the components.`);
9572
+ return {
9573
+ summary: `${s.inFigma} of ${s.visual} components are in Figma${s.drafted ? ` (${s.drafted} drafted with Claude)` : ""}; ${s.layout} layout helpers use auto layout.`,
9574
+ components: list.map((c) => ({ name: c.name, inFigma: figmaCell(c)?.label ?? "Unknown", ...c.reason ? { why: c.reason } : {} })),
9575
+ fidelity: references === null ? "No code images for the fidelity check yet (they live on the bridge-visual branch). The DS Bridge Figma plugin compares Figma with them." : `Code images of ${references.variants} variants (${references.components} components, commit ${references.sha.slice(0, 7)}) are ready for the fidelity check. The DS Bridge Figma plugin compares each Figma variant with them; the scores live in Figma.`
9576
+ };
9577
+ }
9578
+
7658
9579
  // src/server.ts
7659
- var VERSION = true ? "0.3.3" : "dev";
9580
+ var VERSION = true ? "0.4.0" : "dev";
7660
9581
  var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
7661
9582
  var fail = (e) => ({ content: [{ type: "text", text: `Error: ${e.message ?? String(e)}` }], isError: true });
7662
9583
  var run = (fn) => async (a) => {
@@ -7694,6 +9615,8 @@ function createServer(ctx) {
7694
9615
  "To undo a merged token change use bridge_plan_revert first (it shows conflicts with later changes), then bridge_propose_revert; never revert by hand-editing files.",
7695
9616
  'Component changes (new component, variant, prop, structure, visual, token binding) are requests: GitHub issues labeled component-change. bridge_list_requests finds them, bridge_get_request returns a brief to implement one in a pull request that says "Closes #n", bridge_create_request files a new one.',
7696
9617
  `Change texts from Figma and docs from Bridge Studio: when the user says "${DRAFT_PHRASE}" (or asks you to draft their Bridge changes or docs), call bridge_pending_drafts. For change items, look at the before and after images and write each title, summary and change line in the Bridge voice. For docs items (scope "docs"), follow the brief: write only the sections it asks for, grounded in the props, CSS module classes and source it quotes, never an invented prop or behavior, never anatomy. Then call bridge_write_drafts. The designer reviews them in the DS Bridge Figma plugin or on the Library page.`,
9618
+ "Studio parity: a designer can do in Claude what they do in Bridge Studio. Branches in review: bridge_what_needs_me (the queue), bridge_branch_status, bridge_send_for_review, bridge_back_to_draft, bridge_review_branch, bridge_ask_someone_else, bridge_comment, bridge_update_from_main (token conflicts too), bridge_try_branch (try it in a product, share it), bridge_combine_branches, bridge_close_branch, bridge_reopen_branch, bridge_merge_branch (same gate as Studio, owner override with a reason). Docs: bridge_save_docs. Writes act under the person's GitHub token: only do them when the person asked, and merge only when they explicitly asked to merge.",
9619
+ 'Products: work starts for a product (bridge_list_products). Tools that start work take "product"; without it Bridge uses the product of the current folder or repo. Ask the person when it is not obvious.',
7697
9620
  'Explorations try experimental variables and components in a product staging branch without touching main: an explore/<slug> branch with a draft pull request labeled exploration, previews and a snapshot package. bridge_start_exploration starts one, bridge_list_explorations lists them. To work on one, pass "branch": "explore/<slug>" to bridge_propose_token_change, bridge_draft_docs and bridge_get_request, and never open a pull request to main from it: a person promotes it to review or closes it.'
7698
9621
  ].join(" ")
7699
9622
  }
@@ -7828,7 +9751,10 @@ function createServer(ctx) {
7828
9751
  summary: z.string().optional().describe("Why: the problem and the intent, for the reviewers. Plain sentences, no em dash."),
7829
9752
  edits: z.array(EditArg).min(1),
7830
9753
  dryRun: z.boolean().optional().describe("Return the diff, tier, files and PR body without writing."),
7831
- 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.")
9754
+ 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."),
9755
+ 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.'),
9756
+ 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."),
9757
+ 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.")
7832
9758
  },
7833
9759
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
7834
9760
  },
@@ -7896,7 +9822,7 @@ function createServer(ctx) {
7896
9822
  "bridge_review_pr",
7897
9823
  {
7898
9824
  title: "Review pull request",
7899
- description: "Post a review on a PR as the token owner: comment, approve or request changes. Needs GITHUB_TOKEN. Only do this when the user asked for it; never merge (Bridge cannot).",
9825
+ description: "Post a review on a PR as the token owner: comment, approve or request changes, with a body. Kept for older prompts: bridge_review_branch does the same in designer words (an approval may have no text). Needs GITHUB_TOKEN. Only do this when the user asked for it; merging is a separate step (bridge_merge_branch), only when the person asks.",
7900
9826
  inputSchema: { pr: z.number().int().positive(), event: z.enum(["comment", "approve", "request_changes"]), body: z.string().min(1).describe("What to fix or why it is fine. Plain sentences.") },
7901
9827
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
7902
9828
  },
@@ -8068,6 +9994,7 @@ function createServer(ctx) {
8068
9994
  changes: z.array(z.object({ field: z.string().min(1).describe('A prop, variant axis, token or part: "size", "radius", "color.action.primary.bg".'), before: z.string().optional(), after: z.string().optional() })).optional().describe("What changes, from what to what."),
8069
9995
  figmaUrl: z.string().url().optional().describe("Link to the design in Figma, when there is one."),
8070
9996
  exploration: z.string().optional().describe("Exploration branch (explore/<slug>) to implement it on instead of a pull request to main. Adds the exploration label."),
9997
+ product: z.string().optional().describe("Product key or name (bridge_list_products) it is for. Default: the product of the current folder or repo."),
8071
9998
  dryRun: z.boolean().optional().describe("Return the issue title, body and labels without creating it.")
8072
9999
  },
8073
10000
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
@@ -8147,6 +10074,7 @@ function createServer(ctx) {
8147
10074
  purpose: z.string().min(1).describe("What the team wants to learn, one or two plain sentences. No em dash."),
8148
10075
  useCase: z.enum(EXPLORATION_USE_CASES.map((u) => u.value)).optional().describe(EXPLORATION_USE_CASES.map((u) => `${u.value}: ${u.hint}`).join(" ")),
8149
10076
  experimental: z.array(z.string()).optional().describe('What is experimental, one line each: "color.action.primary.bg", "Button: pill radius".'),
10077
+ product: z.string().optional().describe("Product key or name (bridge_list_products) it is tried in. Default: the product of the current folder or repo."),
8150
10078
  dryRun: z.boolean().optional().describe("Return the branch, note and pull request body without writing.")
8151
10079
  },
8152
10080
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
@@ -8163,6 +10091,233 @@ function createServer(ctx) {
8163
10091
  },
8164
10092
  run((a) => listExplorations(ctx, a))
8165
10093
  );
10094
+ server.registerTool(
10095
+ "bridge_promote_exploration",
10096
+ {
10097
+ title: "Promote an exploration to review",
10098
+ description: "Promote an exploration (explore/<name>) to review, as in Studio: the draft becomes ready for review with its tier labels, the exploration label and note go, and the component requests it implements are linked so they close when it merges. Never merges. Needs GITHUB_TOKEN (contents and pull requests: write). Only when the person asked; dryRun shows what changes.",
10099
+ inputSchema: { exploration: z.string().min(1).describe("Its name, explore/<name> branch or number (#42)."), dryRun: z.boolean().optional() },
10100
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
10101
+ },
10102
+ run((a) => promoteExploration(ctx, { ref: a.exploration, dryRun: a.dryRun }))
10103
+ );
10104
+ server.registerTool(
10105
+ "bridge_close_exploration",
10106
+ {
10107
+ title: "Close an exploration",
10108
+ description: "Close an exploration without merging it, as in Studio. Its branch and previews stay on GitHub. Needs GITHUB_TOKEN (pull requests: write). Only when the person asked.",
10109
+ inputSchema: { exploration: z.string().min(1).describe("Its name, explore/<name> branch or number (#42).") },
10110
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
10111
+ },
10112
+ run((a) => closeExploration(ctx, { ref: a.exploration }))
10113
+ );
10114
+ const Pr = z.union([z.number().int().positive(), z.string().min(1)]).describe('The branch number Studio shows (12 or "#12"), or its GitHub link.');
10115
+ const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
10116
+ const READ = { readOnlyHint: true, openWorldHint: true };
10117
+ server.registerTool(
10118
+ "bridge_list_products",
10119
+ {
10120
+ title: "List products",
10121
+ description: 'The products of this design system (bridge.config.json "products": name, repo or folder, preview link pattern, framework) and the one the current folder or repo belongs to, which tools that start work use when no product is given. Ask the person which product when it is not obvious.',
10122
+ inputSchema: {},
10123
+ annotations: RO
10124
+ },
10125
+ run(async () => listProducts(ctx))
10126
+ );
10127
+ server.registerTool(
10128
+ "bridge_what_needs_me",
10129
+ {
10130
+ title: "What needs me",
10131
+ description: 'The review queue, as Studio counts it (open branches, without running explorations and the release automation), each with its status in designer words, and first the ones where the person acts now: an approval they can give, their own branch blocked, ready for them to merge. Needs GITHUB_TOKEN to know who "me" is; without it, the queue only.',
10132
+ inputSchema: { limit: z.number().int().min(1).max(50).optional().describe("Branches read (default 20, each costs a few GitHub reads).") },
10133
+ annotations: READ
10134
+ },
10135
+ run((a) => whatNeedsMe(ctx, a))
10136
+ );
10137
+ server.registerTool(
10138
+ "bridge_branch_status",
10139
+ {
10140
+ title: "Branch status",
10141
+ description: "One branch in review as Studio shows it: its product, status (draft, in review, needs design or tech approval, blocked by tests, conflict, ready to merge, merged, closed), approvals, tests, how it stands against main, whether the person may merge it (same gate as the Merge button), and the next step. For the token diff and contrast, use bridge_get_review.",
10142
+ inputSchema: { pr: Pr },
10143
+ annotations: READ
10144
+ },
10145
+ run((a) => branchStatus(ctx, a))
10146
+ );
10147
+ server.registerTool(
10148
+ "bridge_list_comments",
10149
+ {
10150
+ title: "List comments",
10151
+ description: "The conversation on a branch and its line comment threads, with the ids to reply to (bridge_comment with replyTo).",
10152
+ inputSchema: { pr: Pr },
10153
+ annotations: READ
10154
+ },
10155
+ run((a) => listComments(ctx, a))
10156
+ );
10157
+ server.registerTool(
10158
+ "bridge_try_branch",
10159
+ {
10160
+ title: "Try it in a product, share it",
10161
+ description: 'Try a branch before it merges: the preview link of each product Vercel reported for it (the admin demo opens with the Bridge toolbar on, as "Try it in the product" in Studio), the other previews, and "Share with a product team": the install command a product team runs in a staging branch, or why there is none yet, with a ready-to-paste message. Never a guessed link. Writes nothing.',
10162
+ inputSchema: { pr: Pr },
10163
+ annotations: READ
10164
+ },
10165
+ run((a) => tryBranch(ctx, a))
10166
+ );
10167
+ server.registerTool(
10168
+ "bridge_combine_branches",
10169
+ {
10170
+ title: "Try 2 to 4 branches together",
10171
+ description: `Combined preview of 2 to 4 token branches, as "Try together" in Studio: each branch's own token edits on the latest main, the tokens two branches set differently (pick one per key with picks), and the links that open the combination (the admin demo with the Bridge toolbar, the library views). Branches that change code are refused. Writes nothing.`,
10172
+ inputSchema: {
10173
+ prs: z.array(Pr).min(2).max(4).describe("2 to 4 branch numbers."),
10174
+ picks: z.record(z.string(), z.number().int().positive()).optional().describe('For each conflict key, the branch whose value to use: {"semantic/light.tokens.json#color.text.muted": 118}.')
10175
+ },
10176
+ annotations: READ
10177
+ },
10178
+ run((a) => combineBranches(ctx, a))
10179
+ );
10180
+ server.registerTool(
10181
+ "bridge_get_settings",
10182
+ {
10183
+ title: "Get settings",
10184
+ description: "What Studio Settings shows that is safe to read: who approves design and tech (GitHub teams named with their size), who may merge, the owner override (who, what reason), whether main is protected, the products, the preview apps and the visual impact mode. Never tokens or secrets.",
10185
+ inputSchema: {},
10186
+ annotations: READ
10187
+ },
10188
+ run(async () => getSettings(ctx))
10189
+ );
10190
+ server.registerTool(
10191
+ "bridge_figma_coverage",
10192
+ {
10193
+ title: "Figma coverage",
10194
+ description: "Which components are in Figma, in Studio's words (Yes, Drafted, Auto layout, Not yet with the reason), and whether code images exist for the fidelity check the DS Bridge Figma plugin runs (its scores live in Figma).",
10195
+ inputSchema: { component: z.string().optional().describe("One component, e.g. Button.") },
10196
+ annotations: READ
10197
+ },
10198
+ run((a) => figmaCoverage(ctx, a))
10199
+ );
10200
+ server.registerTool(
10201
+ "bridge_send_for_review",
10202
+ {
10203
+ title: "Send for review",
10204
+ description: 'Ask for review: a draft branch becomes ready for review, as "Mark as ready" in Studio. Its author only. Needs GITHUB_TOKEN (pull requests: write).',
10205
+ inputSchema: { pr: Pr },
10206
+ annotations: { ...WRITE, idempotentHint: true }
10207
+ },
10208
+ run((a) => sendForReview(ctx, a))
10209
+ );
10210
+ server.registerTool(
10211
+ "bridge_back_to_draft",
10212
+ {
10213
+ title: "Back to draft",
10214
+ description: "Take a branch out of review back to draft, as in Studio's More menu. Its author only. Needs GITHUB_TOKEN (pull requests: write).",
10215
+ inputSchema: { pr: Pr },
10216
+ annotations: { ...WRITE, idempotentHint: true }
10217
+ },
10218
+ run((a) => backToDraft(ctx, a))
10219
+ );
10220
+ server.registerTool(
10221
+ "bridge_review_branch",
10222
+ {
10223
+ title: "Review a branch",
10224
+ description: "Approve, request changes or comment on a branch, as the review box in Studio, on the version the person saw, under their GitHub account. Request changes and comment need a text that says what to change; an approval may have none. Only when the person asked. Needs GITHUB_TOKEN (pull requests: write).",
10225
+ inputSchema: {
10226
+ pr: Pr,
10227
+ decision: z.enum(["approve", "request_changes", "comment"]),
10228
+ body: z.string().optional().describe("What to change, or why it is fine. Plain sentences, no em dash.")
10229
+ },
10230
+ annotations: WRITE
10231
+ },
10232
+ run((a) => reviewBranch(ctx, a))
10233
+ );
10234
+ server.registerTool(
10235
+ "bridge_ask_someone_else",
10236
+ {
10237
+ title: "Ask someone else to review",
10238
+ description: 'Ask the other people who can give the missing approvals to review a branch, as "Ask someone else to review" in the Studio review queue, and take the person off its requested reviewers. Never the person or the author. Only when the person asked; dryRun says who would be asked. Needs GITHUB_TOKEN (pull requests: write).',
10239
+ inputSchema: { pr: Pr, dryRun: z.boolean().optional() },
10240
+ annotations: { ...WRITE, idempotentHint: true }
10241
+ },
10242
+ run((a) => askSomeoneElse(ctx, a))
10243
+ );
10244
+ server.registerTool(
10245
+ "bridge_comment",
10246
+ {
10247
+ title: "Comment on a branch",
10248
+ description: "Post a comment on a branch, or reply in the thread of a line comment (replyTo: its id from bridge_list_comments). Needs GITHUB_TOKEN (pull requests: write).",
10249
+ inputSchema: { pr: Pr, body: z.string().min(1).describe("Plain sentences, no em dash."), replyTo: z.number().int().positive().optional().describe("Line comment thread to reply in.") },
10250
+ annotations: WRITE
10251
+ },
10252
+ run((a) => commentOnBranch(ctx, a))
10253
+ );
10254
+ server.registerTool(
10255
+ "bridge_merge_branch",
10256
+ {
10257
+ title: "Merge a branch",
10258
+ description: "Merge a branch into main through the same gate as Studio's Merge button: tests pass, the approvals its tier needs, no changes requested, no conflict, not a draft or an exploration, and the person holds the right to ship (governance.rights). When it is blocked, the error says by what. Owner override: pass overrideReason when only approvals are missing and the person is in governance.override.allowed; the reason is posted on the branch first. Only when the person explicitly asked to merge; dryRun checks without merging. Needs GITHUB_TOKEN (contents and pull requests: write); GitHub's own branch rules still apply.",
10259
+ inputSchema: {
10260
+ pr: Pr,
10261
+ overrideReason: z.string().optional().describe("Owner override only: why it merges without the missing approvals (at least the configured length). Posted on the branch."),
10262
+ dryRun: z.boolean().optional().describe("Check the gate without merging.")
10263
+ },
10264
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }
10265
+ },
10266
+ run((a) => mergeBranch(ctx, a))
10267
+ );
10268
+ server.registerTool(
10269
+ "bridge_close_branch",
10270
+ {
10271
+ title: "Close a branch",
10272
+ description: "Close a branch without merging it, as in Studio's More menu. Its work stays on GitHub and it can be reopened. Explorations close with bridge_close_exploration. Only when the person asked. Needs GITHUB_TOKEN (pull requests: write).",
10273
+ inputSchema: { pr: Pr },
10274
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
10275
+ },
10276
+ run((a) => closeBranch(ctx, a))
10277
+ );
10278
+ server.registerTool(
10279
+ "bridge_reopen_branch",
10280
+ {
10281
+ title: "Reopen a branch",
10282
+ description: "Reopen a closed branch, with Studio's rule: its author or an owner with write access, and only while its work is still on GitHub. Needs GITHUB_TOKEN (pull requests: write).",
10283
+ inputSchema: { pr: Pr },
10284
+ annotations: { ...WRITE, idempotentHint: true }
10285
+ },
10286
+ run((a) => reopenBranch(ctx, a))
10287
+ );
10288
+ server.registerTool(
10289
+ "bridge_update_from_main",
10290
+ {
10291
+ title: "Update from main",
10292
+ description: `Bring the latest main into a branch, as "Update from main" in Studio. Nothing conflicts: GitHub updates it. Only token files conflict: the tokens changed on both sides come back with keys; pass choices ("mine" keeps the branch value, "theirs" uses main's; tokens with a default keep the branch value) and Bridge commits the resolution. Code conflicts stay with a developer on GitHub. dryRun shows what would happen. Needs GITHUB_TOKEN (contents and pull requests: write).`,
10293
+ inputSchema: {
10294
+ pr: Pr,
10295
+ choices: z.record(z.string(), z.enum(["mine", "theirs"])).optional().describe('Per conflict key from a dry run: "mine" or "theirs".'),
10296
+ dryRun: z.boolean().optional()
10297
+ },
10298
+ annotations: WRITE
10299
+ },
10300
+ run((a) => updateFromMain(ctx, a))
10301
+ );
10302
+ server.registerTool(
10303
+ "bridge_save_docs",
10304
+ {
10305
+ title: "Save component docs",
10306
+ 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.`,
10307
+ inputSchema: {
10308
+ component: z.string().min(1).describe("Component name, e.g. Button."),
10309
+ 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"}.'),
10310
+ pr: Pr.optional().describe("An open branch to save the docs on, instead of a new one."),
10311
+ title: z.string().optional().describe('Title of the new branch. Default: "docs(<component>): usage guidance for <Component>".'),
10312
+ notes: z.string().optional().describe("Why, for the reviewers."),
10313
+ jira: z.string().optional().describe("Jira key like DS-42: first in the branch name and title so Jira links them."),
10314
+ product: z.string().optional().describe("Product key or name (bridge_list_products). Default: the product of the current folder or repo."),
10315
+ dryRun: z.boolean().optional()
10316
+ },
10317
+ annotations: WRITE
10318
+ },
10319
+ run((a) => saveDocs(ctx, a))
10320
+ );
8166
10321
  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) => {
8167
10322
  const set = await ctx.tokens();
8168
10323
  const def = set.defaultCombo;
@@ -8195,8 +10350,8 @@ function createServer(ctx) {
8195
10350
  new ResourceTemplate("bridge://components/{name}", {
8196
10351
  list: async () => {
8197
10352
  const { config } = await ctx.config();
8198
- const names = await listComponentNames(ctx.source, config);
8199
- return { resources: names.map((n) => ({ uri: `bridge://components/${n.name}`, name: n.name, mimeType: "application/json" })) };
10353
+ const names2 = await listComponentNames(ctx.source, config);
10354
+ return { resources: names2.map((n) => ({ uri: `bridge://components/${n.name}`, name: n.name, mimeType: "application/json" })) };
8200
10355
  },
8201
10356
  complete: {
8202
10357
  name: async (value) => {
@@ -8376,12 +10531,14 @@ export {
8376
10531
  GitHubSource,
8377
10532
  BridgeContext,
8378
10533
  auditSnippet,
10534
+ branches_exports,
8379
10535
  explorations_exports,
8380
10536
  ops_exports,
8381
10537
  history_exports,
8382
10538
  brand_exports,
8383
10539
  requests_exports,
8384
10540
  drafts_exports,
10541
+ studio_exports,
8385
10542
  VERSION,
8386
10543
  createServer,
8387
10544
  serveStdio,