@sideboard-ai/core 0.1.160 → 0.1.162

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.
@@ -36,12 +36,12 @@ import {
36
36
  syncBoardPins,
37
37
  updateSchedule,
38
38
  waitForDetachedJob
39
- } from "../chunk-QYKADGPP.js";
39
+ } from "../chunk-SAVAC53C.js";
40
40
  import {
41
41
  isInternalAgentStatusText,
42
42
  listModelsForAgent,
43
43
  sideboardMcpProfile
44
- } from "../chunk-7OWRFJK6.js";
44
+ } from "../chunk-GGOU4JJU.js";
45
45
  import "../chunk-LZ6QMEQC.js";
46
46
  import "../chunk-S42XV45P.js";
47
47
  import {
@@ -107,7 +107,7 @@ import {
107
107
  // src/mcp/server.ts
108
108
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
109
109
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
110
- import { z as z7 } from "zod";
110
+ import { z as z8 } from "zod";
111
111
  import { randomUUID as randomUUID2 } from "crypto";
112
112
  import { basename } from "path";
113
113
 
@@ -1189,13 +1189,52 @@ ${githubUrl.trim()}` : githubUrl.trim();
1189
1189
  ${link}` : link;
1190
1190
  }
1191
1191
 
1192
- // src/mcp/slack-tools.ts
1193
- function text(payload, isError = false) {
1192
+ // src/mcp/issue-list.ts
1193
+ var MCP_ISSUE_LIST_DEFAULT_LIMIT = 40;
1194
+ var MCP_ISSUE_LIST_MAX_LIMIT = 250;
1195
+ function clampMcpIssueLimit(limit) {
1196
+ if (limit == null || !Number.isFinite(limit)) return MCP_ISSUE_LIST_DEFAULT_LIMIT;
1197
+ return Math.max(1, Math.min(MCP_ISSUE_LIST_MAX_LIMIT, Math.floor(limit)));
1198
+ }
1199
+ function applyIssueListWindow(items, limit) {
1200
+ const truncated = items.length > limit;
1201
+ return {
1202
+ items: truncated ? items.slice(0, limit) : items,
1203
+ truncated
1204
+ };
1205
+ }
1206
+ function compactIssueRow(issue) {
1207
+ return {
1208
+ identifier: issue.identifier,
1209
+ title: issue.title,
1210
+ ...issue.assignee ? { assignee: issue.assignee } : {},
1211
+ ...issue.labels.length > 0 ? { labels: issue.labels } : {},
1212
+ ...issue.teamKey ? { team: issue.teamKey } : {},
1213
+ ...issue.cycle?.name ? { cycle: issue.cycle.name } : {}
1214
+ };
1215
+ }
1216
+ function formatMcpIssueList(input) {
1217
+ return {
1218
+ ...input.source ? { source: input.source } : {},
1219
+ ...input.viewer ? { viewer: input.viewer } : {},
1220
+ limit: input.limit,
1221
+ returned: input.issues.length,
1222
+ truncated: input.truncated,
1223
+ ...input.truncated ? { hint: "More issues match. Pass a higher limit (max 250) or a tighter query." } : {},
1224
+ issues: input.issues.map(compactIssueRow)
1225
+ };
1226
+ }
1227
+ function mcpJson(payload, isError = false) {
1194
1228
  return {
1195
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
1229
+ content: [{ type: "text", text: JSON.stringify(payload) }],
1196
1230
  ...isError ? { isError: true } : {}
1197
1231
  };
1198
1232
  }
1233
+
1234
+ // src/mcp/slack-tools.ts
1235
+ function text(payload, isError = false) {
1236
+ return mcpJson(payload, isError);
1237
+ }
1199
1238
  function fail(err) {
1200
1239
  return text(
1201
1240
  { error: err instanceof Error ? err.message : String(err) },
@@ -1461,48 +1500,329 @@ function registerSlackTools(server) {
1461
1500
  }
1462
1501
 
1463
1502
  // src/mcp/abletime-tools.ts
1503
+ import { z as z3 } from "zod";
1504
+
1505
+ // src/mcp/issue-payload.ts
1464
1506
  import { z as z2 } from "zod";
1465
1507
 
1466
- // src/mcp/issue-list.ts
1467
- var MCP_ISSUE_LIST_DEFAULT_LIMIT = 40;
1468
- var MCP_ISSUE_LIST_MAX_LIMIT = 250;
1469
- function clampMcpIssueLimit(limit) {
1470
- if (limit == null || !Number.isFinite(limit)) return MCP_ISSUE_LIST_DEFAULT_LIMIT;
1471
- return Math.max(1, Math.min(MCP_ISSUE_LIST_MAX_LIMIT, Math.floor(limit)));
1508
+ // src/mcp/smart-crush.ts
1509
+ var MUST_KEEP_TEXT_RE = /\b(error|exception|failed|failure|fatal|critical|panic|traceback|npm err|assert)\b/i;
1510
+ var CRUSH_MIN_ITEMS = 5;
1511
+ var CRUSH_MIN_CHARS = 800;
1512
+ var CRUSH_COMMENT_MAX_CHARS = 16e3;
1513
+ var CRUSH_BODY_MIN_CHARS = 12e3;
1514
+ var CRUSH_BODY_MAX_CHARS = 8e3;
1515
+ var NEAR_DUP_JACCARD = 0.92;
1516
+ function looksLikeMustKeep(text7) {
1517
+ return MUST_KEEP_TEXT_RE.test(text7);
1518
+ }
1519
+ function normalizeText(text7) {
1520
+ return text7.replace(/\s+/g, " ").trim().toLowerCase();
1521
+ }
1522
+ function wordSet(text7) {
1523
+ return new Set(
1524
+ text7.toLowerCase().split(/\W+/).filter((w) => w.length > 2)
1525
+ );
1472
1526
  }
1473
- function applyIssueListWindow(items, limit) {
1474
- const truncated = items.length > limit;
1475
- return {
1476
- items: truncated ? items.slice(0, limit) : items,
1477
- truncated
1527
+ function jaccard(a, b) {
1528
+ if (a.size === 0 && b.size === 0) return 1;
1529
+ let inter = 0;
1530
+ for (const w of a) {
1531
+ if (b.has(w)) inter += 1;
1532
+ }
1533
+ const union = a.size + b.size - inter;
1534
+ return union === 0 ? 0 : inter / union;
1535
+ }
1536
+ function importance(text7) {
1537
+ let score = text7.length;
1538
+ if (/```/.test(text7)) score += 400;
1539
+ if (/https?:\/\//.test(text7)) score += 50;
1540
+ if (looksLikeMustKeep(text7)) score += 1e4;
1541
+ return score;
1542
+ }
1543
+ function meanStd(values) {
1544
+ if (values.length === 0) return { mean: 0, std: 0 };
1545
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
1546
+ const variance = values.reduce((a, b) => a + (b - mean) * (b - mean), 0) / values.length;
1547
+ return { mean, std: Math.sqrt(variance) };
1548
+ }
1549
+ function dedupTextItems(items, getText) {
1550
+ const kept = [];
1551
+ const keptNorm = [];
1552
+ const keptWords = [];
1553
+ for (const item of items) {
1554
+ const text7 = getText(item);
1555
+ const norm = normalizeText(text7);
1556
+ if (!norm) {
1557
+ kept.push(item);
1558
+ keptNorm.push(norm);
1559
+ keptWords.push(wordSet(text7));
1560
+ continue;
1561
+ }
1562
+ const words = wordSet(text7);
1563
+ let dupAt = -1;
1564
+ for (let i = 0; i < kept.length; i++) {
1565
+ if (keptNorm[i] === norm) {
1566
+ dupAt = i;
1567
+ break;
1568
+ }
1569
+ if (keptWords[i].size >= 8 && words.size >= 8 && jaccard(keptWords[i], words) >= NEAR_DUP_JACCARD) {
1570
+ dupAt = i;
1571
+ break;
1572
+ }
1573
+ }
1574
+ if (dupAt < 0) {
1575
+ kept.push(item);
1576
+ keptNorm.push(norm);
1577
+ keptWords.push(words);
1578
+ continue;
1579
+ }
1580
+ if (text7.length > getText(kept[dupAt]).length) {
1581
+ kept[dupAt] = item;
1582
+ keptNorm[dupAt] = norm;
1583
+ keptWords[dupAt] = words;
1584
+ }
1585
+ }
1586
+ return { items: kept, dropped: items.length - kept.length };
1587
+ }
1588
+ function crushTextItems(items, getText, opts = {}) {
1589
+ const minItems = opts.minItems ?? CRUSH_MIN_ITEMS;
1590
+ const minChars = opts.minChars ?? CRUSH_MIN_CHARS;
1591
+ const maxChars = opts.maxChars ?? CRUSH_COMMENT_MAX_CHARS;
1592
+ const original = items;
1593
+ const totalChars = items.reduce((n, item) => n + getText(item).length, 0);
1594
+ if (items.length < minItems || totalChars < minChars) {
1595
+ return {
1596
+ items,
1597
+ truncated: false,
1598
+ kept: items.length,
1599
+ dropped: 0,
1600
+ reason: "passthrough"
1601
+ };
1602
+ }
1603
+ const deduped = dedupTextItems(items, getText);
1604
+ const unique = deduped.items;
1605
+ const uniqueChars = unique.reduce((n, item) => n + getText(item).length, 0);
1606
+ if (uniqueChars <= maxChars) {
1607
+ const changed = unique.length < original.length;
1608
+ return {
1609
+ items: unique,
1610
+ truncated: changed,
1611
+ kept: unique.length,
1612
+ dropped: original.length - unique.length,
1613
+ reason: changed ? "dedup" : "passthrough"
1614
+ };
1615
+ }
1616
+ const lengths = unique.map((item) => getText(item).length);
1617
+ const { mean, std } = meanStd(lengths);
1618
+ const outlierFloor = mean + 2 * std;
1619
+ const mustKeep = /* @__PURE__ */ new Set();
1620
+ unique.forEach((item, i) => {
1621
+ const text7 = getText(item);
1622
+ if (looksLikeMustKeep(text7) || text7.length > outlierFloor) mustKeep.add(i);
1623
+ });
1624
+ const remainingIdx = unique.map((_, i) => i).filter((i) => !mustKeep.has(i));
1625
+ let used = [...mustKeep].reduce((n, i) => n + lengths[i], 0);
1626
+ const pick = new Set(mustKeep);
1627
+ const canTake = (i) => {
1628
+ if (pick.has(i)) return false;
1629
+ const next = used + lengths[i];
1630
+ if (next > maxChars && pick.size > mustKeep.size) return false;
1631
+ used = next;
1632
+ pick.add(i);
1633
+ return true;
1478
1634
  };
1479
- }
1480
- function compactIssueRow(issue) {
1635
+ const k = remainingIdx.length;
1636
+ const headN = Math.max(1, Math.ceil(k * 0.3));
1637
+ const tailN = Math.max(1, Math.ceil(k * 0.15));
1638
+ for (const i of remainingIdx.slice(0, headN)) canTake(i);
1639
+ for (const i of remainingIdx.slice(-tailN).reverse()) canTake(i);
1640
+ const middle = remainingIdx.filter((i) => !pick.has(i)).sort((a, b) => importance(getText(unique[b])) - importance(getText(unique[a])));
1641
+ for (const i of middle) {
1642
+ if (used >= maxChars) break;
1643
+ canTake(i);
1644
+ }
1645
+ const crushed = unique.filter((_, i) => pick.has(i));
1646
+ const crushedChars = crushed.reduce((n, item) => n + getText(item).length, 0);
1647
+ if (crushed.length >= original.length && crushedChars >= totalChars) {
1648
+ return {
1649
+ items: original,
1650
+ truncated: false,
1651
+ kept: original.length,
1652
+ dropped: 0,
1653
+ reason: "passthrough"
1654
+ };
1655
+ }
1481
1656
  return {
1482
- identifier: issue.identifier,
1483
- title: issue.title,
1484
- ...issue.assignee ? { assignee: issue.assignee } : {},
1485
- ...issue.labels.length > 0 ? { labels: issue.labels } : {},
1486
- ...issue.teamKey ? { team: issue.teamKey } : {},
1487
- ...issue.cycle?.name ? { cycle: issue.cycle.name } : {}
1657
+ items: crushed,
1658
+ truncated: crushed.length < original.length,
1659
+ kept: crushed.length,
1660
+ dropped: original.length - crushed.length,
1661
+ reason: "budget"
1488
1662
  };
1489
1663
  }
1490
- function formatMcpIssueList(input) {
1491
- return {
1492
- ...input.source ? { source: input.source } : {},
1493
- ...input.viewer ? { viewer: input.viewer } : {},
1494
- limit: input.limit,
1495
- returned: input.issues.length,
1496
- truncated: input.truncated,
1497
- ...input.truncated ? { hint: "More issues match. Pass a higher limit (max 250) or a tighter query." } : {},
1498
- issues: input.issues.map(compactIssueRow)
1664
+ function splitBlocks(text7) {
1665
+ if (/\n\n/.test(text7)) return text7.split(/\n\n+/);
1666
+ return text7.split("\n");
1667
+ }
1668
+ function capOversizedBlock(block, maxChars) {
1669
+ if (block.length <= maxChars) return block;
1670
+ const head = Math.floor(maxChars * 0.3);
1671
+ const tail = Math.floor(maxChars * 0.15);
1672
+ return `${block.slice(0, head)}
1673
+
1674
+ [\u2026crushed ${block.length - head - tail} chars; pass include=full to read the rest\u2026]
1675
+
1676
+ ${block.slice(-tail)}`;
1677
+ }
1678
+ function crushMarkdown(text7, opts) {
1679
+ const original = text7 ?? "";
1680
+ const minChars = opts?.minChars ?? CRUSH_BODY_MIN_CHARS;
1681
+ const maxChars = opts?.maxChars ?? CRUSH_BODY_MAX_CHARS;
1682
+ if (original.length < minChars) {
1683
+ return { text: original, truncated: false, originalChars: original.length };
1684
+ }
1685
+ const rawBlocks = splitBlocks(original).filter((b) => b.length > 0);
1686
+ const blocks = rawBlocks.map((b) => capOversizedBlock(b, maxChars));
1687
+ const cappedABlock = blocks.some((b, i) => b !== rawBlocks[i]);
1688
+ if (blocks.length <= 1) {
1689
+ const cut = blocks[0] ?? original;
1690
+ if (cut.length >= original.length) {
1691
+ return { text: original, truncated: false, originalChars: original.length };
1692
+ }
1693
+ return { text: cut, truncated: true, originalChars: original.length };
1694
+ }
1695
+ const must = blocks.filter((b) => looksLikeMustKeep(b));
1696
+ const rest = blocks.filter((b) => !looksLikeMustKeep(b));
1697
+ const marker = `[\u2026crushed body; ${original.length} chars originally. Pass include=full to read the rest.]`;
1698
+ let used = must.reduce((n, b) => n + b.length, 0) + marker.length;
1699
+ const keptRest = [];
1700
+ const headN = Math.max(1, Math.ceil(rest.length * 0.3));
1701
+ const tailN = Math.max(1, Math.ceil(rest.length * 0.15));
1702
+ const head = rest.slice(0, headN);
1703
+ const tail = rest.slice(-tailN);
1704
+ const middle = rest.slice(headN, Math.max(headN, rest.length - tailN));
1705
+ const take = (block) => {
1706
+ if (used + block.length + 2 > maxChars && keptRest.length + must.length > 0) {
1707
+ return false;
1708
+ }
1709
+ used += block.length + 2;
1710
+ keptRest.push(block);
1711
+ return true;
1499
1712
  };
1713
+ for (const b of head) take(b);
1714
+ for (const b of tail) take(b);
1715
+ const ranked = [...middle].sort((a, b) => importance(b) - importance(a));
1716
+ for (const b of ranked) {
1717
+ if (used >= maxChars) break;
1718
+ take(b);
1719
+ }
1720
+ const keepSet = /* @__PURE__ */ new Set([...must, ...keptRest]);
1721
+ const ordered = blocks.filter((b) => keepSet.has(b));
1722
+ const omitted = blocks.length - ordered.length;
1723
+ if (omitted <= 0) {
1724
+ const joined = blocks.join("\n\n");
1725
+ if (cappedABlock && joined.length < original.length) {
1726
+ return { text: joined, truncated: true, originalChars: original.length };
1727
+ }
1728
+ return { text: original, truncated: false, originalChars: original.length };
1729
+ }
1730
+ const out = [];
1731
+ let inserted = false;
1732
+ for (const block of blocks) {
1733
+ if (keepSet.has(block)) {
1734
+ out.push(block);
1735
+ continue;
1736
+ }
1737
+ if (!inserted) {
1738
+ out.push(marker);
1739
+ inserted = true;
1740
+ }
1741
+ }
1742
+ const textOut = out.join("\n\n");
1743
+ if (textOut.length >= original.length) {
1744
+ return { text: original, truncated: false, originalChars: original.length };
1745
+ }
1746
+ return { text: textOut, truncated: true, originalChars: original.length };
1500
1747
  }
1501
- function mcpJson(payload, isError = false) {
1502
- return {
1503
- content: [{ type: "text", text: JSON.stringify(payload) }],
1504
- ...isError ? { isError: true } : {}
1748
+
1749
+ // src/mcp/issue-payload.ts
1750
+ var MCP_ISSUE_INCLUDE_VALUES = ["default", "full"];
1751
+ var mcpIssueIncludeSchema = z2.enum(MCP_ISSUE_INCLUDE_VALUES).optional().describe(
1752
+ "default applies SmartCrusher-style crush (dedup + budget; small unique tickets pass through). full returns the uncompressed vendor payload. Re-call with include=full if crush.truncated."
1753
+ );
1754
+ var MCP_ISSUE_CRUSH_HINT = "Pass include=full to re-fetch the uncompressed ticket from the vendor.";
1755
+ function emptyMeta() {
1756
+ return { truncated: true, hint: MCP_ISSUE_CRUSH_HINT };
1757
+ }
1758
+ function withComments(comments, meta) {
1759
+ const crushed = crushTextItems(comments, (c) => c.body);
1760
+ if (crushed.truncated) {
1761
+ meta.comments = {
1762
+ kept: crushed.kept,
1763
+ dropped: crushed.dropped,
1764
+ reason: crushed.reason
1765
+ };
1766
+ }
1767
+ return { comments: crushed.items, meta };
1768
+ }
1769
+ function withBody(text7, meta) {
1770
+ const crushed = crushMarkdown(text7);
1771
+ if (crushed.truncated) {
1772
+ meta.body = { originalChars: crushed.originalChars };
1773
+ return { value: crushed.text, meta };
1774
+ }
1775
+ return { value: text7, meta };
1776
+ }
1777
+ function attachCrush(payload, meta) {
1778
+ if (!meta.comments && !meta.body) return payload;
1779
+ return { ...payload, crush: meta };
1780
+ }
1781
+ function formatLinearIssuePayload(issue, include) {
1782
+ if (include === "full") return issue;
1783
+ const meta = emptyMeta();
1784
+ const comments = withComments(issue.comments, meta);
1785
+ const description = withBody(issue.description, comments.meta);
1786
+ const { team, ...rest } = issue;
1787
+ const next = {
1788
+ ...rest,
1789
+ ...team ? { team: { id: team.id, key: team.key, name: team.name } } : {},
1790
+ description: description.value,
1791
+ comments: comments.comments
1792
+ };
1793
+ return attachCrush(next, description.meta);
1794
+ }
1795
+ function formatGitHubIssuePayload(issue, include) {
1796
+ if (include === "full") return issue;
1797
+ const meta = emptyMeta();
1798
+ const comments = withComments(issue.comments, meta);
1799
+ const body = withBody(issue.body, comments.meta);
1800
+ const next = {
1801
+ ...issue,
1802
+ body: body.value,
1803
+ comments: comments.comments
1505
1804
  };
1805
+ return attachCrush(next, body.meta);
1806
+ }
1807
+ function formatAbleTimeTaskPayload(task, include) {
1808
+ const base = {
1809
+ ...toAbleTimeIssueInfo(task),
1810
+ description: task.description,
1811
+ state: task.state,
1812
+ comments: task.comments
1813
+ };
1814
+ if (include === "full") return base;
1815
+ const meta = emptyMeta();
1816
+ const comments = withComments(task.comments, meta);
1817
+ const description = withBody(task.description, comments.meta);
1818
+ return attachCrush(
1819
+ {
1820
+ ...base,
1821
+ description: description.value,
1822
+ comments: comments.comments
1823
+ },
1824
+ description.meta
1825
+ );
1506
1826
  }
1507
1827
 
1508
1828
  // src/mcp/abletime-tools.ts
@@ -1548,7 +1868,7 @@ function registerAbleTimeTools(server) {
1548
1868
  server.tool(
1549
1869
  "abletime_list_tasks",
1550
1870
  "List open AbleTime tasks (excludes done/archived). Optional projectId filter.",
1551
- { projectId: z2.string().optional() },
1871
+ { projectId: z3.string().optional() },
1552
1872
  async ({ projectId }) => {
1553
1873
  try {
1554
1874
  const tasks = await listAbleTimeTasks({ projectId });
@@ -1561,7 +1881,7 @@ function registerAbleTimeTools(server) {
1561
1881
  server.tool(
1562
1882
  "abletime_search_tasks",
1563
1883
  "Search AbleTime tasks by text, reference, or tag.",
1564
- { query: z2.string() },
1884
+ { query: z3.string() },
1565
1885
  async ({ query }) => {
1566
1886
  try {
1567
1887
  const tasks = await searchAbleTimeTasks(query);
@@ -1573,17 +1893,11 @@ function registerAbleTimeTools(server) {
1573
1893
  );
1574
1894
  server.tool(
1575
1895
  "abletime_get_task",
1576
- "Get one AbleTime task by id or reference (e.g. CRM-232): description, state, comments. Re-fetch to read new comments.",
1577
- { id: z2.string() },
1578
- async ({ id }) => {
1896
+ "Get one AbleTime task by id or reference (e.g. CRM-232): description, state, comments. Re-fetch to read new comments. Default crushes redundant comments and huge pasted bodies (SmartCrusher-style; small unique tickets pass through). Pass include=full for the uncompressed vendor payload.",
1897
+ { id: z3.string(), include: mcpIssueIncludeSchema },
1898
+ async ({ id, include }) => {
1579
1899
  try {
1580
- const task = await getAbleTimeTask(id);
1581
- return text2({
1582
- ...toAbleTimeIssueInfo(task),
1583
- description: task.description,
1584
- state: task.state,
1585
- comments: task.comments
1586
- });
1900
+ return text2(formatAbleTimeTaskPayload(await getAbleTimeTask(id), include));
1587
1901
  } catch (err) {
1588
1902
  return fail2(err);
1589
1903
  }
@@ -1592,7 +1906,7 @@ function registerAbleTimeTools(server) {
1592
1906
  server.tool(
1593
1907
  "abletime_comment",
1594
1908
  "Add a markdown comment on an AbleTime task (id or CRM-232).",
1595
- { id: z2.string(), body: z2.string() },
1909
+ { id: z3.string(), body: z3.string() },
1596
1910
  async (args) => {
1597
1911
  try {
1598
1912
  return text2(await commentAbleTimeTask(args));
@@ -1605,10 +1919,10 @@ function registerAbleTimeTools(server) {
1605
1919
  "abletime_update_task",
1606
1920
  "Update an AbleTime task (id or CRM-232). Pass title, description, and/or state.",
1607
1921
  {
1608
- id: z2.string(),
1609
- title: z2.string().optional(),
1610
- description: z2.string().optional(),
1611
- state: z2.string().optional()
1922
+ id: z3.string(),
1923
+ title: z3.string().optional(),
1924
+ description: z3.string().optional(),
1925
+ state: z3.string().optional()
1612
1926
  },
1613
1927
  async (args) => {
1614
1928
  try {
@@ -1627,12 +1941,12 @@ function registerAbleTimeTools(server) {
1627
1941
  "abletime_create_task",
1628
1942
  "Create an AbleTime task in a project. Call abletime_list_projects if you do not have a project id. Pass parent (CRM-232) for a spin-off. New tasks start in todo (or backlog).",
1629
1943
  {
1630
- title: z2.string(),
1631
- description: z2.string().optional(),
1632
- projectId: z2.string().optional(),
1633
- categoryId: z2.string().optional(),
1634
- state: z2.enum(["backlog", "todo"]).optional(),
1635
- parent: z2.string().optional().describe("Parent task id or reference (CRM-232) for a spin-off.")
1944
+ title: z3.string(),
1945
+ description: z3.string().optional(),
1946
+ projectId: z3.string().optional(),
1947
+ categoryId: z3.string().optional(),
1948
+ state: z3.enum(["backlog", "todo"]).optional(),
1949
+ parent: z3.string().optional().describe("Parent task id or reference (CRM-232) for a spin-off.")
1636
1950
  },
1637
1951
  async (args) => {
1638
1952
  try {
@@ -1646,10 +1960,10 @@ function registerAbleTimeTools(server) {
1646
1960
  "abletime_ensure_task",
1647
1961
  "Find or create an AbleTime task for this work, then create_thread with sourceType=ticket.",
1648
1962
  {
1649
- title: z2.string(),
1650
- description: z2.string().optional(),
1651
- projectId: z2.string().optional(),
1652
- categoryId: z2.string().optional()
1963
+ title: z3.string(),
1964
+ description: z3.string().optional(),
1965
+ projectId: z3.string().optional(),
1966
+ categoryId: z3.string().optional()
1653
1967
  },
1654
1968
  async (args) => {
1655
1969
  try {
@@ -1663,7 +1977,7 @@ function registerAbleTimeTools(server) {
1663
1977
  }
1664
1978
 
1665
1979
  // src/mcp/github-tools.ts
1666
- import { z as z3 } from "zod";
1980
+ import { z as z4 } from "zod";
1667
1981
 
1668
1982
  // src/integrations/github-issues.ts
1669
1983
  function requireGhOk(result, label) {
@@ -1847,15 +2161,15 @@ function fail3(err) {
1847
2161
  true
1848
2162
  );
1849
2163
  }
1850
- var repoPathSchema = z3.string().optional().describe("Workspace / worktree path. Omit on a worktree turn (uses cwd).");
2164
+ var repoPathSchema = z4.string().optional().describe("Workspace / worktree path. Omit on a worktree turn (uses cwd).");
1851
2165
  function registerGithubIssueTools(server) {
1852
2166
  server.tool(
1853
2167
  "github_get_issue",
1854
- "Get a GitHub issue (#123 or URL): body, comments, state. Re-fetch to read new comments. Uses Account gh.",
1855
- { id: z3.string(), repoPath: repoPathSchema },
1856
- async ({ id, repoPath }) => {
2168
+ "Get a GitHub issue (#123 or URL): body, comments, state. Re-fetch to read new comments. Default crushes redundant comments and huge pasted bodies (SmartCrusher-style; small unique tickets pass through). Pass include=full for the uncompressed vendor payload. Uses Account gh.",
2169
+ { id: z4.string(), include: mcpIssueIncludeSchema, repoPath: repoPathSchema },
2170
+ async ({ id, include, repoPath }) => {
1857
2171
  try {
1858
- return text3(await getGitHubIssue(id, { repoPath }));
2172
+ return text3(formatGitHubIssuePayload(await getGitHubIssue(id, { repoPath }), include));
1859
2173
  } catch (err) {
1860
2174
  return fail3(err);
1861
2175
  }
@@ -1864,7 +2178,7 @@ function registerGithubIssueTools(server) {
1864
2178
  server.tool(
1865
2179
  "github_comment",
1866
2180
  "Add a markdown comment on a GitHub issue (#123 or URL). Uses Account gh.",
1867
- { id: z3.string(), body: z3.string(), repoPath: repoPathSchema },
2181
+ { id: z4.string(), body: z4.string(), repoPath: repoPathSchema },
1868
2182
  async ({ id, body, repoPath }) => {
1869
2183
  try {
1870
2184
  return text3(await commentGitHubIssue({ id, body }, { repoPath }));
@@ -1877,10 +2191,10 @@ function registerGithubIssueTools(server) {
1877
2191
  "github_update_issue",
1878
2192
  "Update a GitHub issue (#123). Pass title, body, and/or state (open|closed).",
1879
2193
  {
1880
- id: z3.string(),
1881
- title: z3.string().optional(),
1882
- body: z3.string().optional(),
1883
- state: z3.string().optional().describe("open or closed"),
2194
+ id: z4.string(),
2195
+ title: z4.string().optional(),
2196
+ body: z4.string().optional(),
2197
+ state: z4.string().optional().describe("open or closed"),
1884
2198
  repoPath: repoPathSchema
1885
2199
  },
1886
2200
  async (args) => {
@@ -1895,9 +2209,9 @@ function registerGithubIssueTools(server) {
1895
2209
  "github_create_issue",
1896
2210
  "Create a GitHub issue. Pass parent (#123) to mark a spin-off in the body.",
1897
2211
  {
1898
- title: z3.string(),
1899
- body: z3.string().optional(),
1900
- parent: z3.string().optional().describe("Parent issue #123 or URL for a spin-off."),
2212
+ title: z4.string(),
2213
+ body: z4.string().optional(),
2214
+ parent: z4.string().optional().describe("Parent issue #123 or URL for a spin-off."),
1901
2215
  repoPath: repoPathSchema
1902
2216
  },
1903
2217
  async (args) => {
@@ -1911,7 +2225,7 @@ function registerGithubIssueTools(server) {
1911
2225
  }
1912
2226
 
1913
2227
  // src/mcp/linear-tools.ts
1914
- import { z as z4 } from "zod";
2228
+ import { z as z5 } from "zod";
1915
2229
  function text4(payload, isError = false) {
1916
2230
  return mcpJson(payload, isError);
1917
2231
  }
@@ -1921,7 +2235,7 @@ function fail4(err) {
1921
2235
  true
1922
2236
  );
1923
2237
  }
1924
- var prioritySchema = z4.number().int().min(0).max(4).optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low");
2238
+ var prioritySchema = z5.number().int().min(0).max(4).optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low");
1925
2239
  function registerLinearTools(server) {
1926
2240
  server.tool(
1927
2241
  "linear_list_teams",
@@ -1939,9 +2253,9 @@ function registerLinearTools(server) {
1939
2253
  "linear_search_issues",
1940
2254
  "Search or list open Linear issues. Default 40; pass query and/or limit (max 250) when truncated. assignee: me, unassigned, all (default with query), or a user id/name.",
1941
2255
  {
1942
- query: z4.string().optional().describe("Search text (identifier, title, description). Omit to list by assignee only."),
1943
- assignee: z4.string().optional().describe("me, unassigned, all, a Linear user id, or a display name. Default: all when query is set, otherwise me."),
1944
- limit: z4.number().int().positive().max(250).optional().describe("Page size (default 40, max 250). Raise when truncated is true.")
2256
+ query: z5.string().optional().describe("Search text (identifier, title, description). Omit to list by assignee only."),
2257
+ assignee: z5.string().optional().describe("me, unassigned, all, a Linear user id, or a display name. Default: all when query is set, otherwise me."),
2258
+ limit: z5.number().int().positive().max(250).optional().describe("Page size (default 40, max 250). Raise when truncated is true.")
1945
2259
  },
1946
2260
  async ({ query, assignee, limit }) => {
1947
2261
  try {
@@ -1968,11 +2282,11 @@ function registerLinearTools(server) {
1968
2282
  );
1969
2283
  server.tool(
1970
2284
  "linear_get_issue",
1971
- "Get a Linear issue by uuid or identifier (ENG-123): description, comments, relations, parent/children.",
1972
- { id: z4.string() },
1973
- async ({ id }) => {
2285
+ "Get a Linear issue by uuid or identifier (ENG-123): description, comments, relations, parent/children. Default crushes redundant comments and huge pasted bodies (SmartCrusher-style; small unique tickets pass through). Pass include=full for the uncompressed vendor payload.",
2286
+ { id: z5.string(), include: mcpIssueIncludeSchema },
2287
+ async ({ id, include }) => {
1974
2288
  try {
1975
- return text4(await getLinearIssue(id));
2289
+ return text4(formatLinearIssuePayload(await getLinearIssue(id), include));
1976
2290
  } catch (err) {
1977
2291
  return fail4(err);
1978
2292
  }
@@ -1982,13 +2296,13 @@ function registerLinearTools(server) {
1982
2296
  "linear_create_issue",
1983
2297
  'Create a Linear issue. Call linear_list_teams first. team is id/key/name; state is name/type/id; assignee is "me" or a user id. Pass parent (ENG-123 or uuid) to nest a spin-off under the current ticket.',
1984
2298
  {
1985
- team: z4.string(),
1986
- title: z4.string(),
1987
- description: z4.string().optional(),
1988
- state: z4.string().optional(),
1989
- assignee: z4.string().nullable().optional(),
2299
+ team: z5.string(),
2300
+ title: z5.string(),
2301
+ description: z5.string().optional(),
2302
+ state: z5.string().optional(),
2303
+ assignee: z5.string().nullable().optional(),
1990
2304
  priority: prioritySchema,
1991
- parent: z4.string().optional().describe("Parent issue uuid or identifier (ENG-123) for a spin-off / sub-issue.")
2305
+ parent: z5.string().optional().describe("Parent issue uuid or identifier (ENG-123) for a spin-off / sub-issue.")
1992
2306
  },
1993
2307
  async (args) => {
1994
2308
  try {
@@ -2002,11 +2316,11 @@ function registerLinearTools(server) {
2002
2316
  "linear_update_issue",
2003
2317
  "Update a Linear issue (uuid or ENG-123). Pass title, description, state, assignee, and/or priority.",
2004
2318
  {
2005
- id: z4.string(),
2006
- title: z4.string().optional(),
2007
- description: z4.string().optional(),
2008
- state: z4.string().optional(),
2009
- assignee: z4.string().nullable().optional(),
2319
+ id: z5.string(),
2320
+ title: z5.string().optional(),
2321
+ description: z5.string().optional(),
2322
+ state: z5.string().optional(),
2323
+ assignee: z5.string().nullable().optional(),
2010
2324
  priority: prioritySchema
2011
2325
  },
2012
2326
  async (args) => {
@@ -2021,8 +2335,8 @@ function registerLinearTools(server) {
2021
2335
  "linear_comment",
2022
2336
  "Add a markdown comment on a Linear issue (uuid or ENG-123).",
2023
2337
  {
2024
- id: z4.string(),
2025
- body: z4.string()
2338
+ id: z5.string(),
2339
+ body: z5.string()
2026
2340
  },
2027
2341
  async (args) => {
2028
2342
  try {
@@ -2042,7 +2356,7 @@ function registerConnectedIssueVendorTools(server, settings = loadAppSettings())
2042
2356
  }
2043
2357
 
2044
2358
  // src/mcp/viewer-context-tools.ts
2045
- import { z as z5 } from "zod";
2359
+ import { z as z6 } from "zod";
2046
2360
  function text5(payload, isError = false) {
2047
2361
  return mcpJson(payload, isError);
2048
2362
  }
@@ -2074,7 +2388,7 @@ function registerViewerContextTools(server) {
2074
2388
  "get_viewer_context",
2075
2389
  "Read account and project user context (Settings \u2192 Agents / Projects): roles, tickets, and review queues as freeform text. Orchestration must pass repoPath from list_workspaces for project context. Worktree turns may omit repoPath (uses cwd).",
2076
2390
  {
2077
- repoPath: z5.string().optional().describe(
2391
+ repoPath: z6.string().optional().describe(
2078
2392
  "Registered workspace or worktree path. Required from orchestration for a project. Omit on a worktree turn (uses cwd)."
2079
2393
  )
2080
2394
  },
@@ -2094,10 +2408,10 @@ function registerViewerContextTools(server) {
2094
2408
  "update_viewer_context",
2095
2409
  "Replace account or project user context (Settings \u2192 Agents / Projects). Never call until the user confirmed. Show the proposed text, ask_user (Save this context / Do not save), wait, then pass confirmed=true. Empty context clears that field.",
2096
2410
  {
2097
- scope: z5.enum(["account", "project"]).describe("account = Settings \u2192 Agents; project = Settings \u2192 Projects for one repo"),
2098
- context: z5.string().describe("Full replacement text (roles, tickets, review queues). Empty clears."),
2099
- confirmed: z5.boolean().optional().describe("Must be true after the user confirmed via ask_user. False/omit is rejected."),
2100
- repoPath: z5.string().optional().describe(
2411
+ scope: z6.enum(["account", "project"]).describe("account = Settings \u2192 Agents; project = Settings \u2192 Projects for one repo"),
2412
+ context: z6.string().describe("Full replacement text (roles, tickets, review queues). Empty clears."),
2413
+ confirmed: z6.boolean().optional().describe("Must be true after the user confirmed via ask_user. False/omit is rejected."),
2414
+ repoPath: z6.string().optional().describe(
2101
2415
  "Required for scope=project from orchestration (path from list_workspaces). Worktree turns may omit it (cwd)."
2102
2416
  )
2103
2417
  },
@@ -2174,12 +2488,9 @@ function formatMcpPrList(input) {
2174
2488
  }
2175
2489
 
2176
2490
  // src/mcp/schedule-tools.ts
2177
- import { z as z6 } from "zod";
2491
+ import { z as z7 } from "zod";
2178
2492
  function text6(payload, isError = false) {
2179
- return {
2180
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
2181
- ...isError ? { isError: true } : {}
2182
- };
2493
+ return mcpJson(payload, isError);
2183
2494
  }
2184
2495
  function fail5(err) {
2185
2496
  return text6(
@@ -2217,17 +2528,17 @@ function registerScheduleTools(server) {
2217
2528
  "create_schedule",
2218
2529
  "Create a local schedule that, when due, sends a prompt to an orchestration chat (threadId) or starts a new Global orchestration chat (omit threadId). Pass threadId=self to continue this coordinator. Exactly one of at (ISO datetime), every (15m/1h/6h/1d), or cron (5-field). Recurring jobs without threadId open a new chat each run. Overnight/unattended runs need Settings \u2192 Advanced \u2192 Caffeinate while schedules are enabled, or set_caffeinate. Sideboard.app must be running for the job to fire.",
2219
2530
  {
2220
- prompt: z6.string().describe("User message / goal queued when the schedule fires"),
2221
- name: z6.string().optional(),
2222
- at: z6.string().optional().describe("ISO datetime for a one-shot"),
2223
- every: z6.string().optional().describe("Interval such as 15m, 1h, 6h, 1d"),
2224
- cron: z6.string().optional().describe("5-field cron expression"),
2225
- tz: z6.string().optional().describe("IANA timezone for cron (default: system)"),
2226
- threadId: z6.string().optional().describe(
2531
+ prompt: z7.string().describe("User message / goal queued when the schedule fires"),
2532
+ name: z7.string().optional(),
2533
+ at: z7.string().optional().describe("ISO datetime for a one-shot"),
2534
+ every: z7.string().optional().describe("Interval such as 15m, 1h, 6h, 1d"),
2535
+ cron: z7.string().optional().describe("5-field cron expression"),
2536
+ tz: z7.string().optional().describe("IANA timezone for cron (default: system)"),
2537
+ threadId: z7.string().optional().describe(
2227
2538
  'Existing orchestration chat id, or "self" for this coordinator. Omit to create a new Global chat on fire.'
2228
2539
  ),
2229
- agent: z6.enum(["claude", "cursor", "codex", "opencode"]).optional().describe("Agent for a new Global chat (omit for Account default)"),
2230
- model: z6.string().optional()
2540
+ agent: z7.enum(["claude", "cursor", "codex", "opencode"]).optional().describe("Agent for a new Global chat (omit for Account default)"),
2541
+ model: z7.string().optional()
2231
2542
  },
2232
2543
  async (args) => {
2233
2544
  try {
@@ -2260,17 +2571,17 @@ function registerScheduleTools(server) {
2260
2571
  "update_schedule",
2261
2572
  "Update a local schedule (prompt, cadence, target thread, enabled). Pass id from list_schedules.",
2262
2573
  {
2263
- id: z6.string(),
2264
- prompt: z6.string().optional(),
2265
- name: z6.string().optional(),
2266
- at: z6.string().optional(),
2267
- every: z6.string().optional(),
2268
- cron: z6.string().optional(),
2269
- tz: z6.string().optional(),
2270
- threadId: z6.string().optional().describe('Existing orchestration chat, "self", or empty string to create a new chat each run'),
2271
- agent: z6.enum(["claude", "cursor", "codex", "opencode"]).optional(),
2272
- model: z6.string().optional(),
2273
- enabled: z6.boolean().optional()
2574
+ id: z7.string(),
2575
+ prompt: z7.string().optional(),
2576
+ name: z7.string().optional(),
2577
+ at: z7.string().optional(),
2578
+ every: z7.string().optional(),
2579
+ cron: z7.string().optional(),
2580
+ tz: z7.string().optional(),
2581
+ threadId: z7.string().optional().describe('Existing orchestration chat, "self", or empty string to create a new chat each run'),
2582
+ agent: z7.enum(["claude", "cursor", "codex", "opencode"]).optional(),
2583
+ model: z7.string().optional(),
2584
+ enabled: z7.boolean().optional()
2274
2585
  },
2275
2586
  async (args) => {
2276
2587
  try {
@@ -2308,7 +2619,7 @@ function registerScheduleTools(server) {
2308
2619
  server.tool(
2309
2620
  "delete_schedule",
2310
2621
  "Delete a local schedule. Pass id from list_schedules.",
2311
- { id: z6.string() },
2622
+ { id: z7.string() },
2312
2623
  async ({ id }) => {
2313
2624
  try {
2314
2625
  deleteSchedule(id);
@@ -2321,7 +2632,7 @@ function registerScheduleTools(server) {
2321
2632
  server.tool(
2322
2633
  "run_schedule",
2323
2634
  "Fire a schedule now (does not wait for nextRunAt). Queues the prompt or starts a new Global chat. If Sideboard.app is running, the desktop drains the turn.",
2324
- { id: z6.string() },
2635
+ { id: z7.string() },
2325
2636
  async ({ id }) => {
2326
2637
  try {
2327
2638
  if (!getSchedule(id)) {
@@ -2742,13 +3053,13 @@ async function startMcpServer() {
2742
3053
  "list_board",
2743
3054
  "Home Kanban of worktrees (New, Draft, Review, Merged) \u2014 one card per checkout; sibling chat tabs nest as inner cards. Same cards as desktop Home. Path to merge: no PR \u2192 draft PR \u2192 open PR \u2192 merged. Archive removes the card to Settings \u2192 History. Queued/running are activity on the card, not columns. Orchestration chats are not on the board. Filters: query, repoPath, kind (ticket/PR/branch source), column, limit (default 40). create_thread adds a worktree (and a Home card), or returns the live one if that ticket/PR/named branch is already checked out.",
2744
3055
  {
2745
- query: z7.string().optional().describe("Case-insensitive token search across title, id, labels, repo"),
2746
- repoPath: z7.string().optional().describe("Limit to one workspace path from list_workspaces"),
2747
- kind: z7.enum(["all", "tickets", "prs", "branches", "threads"]).optional().describe("Filter by worktree source (default all)"),
2748
- column: z7.enum(["new", "draft", "review", "done", "needs_you"]).optional().describe(
3056
+ query: z8.string().optional().describe("Case-insensitive token search across title, id, labels, repo"),
3057
+ repoPath: z8.string().optional().describe("Limit to one workspace path from list_workspaces"),
3058
+ kind: z8.enum(["all", "tickets", "prs", "branches", "threads"]).optional().describe("Filter by worktree source (default all)"),
3059
+ column: z8.enum(["new", "draft", "review", "done", "needs_you"]).optional().describe(
2749
3060
  "Return cards for this column only (totals still include the rest). needs_you is a legacy alias for new."
2750
3061
  ),
2751
- limit: z7.number().int().positive().optional().describe("Max cards per column (default 40). hidden counts the remainder.")
3062
+ limit: z8.number().int().positive().optional().describe("Max cards per column (default 40). hidden counts the remainder.")
2752
3063
  },
2753
3064
  async ({ query, repoPath, kind, column, limit }) => {
2754
3065
  const workspaces = orch.listWorkspaces();
@@ -2763,30 +3074,19 @@ async function startMcpServer() {
2763
3074
  limit,
2764
3075
  workspaceName: (path) => names.get(path) ?? ""
2765
3076
  });
2766
- return {
2767
- content: [
2768
- {
2769
- type: "text",
2770
- text: JSON.stringify(
2771
- {
2772
- columns: snap.columns,
2773
- hidden: snap.hidden,
2774
- totals: snap.totals,
2775
- columnDefs: BOARD_COLUMN_DEFS,
2776
- hint: HOME_BOARD_AGENT_HINT
2777
- },
2778
- null,
2779
- 2
2780
- )
2781
- }
2782
- ]
2783
- };
3077
+ return mcpJson({
3078
+ columns: snap.columns,
3079
+ hidden: snap.hidden,
3080
+ totals: snap.totals,
3081
+ columnDefs: BOARD_COLUMN_DEFS,
3082
+ hint: HOME_BOARD_AGENT_HINT
3083
+ });
2784
3084
  }
2785
3085
  );
2786
3086
  server.tool(
2787
3087
  "get_thread",
2788
3088
  "Get a compact thread summary by id/ref. Includes last message preview, parentThreadId, and child worktree threads (status + lastText). While running, includes progress (last tool/thinking) and lastActivityAt. Includes usage (thread billed token + costUsd totals when providers reported cost) and lastTurnUsage.",
2789
- { ref: z7.string() },
3089
+ { ref: z8.string() },
2790
3090
  async ({ ref }) => {
2791
3091
  const t = orch.getThread(ref);
2792
3092
  if (!t) {
@@ -2820,7 +3120,7 @@ async function startMcpServer() {
2820
3120
  usage: spend.usage,
2821
3121
  lastTurnUsage: spend.lastTurnUsage
2822
3122
  };
2823
- return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
3123
+ return mcpJson(summary);
2824
3124
  }
2825
3125
  );
2826
3126
  }
@@ -2828,17 +3128,17 @@ async function startMcpServer() {
2828
3128
  "present_artifact",
2829
3129
  "Show a document or live log in Sideboard\u2019s side column. For html/svg/markdown/react, pass the FULL document and do not also fence that same body in chat. For type=log, pass only NEW lines (same artifact_id appends). Prefer type=log for long-running job output \u2014 do not resend HTML. type=react is a single default-export component (JSX/TSX); only react/react-dom imports.",
2830
3130
  {
2831
- title: z7.string().describe("Short title shown in the artifact pane header"),
2832
- type: z7.enum(["html", "svg", "markdown", "react", "log"]).describe(
3131
+ title: z8.string().describe("Short title shown in the artifact pane header"),
3132
+ type: z8.enum(["html", "svg", "markdown", "react", "log"]).describe(
2833
3133
  "html/svg/markdown/react replace the pane. log appends content to the same artifact_id (new lines only)."
2834
3134
  ),
2835
- content: z7.string().describe(
3135
+ content: z8.string().describe(
2836
3136
  "html/svg/markdown/react: full document. log: only the new lines since the last call (empty is ok for a status-only update)."
2837
3137
  ),
2838
- artifact_id: z7.string().optional().describe("Stable id. Required for type=log so later calls append to the same pane."),
2839
- status: z7.enum(["running", "ok", "failed", "idle"]).optional().describe("Log header pill: running (working), ok (done), failed, idle"),
2840
- phase: z7.string().optional().describe("Log subtitle (Signing, Notarizing, \u2026)"),
2841
- mode: z7.enum(["append", "replace"]).optional().describe("log only: append (default) or replace the buffer")
3138
+ artifact_id: z8.string().optional().describe("Stable id. Required for type=log so later calls append to the same pane."),
3139
+ status: z8.enum(["running", "ok", "failed", "idle"]).optional().describe("Log header pill: running (working), ok (done), failed, idle"),
3140
+ phase: z8.string().optional().describe("Log subtitle (Signing, Notarizing, \u2026)"),
3141
+ mode: z8.enum(["append", "replace"]).optional().describe("log only: append (default) or replace the buffer")
2842
3142
  },
2843
3143
  async ({ title, type, artifact_id, status, phase, mode }) => {
2844
3144
  const id = artifact_id?.trim() || `artifact_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -2852,22 +3152,22 @@ async function startMcpServer() {
2852
3152
  mode: type === "log" ? mode ?? "append" : void 0,
2853
3153
  message: type === "log" ? "Log accepted. Same artifact_id appends; send only new lines next time." : "Artifact accepted. Sideboard desktop opens it in the side column beside chat."
2854
3154
  };
2855
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
3155
+ return mcpJson(payload);
2856
3156
  }
2857
3157
  );
2858
3158
  server.tool(
2859
3159
  "ask_user",
2860
3160
  "Ask the user a clarifying multiple-choice question in Sideboard\u2019s composer. Call only when work is blocked on choosing among a few concrete options (approach fork, which API, auth vs cookies). Do not call for greetings, check-ins, \u201Chello\u201D, open-ended how-can-I-help, or to invent a menu of possible next tasks \u2014 reply in chat instead. If one option is the obvious default, proceed without asking. Before calling, write a short chat message explaining the decision and what each option means. Include a description on every option. After calling, stop and wait for answers. Not for \u201Cis the plan ready?\u201D.",
2861
3161
  {
2862
- questions: z7.array(
2863
- z7.object({
2864
- question: z7.string().describe("Full question text ending with ?"),
2865
- header: z7.string().max(24).optional().describe("Short label shown above the question"),
2866
- multiSelect: z7.boolean().optional().describe("Allow selecting multiple options"),
2867
- options: z7.array(
2868
- z7.object({
2869
- label: z7.string(),
2870
- description: z7.string().optional().describe("What this option means / when to choose it (strongly preferred)")
3162
+ questions: z8.array(
3163
+ z8.object({
3164
+ question: z8.string().describe("Full question text ending with ?"),
3165
+ header: z8.string().max(24).optional().describe("Short label shown above the question"),
3166
+ multiSelect: z8.boolean().optional().describe("Allow selecting multiple options"),
3167
+ options: z8.array(
3168
+ z8.object({
3169
+ label: z8.string(),
3170
+ description: z8.string().optional().describe("What this option means / when to choose it (strongly preferred)")
2871
3171
  })
2872
3172
  ).min(2).max(6).describe("2\u20136 choices (Sideboard also offers Other)")
2873
3173
  })
@@ -2879,16 +3179,16 @@ async function startMcpServer() {
2879
3179
  questions,
2880
3180
  message: "Questions shown in Sideboard\u2019s composer. Wait for the user\u2019s next message with their answers before continuing."
2881
3181
  };
2882
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
3182
+ return mcpJson(payload);
2883
3183
  }
2884
3184
  );
2885
3185
  server.tool(
2886
3186
  "present_plan",
2887
3187
  "Save the implementation plan as markdown to .context/attachments/plan.md and show it in Sideboard chat for user approval (Copy / Hand off / Approve). Call this when the plan is ready \u2014 required in plan mode. Pass the full plan body in content. Then Claude should call ExitPlanMode.",
2888
3188
  {
2889
- title: z7.string().optional().describe("Short plan title (defaults to Plan)"),
2890
- content: z7.string().min(1).describe("Full plan markdown (headings, steps, risks, open questions)"),
2891
- thread_id: z7.string().optional().describe("Sideboard thread id when cwd is not the worktree")
3189
+ title: z8.string().optional().describe("Short plan title (defaults to Plan)"),
3190
+ content: z8.string().min(1).describe("Full plan markdown (headings, steps, risks, open questions)"),
3191
+ thread_id: z8.string().optional().describe("Sideboard thread id when cwd is not the worktree")
2892
3192
  },
2893
3193
  async ({ title, content, thread_id }) => {
2894
3194
  const { writePlanFile } = await import("../plan-file-I24VJVP2.js");
@@ -2904,22 +3204,22 @@ async function startMcpServer() {
2904
3204
  title: title?.trim() || "Plan",
2905
3205
  message: "Plan saved to .context/attachments/plan.md and shown in Sideboard chat for approval."
2906
3206
  };
2907
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
3207
+ return mcpJson(payload);
2908
3208
  }
2909
3209
  );
2910
3210
  server.tool(
2911
3211
  "present_schema",
2912
3212
  "Open Sideboard\u2019s schema-driven side column (filterable table and/or form) when the user needs to filter, edit, publish, or persist records. Do not call this just to re-display rows you already wrote as a markdown table. If the user asks for an editable / interactive table, call this even if chat already showed those rows. Pass JSON Schema + optional schemaUi. Prefer datasource=inline with embedded resource/records. Use datasource=brightsy with resource_id only when the user is logged into Brightsy.",
2913
3213
  {
2914
- title: z7.string().describe("Pane title"),
2915
- mode: z7.enum(["table", "form"]).optional().describe("table = list/filter records; form = edit one record"),
2916
- datasource: z7.enum(["brightsy", "inline"]).optional().describe("brightsy resolves via login; inline uses embedded resource/records"),
2917
- resource_id: z7.string().optional().describe("Brightsy record type UUID (or generic resource id)"),
2918
- record_id: z7.string().optional().describe("Record id when opening form mode"),
2919
- resource: z7.record(z7.unknown()).optional().describe("Inline { id, title, schema, schemaUi?, slug? }"),
2920
- record: z7.record(z7.unknown()).optional().describe("Inline record { id, data, published_at? }"),
2921
- records: z7.array(z7.record(z7.unknown())).optional().describe("Inline records for table mode"),
2922
- pane_id: z7.string().optional().describe("Stable pane id across updates")
3214
+ title: z8.string().describe("Pane title"),
3215
+ mode: z8.enum(["table", "form"]).optional().describe("table = list/filter records; form = edit one record"),
3216
+ datasource: z8.enum(["brightsy", "inline"]).optional().describe("brightsy resolves via login; inline uses embedded resource/records"),
3217
+ resource_id: z8.string().optional().describe("Brightsy record type UUID (or generic resource id)"),
3218
+ record_id: z8.string().optional().describe("Record id when opening form mode"),
3219
+ resource: z8.record(z8.unknown()).optional().describe("Inline { id, title, schema, schemaUi?, slug? }"),
3220
+ record: z8.record(z8.unknown()).optional().describe("Inline record { id, data, published_at? }"),
3221
+ records: z8.array(z8.record(z8.unknown())).optional().describe("Inline records for table mode"),
3222
+ pane_id: z8.string().optional().describe("Stable pane id across updates")
2923
3223
  },
2924
3224
  async (args) => {
2925
3225
  const id = args.pane_id?.trim() || `schema_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -2933,17 +3233,17 @@ async function startMcpServer() {
2933
3233
  record_id: args.record_id,
2934
3234
  message: "Schema pane accepted. Sideboard desktop opens the CMS column beside chat."
2935
3235
  };
2936
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
3236
+ return mcpJson(payload);
2937
3237
  }
2938
3238
  );
2939
3239
  server.tool(
2940
3240
  "present_files",
2941
3241
  "Open Sideboard\u2019s Files column (CMS-style file manager: browse, upload, pick). Use datasource=brightsy when the user is logged into Brightsy account storage; datasource=memory for a session-local demo store. Prefer this over claiming a file manager UI is unavailable. Pair with present_schema when editing records that need media.",
2942
3242
  {
2943
- title: z7.string().optional().describe("Pane title (default: Files)"),
2944
- datasource: z7.enum(["brightsy", "memory"]).optional().describe("brightsy = account storage via login; memory = session demo store"),
2945
- path: z7.string().optional().describe("Initial folder path (e.g. public)"),
2946
- pane_id: z7.string().optional().describe("Stable pane id across updates")
3243
+ title: z8.string().optional().describe("Pane title (default: Files)"),
3244
+ datasource: z8.enum(["brightsy", "memory"]).optional().describe("brightsy = account storage via login; memory = session demo store"),
3245
+ path: z8.string().optional().describe("Initial folder path (e.g. public)"),
3246
+ pane_id: z8.string().optional().describe("Stable pane id across updates")
2947
3247
  },
2948
3248
  async (args) => {
2949
3249
  const id = args.pane_id?.trim() || `files_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -2955,21 +3255,21 @@ async function startMcpServer() {
2955
3255
  path: args.path,
2956
3256
  message: "Files pane accepted. Sideboard desktop opens the Files column beside chat."
2957
3257
  };
2958
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
3258
+ return mcpJson(payload);
2959
3259
  }
2960
3260
  );
2961
3261
  server.tool(
2962
3262
  "wait_for_job",
2963
3263
  "Wait on a detached long job (tests, pack, deploy) started with detached-job.js. MCP clients kill tools around 60s, so this returns within 45s. stillRunning is the source of truth \u2014 if true, present_artifact type=log with content=delta (same artifact_id) and call wait_for_job again. Do not end the turn or tell the user you will let them know later. If false, ok/failed is the result.",
2964
3264
  {
2965
- id: z7.string().describe("Detached job id (same kebab-case id passed to detached-job.js start)"),
2966
- timeoutMs: z7.number().optional()
3265
+ id: z8.string().describe("Detached job id (same kebab-case id passed to detached-job.js start)"),
3266
+ timeoutMs: z8.number().optional()
2967
3267
  },
2968
3268
  async ({ id, timeoutMs }) => {
2969
3269
  const result = await waitForDetachedJob(process.cwd(), id, {
2970
3270
  timeoutMs: mcpWaitForJobTimeoutMs(timeoutMs)
2971
3271
  });
2972
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
3272
+ return mcpJson(result);
2973
3273
  }
2974
3274
  );
2975
3275
  if (!worktreeProfile) {
@@ -2984,56 +3284,44 @@ async function startMcpServer() {
2984
3284
  "set_caffeinate",
2985
3285
  "Keep this Mac awake with caffeinate (like Claude Code) across turns \u2014 independent of Settings toggles. Turn ON when the user will be away from the keyboard, is driving work from Slack, or asks you to keep the machine awake. Turn OFF when they say they are done, wrapping up, going to sleep, or no longer need the Mac awake. Closing or archiving this orchestration chat also releases the hold. macOS only.",
2986
3286
  {
2987
- enabled: z7.boolean().describe("true = hold caffeinate on; false = release and let the Mac sleep")
3287
+ enabled: z8.boolean().describe("true = hold caffeinate on; false = release and let the Mac sleep")
2988
3288
  },
2989
3289
  async ({ enabled }) => {
2990
3290
  const threadId = process.env.SIDEBOARD_ORCHESTRATOR_THREAD_ID?.trim() || null;
2991
3291
  const state = setCaffeinateHold(enabled, { threadId });
2992
3292
  if (enabled && !state.held) {
2993
- return {
2994
- content: [
2995
- {
2996
- type: "text",
2997
- text: JSON.stringify({
2998
- ...state,
2999
- ok: false,
3000
- message: state.platform === "darwin" ? "Could not start caffeinate." : "Caffeinate is macOS only."
3001
- })
3002
- }
3003
- ],
3004
- isError: true
3005
- };
3006
- }
3007
- return {
3008
- content: [
3293
+ return mcpJson(
3009
3294
  {
3010
- type: "text",
3011
- text: JSON.stringify({
3012
- ...getCaffeinateHold(),
3013
- ok: true,
3014
- message: state.held ? "Mac will stay awake until you call set_caffeinate with enabled=false, the user says they are done, or this orchestration chat is closed." : "Caffeinate hold released. The Mac can sleep (unless Settings caffeinate toggles are on)."
3015
- })
3016
- }
3017
- ]
3018
- };
3295
+ ...state,
3296
+ ok: false,
3297
+ message: state.platform === "darwin" ? "Could not start caffeinate." : "Caffeinate is macOS only."
3298
+ },
3299
+ true
3300
+ );
3301
+ }
3302
+ return mcpJson({
3303
+ ...getCaffeinateHold(),
3304
+ ok: true,
3305
+ message: state.held ? "Mac will stay awake until you call set_caffeinate with enabled=false, the user says they are done, or this orchestration chat is closed." : "Caffeinate hold released. The Mac can sleep (unless Settings caffeinate toggles are on)."
3306
+ });
3019
3307
  }
3020
3308
  );
3021
3309
  server.tool(
3022
3310
  "create_thread",
3023
3311
  `Create a worktree thread (chat) from branch, pr, or ticket. A ticket, PR, or named branch may have only one live worktree \u2014 if one already matches, returns it (alreadyStarted=true) instead of a second checkout. Creating from the default branch still opens a new isolated worktree. Pass repoPath from list_workspaces. cowboy=true uses the project folder on the default branch (no isolated worktree; land is commit+push). From an orchestration chat, omit parentThreadId (Sideboard binds the child to this chat) or pass the exact id from the turn reminder \u2014 never invent a uuid. Prefer omitting agent/model so Sideboard applies ${accountDefaultsHint}. Setup (settings.toml, .cursor/worktrees.json, or script/setup) runs in the background in parallel with the first turn (skipped for cowboy). Then use send_to_thread to chat.`,
3024
3312
  {
3025
- sourceType: z7.enum(["branch", "pr", "ticket"]),
3026
- sourceRef: z7.string(),
3027
- agent: z7.enum(["claude", "codex", "opencode", "brightsy", "cursor"]).optional().describe(`Omit to use Account default agent (${accountDefaults.agent})`),
3028
- model: z7.string().nullable().optional().describe(
3313
+ sourceType: z8.enum(["branch", "pr", "ticket"]),
3314
+ sourceRef: z8.string(),
3315
+ agent: z8.enum(["claude", "codex", "opencode", "brightsy", "cursor"]).optional().describe(`Omit to use Account default agent (${accountDefaults.agent})`),
3316
+ model: z8.string().nullable().optional().describe(
3029
3317
  `Usually omit to use Account default model (${accountDefaults.model?.trim() || "Auto"}). Pass null only to force Auto / agent-default.`
3030
3318
  ),
3031
- repoPath: z7.string(),
3032
- title: z7.string().optional(),
3033
- cowboy: z7.boolean().optional().describe(
3319
+ repoPath: z8.string(),
3320
+ title: z8.string().optional(),
3321
+ cowboy: z8.boolean().optional().describe(
3034
3322
  "If true, work in the project folder on the default branch (no thread/* worktree). Requires Settings \u2192 Advanced \u2192 Cowboy mode. Land is commit+push to that branch. Archive does not delete the folder."
3035
3323
  ),
3036
- parentThreadId: z7.string().optional().describe(
3324
+ parentThreadId: z8.string().optional().describe(
3037
3325
  "Orchestration: omit (preferred) or pass YOUR chat id from the turn reminder / AGENTS.md. Do not invent uuids."
3038
3326
  )
3039
3327
  },
@@ -3049,10 +3337,10 @@ async function startMcpServer() {
3049
3337
  "start_board_card",
3050
3338
  "Same as create_thread for a ticket, PR, or named branch (attaches issue text when Sideboard can resolve it). Does not create a second worktree when one already matches \u2014 returns that thread (alreadyStarted). Then send_to_thread.",
3051
3339
  {
3052
- kind: z7.enum(["ticket", "pr", "branch"]),
3053
- ref: z7.string().describe("Ticket identifier (ENG-12), PR number (44), or branch name from list_board"),
3054
- repoPath: z7.string().describe("Workspace path from list_workspaces / list_board"),
3055
- title: z7.string().optional()
3340
+ kind: z8.enum(["ticket", "pr", "branch"]),
3341
+ ref: z8.string().describe("Ticket identifier (ENG-12), PR number (44), or branch name from list_board"),
3342
+ repoPath: z8.string().describe("Workspace path from list_workspaces / list_board"),
3343
+ title: z8.string().optional()
3056
3344
  },
3057
3345
  async ({ kind, ref, repoPath, title }) => {
3058
3346
  const root = await resolveRepoRoot(repoPath);
@@ -3069,20 +3357,13 @@ async function startMcpServer() {
3069
3357
  }))
3070
3358
  );
3071
3359
  if (existing) {
3072
- return {
3073
- content: [
3074
- {
3075
- type: "text",
3076
- text: JSON.stringify({
3077
- alreadyStarted: true,
3078
- id: existing.id,
3079
- title: existing.title,
3080
- status: existing.status,
3081
- link: `sideboard://thread/${existing.id}`
3082
- })
3083
- }
3084
- ]
3085
- };
3360
+ return mcpJson({
3361
+ alreadyStarted: true,
3362
+ id: existing.id,
3363
+ title: existing.title,
3364
+ status: existing.status,
3365
+ link: `sideboard://thread/${existing.id}`
3366
+ });
3086
3367
  }
3087
3368
  const workspaces = orch.listWorkspaces();
3088
3369
  const loaded = await getHomeBoardInputs(workspaces);
@@ -3156,9 +3437,9 @@ async function startMcpServer() {
3156
3437
  "send_to_thread",
3157
3438
  'Queue a prompt on a worktree thread chat (runs under concurrency cap). Use after create_thread to start or continue a conversation. For commit/push/PR, prefer ask_git (canonical desktop-button phrases). Send "Merge PR." / ask_git merge only when the user explicitly asked to merge. force_stop=true kills the in-flight turn and clears the queue before this prompt \u2014 only when the current request is wrong and must be replaced. Do not force_stop to check in, resume after a halt notice, or because wait_for_turn returned stillRunning; that stops the child mid-thought. Call wait_for_turn again instead.',
3158
3439
  {
3159
- ref: z7.string(),
3160
- prompt: z7.string(),
3161
- force_stop: z7.boolean().optional()
3440
+ ref: z8.string(),
3441
+ prompt: z8.string(),
3442
+ force_stop: z8.boolean().optional()
3162
3443
  },
3163
3444
  async ({ ref, prompt, force_stop }) => {
3164
3445
  if (force_stop) {
@@ -3168,79 +3449,58 @@ async function startMcpServer() {
3168
3449
  }
3169
3450
  }
3170
3451
  const thread = await orch.send(ref, prompt);
3171
- return {
3172
- content: [
3173
- {
3174
- type: "text",
3175
- text: JSON.stringify({
3176
- id: thread.id,
3177
- status: thread.status,
3178
- queueLength: thread.queue.length,
3179
- forceStopped: Boolean(force_stop)
3180
- })
3181
- }
3182
- ]
3183
- };
3452
+ return mcpJson({
3453
+ id: thread.id,
3454
+ status: thread.status,
3455
+ queueLength: thread.queue.length,
3456
+ forceStopped: Boolean(force_stop)
3457
+ });
3184
3458
  }
3185
3459
  );
3186
3460
  server.tool(
3187
3461
  "wait_for_turn",
3188
3462
  "Wait until the thread finishes its current/queued turn, or return early with a live progress snapshot. MCP clients often kill tools around 60s, so this returns within 45s even while the child is still working. stillRunning is the source of truth \u2014 if false, the child is not working (do not say it is waiting for a gate). If stillRunning is true, progress is tools/thinking (or \u201Cqueued, waiting for a concurrency slot\u201D if it has not started). Call wait_for_turn again. Do not send a check-in prompt, force_stop, or assume a hang. On status error, lastError/text is the failure. On status stopped or broken, the child did not finish \u2014 resume with send_to_thread or tell the user; do not treat that as success. When finished, usage is the last agent turn\u2019s tokens + costUsd (when the provider reported cost).",
3189
3463
  {
3190
- ref: z7.string(),
3191
- timeoutMs: z7.number().optional()
3464
+ ref: z8.string(),
3465
+ timeoutMs: z8.number().optional()
3192
3466
  },
3193
3467
  async ({ ref, timeoutMs }) => {
3194
3468
  const thread = await orch.waitForTurn(ref, mcpWaitForTurnTimeoutMs(timeoutMs), {
3195
3469
  resolveIfStillRunning: true
3196
3470
  });
3197
3471
  const result = orch.getTurnResult(thread.id);
3198
- return {
3199
- content: [
3200
- {
3201
- type: "text",
3202
- text: JSON.stringify({
3203
- id: thread.id,
3204
- status: result.status,
3205
- text: result.text,
3206
- lastError: result.lastError,
3207
- stillRunning: result.stillRunning,
3208
- progress: result.progress,
3209
- lastActivityAt: result.lastActivityAt,
3210
- hint: result.stillRunning ? mcpWaitStillRunningHint(result.status) : mcpWaitFinishedHint(result.status),
3211
- incomplete: !result.stillRunning && Boolean(mcpWaitFinishedHint(result.status))
3212
- })
3213
- }
3214
- ]
3215
- };
3472
+ return mcpJson({
3473
+ id: thread.id,
3474
+ status: result.status,
3475
+ text: result.text,
3476
+ lastError: result.lastError,
3477
+ stillRunning: result.stillRunning,
3478
+ progress: result.progress,
3479
+ lastActivityAt: result.lastActivityAt,
3480
+ hint: result.stillRunning ? mcpWaitStillRunningHint(result.status) : mcpWaitFinishedHint(result.status),
3481
+ incomplete: !result.stillRunning && Boolean(mcpWaitFinishedHint(result.status))
3482
+ });
3216
3483
  }
3217
3484
  );
3218
3485
  server.tool(
3219
3486
  "get_turn_result",
3220
3487
  "Assistant message when the turn finished, or live progress while stillRunning. Not the full transcript. Includes usage for the last agent turn (tokens + costUsd when reported).",
3221
- { ref: z7.string() },
3488
+ { ref: z8.string() },
3222
3489
  async ({ ref }) => {
3223
3490
  const result = orch.getTurnResult(ref);
3224
- return {
3225
- content: [
3226
- {
3227
- type: "text",
3228
- text: JSON.stringify({
3229
- ...result,
3230
- hint: result.stillRunning ? mcpWaitStillRunningHint(result.status) : mcpWaitFinishedHint(result.status),
3231
- incomplete: !result.stillRunning && Boolean(mcpWaitFinishedHint(result.status))
3232
- })
3233
- }
3234
- ]
3235
- };
3491
+ return mcpJson({
3492
+ ...result,
3493
+ hint: result.stillRunning ? mcpWaitStillRunningHint(result.status) : mcpWaitFinishedHint(result.status),
3494
+ incomplete: !result.stillRunning && Boolean(mcpWaitFinishedHint(result.status))
3495
+ });
3236
3496
  }
3237
3497
  );
3238
3498
  server.tool(
3239
3499
  "stop_thread",
3240
3500
  "Force-stop a thread: kill any in-flight agent turn AND clear queued prompts so drainQueue cannot continue. Does not archive the worktree. Optional force defaults to true.",
3241
3501
  {
3242
- ref: z7.string(),
3243
- force: z7.boolean().optional()
3502
+ ref: z8.string(),
3503
+ force: z8.boolean().optional()
3244
3504
  },
3245
3505
  async ({ ref, force }) => {
3246
3506
  const t = orch.getThread(ref);
@@ -3253,24 +3513,17 @@ async function startMcpServer() {
3253
3513
  const clearQueue = force !== false;
3254
3514
  const hadQueued = t.queue.length > 0;
3255
3515
  const stopped = orch.stop(ref, { clearQueue, notifyParent: false });
3256
- return {
3257
- content: [
3258
- {
3259
- type: "text",
3260
- text: JSON.stringify({
3261
- id: stopped.id,
3262
- status: stopped.status,
3263
- clearedQueue: clearQueue && hadQueued
3264
- })
3265
- }
3266
- ]
3267
- };
3516
+ return mcpJson({
3517
+ id: stopped.id,
3518
+ status: stopped.status,
3519
+ clearedQueue: clearQueue && hadQueued
3520
+ });
3268
3521
  }
3269
3522
  );
3270
3523
  server.tool(
3271
3524
  "archive_thread",
3272
3525
  "Archive a thread (stops agent/dev, runs archive script, removes worktree when last chat tab). Coordinators commit, push, and open PRs by asking the worktree agent (ask_git). Merge only when the user explicitly asked.",
3273
- { ref: z7.string() },
3526
+ { ref: z8.string() },
3274
3527
  async ({ ref }) => {
3275
3528
  const t = orch.getThread(ref);
3276
3529
  if (!t) {
@@ -3287,38 +3540,24 @@ async function startMcpServer() {
3287
3540
  };
3288
3541
  }
3289
3542
  const archived = await orch.archive(ref);
3290
- return {
3291
- content: [
3292
- {
3293
- type: "text",
3294
- text: JSON.stringify({
3295
- id: archived.id,
3296
- status: archived.status
3297
- })
3298
- }
3299
- ]
3300
- };
3543
+ return mcpJson({
3544
+ id: archived.id,
3545
+ status: archived.status
3546
+ });
3301
3547
  }
3302
3548
  );
3303
3549
  server.tool(
3304
3550
  "restore_thread",
3305
3551
  "Restore an archived thread (recreates worktree from branch when needed)",
3306
- { ref: z7.string() },
3552
+ { ref: z8.string() },
3307
3553
  async ({ ref }) => {
3308
3554
  try {
3309
3555
  const restored = await orch.restore(ref);
3310
- return {
3311
- content: [
3312
- {
3313
- type: "text",
3314
- text: JSON.stringify({
3315
- id: restored.id,
3316
- status: restored.status,
3317
- worktreePath: restored.worktreePath
3318
- })
3319
- }
3320
- ]
3321
- };
3556
+ return mcpJson({
3557
+ id: restored.id,
3558
+ status: restored.status,
3559
+ worktreePath: restored.worktreePath
3560
+ });
3322
3561
  } catch (err) {
3323
3562
  const message = err instanceof Error ? err.message : String(err);
3324
3563
  return { content: [{ type: "text", text: message }], isError: true };
@@ -3329,34 +3568,25 @@ async function startMcpServer() {
3329
3568
  "get_diff",
3330
3569
  "Compact diff summary (capped hunks, paginated)",
3331
3570
  {
3332
- ref: z7.string(),
3333
- maxFiles: z7.number().optional()
3571
+ ref: z8.string(),
3572
+ maxFiles: z8.number().optional()
3334
3573
  },
3335
3574
  async ({ ref, maxFiles }) => {
3336
3575
  const summary = await orch.diffSummary(ref);
3337
- return {
3338
- content: [
3339
- {
3340
- type: "text",
3341
- text: JSON.stringify({
3342
- ...summary,
3343
- files: summary.files.slice(0, maxFiles ?? 10)
3344
- })
3345
- }
3346
- ]
3347
- };
3576
+ return mcpJson({
3577
+ ...summary,
3578
+ files: summary.files.slice(0, maxFiles ?? 10)
3579
+ });
3348
3580
  }
3349
3581
  );
3350
3582
  server.tool(
3351
3583
  "get_pr_checks",
3352
3584
  "Snapshot of GitHub PR checks for a worktree thread (`gh pr checks` plus merge/review gates). null = no PR. If the user gave a goal (Greptile 5/5, CI green), the worktree agent watches with `gh pr checks --watch` via /long-running \u2014 this tool is a snapshot, not a waiter. Coordinators: do not run gh from the orchestration cwd.",
3353
- { ref: z7.string().describe("Worktree thread id/ref") },
3585
+ { ref: z8.string().describe("Worktree thread id/ref") },
3354
3586
  async ({ ref }) => {
3355
3587
  try {
3356
3588
  const checks = await orch.getPrChecks(ref);
3357
- return {
3358
- content: [{ type: "text", text: JSON.stringify(checks) }]
3359
- };
3589
+ return mcpJson(checks);
3360
3590
  } catch (err) {
3361
3591
  const message = err instanceof Error ? err.message : String(err);
3362
3592
  return { content: [{ type: "text", text: message }], isError: true };
@@ -3366,25 +3596,18 @@ async function startMcpServer() {
3366
3596
  server.tool(
3367
3597
  "request_review",
3368
3598
  'Start a merge-readiness Review on a worktree agent thread (same as the desktop Review button). Opens a new Review chat tab, attaches .claude/skills/review/SKILL.md when present (else copies .sideboard/review.md into .context/review.md, or seeds that file from the stock template), and sends "Review changes in this workspace." Expect Approve / Approve with nits / Request changes / Needs more information. Pass a worktree thread ref \u2014 not the orchestrator. Then wait_for_turn (loop while stillRunning) / get_turn_result on the returned review tab id.',
3369
- { ref: z7.string().describe("Worktree thread id/ref to review") },
3599
+ { ref: z8.string().describe("Worktree thread id/ref to review") },
3370
3600
  async ({ ref }) => {
3371
3601
  try {
3372
3602
  const tab = await orch.requestReview(ref);
3373
3603
  const from = orch.getThread(ref);
3374
- return {
3375
- content: [
3376
- {
3377
- type: "text",
3378
- text: JSON.stringify({
3379
- id: tab.id,
3380
- title: tab.title,
3381
- status: tab.status,
3382
- fromThreadId: from?.id ?? ref,
3383
- link: `sideboard://thread/${tab.id}`
3384
- })
3385
- }
3386
- ]
3387
- };
3604
+ return mcpJson({
3605
+ id: tab.id,
3606
+ title: tab.title,
3607
+ status: tab.status,
3608
+ fromThreadId: from?.id ?? ref,
3609
+ link: `sideboard://thread/${tab.id}`
3610
+ });
3388
3611
  } catch (err) {
3389
3612
  const message = err instanceof Error ? err.message : String(err);
3390
3613
  return { content: [{ type: "text", text: message }], isError: true };
@@ -3395,53 +3618,37 @@ async function startMcpServer() {
3395
3618
  "ask_git",
3396
3619
  "Commit & push, open a draft PR, resolve conflicts, or merge \u2014 same actions as the desktop git buttons. When the worktree is clean, Sideboard pushes / opens the PR itself (HTTPS via `gh` even when origin is SSH / Settings \u2192 Git is SSH). When dirty, queues the worktree agent to commit; then wait_for_turn (loop while stillRunning). Do not start a checks loop on a plain push \u2014 only if the user gave a goal (Greptile 5/5, CI green). Pass a worktree thread ref (not the orchestrator). action=merge only when the user explicitly asked to merge that PR. Do not run git or gh from the orchestration cwd. If this tool errors that the GraphQL/PR body is too long, the branch is already pushed \u2014 have the worktree agent retry `gh pr create --body-file` with a short description (GitHub limit 65,536 characters). Do not invent SSH/auth failures from that error.",
3397
3620
  {
3398
- ref: z7.string().describe("Worktree thread id/ref"),
3399
- action: z7.enum(AGENT_GIT_ACTIONS).describe(
3621
+ ref: z8.string().describe("Worktree thread id/ref"),
3622
+ action: z8.enum(AGENT_GIT_ACTIONS).describe(
3400
3623
  "commit-push | create-draft | create-web | resolve-conflicts | merge"
3401
3624
  )
3402
3625
  },
3403
3626
  async ({ ref, action }) => {
3404
3627
  try {
3405
3628
  const thread = await orch.askGit(ref, action);
3406
- return {
3407
- content: [
3408
- {
3409
- type: "text",
3410
- text: JSON.stringify({
3411
- id: thread.id,
3412
- status: thread.status,
3413
- queueLength: thread.queue.length,
3414
- action,
3415
- link: `sideboard://thread/${thread.id}`
3416
- })
3417
- }
3418
- ]
3419
- };
3629
+ return mcpJson({
3630
+ id: thread.id,
3631
+ status: thread.status,
3632
+ queueLength: thread.queue.length,
3633
+ action,
3634
+ link: `sideboard://thread/${thread.id}`
3635
+ });
3420
3636
  } catch (err) {
3421
3637
  const raw = err instanceof Error ? err.message : String(err);
3422
3638
  const message = formatGhLandError(raw);
3423
- return {
3424
- content: [
3425
- {
3426
- type: "text",
3427
- text: JSON.stringify(
3428
- {
3429
- error: true,
3430
- action,
3431
- lastError: message,
3432
- hint: /body is too long/i.test(message) ? "Branch is already pushed. send_to_thread so the worktree agent runs gh pr create --draft -R <origin> --body-file <short.md>. Keep the description short." : void 0
3433
- },
3434
- null,
3435
- 2
3436
- )
3437
- }
3438
- ],
3439
- isError: true
3440
- };
3639
+ return mcpJson(
3640
+ {
3641
+ error: true,
3642
+ action,
3643
+ lastError: message,
3644
+ hint: /body is too long/i.test(message) ? "Branch is already pushed. send_to_thread so the worktree agent runs gh pr create --draft -R <origin> --body-file <short.md>. Keep the description short." : void 0
3645
+ },
3646
+ true
3647
+ );
3441
3648
  }
3442
3649
  }
3443
3650
  );
3444
- const agentEnum = z7.enum(["claude", "codex", "opencode", "brightsy", "cursor"]);
3651
+ const agentEnum = z8.enum(["claude", "codex", "opencode", "brightsy", "cursor"]);
3445
3652
  server.tool(
3446
3653
  "list_models",
3447
3654
  "List models for an agent. Prefer Auto: do not call this unless you have a reason to pin a specific model (user request, cost/latency, capability). Omit agent to list all.",
@@ -3451,9 +3658,7 @@ async function startMcpServer() {
3451
3658
  async ({ agent }) => {
3452
3659
  try {
3453
3660
  const catalogs = await listModelsForAgent(agent);
3454
- return {
3455
- content: [{ type: "text", text: JSON.stringify(catalogs, null, 2) }]
3456
- };
3661
+ return mcpJson(catalogs);
3457
3662
  } catch (err) {
3458
3663
  const message = err instanceof Error ? err.message : String(err);
3459
3664
  return { content: [{ type: "text", text: message }], isError: true };
@@ -3464,11 +3669,11 @@ async function startMcpServer() {
3464
3669
  "fork_worktree",
3465
3670
  "Fork a worktree agent chat into a NEW git worktree + chat (desktop \u201CFork to new workspace\u201D). Seeds a transcript (through through_index, default all). Optional agent override. Leave model unset for Auto (default) \u2014 only pass model when you have a reason. Not for the orchestrator. Then send_to_thread / wait_for_turn (loop while stillRunning) on the returned id.",
3466
3671
  {
3467
- ref: z7.string().describe("Worktree thread id/ref to fork"),
3468
- through_index: z7.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
3672
+ ref: z8.string().describe("Worktree thread id/ref to fork"),
3673
+ through_index: z8.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
3469
3674
  agent: agentEnum.optional().describe("Agent for the forked chat (default: same as source)"),
3470
- model: z7.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
3471
- title: z7.string().optional()
3675
+ model: z8.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
3676
+ title: z8.string().optional()
3472
3677
  },
3473
3678
  async ({ ref, through_index, agent, model, title }) => {
3474
3679
  try {
@@ -3481,24 +3686,17 @@ async function startMcpServer() {
3481
3686
  model,
3482
3687
  title
3483
3688
  });
3484
- return {
3485
- content: [
3486
- {
3487
- type: "text",
3488
- text: JSON.stringify({
3489
- id: thread.id,
3490
- title: thread.title,
3491
- status: thread.status,
3492
- agent: thread.agent,
3493
- model: thread.model,
3494
- branchName: thread.branchName,
3495
- worktreePath: thread.worktreePath,
3496
- fromThreadId: source?.id ?? ref,
3497
- link: `sideboard://thread/${thread.id}`
3498
- })
3499
- }
3500
- ]
3501
- };
3689
+ return mcpJson({
3690
+ id: thread.id,
3691
+ title: thread.title,
3692
+ status: thread.status,
3693
+ agent: thread.agent,
3694
+ model: thread.model,
3695
+ branchName: thread.branchName,
3696
+ worktreePath: thread.worktreePath,
3697
+ fromThreadId: source?.id ?? ref,
3698
+ link: `sideboard://thread/${thread.id}`
3699
+ });
3502
3700
  } catch (err) {
3503
3701
  const message = err instanceof Error ? err.message : String(err);
3504
3702
  return { content: [{ type: "text", text: message }], isError: true };
@@ -3509,11 +3707,11 @@ async function startMcpServer() {
3509
3707
  "fork_chat",
3510
3708
  "Fork a chat into a NEW tab on the SAME workspace: worktree agent \u2192 same worktree tab; Global orchestration chat \u2192 new orchestration chat (same synthetic home). Seeds a transcript; optional agent override. Leave model unset for Auto unless you have a reason. Orchestration forks require an MCP-capable agent (claude, cursor, codex, opencode \u2014 not brightsy). Slack / Global orchestrators use this to continue an orchestration chat on another agent after session limits. Then send_to_thread / wait_for_turn (loop while stillRunning) on the returned id. Use fork_worktree only for worktree agents that need a new git worktree.",
3511
3709
  {
3512
- ref: z7.string().describe("Thread id/ref to fork (worktree agent or orchestration chat)"),
3513
- through_index: z7.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
3710
+ ref: z8.string().describe("Thread id/ref to fork (worktree agent or orchestration chat)"),
3711
+ through_index: z8.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
3514
3712
  agent: agentEnum.optional().describe("Agent for the forked chat (default: same as source)"),
3515
- model: z7.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
3516
- title: z7.string().optional()
3713
+ model: z8.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
3714
+ title: z8.string().optional()
3517
3715
  },
3518
3716
  async ({ ref, through_index, agent, model, title }) => {
3519
3717
  try {
@@ -3531,24 +3729,17 @@ async function startMcpServer() {
3531
3729
  model,
3532
3730
  title
3533
3731
  });
3534
- return {
3535
- content: [
3536
- {
3537
- type: "text",
3538
- text: JSON.stringify({
3539
- id: tab.id,
3540
- title: tab.title,
3541
- status: tab.status,
3542
- agent: tab.agent,
3543
- model: tab.model,
3544
- sourceType: tab.sourceType,
3545
- worktreePath: tab.worktreePath,
3546
- fromThreadId: source.id,
3547
- link: `sideboard://thread/${tab.id}`
3548
- })
3549
- }
3550
- ]
3551
- };
3732
+ return mcpJson({
3733
+ id: tab.id,
3734
+ title: tab.title,
3735
+ status: tab.status,
3736
+ agent: tab.agent,
3737
+ model: tab.model,
3738
+ sourceType: tab.sourceType,
3739
+ worktreePath: tab.worktreePath,
3740
+ fromThreadId: source.id,
3741
+ link: `sideboard://thread/${tab.id}`
3742
+ });
3552
3743
  } catch (err) {
3553
3744
  const message = err instanceof Error ? err.message : String(err);
3554
3745
  return { content: [{ type: "text", text: message }], isError: true };
@@ -3559,49 +3750,35 @@ async function startMcpServer() {
3559
3750
  "run_dev_script",
3560
3751
  "Start a .sideboard/.conductor run script for a thread (default script if name omitted); returns port",
3561
3752
  {
3562
- ref: z7.string(),
3563
- name: z7.string().optional()
3753
+ ref: z8.string(),
3754
+ name: z8.string().optional()
3564
3755
  },
3565
3756
  async ({ ref, name }) => {
3566
3757
  const result = await orch.startDev(ref, name);
3567
- return {
3568
- content: [
3569
- {
3570
- type: "text",
3571
- text: JSON.stringify({
3572
- port: result.port,
3573
- scriptName: result.scriptName,
3574
- ports: result.ports,
3575
- url: `http://localhost:${result.port}`
3576
- })
3577
- }
3578
- ]
3579
- };
3758
+ return mcpJson({
3759
+ port: result.port,
3760
+ scriptName: result.scriptName,
3761
+ ports: result.ports,
3762
+ url: `http://localhost:${result.port}`
3763
+ });
3580
3764
  }
3581
3765
  );
3582
3766
  server.tool(
3583
3767
  "list_run_scripts",
3584
3768
  "List named run scripts available for a thread",
3585
- { ref: z7.string() },
3769
+ { ref: z8.string() },
3586
3770
  async ({ ref }) => {
3587
3771
  const scripts = orch.listThreadRunScripts(ref);
3588
3772
  const active = orch.getActiveRuns(ref);
3589
- return {
3590
- content: [
3591
- {
3592
- type: "text",
3593
- text: JSON.stringify({ scripts, active }, null, 2)
3594
- }
3595
- ]
3596
- };
3773
+ return mcpJson({ scripts, active });
3597
3774
  }
3598
3775
  );
3599
3776
  server.tool(
3600
3777
  "stop_dev_script",
3601
3778
  "Stop a running script for a thread (all scripts if name omitted)",
3602
3779
  {
3603
- ref: z7.string(),
3604
- name: z7.string().optional()
3780
+ ref: z8.string(),
3781
+ name: z8.string().optional()
3605
3782
  },
3606
3783
  async ({ ref, name }) => {
3607
3784
  orch.stopDev(ref, name);
@@ -3611,27 +3788,25 @@ async function startMcpServer() {
3611
3788
  server.tool(
3612
3789
  "run_setup",
3613
3790
  "Re-run workspace setup (Sideboard/Conductor settings, .cursor/worktrees.json, or script/setup). New worktrees already run this automatically.",
3614
- { ref: z7.string() },
3791
+ { ref: z8.string() },
3615
3792
  async ({ ref }) => {
3616
3793
  const result = await orch.runSetup(ref);
3617
- return {
3618
- content: [{ type: "text", text: JSON.stringify(result) }]
3619
- };
3794
+ return mcpJson(result);
3620
3795
  }
3621
3796
  );
3622
3797
  server.tool(
3623
3798
  "add_workspace",
3624
3799
  "Register a git repo as a Sideboard workspace",
3625
- { repoPath: z7.string() },
3800
+ { repoPath: z8.string() },
3626
3801
  async ({ repoPath }) => {
3627
3802
  const ws = await orch.addWorkspace(repoPath);
3628
- return { content: [{ type: "text", text: JSON.stringify(ws) }] };
3803
+ return mcpJson(ws);
3629
3804
  }
3630
3805
  );
3631
3806
  server.tool(
3632
3807
  "remove_workspace",
3633
3808
  "Unregister a Sideboard workspace (does not archive threads)",
3634
- { repoPath: z7.string() },
3809
+ { repoPath: z8.string() },
3635
3810
  async ({ repoPath }) => {
3636
3811
  orch.removeWorkspace(repoPath);
3637
3812
  return { content: [{ type: "text", text: "ok" }] };
@@ -3641,40 +3816,31 @@ async function startMcpServer() {
3641
3816
  "fanout",
3642
3817
  "Best-of-n: create one thread per agent with the same prompt (parallel attempts)",
3643
3818
  {
3644
- prompt: z7.string(),
3645
- agents: z7.array(z7.enum(["claude", "codex", "opencode", "brightsy", "cursor"])),
3646
- repoPath: z7.string(),
3647
- sourceType: z7.enum(["branch", "pr", "ticket"]).optional(),
3648
- sourceRef: z7.string().optional(),
3649
- title: z7.string().optional()
3819
+ prompt: z8.string(),
3820
+ agents: z8.array(z8.enum(["claude", "codex", "opencode", "brightsy", "cursor"])),
3821
+ repoPath: z8.string(),
3822
+ sourceType: z8.enum(["branch", "pr", "ticket"]).optional(),
3823
+ sourceRef: z8.string().optional(),
3824
+ title: z8.string().optional()
3650
3825
  },
3651
3826
  async (args) => {
3652
3827
  const threads = await orch.bestOfN(args);
3653
- return {
3654
- content: [
3655
- {
3656
- type: "text",
3657
- text: JSON.stringify(
3658
- threads.map((t) => ({
3659
- id: t.id,
3660
- agent: t.agent,
3661
- branchName: t.branchName,
3662
- worktreePath: t.worktreePath
3663
- })),
3664
- null,
3665
- 2
3666
- )
3667
- }
3668
- ]
3669
- };
3828
+ return mcpJson(
3829
+ threads.map((t) => ({
3830
+ id: t.id,
3831
+ agent: t.agent,
3832
+ branchName: t.branchName,
3833
+ worktreePath: t.worktreePath
3834
+ }))
3835
+ );
3670
3836
  }
3671
3837
  );
3672
3838
  server.tool(
3673
3839
  "list_branches",
3674
3840
  "List git branches in a registered workspace. Pass repoPath from list_workspaces (unmerged into the default branch by default \u2014 for create_thread sourceType=branch).",
3675
3841
  {
3676
- repoPath: z7.string(),
3677
- unmergedOnly: z7.boolean().optional()
3842
+ repoPath: z8.string(),
3843
+ unmergedOnly: z8.boolean().optional()
3678
3844
  },
3679
3845
  async ({ repoPath, unmergedOnly }) => {
3680
3846
  const root = await resolveRepoRoot(repoPath);
@@ -3695,19 +3861,19 @@ async function startMcpServer() {
3695
3861
  "list_prs",
3696
3862
  'List GitHub PRs for a registered workspace (the review surface for assigned ticket work \u2014 not the tickets). Pass repoPath from list_workspaces. "Get me N tickets to review" \u2192 queue=review and limit=N: open non-draft PRs labeled eng-review with no individual user reviewer. Prefer teams that match Settings \u2192 Agents / Projects roles for this repo. A team like engineering-team is not a claim (you are on that team). Also: state (open|closed|merged|all|review), label, reviewer (me|unassigned|login), query, limit (default 40, max 250). Then create_thread with sourceType=pr.',
3697
3863
  {
3698
- repoPath: z7.string(),
3699
- query: z7.string().optional().describe("GitHub search tokens (title, draft:true, \u2026)"),
3700
- queue: z7.enum(["review", "mine", "approved", "changes"]).optional().describe(
3864
+ repoPath: z8.string(),
3865
+ query: z8.string().optional().describe("GitHub search tokens (title, draft:true, \u2026)"),
3866
+ queue: z8.enum(["review", "mine", "approved", "changes"]).optional().describe(
3701
3867
  'review = unclaimed eng-review inbox (use for "get me N tickets to review"). mine = review-requested:@me. approved / changes = eng-approved / eng-requested-changes.'
3702
3868
  ),
3703
- state: z7.enum(["open", "closed", "merged", "all", "review"]).optional().describe("GitHub PR state (default open). review is an alias for queue=review."),
3704
- label: z7.string().optional().describe(
3869
+ state: z8.enum(["open", "closed", "merged", "all", "review"]).optional().describe("GitHub PR state (default open). review is an alias for queue=review."),
3870
+ label: z8.string().optional().describe(
3705
3871
  "GitHub label / workflow tag. Comma-separated AND. Examples: eng-review, eng-approved, eng-requested-changes"
3706
3872
  ),
3707
- reviewer: z7.string().optional().describe(
3873
+ reviewer: z8.string().optional().describe(
3708
3874
  "me (review requested of you), unassigned (no individual reviewer; team queues like engineering-team still count), all, or a GitHub login"
3709
3875
  ),
3710
- limit: z7.number().int().positive().max(250).optional().describe("Page size (default 40, max 250). Raise when truncated is true.")
3876
+ limit: z8.number().int().positive().max(250).optional().describe("Page size (default 40, max 250). Raise when truncated is true.")
3711
3877
  },
3712
3878
  async ({ repoPath, query, queue, state, label, reviewer, limit }) => {
3713
3879
  const root = await resolveRepoRoot(repoPath);
@@ -3739,89 +3905,65 @@ async function startMcpServer() {
3739
3905
  server.tool(
3740
3906
  "get_pr_stack",
3741
3907
  "Load the GitHub PR stack for a thread worktree (`gh stack view --json`). Returns null JSON when the branch is not stacked. Prefer this before ask_git merge on stacked PRs (and only merge when the user explicitly asked).",
3742
- { ref: z7.string() },
3908
+ { ref: z8.string() },
3743
3909
  async ({ ref }) => {
3744
3910
  const stack = await orch.getPrStack(ref);
3745
- return {
3746
- content: [{ type: "text", text: JSON.stringify(stack, null, 2) }]
3747
- };
3911
+ return mcpJson(stack);
3748
3912
  }
3749
3913
  );
3750
3914
  server.tool(
3751
3915
  "open_pr_stack_layers",
3752
3916
  "Materialize one worktree+thread per stack layer (or a single 1-based layer). Pass a thread ref already on the stack.",
3753
3917
  {
3754
- ref: z7.string(),
3755
- layer: z7.number().int().positive().optional()
3918
+ ref: z8.string(),
3919
+ layer: z8.number().int().positive().optional()
3756
3920
  },
3757
3921
  async ({ ref, layer }) => {
3758
3922
  const result = await orch.openPrStackLayers(ref, { layer });
3759
- return {
3760
- content: [
3761
- {
3762
- type: "text",
3763
- text: JSON.stringify(
3764
- {
3765
- stackNumber: result.stack.stackNumber,
3766
- trunk: result.stack.trunk,
3767
- threads: result.threads.map((t) => ({
3768
- id: t.id,
3769
- title: t.title,
3770
- branchName: t.branchName,
3771
- stackLayer: t.stackLayer,
3772
- worktreePath: t.worktreePath,
3773
- prUrl: t.prUrl,
3774
- link: `sideboard://thread/${t.id}`
3775
- }))
3776
- },
3777
- null,
3778
- 2
3779
- )
3780
- }
3781
- ]
3782
- };
3923
+ return mcpJson({
3924
+ stackNumber: result.stack.stackNumber,
3925
+ trunk: result.stack.trunk,
3926
+ threads: result.threads.map((t) => ({
3927
+ id: t.id,
3928
+ title: t.title,
3929
+ branchName: t.branchName,
3930
+ stackLayer: t.stackLayer,
3931
+ worktreePath: t.worktreePath,
3932
+ prUrl: t.prUrl,
3933
+ link: `sideboard://thread/${t.id}`
3934
+ }))
3935
+ });
3783
3936
  }
3784
3937
  );
3785
3938
  server.tool(
3786
3939
  "add_stack_layer",
3787
3940
  "Add a branch on top of the current stack (`gh stack add`) and open a worktree+thread for it.",
3788
3941
  {
3789
- ref: z7.string(),
3790
- branchName: z7.string(),
3791
- title: z7.string().optional()
3942
+ ref: z8.string(),
3943
+ branchName: z8.string(),
3944
+ title: z8.string().optional()
3792
3945
  },
3793
3946
  async ({ ref, branchName, title }) => {
3794
3947
  const result = await orch.addStackLayer(ref, branchName, { title });
3795
- return {
3796
- content: [
3797
- {
3798
- type: "text",
3799
- text: JSON.stringify(
3800
- {
3801
- id: result.thread.id,
3802
- title: result.thread.title,
3803
- branchName: result.thread.branchName,
3804
- stackLayer: result.thread.stackLayer,
3805
- worktreePath: result.thread.worktreePath,
3806
- link: `sideboard://thread/${result.thread.id}`
3807
- },
3808
- null,
3809
- 2
3810
- )
3811
- }
3812
- ]
3813
- };
3948
+ return mcpJson({
3949
+ id: result.thread.id,
3950
+ title: result.thread.title,
3951
+ branchName: result.thread.branchName,
3952
+ stackLayer: result.thread.stackLayer,
3953
+ worktreePath: result.thread.worktreePath,
3954
+ link: `sideboard://thread/${result.thread.id}`
3955
+ });
3814
3956
  }
3815
3957
  );
3816
3958
  server.tool(
3817
3959
  "create_pr_stack",
3818
3960
  "Create a new GitHub PR stack with one Sideboard worktree per layer (bottom\u2192top branch names). Requires `gh extension install github/gh-stack`.",
3819
3961
  {
3820
- repoPath: z7.string(),
3821
- branches: z7.array(z7.string()).min(1),
3822
- agent: z7.enum(["claude", "codex", "opencode", "brightsy", "cursor"]),
3823
- base: z7.string().optional(),
3824
- title: z7.string().optional()
3962
+ repoPath: z8.string(),
3963
+ branches: z8.array(z8.string()).min(1),
3964
+ agent: z8.enum(["claude", "codex", "opencode", "brightsy", "cursor"]),
3965
+ base: z8.string().optional(),
3966
+ title: z8.string().optional()
3825
3967
  },
3826
3968
  async (args) => {
3827
3969
  const result = await orch.createPrStack({
@@ -3831,39 +3973,28 @@ async function startMcpServer() {
3831
3973
  base: args.base,
3832
3974
  title: args.title
3833
3975
  });
3834
- return {
3835
- content: [
3836
- {
3837
- type: "text",
3838
- text: JSON.stringify(
3839
- {
3840
- stackNumber: result.stack.stackNumber,
3841
- trunk: result.stack.trunk,
3842
- threads: result.threads.map((t) => ({
3843
- id: t.id,
3844
- title: t.title,
3845
- branchName: t.branchName,
3846
- stackLayer: t.stackLayer,
3847
- worktreePath: t.worktreePath,
3848
- link: `sideboard://thread/${t.id}`
3849
- }))
3850
- },
3851
- null,
3852
- 2
3853
- )
3854
- }
3855
- ]
3856
- };
3976
+ return mcpJson({
3977
+ stackNumber: result.stack.stackNumber,
3978
+ trunk: result.stack.trunk,
3979
+ threads: result.threads.map((t) => ({
3980
+ id: t.id,
3981
+ title: t.title,
3982
+ branchName: t.branchName,
3983
+ stackLayer: t.stackLayer,
3984
+ worktreePath: t.worktreePath,
3985
+ link: `sideboard://thread/${t.id}`
3986
+ }))
3987
+ });
3857
3988
  }
3858
3989
  );
3859
3990
  server.tool(
3860
3991
  "list_issues",
3861
3992
  "List or search issues (Linear, AbleTime, or GitHub; falls back to GitHub). Use Settings \u2192 Agents / Projects notes and roles to pick tickets relevant to the viewer (query + assignee=me or unassigned as the notes say). Default 40; pass query and/or limit (max 250) when truncated. assignee: me (Linear default), unassigned, all, or a user id. Then create_thread with sourceType=ticket.",
3862
3993
  {
3863
- repoPath: z7.string(),
3864
- query: z7.string().optional().describe("Search title, identifier, or description"),
3865
- assignee: z7.string().optional().describe("me (Linear default), unassigned, all, a user id, or a GitHub login"),
3866
- limit: z7.number().int().positive().max(250).optional().describe("Page size (default 40, max 250). Raise when truncated is true.")
3994
+ repoPath: z8.string(),
3995
+ query: z8.string().optional().describe("Search title, identifier, or description"),
3996
+ assignee: z8.string().optional().describe("me (Linear default), unassigned, all, a user id, or a GitHub login"),
3997
+ limit: z8.number().int().positive().max(250).optional().describe("Page size (default 40, max 250). Raise when truncated is true.")
3867
3998
  },
3868
3999
  async ({ repoPath, query, assignee, limit }) => {
3869
4000
  const root = await resolveRepoRoot(repoPath);