maxsimcli 4.6.0 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/dist/assets/CHANGELOG.md +39 -0
  2. package/dist/cli.cjs +470 -961
  3. package/dist/cli.cjs.map +1 -1
  4. package/dist/{core-RRjCSt0G.cjs → core-D5zUr9cb.cjs} +4 -3
  5. package/dist/core-D5zUr9cb.cjs.map +1 -0
  6. package/dist/install.cjs +3 -195
  7. package/dist/install.cjs.map +1 -1
  8. package/dist/mcp-server.cjs +2853 -217
  9. package/dist/mcp-server.cjs.map +1 -1
  10. package/dist/{skills-MYlMkYNt.cjs → skills-CjFWZIGM.cjs} +6 -6
  11. package/dist/{skills-MYlMkYNt.cjs.map → skills-CjFWZIGM.cjs.map} +1 -1
  12. package/package.json +1 -7
  13. package/dist/.tsbuildinfo +0 -1
  14. package/dist/assets/dashboard/client/assets/index-C199D4Eb.css +0 -32
  15. package/dist/assets/dashboard/client/assets/index-nAXJLp0_.js +0 -233
  16. package/dist/assets/dashboard/client/index.html +0 -19
  17. package/dist/assets/dashboard/server.js +0 -78813
  18. package/dist/backend/index.d.ts +0 -4
  19. package/dist/backend/index.d.ts.map +0 -1
  20. package/dist/backend/index.js +0 -12
  21. package/dist/backend/index.js.map +0 -1
  22. package/dist/backend/lifecycle.d.ts +0 -13
  23. package/dist/backend/lifecycle.d.ts.map +0 -1
  24. package/dist/backend/lifecycle.js +0 -168
  25. package/dist/backend/lifecycle.js.map +0 -1
  26. package/dist/backend/server.d.ts +0 -13
  27. package/dist/backend/server.d.ts.map +0 -1
  28. package/dist/backend/server.js +0 -1013
  29. package/dist/backend/server.js.map +0 -1
  30. package/dist/backend/terminal.d.ts +0 -49
  31. package/dist/backend/terminal.d.ts.map +0 -1
  32. package/dist/backend/terminal.js +0 -209
  33. package/dist/backend/terminal.js.map +0 -1
  34. package/dist/backend/types.d.ts +0 -77
  35. package/dist/backend/types.d.ts.map +0 -1
  36. package/dist/backend/types.js +0 -6
  37. package/dist/backend/types.js.map +0 -1
  38. package/dist/backend-server.cjs +0 -80672
  39. package/dist/backend-server.cjs.map +0 -1
  40. package/dist/backend-server.d.cts +0 -2
  41. package/dist/backend-server.d.ts +0 -11
  42. package/dist/backend-server.d.ts.map +0 -1
  43. package/dist/backend-server.js +0 -43
  44. package/dist/backend-server.js.map +0 -1
  45. package/dist/cli.d.cts +0 -2
  46. package/dist/cli.d.ts +0 -7
  47. package/dist/cli.d.ts.map +0 -1
  48. package/dist/cli.js +0 -510
  49. package/dist/cli.js.map +0 -1
  50. package/dist/core/artefakte.d.ts +0 -12
  51. package/dist/core/artefakte.d.ts.map +0 -1
  52. package/dist/core/artefakte.js +0 -152
  53. package/dist/core/artefakte.js.map +0 -1
  54. package/dist/core/commands.d.ts +0 -26
  55. package/dist/core/commands.d.ts.map +0 -1
  56. package/dist/core/commands.js +0 -550
  57. package/dist/core/commands.js.map +0 -1
  58. package/dist/core/config.d.ts +0 -10
  59. package/dist/core/config.d.ts.map +0 -1
  60. package/dist/core/config.js +0 -143
  61. package/dist/core/config.js.map +0 -1
  62. package/dist/core/context-loader.d.ts +0 -21
  63. package/dist/core/context-loader.d.ts.map +0 -1
  64. package/dist/core/context-loader.js +0 -212
  65. package/dist/core/context-loader.js.map +0 -1
  66. package/dist/core/core.d.ts +0 -91
  67. package/dist/core/core.d.ts.map +0 -1
  68. package/dist/core/core.js +0 -823
  69. package/dist/core/core.js.map +0 -1
  70. package/dist/core/dashboard-launcher.d.ts +0 -56
  71. package/dist/core/dashboard-launcher.d.ts.map +0 -1
  72. package/dist/core/dashboard-launcher.js +0 -246
  73. package/dist/core/dashboard-launcher.js.map +0 -1
  74. package/dist/core/drift.d.ts +0 -37
  75. package/dist/core/drift.d.ts.map +0 -1
  76. package/dist/core/drift.js +0 -213
  77. package/dist/core/drift.js.map +0 -1
  78. package/dist/core/frontmatter.d.ts +0 -33
  79. package/dist/core/frontmatter.d.ts.map +0 -1
  80. package/dist/core/frontmatter.js +0 -193
  81. package/dist/core/frontmatter.js.map +0 -1
  82. package/dist/core/index.d.ts +0 -28
  83. package/dist/core/index.d.ts.map +0 -1
  84. package/dist/core/index.js +0 -189
  85. package/dist/core/index.js.map +0 -1
  86. package/dist/core/init.d.ts +0 -287
  87. package/dist/core/init.d.ts.map +0 -1
  88. package/dist/core/init.js +0 -816
  89. package/dist/core/init.js.map +0 -1
  90. package/dist/core/milestone.d.ts +0 -9
  91. package/dist/core/milestone.d.ts.map +0 -1
  92. package/dist/core/milestone.js +0 -230
  93. package/dist/core/milestone.js.map +0 -1
  94. package/dist/core/phase.d.ts +0 -53
  95. package/dist/core/phase.d.ts.map +0 -1
  96. package/dist/core/phase.js +0 -891
  97. package/dist/core/phase.js.map +0 -1
  98. package/dist/core/roadmap.d.ts +0 -10
  99. package/dist/core/roadmap.d.ts.map +0 -1
  100. package/dist/core/roadmap.js +0 -165
  101. package/dist/core/roadmap.js.map +0 -1
  102. package/dist/core/skills.d.ts +0 -20
  103. package/dist/core/skills.d.ts.map +0 -1
  104. package/dist/core/skills.js +0 -144
  105. package/dist/core/skills.js.map +0 -1
  106. package/dist/core/start.d.ts +0 -15
  107. package/dist/core/start.d.ts.map +0 -1
  108. package/dist/core/start.js +0 -80
  109. package/dist/core/start.js.map +0 -1
  110. package/dist/core/state.d.ts +0 -32
  111. package/dist/core/state.d.ts.map +0 -1
  112. package/dist/core/state.js +0 -582
  113. package/dist/core/state.js.map +0 -1
  114. package/dist/core/template.d.ts +0 -30
  115. package/dist/core/template.d.ts.map +0 -1
  116. package/dist/core/template.js +0 -223
  117. package/dist/core/template.js.map +0 -1
  118. package/dist/core/types.d.ts +0 -519
  119. package/dist/core/types.d.ts.map +0 -1
  120. package/dist/core/types.js +0 -60
  121. package/dist/core/types.js.map +0 -1
  122. package/dist/core/verify.d.ts +0 -128
  123. package/dist/core/verify.d.ts.map +0 -1
  124. package/dist/core/verify.js +0 -754
  125. package/dist/core/verify.js.map +0 -1
  126. package/dist/core-RRjCSt0G.cjs.map +0 -1
  127. package/dist/esm-iIOBzmdz.cjs +0 -1561
  128. package/dist/esm-iIOBzmdz.cjs.map +0 -1
  129. package/dist/hooks/index.d.ts +0 -11
  130. package/dist/hooks/index.d.ts.map +0 -1
  131. package/dist/hooks/index.js +0 -18
  132. package/dist/hooks/index.js.map +0 -1
  133. package/dist/hooks/maxsim-check-update.d.ts +0 -17
  134. package/dist/hooks/maxsim-check-update.d.ts.map +0 -1
  135. package/dist/hooks/maxsim-check-update.js +0 -101
  136. package/dist/hooks/maxsim-check-update.js.map +0 -1
  137. package/dist/hooks/maxsim-context-monitor.d.ts +0 -21
  138. package/dist/hooks/maxsim-context-monitor.d.ts.map +0 -1
  139. package/dist/hooks/maxsim-context-monitor.js +0 -131
  140. package/dist/hooks/maxsim-context-monitor.js.map +0 -1
  141. package/dist/hooks/maxsim-statusline.d.ts +0 -19
  142. package/dist/hooks/maxsim-statusline.d.ts.map +0 -1
  143. package/dist/hooks/maxsim-statusline.js +0 -146
  144. package/dist/hooks/maxsim-statusline.js.map +0 -1
  145. package/dist/hooks/shared.d.ts +0 -11
  146. package/dist/hooks/shared.d.ts.map +0 -1
  147. package/dist/hooks/shared.js +0 -29
  148. package/dist/hooks/shared.js.map +0 -1
  149. package/dist/index.d.ts +0 -2
  150. package/dist/index.d.ts.map +0 -1
  151. package/dist/index.js +0 -3
  152. package/dist/index.js.map +0 -1
  153. package/dist/install/adapters.d.ts +0 -6
  154. package/dist/install/adapters.d.ts.map +0 -1
  155. package/dist/install/adapters.js +0 -65
  156. package/dist/install/adapters.js.map +0 -1
  157. package/dist/install/copy.d.ts +0 -6
  158. package/dist/install/copy.d.ts.map +0 -1
  159. package/dist/install/copy.js +0 -71
  160. package/dist/install/copy.js.map +0 -1
  161. package/dist/install/dashboard.d.ts +0 -16
  162. package/dist/install/dashboard.d.ts.map +0 -1
  163. package/dist/install/dashboard.js +0 -273
  164. package/dist/install/dashboard.js.map +0 -1
  165. package/dist/install/hooks.d.ts +0 -31
  166. package/dist/install/hooks.d.ts.map +0 -1
  167. package/dist/install/hooks.js +0 -260
  168. package/dist/install/hooks.js.map +0 -1
  169. package/dist/install/index.d.ts +0 -2
  170. package/dist/install/index.d.ts.map +0 -1
  171. package/dist/install/index.js +0 -534
  172. package/dist/install/index.js.map +0 -1
  173. package/dist/install/manifest.d.ts +0 -23
  174. package/dist/install/manifest.d.ts.map +0 -1
  175. package/dist/install/manifest.js +0 -133
  176. package/dist/install/manifest.js.map +0 -1
  177. package/dist/install/patches.d.ts +0 -10
  178. package/dist/install/patches.d.ts.map +0 -1
  179. package/dist/install/patches.js +0 -124
  180. package/dist/install/patches.js.map +0 -1
  181. package/dist/install/shared.d.ts +0 -56
  182. package/dist/install/shared.d.ts.map +0 -1
  183. package/dist/install/shared.js +0 -181
  184. package/dist/install/shared.js.map +0 -1
  185. package/dist/install/uninstall.d.ts +0 -5
  186. package/dist/install/uninstall.d.ts.map +0 -1
  187. package/dist/install/uninstall.js +0 -222
  188. package/dist/install/uninstall.js.map +0 -1
  189. package/dist/install/utils.d.ts +0 -27
  190. package/dist/install/utils.d.ts.map +0 -1
  191. package/dist/install/utils.js +0 -99
  192. package/dist/install/utils.js.map +0 -1
  193. package/dist/install.d.cts +0 -2
  194. package/dist/lifecycle-DxCru7rk.cjs +0 -136
  195. package/dist/lifecycle-DxCru7rk.cjs.map +0 -1
  196. package/dist/mcp/config-tools.d.ts +0 -13
  197. package/dist/mcp/config-tools.d.ts.map +0 -1
  198. package/dist/mcp/config-tools.js +0 -66
  199. package/dist/mcp/config-tools.js.map +0 -1
  200. package/dist/mcp/context-tools.d.ts +0 -13
  201. package/dist/mcp/context-tools.d.ts.map +0 -1
  202. package/dist/mcp/context-tools.js +0 -176
  203. package/dist/mcp/context-tools.js.map +0 -1
  204. package/dist/mcp/index.d.ts +0 -11
  205. package/dist/mcp/index.d.ts.map +0 -1
  206. package/dist/mcp/index.js +0 -26
  207. package/dist/mcp/index.js.map +0 -1
  208. package/dist/mcp/phase-tools.d.ts +0 -13
  209. package/dist/mcp/phase-tools.d.ts.map +0 -1
  210. package/dist/mcp/phase-tools.js +0 -177
  211. package/dist/mcp/phase-tools.js.map +0 -1
  212. package/dist/mcp/roadmap-tools.d.ts +0 -13
  213. package/dist/mcp/roadmap-tools.d.ts.map +0 -1
  214. package/dist/mcp/roadmap-tools.js +0 -79
  215. package/dist/mcp/roadmap-tools.js.map +0 -1
  216. package/dist/mcp/state-tools.d.ts +0 -13
  217. package/dist/mcp/state-tools.d.ts.map +0 -1
  218. package/dist/mcp/state-tools.js +0 -185
  219. package/dist/mcp/state-tools.js.map +0 -1
  220. package/dist/mcp/todo-tools.d.ts +0 -13
  221. package/dist/mcp/todo-tools.d.ts.map +0 -1
  222. package/dist/mcp/todo-tools.js +0 -143
  223. package/dist/mcp/todo-tools.js.map +0 -1
  224. package/dist/mcp/utils.d.ts +0 -27
  225. package/dist/mcp/utils.d.ts.map +0 -1
  226. package/dist/mcp/utils.js +0 -82
  227. package/dist/mcp/utils.js.map +0 -1
  228. package/dist/mcp-server.d.cts +0 -2
  229. package/dist/mcp-server.d.ts +0 -12
  230. package/dist/mcp-server.d.ts.map +0 -1
  231. package/dist/mcp-server.js +0 -31
  232. package/dist/mcp-server.js.map +0 -1
  233. package/dist/server-By0TN-nC.cjs +0 -2995
  234. package/dist/server-By0TN-nC.cjs.map +0 -1
@@ -37,6 +37,8 @@ require("node:os");
37
37
  let node_buffer = require("node:buffer");
38
38
  let child_process = require("child_process");
39
39
  require("node:events");
40
+ let node_child_process = require("node:child_process");
41
+ let node_util = require("node:util");
40
42
 
41
43
  //#region ../../node_modules/zod/v3/helpers/util.js
42
44
  var util$2;
@@ -26769,17 +26771,12 @@ const isSummaryFile = (f) => f.endsWith("-SUMMARY.md") || f === "SUMMARY.md";
26769
26771
  const planId = (f) => f.replace("-PLAN.md", "").replace("PLAN.md", "");
26770
26772
  const summaryId = (f) => f.replace("-SUMMARY.md", "").replace("SUMMARY.md", "");
26771
26773
  /** List subdirectory names, optionally sorted by phase number. */
26772
- function listSubDirs(dir, sortByPhase = false) {
26773
- const dirs = node_fs.default.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
26774
- return sortByPhase ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
26775
- }
26776
- /** Async version of listSubDirs using fs.promises. */
26777
- async function listSubDirsAsync(dir, sortByPhase = false) {
26774
+ async function listSubDirs(dir, sortByPhase = false) {
26778
26775
  const dirs = (await node_fs.promises.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
26779
26776
  return sortByPhase ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
26780
26777
  }
26781
- /** Async version of safeReadFile using fs.promises. */
26782
- async function safeReadFileAsync(filePath) {
26778
+ /** Read a file, returning null if it doesn't exist or fails. */
26779
+ async function safeReadFile(filePath) {
26783
26780
  try {
26784
26781
  return await node_fs.promises.readFile(filePath, "utf-8");
26785
26782
  } catch {
@@ -26800,15 +26797,65 @@ function debugLog(contextOrError, error) {
26800
26797
  function escapePhaseNum(phase) {
26801
26798
  return String(phase).replace(/\./g, "\\.");
26802
26799
  }
26803
- function safeReadFile(filePath) {
26800
+ let _configCache = null;
26801
+ function normalizePhaseName(phase) {
26802
+ const match = phase.match(/^(\d+)([A-Z])?(\.\d+)?/i);
26803
+ if (!match) return phase;
26804
+ const padded = match[1].padStart(2, "0");
26805
+ const letter = match[2] ? match[2].toUpperCase() : "";
26806
+ const decimal = match[3] || "";
26807
+ return padded + letter + decimal;
26808
+ }
26809
+ function comparePhaseNum(a, b) {
26810
+ const pa = String(a).match(/^(\d+)([A-Z])?(\.\d+)?/i);
26811
+ const pb = String(b).match(/^(\d+)([A-Z])?(\.\d+)?/i);
26812
+ if (!pa || !pb) return String(a).localeCompare(String(b));
26813
+ const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
26814
+ if (intDiff !== 0) return intDiff;
26815
+ const la = (pa[2] || "").toUpperCase();
26816
+ const lb = (pb[2] || "").toUpperCase();
26817
+ if (la !== lb) {
26818
+ if (!la) return -1;
26819
+ if (!lb) return 1;
26820
+ return la < lb ? -1 : 1;
26821
+ }
26822
+ return (pa[3] ? parseFloat(pa[3]) : -1) - (pb[3] ? parseFloat(pb[3]) : -1);
26823
+ }
26824
+ /**
26825
+ * Returns the canonical regex for matching Phase heading lines in ROADMAP.md.
26826
+ *
26827
+ * General form (no escapedPhaseNum):
26828
+ * Matches: ## Phase 03: Name Here
26829
+ * Group 1: phase number string (e.g. "03", "3A", "2.1")
26830
+ * Group 2: phase name string (e.g. "Name Here")
26831
+ *
26832
+ * Specific form (with escapedPhaseNum):
26833
+ * Matches: ## Phase 03: Name Here
26834
+ * Group 1: phase name string only
26835
+ *
26836
+ * @param escapedPhaseNum - regex-escaped phase number string to match a specific phase
26837
+ * @param flags - regex flags (default: 'gi')
26838
+ */
26839
+ function getPhasePattern(escapedPhaseNum, flags = "gim") {
26840
+ if (escapedPhaseNum) return new RegExp(`^#{2,4}\\s*Phase\\s+${escapedPhaseNum}:\\s*([^\\n]+)`, flags);
26841
+ return new RegExp(`^#{2,4}\\s*Phase\\s+(\\d+[A-Z]?(?:\\.\\d+)?)\\s*:\\s*([^\\n]+)`, flags);
26842
+ }
26843
+ function generateSlugInternal(text) {
26844
+ if (!text) return null;
26845
+ return (0, import_slugify.default)(text, {
26846
+ lower: true,
26847
+ strict: true
26848
+ });
26849
+ }
26850
+ async function pathExistsInternal(p) {
26804
26851
  try {
26805
- return node_fs.default.readFileSync(filePath, "utf-8");
26852
+ await node_fs.promises.access(p);
26853
+ return true;
26806
26854
  } catch {
26807
- return null;
26855
+ return false;
26808
26856
  }
26809
26857
  }
26810
- let _configCache = null;
26811
- function loadConfig(cwd) {
26858
+ async function loadConfig(cwd) {
26812
26859
  if (_configCache && _configCache.cwd === cwd) return _configCache.config;
26813
26860
  const cfgPath = configPath(cwd);
26814
26861
  const defaults = {
@@ -26825,7 +26872,7 @@ function loadConfig(cwd) {
26825
26872
  brave_search: false
26826
26873
  };
26827
26874
  try {
26828
- const raw = node_fs.default.readFileSync(cfgPath, "utf-8");
26875
+ const raw = await node_fs.promises.readFile(cfgPath, "utf-8");
26829
26876
  const parsed = JSON.parse(raw);
26830
26877
  const get = (key, nested) => {
26831
26878
  if (parsed[key] !== void 0) return parsed[key];
@@ -26887,7 +26934,7 @@ function loadConfig(cwd) {
26887
26934
  };
26888
26935
  return result;
26889
26936
  } catch (e) {
26890
- if (node_fs.default.existsSync(cfgPath)) {
26937
+ if (await pathExistsInternal(cfgPath)) {
26891
26938
  console.warn(`[maxsim] Warning: config.json exists but could not be parsed — using defaults.`);
26892
26939
  debugLog("config-load-failed", e);
26893
26940
  }
@@ -26898,57 +26945,15 @@ function loadConfig(cwd) {
26898
26945
  return defaults;
26899
26946
  }
26900
26947
  }
26901
- function normalizePhaseName(phase) {
26902
- const match = phase.match(/^(\d+)([A-Z])?(\.\d+)?/i);
26903
- if (!match) return phase;
26904
- const padded = match[1].padStart(2, "0");
26905
- const letter = match[2] ? match[2].toUpperCase() : "";
26906
- const decimal = match[3] || "";
26907
- return padded + letter + decimal;
26908
- }
26909
- function comparePhaseNum(a, b) {
26910
- const pa = String(a).match(/^(\d+)([A-Z])?(\.\d+)?/i);
26911
- const pb = String(b).match(/^(\d+)([A-Z])?(\.\d+)?/i);
26912
- if (!pa || !pb) return String(a).localeCompare(String(b));
26913
- const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
26914
- if (intDiff !== 0) return intDiff;
26915
- const la = (pa[2] || "").toUpperCase();
26916
- const lb = (pb[2] || "").toUpperCase();
26917
- if (la !== lb) {
26918
- if (!la) return -1;
26919
- if (!lb) return 1;
26920
- return la < lb ? -1 : 1;
26921
- }
26922
- return (pa[3] ? parseFloat(pa[3]) : -1) - (pb[3] ? parseFloat(pb[3]) : -1);
26923
- }
26924
- /**
26925
- * Returns the canonical regex for matching Phase heading lines in ROADMAP.md.
26926
- *
26927
- * General form (no escapedPhaseNum):
26928
- * Matches: ## Phase 03: Name Here
26929
- * Group 1: phase number string (e.g. "03", "3A", "2.1")
26930
- * Group 2: phase name string (e.g. "Name Here")
26931
- *
26932
- * Specific form (with escapedPhaseNum):
26933
- * Matches: ## Phase 03: Name Here
26934
- * Group 1: phase name string only
26935
- *
26936
- * @param escapedPhaseNum - regex-escaped phase number string to match a specific phase
26937
- * @param flags - regex flags (default: 'gi')
26938
- */
26939
- function getPhasePattern(escapedPhaseNum, flags = "gim") {
26940
- if (escapedPhaseNum) return new RegExp(`^#{2,4}\\s*Phase\\s+${escapedPhaseNum}:\\s*([^\\n]+)`, flags);
26941
- return new RegExp(`^#{2,4}\\s*Phase\\s+(\\d+[A-Z]?(?:\\.\\d+)?)\\s*:\\s*([^\\n]+)`, flags);
26942
- }
26943
- function searchPhaseInDir(baseDir, relBase, normalized) {
26948
+ async function searchPhaseInDir(baseDir, relBase, normalized) {
26944
26949
  try {
26945
- const match = listSubDirs(baseDir, true).find((d) => d.startsWith(normalized));
26950
+ const match = (await listSubDirs(baseDir, true)).find((d) => d.startsWith(normalized));
26946
26951
  if (!match) return null;
26947
26952
  const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)?)-?(.*)/i);
26948
26953
  const phaseNumber = dirMatch ? dirMatch[1] : normalized;
26949
26954
  const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
26950
26955
  const phaseDir = node_path.default.join(baseDir, match);
26951
- const phaseFiles = node_fs.default.readdirSync(phaseDir);
26956
+ const phaseFiles = await node_fs.promises.readdir(phaseDir);
26952
26957
  const plans = phaseFiles.filter(isPlanFile).sort();
26953
26958
  const summaries = phaseFiles.filter(isSummaryFile).sort();
26954
26959
  const hasResearch = phaseFiles.some((f) => f.endsWith("-RESEARCH.md") || f === "RESEARCH.md");
@@ -26970,7 +26975,7 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
26970
26975
  has_verification: hasVerification
26971
26976
  };
26972
26977
  } catch (e) {
26973
- debugLog("search-phase-in-dir-failed", {
26978
+ debugLog("search-phase-in-dir-async-failed", {
26974
26979
  dir: baseDir,
26975
26980
  phase: normalized,
26976
26981
  error: errorMsg(e)
@@ -26978,148 +26983,82 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
26978
26983
  return null;
26979
26984
  }
26980
26985
  }
26981
- function findPhaseInternal(cwd, phase) {
26986
+ async function findPhaseInternal(cwd, phase) {
26982
26987
  if (!phase) return null;
26983
26988
  const pd = phasesPath(cwd);
26984
26989
  const normalized = normalizePhaseName(phase);
26985
- const current = searchPhaseInDir(pd, node_path.default.join(".planning", "phases"), normalized);
26990
+ const current = await searchPhaseInDir(pd, node_path.default.join(".planning", "phases"), normalized);
26986
26991
  if (current) return current;
26987
- const milestonesDir = planningPath(cwd, "milestones");
26988
- try {
26989
- node_fs.default.statSync(milestonesDir);
26990
- } catch {
26991
- return null;
26992
+ const archiveDir = planningPath(cwd, "archive");
26993
+ if (await pathExistsInternal(archiveDir)) try {
26994
+ const versionDirs = (await node_fs.promises.readdir(archiveDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort().reverse();
26995
+ for (const versionName of versionDirs) {
26996
+ const result = await searchPhaseInDir(node_path.default.join(archiveDir, versionName), node_path.default.join(".planning", "archive", versionName), normalized);
26997
+ if (result) {
26998
+ result.archived = versionName;
26999
+ return result;
27000
+ }
27001
+ }
27002
+ } catch (e) {
27003
+ debugLog("find-phase-async-archive-search-failed", e);
26992
27004
  }
27005
+ const milestonesDir = planningPath(cwd, "milestones");
27006
+ if (!await pathExistsInternal(milestonesDir)) return null;
26993
27007
  try {
26994
- const archiveDirs = node_fs.default.readdirSync(milestonesDir, { withFileTypes: true }).filter((e) => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)).map((e) => e.name).sort().reverse();
27008
+ const archiveDirs = (await node_fs.promises.readdir(milestonesDir, { withFileTypes: true })).filter((e) => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)).map((e) => e.name).sort().reverse();
26995
27009
  for (const archiveName of archiveDirs) {
26996
27010
  const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
26997
27011
  if (!versionMatch) continue;
26998
27012
  const version = versionMatch[1];
26999
- const result = searchPhaseInDir(node_path.default.join(milestonesDir, archiveName), node_path.default.join(".planning", "milestones", archiveName), normalized);
27013
+ const result = await searchPhaseInDir(node_path.default.join(milestonesDir, archiveName), node_path.default.join(".planning", "milestones", archiveName), normalized);
27000
27014
  if (result) {
27001
27015
  result.archived = version;
27002
27016
  return result;
27003
27017
  }
27004
27018
  }
27005
27019
  } catch (e) {
27006
- debugLog("find-phase-milestone-search-failed", e);
27020
+ debugLog("find-phase-async-milestone-search-failed", e);
27007
27021
  }
27008
27022
  return null;
27009
27023
  }
27010
- function getArchivedPhaseDirs(cwd) {
27011
- const milestonesDir = planningPath(cwd, "milestones");
27024
+ async function getArchivedPhaseDirs(cwd) {
27012
27025
  const results = [];
27026
+ const archiveDir = planningPath(cwd, "archive");
27013
27027
  try {
27014
- const phaseDirs = node_fs.default.readdirSync(milestonesDir, { withFileTypes: true }).filter((e) => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)).map((e) => e.name).sort().reverse();
27015
- for (const archiveName of phaseDirs) {
27016
- const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
27017
- if (!versionMatch) continue;
27018
- const version = versionMatch[1];
27019
- const archivePath = node_path.default.join(milestonesDir, archiveName);
27020
- const dirs = listSubDirs(archivePath, true);
27028
+ const versionDirs = (await node_fs.promises.readdir(archiveDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort().reverse();
27029
+ for (const versionName of versionDirs) {
27030
+ const versionPath = node_path.default.join(archiveDir, versionName);
27031
+ const dirs = await listSubDirs(versionPath, true);
27021
27032
  for (const dir of dirs) results.push({
27022
27033
  name: dir,
27023
- milestone: version,
27024
- basePath: node_path.default.join(".planning", "milestones", archiveName),
27025
- fullPath: node_path.default.join(archivePath, dir)
27034
+ milestone: versionName,
27035
+ basePath: node_path.default.join(".planning", "archive", versionName),
27036
+ fullPath: node_path.default.join(versionPath, dir)
27026
27037
  });
27027
27038
  }
27028
27039
  } catch (e) {
27029
- debugLog("get-archived-phase-dirs-failed", e);
27030
- }
27031
- return results;
27032
- }
27033
- function generateSlugInternal(text) {
27034
- if (!text) return null;
27035
- return (0, import_slugify.default)(text, {
27036
- lower: true,
27037
- strict: true
27038
- });
27039
- }
27040
- async function pathExistsAsync(p) {
27041
- try {
27042
- await node_fs.promises.access(p);
27043
- return true;
27044
- } catch {
27045
- return false;
27046
- }
27047
- }
27048
- async function searchPhaseInDirAsync(baseDir, relBase, normalized) {
27049
- try {
27050
- const match = (await listSubDirsAsync(baseDir, true)).find((d) => d.startsWith(normalized));
27051
- if (!match) return null;
27052
- const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)?)-?(.*)/i);
27053
- const phaseNumber = dirMatch ? dirMatch[1] : normalized;
27054
- const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
27055
- const phaseDir = node_path.default.join(baseDir, match);
27056
- const phaseFiles = await node_fs.promises.readdir(phaseDir);
27057
- const plans = phaseFiles.filter(isPlanFile).sort();
27058
- const summaries = phaseFiles.filter(isSummaryFile).sort();
27059
- const hasResearch = phaseFiles.some((f) => f.endsWith("-RESEARCH.md") || f === "RESEARCH.md");
27060
- const hasContext = phaseFiles.some((f) => f.endsWith("-CONTEXT.md") || f === "CONTEXT.md");
27061
- const hasVerification = phaseFiles.some((f) => f.endsWith("-VERIFICATION.md") || f === "VERIFICATION.md");
27062
- const completedPlanIds = new Set(summaries.map(summaryId));
27063
- const incompletePlans = plans.filter((p) => !completedPlanIds.has(planId(p)));
27064
- return {
27065
- found: true,
27066
- directory: node_path.default.join(relBase, match),
27067
- phase_number: phaseNumber,
27068
- phase_name: phaseName,
27069
- phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") : null,
27070
- plans,
27071
- summaries,
27072
- incomplete_plans: incompletePlans,
27073
- has_research: hasResearch,
27074
- has_context: hasContext,
27075
- has_verification: hasVerification
27076
- };
27077
- } catch (e) {
27078
- debugLog("search-phase-in-dir-async-failed", {
27079
- dir: baseDir,
27080
- phase: normalized,
27081
- error: errorMsg(e)
27082
- });
27083
- return null;
27084
- }
27085
- }
27086
- async function findPhaseInternalAsync(cwd, phase) {
27087
- if (!phase) return null;
27088
- const pd = phasesPath(cwd);
27089
- const normalized = normalizePhaseName(phase);
27090
- const current = await searchPhaseInDirAsync(pd, node_path.default.join(".planning", "phases"), normalized);
27091
- if (current) return current;
27092
- const archiveDir = planningPath(cwd, "archive");
27093
- if (await pathExistsAsync(archiveDir)) try {
27094
- const versionDirs = (await node_fs.promises.readdir(archiveDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort().reverse();
27095
- for (const versionName of versionDirs) {
27096
- const result = await searchPhaseInDirAsync(node_path.default.join(archiveDir, versionName), node_path.default.join(".planning", "archive", versionName), normalized);
27097
- if (result) {
27098
- result.archived = versionName;
27099
- return result;
27100
- }
27101
- }
27102
- } catch (e) {
27103
- debugLog("find-phase-async-archive-search-failed", e);
27040
+ debugLog("get-archived-phase-dirs-async-archive-failed", e);
27104
27041
  }
27105
27042
  const milestonesDir = planningPath(cwd, "milestones");
27106
- if (!await pathExistsAsync(milestonesDir)) return null;
27107
27043
  try {
27108
- const archiveDirs = (await node_fs.promises.readdir(milestonesDir, { withFileTypes: true })).filter((e) => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)).map((e) => e.name).sort().reverse();
27109
- for (const archiveName of archiveDirs) {
27044
+ const phaseDirs = (await node_fs.promises.readdir(milestonesDir, { withFileTypes: true })).filter((e) => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)).map((e) => e.name).sort().reverse();
27045
+ for (const archiveName of phaseDirs) {
27110
27046
  const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
27111
27047
  if (!versionMatch) continue;
27112
27048
  const version = versionMatch[1];
27113
- const result = await searchPhaseInDirAsync(node_path.default.join(milestonesDir, archiveName), node_path.default.join(".planning", "milestones", archiveName), normalized);
27114
- if (result) {
27115
- result.archived = version;
27116
- return result;
27117
- }
27049
+ const archiveMilestonePath = node_path.default.join(milestonesDir, archiveName);
27050
+ const dirs = await listSubDirs(archiveMilestonePath, true);
27051
+ for (const dir of dirs) results.push({
27052
+ name: dir,
27053
+ milestone: version,
27054
+ basePath: node_path.default.join(".planning", "milestones", archiveName),
27055
+ fullPath: node_path.default.join(archiveMilestonePath, dir)
27056
+ });
27118
27057
  }
27119
27058
  } catch (e) {
27120
- debugLog("find-phase-async-milestone-search-failed", e);
27059
+ debugLog("get-archived-phase-dirs-async-failed", e);
27121
27060
  }
27122
- return null;
27061
+ return results;
27123
27062
  }
27124
27063
 
27125
27064
  //#endregion
@@ -33860,7 +33799,7 @@ async function phaseInsertCore(cwd, afterPhase, description, options) {
33860
33799
  const normalizedBase = normalizePhaseName(afterPhase);
33861
33800
  const existingDecimals = [];
33862
33801
  try {
33863
- const dirs = await listSubDirsAsync(phasesDirPath);
33802
+ const dirs = await listSubDirs(phasesDirPath);
33864
33803
  const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`);
33865
33804
  for (const dir of dirs) {
33866
33805
  const dm = dir.match(decimalPattern);
@@ -33899,12 +33838,12 @@ async function phaseCompleteCore(cwd, phaseNum) {
33899
33838
  const stPath = statePath(cwd);
33900
33839
  const phasesDirPath = phasesPath(cwd);
33901
33840
  const today = todayISO();
33902
- const phaseInfo = await findPhaseInternalAsync(cwd, phaseNum);
33841
+ const phaseInfo = await findPhaseInternal(cwd, phaseNum);
33903
33842
  if (!phaseInfo) throw new Error(`Phase ${phaseNum} not found`);
33904
33843
  const planCount = phaseInfo.plans.length;
33905
33844
  const summaryCount = phaseInfo.summaries.length;
33906
33845
  let requirementsUpdated = false;
33907
- const rmExists = await pathExistsAsync(rmPath);
33846
+ const rmExists = await pathExistsInternal(rmPath);
33908
33847
  if (rmExists) {
33909
33848
  let roadmapContent = await node_fs.promises.readFile(rmPath, "utf-8");
33910
33849
  const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapePhaseNum(phaseNum)}[:\\s][^\\n]*)`, "i");
@@ -33918,7 +33857,7 @@ async function phaseCompleteCore(cwd, phaseNum) {
33918
33857
  await node_fs.promises.writeFile(rmPath, roadmapContent, "utf-8");
33919
33858
  debugLog("phase-complete-write", `ROADMAP.md updated for phase ${phaseNum}`);
33920
33859
  const reqPath = planningPath(cwd, "REQUIREMENTS.md");
33921
- if (await pathExistsAsync(reqPath)) {
33860
+ if (await pathExistsInternal(reqPath)) {
33922
33861
  const reqMatch = roadmapContent.match(new RegExp(`Phase\\s+${escapePhaseNum(phaseNum)}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, "i"));
33923
33862
  if (reqMatch) {
33924
33863
  const reqIds = reqMatch[1].replace(/[\[\]]/g, "").split(/[,\s]+/).map((r) => r.trim()).filter(Boolean);
@@ -33938,7 +33877,7 @@ async function phaseCompleteCore(cwd, phaseNum) {
33938
33877
  let nextPhaseName = null;
33939
33878
  let isLastPhase = true;
33940
33879
  try {
33941
- const dirs = await listSubDirsAsync(phasesDirPath, true);
33880
+ const dirs = await listSubDirs(phasesDirPath, true);
33942
33881
  for (const dir of dirs) {
33943
33882
  const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)?)-?(.*)/i);
33944
33883
  if (dm) {
@@ -33953,7 +33892,7 @@ async function phaseCompleteCore(cwd, phaseNum) {
33953
33892
  } catch (e) {
33954
33893
  debugLog("phase-complete-next-phase-scan-failed", e);
33955
33894
  }
33956
- const stExists = await pathExistsAsync(stPath);
33895
+ const stExists = await pathExistsInternal(stPath);
33957
33896
  if (stExists) {
33958
33897
  let stateContent = await node_fs.promises.readFile(stPath, "utf-8");
33959
33898
  stateContent = stateContent.replace(/(\*\*Current Phase:\*\*\s*).*/, `$1${nextPhaseNum || phaseNum}`);
@@ -33980,6 +33919,1462 @@ async function phaseCompleteCore(cwd, phaseNum) {
33980
33919
  };
33981
33920
  }
33982
33921
 
33922
+ //#endregion
33923
+ //#region src/github/gh.ts
33924
+ /**
33925
+ * GitHub CLI Wrapper — Core gh CLI interaction layer
33926
+ *
33927
+ * Wraps the `gh` CLI using `child_process.execFile` (never `exec`) for security.
33928
+ * Provides typed results via GhResult<T> discriminated union.
33929
+ * Supports graceful degradation: detectGitHubMode() returns 'local-only'
33930
+ * when gh is not installed or not authenticated with required scopes.
33931
+ *
33932
+ * CRITICAL: Never import octokit or any npm GitHub SDK.
33933
+ * CRITICAL: Never call process.exit() — return GhResult instead.
33934
+ */
33935
+ const execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
33936
+ /**
33937
+ * Check if the `gh` CLI is installed and authenticated with required scopes.
33938
+ *
33939
+ * Parses the output of `gh auth status` (which writes to stderr, not stdout).
33940
+ * Returns structured AuthStatus with scope detection for 'project' scope.
33941
+ * Timeout: 10 seconds.
33942
+ */
33943
+ async function checkGhAuth() {
33944
+ try {
33945
+ const { stdout, stderr } = await execFileAsync("gh", ["auth", "status"], { timeout: 1e4 });
33946
+ const output = stderr || stdout;
33947
+ const authenticated = !output.includes("not logged in");
33948
+ const scopeMatch = output.match(/Token scopes?:\s*'([^']+(?:',\s*'[^']+)*)'/);
33949
+ const scopes = [];
33950
+ if (scopeMatch) {
33951
+ const allScopes = scopeMatch[0].matchAll(/'([^']+)'/g);
33952
+ for (const m of allScopes) scopes.push(m[1]);
33953
+ }
33954
+ const userMatch = output.match(/Logged in to [^\s]+ as ([^\s(]+)/);
33955
+ return {
33956
+ installed: true,
33957
+ authenticated,
33958
+ scopes,
33959
+ hasProjectScope: scopes.includes("project") || scopes.includes("read:project"),
33960
+ username: userMatch ? userMatch[1] : null
33961
+ };
33962
+ } catch (e) {
33963
+ const error = e;
33964
+ if (error.code === "ENOENT") return {
33965
+ installed: false,
33966
+ authenticated: false,
33967
+ scopes: [],
33968
+ hasProjectScope: false,
33969
+ username: null
33970
+ };
33971
+ error.stderr || error.message;
33972
+ return {
33973
+ installed: true,
33974
+ authenticated: false,
33975
+ scopes: [],
33976
+ hasProjectScope: false,
33977
+ username: null
33978
+ };
33979
+ }
33980
+ }
33981
+ /**
33982
+ * Detect the GitHub integration mode based on auth status.
33983
+ *
33984
+ * Returns 'full' only when gh is installed, authenticated, and has the
33985
+ * 'project' scope. Otherwise returns 'local-only' for graceful degradation.
33986
+ */
33987
+ async function detectGitHubMode() {
33988
+ const auth = await checkGhAuth();
33989
+ if (!auth.installed) return "local-only";
33990
+ if (!auth.authenticated) return "local-only";
33991
+ if (!auth.hasProjectScope) {
33992
+ console.error("[maxsim] GitHub Projects requires 'project' scope. Run: gh auth refresh -s project");
33993
+ return "local-only";
33994
+ }
33995
+ return "full";
33996
+ }
33997
+ /**
33998
+ * Execute a `gh` CLI command and return a typed GhResult.
33999
+ *
34000
+ * - Uses `execFile` (not `exec`) for security
34001
+ * - Default timeout: 30 seconds
34002
+ * - Auto-detects JSON output when args contain `--json` or `--format`
34003
+ * - Maps exit codes and stderr patterns to GhErrorCode
34004
+ * - For `gh issue create`: does NOT try to parse JSON (it returns a URL string)
34005
+ * - Always includes raw stderr in error messages for AI consumption
34006
+ */
34007
+ async function ghExec(args, options) {
34008
+ const timeout = options?.timeout ?? 3e4;
34009
+ const isIssueCreate = args[0] === "issue" && args[1] === "create";
34010
+ const hasJsonFlag = args.includes("--json") || args.some((a) => a.startsWith("--format"));
34011
+ const shouldParseJson = options?.parseJson ?? (hasJsonFlag && !isIssueCreate);
34012
+ try {
34013
+ const { stdout, stderr } = await execFileAsync("gh", args, {
34014
+ cwd: options?.cwd,
34015
+ timeout,
34016
+ maxBuffer: 10 * 1024 * 1024
34017
+ });
34018
+ if (shouldParseJson) try {
34019
+ return {
34020
+ ok: true,
34021
+ data: JSON.parse(stdout)
34022
+ };
34023
+ } catch {
34024
+ return {
34025
+ ok: false,
34026
+ error: `Failed to parse gh output as JSON: ${stdout.slice(0, 500)}`,
34027
+ code: "UNKNOWN"
34028
+ };
34029
+ }
34030
+ return {
34031
+ ok: true,
34032
+ data: stdout.trim()
34033
+ };
34034
+ } catch (e) {
34035
+ return mapExecError(e);
34036
+ }
34037
+ }
34038
+ /**
34039
+ * Execute a GraphQL query via `gh api graphql`.
34040
+ *
34041
+ * - String variables use `-f key=value`
34042
+ * - Non-string variables (numbers, booleans) use `-F key=value`
34043
+ * - Parses JSON response and checks for GraphQL `errors` array
34044
+ */
34045
+ async function ghGraphQL(query, variables) {
34046
+ const args = [
34047
+ "api",
34048
+ "graphql",
34049
+ "-f",
34050
+ `query=${query}`
34051
+ ];
34052
+ if (variables) for (const [key, value] of Object.entries(variables)) if (typeof value === "string") args.push("-f", `${key}=${value}`);
34053
+ else args.push("-F", `${key}=${String(value)}`);
34054
+ const result = await ghExec(args, { parseJson: true });
34055
+ if (!result.ok) return result;
34056
+ if (result.data.errors && result.data.errors.length > 0) {
34057
+ const messages = result.data.errors.map((e) => e.message).join("; ");
34058
+ const code = mapGraphQLErrorCode(messages);
34059
+ return {
34060
+ ok: false,
34061
+ error: `GraphQL error: ${messages}`,
34062
+ code
34063
+ };
34064
+ }
34065
+ if (result.data.data === void 0) return {
34066
+ ok: false,
34067
+ error: "GraphQL response missing data field",
34068
+ code: "UNKNOWN"
34069
+ };
34070
+ return {
34071
+ ok: true,
34072
+ data: result.data.data
34073
+ };
34074
+ }
34075
+ /**
34076
+ * Map an execFile error to a GhResult with appropriate GhErrorCode.
34077
+ */
34078
+ function mapExecError(e) {
34079
+ const error = e;
34080
+ if (error.code === "ENOENT") return {
34081
+ ok: false,
34082
+ error: "gh CLI is not installed. Install from https://cli.github.com/",
34083
+ code: "NOT_INSTALLED"
34084
+ };
34085
+ const stderr = error.stderr || error.message || "";
34086
+ if (error.status === 4) return {
34087
+ ok: false,
34088
+ error: `Not found: ${stderr}`,
34089
+ code: "NOT_FOUND"
34090
+ };
34091
+ if (stderr.includes("not logged in") || stderr.includes("authentication") || stderr.includes("auth login") || stderr.includes("401")) return {
34092
+ ok: false,
34093
+ error: `Authentication required: ${stderr}`,
34094
+ code: "NOT_AUTHENTICATED"
34095
+ };
34096
+ if (stderr.includes("403") || stderr.includes("permission") || stderr.includes("denied")) return {
34097
+ ok: false,
34098
+ error: `Permission denied: ${stderr}`,
34099
+ code: "PERMISSION_DENIED"
34100
+ };
34101
+ if (stderr.includes("rate limit") || stderr.includes("429") || stderr.includes("API rate")) return {
34102
+ ok: false,
34103
+ error: `Rate limited: ${stderr}`,
34104
+ code: "RATE_LIMITED"
34105
+ };
34106
+ if (stderr.includes("scope") || stderr.includes("insufficient")) return {
34107
+ ok: false,
34108
+ error: `Missing scope: ${stderr}`,
34109
+ code: "SCOPE_MISSING"
34110
+ };
34111
+ if (stderr.includes("not found") || stderr.includes("404") || stderr.includes("Could not resolve")) return {
34112
+ ok: false,
34113
+ error: `Not found: ${stderr}`,
34114
+ code: "NOT_FOUND"
34115
+ };
34116
+ return {
34117
+ ok: false,
34118
+ error: `gh command failed: ${stderr}`,
34119
+ code: "UNKNOWN"
34120
+ };
34121
+ }
34122
+ /**
34123
+ * Map GraphQL error messages to GhErrorCode.
34124
+ */
34125
+ function mapGraphQLErrorCode(message) {
34126
+ const lower = message.toLowerCase();
34127
+ if (lower.includes("not found") || lower.includes("could not resolve")) return "NOT_FOUND";
34128
+ if (lower.includes("insufficient") || lower.includes("scope")) return "SCOPE_MISSING";
34129
+ if (lower.includes("forbidden") || lower.includes("permission")) return "PERMISSION_DENIED";
34130
+ if (lower.includes("rate") || lower.includes("throttl")) return "RATE_LIMITED";
34131
+ return "UNKNOWN";
34132
+ }
34133
+
34134
+ //#endregion
34135
+ //#region src/github/mapping.ts
34136
+ /**
34137
+ * GitHub Issues Mapping — Persistence layer for github-issues.json
34138
+ *
34139
+ * Manages the `.planning/github-issues.json` file that maps MAXSIM tasks/todos
34140
+ * to their corresponding GitHub issue numbers, node IDs, and project item IDs.
34141
+ *
34142
+ * All file operations use synchronous fs (matching the pattern in existing core modules).
34143
+ * Uses planningPath() from core to construct file paths.
34144
+ *
34145
+ * CRITICAL: Never call process.exit() — throw or return null instead.
34146
+ */
34147
+ const MAPPING_FILENAME = "github-issues.json";
34148
+ /**
34149
+ * Get the absolute path to `.planning/github-issues.json` for a given cwd.
34150
+ */
34151
+ function mappingFilePath(cwd) {
34152
+ return planningPath(cwd, MAPPING_FILENAME);
34153
+ }
34154
+ /**
34155
+ * Load and parse the mapping file.
34156
+ *
34157
+ * Returns null if the file does not exist.
34158
+ * Throws on malformed JSON or invalid structure (missing required fields).
34159
+ */
34160
+ function loadMapping(cwd) {
34161
+ const filePath = mappingFilePath(cwd);
34162
+ try {
34163
+ node_fs.default.statSync(filePath);
34164
+ } catch {
34165
+ return null;
34166
+ }
34167
+ const raw = node_fs.default.readFileSync(filePath, "utf-8");
34168
+ const parsed = JSON.parse(raw);
34169
+ if (typeof parsed.project_number !== "number" || typeof parsed.repo !== "string") throw new Error(`Invalid github-issues.json: missing required fields 'project_number' (number) and 'repo' (string)`);
34170
+ return parsed;
34171
+ }
34172
+ /**
34173
+ * Write the mapping file to `.planning/github-issues.json`.
34174
+ *
34175
+ * Creates the `.planning/` directory if it does not exist.
34176
+ * Writes with 2-space indent for readability and diff-friendliness.
34177
+ */
34178
+ function saveMapping(cwd, mapping) {
34179
+ const filePath = mappingFilePath(cwd);
34180
+ const dir = node_path.default.dirname(filePath);
34181
+ node_fs.default.mkdirSync(dir, { recursive: true });
34182
+ node_fs.default.writeFileSync(filePath, JSON.stringify(mapping, null, 2) + "\n", "utf-8");
34183
+ }
34184
+ /**
34185
+ * Update a specific task's issue mapping within a phase.
34186
+ *
34187
+ * Load-modify-save pattern. Creates phase entry if it does not exist.
34188
+ * Merges partial data with existing entry (if any).
34189
+ *
34190
+ * @throws If mapping file does not exist (must be initialized first via saveMapping)
34191
+ */
34192
+ function updateTaskMapping(cwd, phaseNum, taskId, data) {
34193
+ const mapping = loadMapping(cwd);
34194
+ if (!mapping) throw new Error("github-issues.json does not exist. Run project setup first.");
34195
+ if (!mapping.phases[phaseNum]) mapping.phases[phaseNum] = {
34196
+ tracking_issue: {
34197
+ number: 0,
34198
+ node_id: "",
34199
+ item_id: "",
34200
+ status: "To Do"
34201
+ },
34202
+ plan: "",
34203
+ tasks: {}
34204
+ };
34205
+ const existing = mapping.phases[phaseNum].tasks[taskId];
34206
+ const defaults = {
34207
+ number: 0,
34208
+ node_id: "",
34209
+ item_id: "",
34210
+ status: "To Do"
34211
+ };
34212
+ mapping.phases[phaseNum].tasks[taskId] = Object.assign(defaults, existing, data);
34213
+ saveMapping(cwd, mapping);
34214
+ }
34215
+ /**
34216
+ * Update a specific todo's issue mapping.
34217
+ *
34218
+ * Load-modify-save pattern. Creates `todos` section if missing.
34219
+ * Merges partial data with existing entry (if any).
34220
+ *
34221
+ * @throws If mapping file does not exist (must be initialized first via saveMapping)
34222
+ */
34223
+ function updateTodoMapping(cwd, todoId, data) {
34224
+ const mapping = loadMapping(cwd);
34225
+ if (!mapping) throw new Error("github-issues.json does not exist. Run project setup first.");
34226
+ if (!mapping.todos) mapping.todos = {};
34227
+ const existing = mapping.todos[todoId];
34228
+ const defaults = {
34229
+ number: 0,
34230
+ node_id: "",
34231
+ item_id: "",
34232
+ status: "To Do"
34233
+ };
34234
+ mapping.todos[todoId] = Object.assign(defaults, existing, data);
34235
+ saveMapping(cwd, mapping);
34236
+ }
34237
+ /**
34238
+ * Create a properly typed empty mapping object with sensible defaults.
34239
+ *
34240
+ * Used during initial project setup to create the mapping file.
34241
+ */
34242
+ function createEmptyMapping(repo) {
34243
+ return {
34244
+ project_number: 0,
34245
+ project_id: "",
34246
+ repo,
34247
+ status_field_id: "",
34248
+ status_options: {},
34249
+ estimate_field_id: "",
34250
+ milestone_id: 0,
34251
+ milestone_title: "",
34252
+ labels: {},
34253
+ phases: {},
34254
+ todos: {}
34255
+ };
34256
+ }
34257
+
34258
+ //#endregion
34259
+ //#region src/github/issues.ts
34260
+ /**
34261
+ * Parse an issue number from a `gh issue create` stdout URL.
34262
+ *
34263
+ * `gh issue create` outputs a URL like:
34264
+ * https://github.com/owner/repo/issues/42\n
34265
+ *
34266
+ * We trim whitespace and extract the last path segment as the issue number.
34267
+ */
34268
+ function parseIssueNumberFromUrl(stdout) {
34269
+ const lastSegment = stdout.trim().split("/").pop();
34270
+ if (!lastSegment) return null;
34271
+ const num = parseInt(lastSegment, 10);
34272
+ return Number.isNaN(num) ? null : num;
34273
+ }
34274
+ /**
34275
+ * After creating an issue (parsed number from URL), fetch its node_id
34276
+ * via `gh issue view {number} --json nodeId,number,url`.
34277
+ */
34278
+ async function fetchIssueDetails(issueNumber) {
34279
+ const result = await ghExec([
34280
+ "issue",
34281
+ "view",
34282
+ String(issueNumber),
34283
+ "--json",
34284
+ "nodeId,number,url"
34285
+ ], { parseJson: true });
34286
+ if (!result.ok) return {
34287
+ ok: false,
34288
+ error: result.error,
34289
+ code: result.code
34290
+ };
34291
+ return {
34292
+ ok: true,
34293
+ data: {
34294
+ number: result.data.number,
34295
+ url: result.data.url,
34296
+ node_id: result.data.nodeId
34297
+ }
34298
+ };
34299
+ }
34300
+ /**
34301
+ * Create a task issue with full specification body in collapsible details section.
34302
+ *
34303
+ * Title format: `[P{phaseNum}] {title}`
34304
+ * Body includes summary, actions, acceptance criteria, dependencies in `<details>`.
34305
+ * Labels: maxsim, phase-task.
34306
+ *
34307
+ * Returns the issue number, URL, and node_id.
34308
+ */
34309
+ async function createTaskIssue(opts) {
34310
+ const issueTitle = `[P${opts.phaseNum}] ${opts.title}`;
34311
+ const depsSection = opts.dependencies && opts.dependencies.length > 0 ? `\n### Dependencies\nDepends on: ${opts.dependencies.map((d) => `#${d}`).join(", ")}\n` : "";
34312
+ const estimateSection = opts.estimate !== void 0 ? `\n### Estimate\n${opts.estimate} points\n` : "";
34313
+ const args = [
34314
+ "issue",
34315
+ "create",
34316
+ "--title",
34317
+ issueTitle,
34318
+ "--body",
34319
+ `## Summary
34320
+ ${opts.summary}
34321
+
34322
+ <details>
34323
+ <summary>Full Specification</summary>
34324
+
34325
+ ### Actions
34326
+ ${opts.actions.map((a) => `- ${a}`).join("\n")}
34327
+
34328
+ ### Acceptance Criteria
34329
+ ${opts.acceptanceCriteria.map((c) => `- [ ] ${c}`).join("\n")}
34330
+ ${depsSection}${estimateSection}
34331
+ </details>
34332
+
34333
+ ---
34334
+ *Phase: ${opts.phaseNum} | Plan: ${opts.planNum} | Task: ${opts.taskId}*
34335
+ *Generated by MAXSIM*`
34336
+ ];
34337
+ args.push("--label", "maxsim");
34338
+ args.push("--label", "phase-task");
34339
+ if (opts.labels) for (const label of opts.labels) args.push("--label", label);
34340
+ if (opts.milestone) args.push("--milestone", opts.milestone);
34341
+ if (opts.projectTitle) args.push("--project", opts.projectTitle);
34342
+ const createResult = await ghExec(args);
34343
+ if (!createResult.ok) return {
34344
+ ok: false,
34345
+ error: createResult.error,
34346
+ code: createResult.code
34347
+ };
34348
+ const issueNumber = parseIssueNumberFromUrl(createResult.data);
34349
+ if (issueNumber === null) return {
34350
+ ok: false,
34351
+ error: `Failed to parse issue number from gh output: ${createResult.data}`,
34352
+ code: "UNKNOWN"
34353
+ };
34354
+ return fetchIssueDetails(issueNumber);
34355
+ }
34356
+ /**
34357
+ * Create a parent tracking issue for a phase with a live checkbox task list.
34358
+ *
34359
+ * Title format: `[Phase {phaseNum}] {phaseName}`
34360
+ * Body includes task list with checkbox links: `- [ ] #{childNumber}`
34361
+ * Labels: maxsim, phase-task.
34362
+ */
34363
+ async function createParentTrackingIssue(opts) {
34364
+ const issueTitle = `[Phase ${opts.phaseNum}] ${opts.phaseName}`;
34365
+ const taskList = opts.childIssueNumbers.map((n) => `- [ ] #${n}`).join("\n");
34366
+ const args = [
34367
+ "issue",
34368
+ "create",
34369
+ "--title",
34370
+ issueTitle,
34371
+ "--body",
34372
+ `## Phase ${opts.phaseNum}: ${opts.phaseName}
34373
+
34374
+ ### Tasks
34375
+ ${taskList}
34376
+
34377
+ ---
34378
+ *Phase tracking issue -- Generated by MAXSIM*`
34379
+ ];
34380
+ args.push("--label", "maxsim");
34381
+ args.push("--label", "phase-task");
34382
+ if (opts.milestone) args.push("--milestone", opts.milestone);
34383
+ if (opts.projectTitle) args.push("--project", opts.projectTitle);
34384
+ const createResult = await ghExec(args);
34385
+ if (!createResult.ok) return {
34386
+ ok: false,
34387
+ error: createResult.error,
34388
+ code: createResult.code
34389
+ };
34390
+ const issueNumber = parseIssueNumberFromUrl(createResult.data);
34391
+ if (issueNumber === null) return {
34392
+ ok: false,
34393
+ error: `Failed to parse issue number from gh output: ${createResult.data}`,
34394
+ code: "UNKNOWN"
34395
+ };
34396
+ return fetchIssueDetails(issueNumber);
34397
+ }
34398
+ /**
34399
+ * Create a todo issue with a lighter body (no collapsible details section).
34400
+ *
34401
+ * Labels: maxsim, todo.
34402
+ */
34403
+ async function createTodoIssue(opts) {
34404
+ let body = "";
34405
+ if (opts.description) body += `${opts.description}\n`;
34406
+ if (opts.acceptanceCriteria && opts.acceptanceCriteria.length > 0) {
34407
+ body += `\n### Acceptance Criteria\n`;
34408
+ body += opts.acceptanceCriteria.map((c) => `- [ ] ${c}`).join("\n");
34409
+ body += "\n";
34410
+ }
34411
+ body += `\n---\n*Generated by MAXSIM*`;
34412
+ const args = [
34413
+ "issue",
34414
+ "create",
34415
+ "--title",
34416
+ opts.title,
34417
+ "--body",
34418
+ body
34419
+ ];
34420
+ args.push("--label", "maxsim");
34421
+ args.push("--label", "todo");
34422
+ if (opts.milestone) args.push("--milestone", opts.milestone);
34423
+ if (opts.projectTitle) args.push("--project", opts.projectTitle);
34424
+ const createResult = await ghExec(args);
34425
+ if (!createResult.ok) return {
34426
+ ok: false,
34427
+ error: createResult.error,
34428
+ code: createResult.code
34429
+ };
34430
+ const issueNumber = parseIssueNumberFromUrl(createResult.data);
34431
+ if (issueNumber === null) return {
34432
+ ok: false,
34433
+ error: `Failed to parse issue number from gh output: ${createResult.data}`,
34434
+ code: "UNKNOWN"
34435
+ };
34436
+ return fetchIssueDetails(issueNumber);
34437
+ }
34438
+ /**
34439
+ * Build a PR description body with `Closes #{N}` lines for auto-close on merge (AC-08).
34440
+ *
34441
+ * This function is called by `mcp_create_pr` in Plan 04's github-tools.ts.
34442
+ */
34443
+ function buildPrBody(closesIssues, additionalContent) {
34444
+ return `${closesIssues.map((n) => `Closes #${n}`).join("\n")}${additionalContent ? `\n\n${additionalContent}` : ""}`;
34445
+ }
34446
+ /**
34447
+ * Close an issue with an optional reason.
34448
+ *
34449
+ * Reason defaults to 'completed' if not specified.
34450
+ */
34451
+ async function closeIssue(issueNumber, reason) {
34452
+ const args = [
34453
+ "issue",
34454
+ "close",
34455
+ String(issueNumber)
34456
+ ];
34457
+ if (reason) args.push("--reason", reason);
34458
+ const result = await ghExec(args);
34459
+ if (!result.ok) return {
34460
+ ok: false,
34461
+ error: result.error,
34462
+ code: result.code
34463
+ };
34464
+ return {
34465
+ ok: true,
34466
+ data: void 0
34467
+ };
34468
+ }
34469
+ /**
34470
+ * Close an issue as superseded by a newer issue.
34471
+ *
34472
+ * 1. Posts "Superseded by #{newIssueNumber}" comment on old issue
34473
+ * 2. Adds 'superseded' label to old issue
34474
+ * 3. Closes old issue as completed
34475
+ * 4. Posts "Replaces #{oldIssueNumber}" comment on new issue
34476
+ *
34477
+ * Creates bidirectional cross-references.
34478
+ */
34479
+ async function closeIssueAsSuperseded(oldIssueNumber, newIssueNumber) {
34480
+ const commentResult = await postComment(oldIssueNumber, `Superseded by #${newIssueNumber}`);
34481
+ if (!commentResult.ok) return {
34482
+ ok: false,
34483
+ error: commentResult.error,
34484
+ code: commentResult.code
34485
+ };
34486
+ const labelResult = await ghExec([
34487
+ "issue",
34488
+ "edit",
34489
+ String(oldIssueNumber),
34490
+ "--add-label",
34491
+ "superseded"
34492
+ ]);
34493
+ if (!labelResult.ok) return {
34494
+ ok: false,
34495
+ error: labelResult.error,
34496
+ code: labelResult.code
34497
+ };
34498
+ const closeResult = await closeIssue(oldIssueNumber, "completed");
34499
+ if (!closeResult.ok) return closeResult;
34500
+ const replaceCommentResult = await postComment(newIssueNumber, `Replaces #${oldIssueNumber}`);
34501
+ if (!replaceCommentResult.ok) return {
34502
+ ok: false,
34503
+ error: replaceCommentResult.error,
34504
+ code: replaceCommentResult.code
34505
+ };
34506
+ return {
34507
+ ok: true,
34508
+ data: void 0
34509
+ };
34510
+ }
34511
+ /**
34512
+ * Post a comment on an issue.
34513
+ */
34514
+ async function postComment(issueNumber, body) {
34515
+ const result = await ghExec([
34516
+ "issue",
34517
+ "comment",
34518
+ String(issueNumber),
34519
+ "--body",
34520
+ body
34521
+ ]);
34522
+ if (!result.ok) return {
34523
+ ok: false,
34524
+ error: result.error,
34525
+ code: result.code
34526
+ };
34527
+ return {
34528
+ ok: true,
34529
+ data: void 0
34530
+ };
34531
+ }
34532
+ /**
34533
+ * Import an existing external GitHub issue into MAXSIM tracking.
34534
+ *
34535
+ * Reads the issue details and adds 'maxsim' and 'imported' labels.
34536
+ * Returns the issue details for the AI to decide placement.
34537
+ */
34538
+ async function importExternalIssue(issueNumber) {
34539
+ const viewResult = await ghExec([
34540
+ "issue",
34541
+ "view",
34542
+ String(issueNumber),
34543
+ "--json",
34544
+ "title,labels,body,state"
34545
+ ], { parseJson: true });
34546
+ if (!viewResult.ok) return {
34547
+ ok: false,
34548
+ error: viewResult.error,
34549
+ code: viewResult.code
34550
+ };
34551
+ const labelResult = await ghExec([
34552
+ "issue",
34553
+ "edit",
34554
+ String(issueNumber),
34555
+ "--add-label",
34556
+ "maxsim,imported"
34557
+ ]);
34558
+ if (!labelResult.ok) return {
34559
+ ok: false,
34560
+ error: labelResult.error,
34561
+ code: labelResult.code
34562
+ };
34563
+ const existingLabels = viewResult.data.labels.map((l) => l.name);
34564
+ const allLabels = Array.from(new Set([
34565
+ ...existingLabels,
34566
+ "maxsim",
34567
+ "imported"
34568
+ ]));
34569
+ return {
34570
+ ok: true,
34571
+ data: {
34572
+ number: issueNumber,
34573
+ title: viewResult.data.title,
34574
+ labels: allLabels
34575
+ }
34576
+ };
34577
+ }
34578
+ /**
34579
+ * Update the parent tracking issue's task list checkbox.
34580
+ *
34581
+ * Reads the parent issue body, finds `- [ ] #{childNumber}` or `- [x] #{childNumber}`,
34582
+ * toggles the checkbox, and updates the issue body via `gh issue edit`.
34583
+ */
34584
+ async function updateParentTaskList(parentIssueNumber, childIssueNumber, checked) {
34585
+ const viewResult = await ghExec([
34586
+ "issue",
34587
+ "view",
34588
+ String(parentIssueNumber),
34589
+ "--json",
34590
+ "body"
34591
+ ], { parseJson: true });
34592
+ if (!viewResult.ok) return {
34593
+ ok: false,
34594
+ error: viewResult.error,
34595
+ code: viewResult.code
34596
+ };
34597
+ const currentBody = viewResult.data.body;
34598
+ const checkboxPattern = new RegExp(`- \\[([ x])\\] #${childIssueNumber}\\b`, "g");
34599
+ const newCheckState = checked ? "x" : " ";
34600
+ const updatedBody = currentBody.replace(checkboxPattern, `- [${newCheckState}] #${childIssueNumber}`);
34601
+ if (updatedBody === currentBody) return {
34602
+ ok: true,
34603
+ data: void 0
34604
+ };
34605
+ const editResult = await ghExec([
34606
+ "issue",
34607
+ "edit",
34608
+ String(parentIssueNumber),
34609
+ "--body",
34610
+ updatedBody
34611
+ ]);
34612
+ if (!editResult.ok) return {
34613
+ ok: false,
34614
+ error: editResult.error,
34615
+ code: editResult.code
34616
+ };
34617
+ return {
34618
+ ok: true,
34619
+ data: void 0
34620
+ };
34621
+ }
34622
+ /**
34623
+ * Create all issues for a plan at once (eager creation on plan finalization).
34624
+ *
34625
+ * 1. Creates all task issues with concurrency limit of 5 (rate limit safety).
34626
+ * 2. After all task issues created, creates parent tracking issue.
34627
+ * 3. Updates mapping file for all created issues.
34628
+ * 4. Returns parent issue number and all task issue numbers.
34629
+ *
34630
+ * Handles partial failures: continues batch, reports which failed.
34631
+ */
34632
+ async function createAllPlanIssues(opts) {
34633
+ const BATCH_SIZE = 5;
34634
+ const results = [];
34635
+ const failures = [];
34636
+ for (let i = 0; i < opts.tasks.length; i += BATCH_SIZE) {
34637
+ const batchPromises = opts.tasks.slice(i, i + BATCH_SIZE).map(async (task) => {
34638
+ let depIssueNumbers;
34639
+ if (task.dependencies && task.dependencies.length > 0) depIssueNumbers = task.dependencies.map((depId) => {
34640
+ const found = results.find((r) => r.taskId === depId);
34641
+ return found ? found.issueNumber : 0;
34642
+ }).filter((n) => n > 0);
34643
+ const result = await createTaskIssue({
34644
+ title: task.title,
34645
+ phaseNum: opts.phaseNum,
34646
+ planNum: opts.planNum,
34647
+ taskId: task.taskId,
34648
+ summary: task.summary,
34649
+ actions: task.actions,
34650
+ acceptanceCriteria: task.acceptanceCriteria,
34651
+ dependencies: depIssueNumbers,
34652
+ milestone: opts.milestone,
34653
+ projectTitle: opts.projectTitle,
34654
+ estimate: task.estimate
34655
+ });
34656
+ return {
34657
+ taskId: task.taskId,
34658
+ result
34659
+ };
34660
+ });
34661
+ const batchResults = await Promise.all(batchPromises);
34662
+ for (const { taskId, result } of batchResults) if (result.ok) results.push({
34663
+ taskId,
34664
+ issueNumber: result.data.number,
34665
+ nodeId: result.data.node_id
34666
+ });
34667
+ else failures.push({
34668
+ taskId,
34669
+ error: result.error
34670
+ });
34671
+ }
34672
+ if (results.length === 0) return {
34673
+ ok: false,
34674
+ error: `All task issue creations failed: ${failures.map((f) => `${f.taskId}: ${f.error}`).join("; ")}`,
34675
+ code: "UNKNOWN"
34676
+ };
34677
+ const childNumbers = results.map((r) => r.issueNumber);
34678
+ const parentResult = await createParentTrackingIssue({
34679
+ phaseNum: opts.phaseNum,
34680
+ phaseName: opts.phaseName,
34681
+ childIssueNumbers: childNumbers,
34682
+ milestone: opts.milestone,
34683
+ projectTitle: opts.projectTitle
34684
+ });
34685
+ if (!parentResult.ok) return {
34686
+ ok: false,
34687
+ error: `Task issues created but parent tracking issue failed: ${parentResult.error}`,
34688
+ code: parentResult.code
34689
+ };
34690
+ const mapping = loadMapping(opts.cwd);
34691
+ if (mapping) {
34692
+ if (!mapping.phases[opts.phaseNum]) mapping.phases[opts.phaseNum] = {
34693
+ tracking_issue: {
34694
+ number: 0,
34695
+ node_id: "",
34696
+ item_id: "",
34697
+ status: "To Do"
34698
+ },
34699
+ plan: "",
34700
+ tasks: {}
34701
+ };
34702
+ mapping.phases[opts.phaseNum].tracking_issue = {
34703
+ number: parentResult.data.number,
34704
+ node_id: parentResult.data.node_id,
34705
+ item_id: "",
34706
+ status: "To Do"
34707
+ };
34708
+ mapping.phases[opts.phaseNum].plan = `${opts.phaseNum}-${opts.planNum}`;
34709
+ for (const r of results) mapping.phases[opts.phaseNum].tasks[r.taskId] = {
34710
+ number: r.issueNumber,
34711
+ node_id: r.nodeId,
34712
+ item_id: "",
34713
+ status: "To Do"
34714
+ };
34715
+ saveMapping(opts.cwd, mapping);
34716
+ }
34717
+ const taskIssues = results.map((r) => ({
34718
+ taskId: r.taskId,
34719
+ issueNumber: r.issueNumber
34720
+ }));
34721
+ return {
34722
+ ok: true,
34723
+ data: {
34724
+ parentIssue: parentResult.data.number,
34725
+ taskIssues
34726
+ }
34727
+ };
34728
+ }
34729
+ /**
34730
+ * Supersede old plan's issues when a plan is re-planned (fresh issues per plan).
34731
+ *
34732
+ * 1. Load mapping to find old plan's issue numbers.
34733
+ * 2. For each old issue, close as superseded with cross-reference to new issue.
34734
+ * 3. Close old parent tracking issue as superseded.
34735
+ * 4. Update mapping: mark old entries, add new entries.
34736
+ */
34737
+ async function supersedePlanIssues(opts) {
34738
+ const mapping = loadMapping(opts.cwd);
34739
+ if (!mapping) return {
34740
+ ok: false,
34741
+ error: "github-issues.json does not exist. Run project setup first.",
34742
+ code: "NOT_FOUND"
34743
+ };
34744
+ const phase = mapping.phases[opts.phaseNum];
34745
+ if (!phase) return {
34746
+ ok: false,
34747
+ error: `No phase ${opts.phaseNum} found in mapping file`,
34748
+ code: "NOT_FOUND"
34749
+ };
34750
+ const currentPlan = phase.plan;
34751
+ const expectedOldPlan = `${opts.phaseNum}-${opts.oldPlanNum}`;
34752
+ if (currentPlan !== expectedOldPlan) return {
34753
+ ok: false,
34754
+ error: `Phase ${opts.phaseNum} is on plan '${currentPlan}', expected '${expectedOldPlan}'`,
34755
+ code: "UNKNOWN"
34756
+ };
34757
+ const failures = [];
34758
+ const oldTasks = Object.entries(phase.tasks);
34759
+ for (const [taskId, oldTask] of oldTasks) {
34760
+ const newIssue = opts.newIssueNumbers.find((n) => n.taskId === taskId);
34761
+ if (!newIssue) {
34762
+ const closeResult = await closeIssue(oldTask.number, "completed");
34763
+ if (!closeResult.ok) failures.push(`close task ${taskId} (#${oldTask.number}): ${closeResult.error}`);
34764
+ continue;
34765
+ }
34766
+ const supersedeResult = await closeIssueAsSuperseded(oldTask.number, newIssue.issueNumber);
34767
+ if (!supersedeResult.ok) failures.push(`supersede task ${taskId} (#${oldTask.number} -> #${newIssue.issueNumber}): ${supersedeResult.error}`);
34768
+ }
34769
+ if (phase.tracking_issue.number > 0) {
34770
+ const closeResult = await closeIssue(phase.tracking_issue.number, "completed");
34771
+ if (!closeResult.ok) failures.push(`close parent tracking issue #${phase.tracking_issue.number}: ${closeResult.error}`);
34772
+ }
34773
+ phase.plan = `${opts.phaseNum}-${opts.newPlanNum}`;
34774
+ phase.tasks = {};
34775
+ for (const newIssue of opts.newIssueNumbers) phase.tasks[newIssue.taskId] = {
34776
+ number: newIssue.issueNumber,
34777
+ node_id: "",
34778
+ item_id: "",
34779
+ status: "To Do"
34780
+ };
34781
+ saveMapping(opts.cwd, mapping);
34782
+ if (failures.length > 0) return {
34783
+ ok: false,
34784
+ error: `Partial failure during supersession: ${failures.join("; ")}`,
34785
+ code: "UNKNOWN"
34786
+ };
34787
+ return {
34788
+ ok: true,
34789
+ data: void 0
34790
+ };
34791
+ }
34792
+
34793
+ //#endregion
34794
+ //#region src/github/projects.ts
34795
+ /**
34796
+ * Extract error info from a failed GhResult and re-wrap it for a different
34797
+ * generic type. This avoids TypeScript narrowing issues with discriminated
34798
+ * union property access.
34799
+ */
34800
+ function fail$2(result) {
34801
+ return {
34802
+ ok: false,
34803
+ error: result.error,
34804
+ code: result.code
34805
+ };
34806
+ }
34807
+ /**
34808
+ * Create a new GitHub Projects v2 board.
34809
+ *
34810
+ * Runs `gh project create --owner @me --title "{title}" --format json`.
34811
+ * Returns the project number and node ID.
34812
+ */
34813
+ async function createProjectBoard(title) {
34814
+ const result = await ghExec([
34815
+ "project",
34816
+ "create",
34817
+ "--owner",
34818
+ "@me",
34819
+ "--title",
34820
+ title,
34821
+ "--format",
34822
+ "json"
34823
+ ], { parseJson: true });
34824
+ if (!result.ok) return result;
34825
+ return {
34826
+ ok: true,
34827
+ data: {
34828
+ number: result.data.number,
34829
+ id: result.data.id
34830
+ }
34831
+ };
34832
+ }
34833
+ /**
34834
+ * Ensure a project board exists, creating it if needed.
34835
+ *
34836
+ * Checks the mapping file for an existing project. If found, verifies it
34837
+ * still exists via `gh project view`. If not found, creates a new board
34838
+ * and sets up fields and status options.
34839
+ *
34840
+ * Returns the project number, ID, and whether it was newly created.
34841
+ */
34842
+ async function ensureProjectBoard(title, cwd) {
34843
+ const mapping = loadMapping(cwd);
34844
+ if (mapping && mapping.project_number > 0 && mapping.project_id) {
34845
+ if ((await ghExec([
34846
+ "project",
34847
+ "view",
34848
+ String(mapping.project_number),
34849
+ "--owner",
34850
+ "@me",
34851
+ "--format",
34852
+ "json"
34853
+ ], { parseJson: true })).ok) return {
34854
+ ok: true,
34855
+ data: {
34856
+ number: mapping.project_number,
34857
+ id: mapping.project_id,
34858
+ created: false
34859
+ }
34860
+ };
34861
+ }
34862
+ const createResult = await createProjectBoard(title);
34863
+ if (!createResult.ok) return fail$2(createResult);
34864
+ const { number, id } = createResult.data;
34865
+ const setupResult = await setupProjectFields(number, id, cwd);
34866
+ if (!setupResult.ok) return fail$2(setupResult);
34867
+ return {
34868
+ ok: true,
34869
+ data: {
34870
+ number,
34871
+ id,
34872
+ created: true
34873
+ }
34874
+ };
34875
+ }
34876
+ /**
34877
+ * Get all fields for a project board.
34878
+ *
34879
+ * Runs `gh project field-list {num} --owner @me --format json`.
34880
+ * Returns field list with IDs, names, types, and options (for single-select).
34881
+ */
34882
+ async function getProjectFields(projectNum) {
34883
+ const result = await ghExec([
34884
+ "project",
34885
+ "field-list",
34886
+ String(projectNum),
34887
+ "--owner",
34888
+ "@me",
34889
+ "--format",
34890
+ "json"
34891
+ ], { parseJson: true });
34892
+ if (!result.ok) return fail$2(result);
34893
+ return {
34894
+ ok: true,
34895
+ data: result.data.fields ?? result.data
34896
+ };
34897
+ }
34898
+ /**
34899
+ * Add a new single-select option to a project field via GraphQL.
34900
+ *
34901
+ * The `updateProjectV2Field` mutation REPLACES all options, so all existing
34902
+ * options must be included alongside the new one.
34903
+ *
34904
+ * Returns the new option's ID.
34905
+ */
34906
+ async function addStatusOption(projectId, statusFieldId, optionName, existingOptions) {
34907
+ const result = await ghGraphQL(`
34908
+ mutation {
34909
+ updateProjectV2Field(input: {
34910
+ projectId: "${projectId}"
34911
+ fieldId: "${statusFieldId}"
34912
+ singleSelectOptions: [${[...existingOptions.map((o) => `{name: "${o.name}", description: "", color: GRAY}`), `{name: "${optionName}", description: "", color: BLUE}`].join(", ")}]
34913
+ }) {
34914
+ projectV2Field {
34915
+ ... on ProjectV2SingleSelectField {
34916
+ options { id name }
34917
+ }
34918
+ }
34919
+ }
34920
+ }
34921
+ `);
34922
+ if (!result.ok) return fail$2(result);
34923
+ const newOption = result.data.updateProjectV2Field.projectV2Field.options.find((o) => o.name === optionName);
34924
+ if (!newOption) return {
34925
+ ok: false,
34926
+ error: `Option "${optionName}" was not found after mutation — it may have been renamed or rejected`,
34927
+ code: "UNKNOWN"
34928
+ };
34929
+ return {
34930
+ ok: true,
34931
+ data: newOption.id
34932
+ };
34933
+ }
34934
+ /**
34935
+ * Set up project fields: Status options and Estimate number field.
34936
+ *
34937
+ * Orchestrates the full field setup:
34938
+ * (a) Get existing fields
34939
+ * (b) Find Status field, verify "In Review" option exists or add it
34940
+ * (c) Create Estimate NUMBER field via `gh project field-create`
34941
+ * (d) Store all field/option IDs in the mapping file
34942
+ */
34943
+ async function setupProjectFields(projectNum, projectId, cwd) {
34944
+ const fieldsResult = await getProjectFields(projectNum);
34945
+ if (!fieldsResult.ok) return fail$2(fieldsResult);
34946
+ const fields = fieldsResult.data;
34947
+ const statusField = fields.find((f) => f.name === "Status" && (f.type === "SINGLE_SELECT" || f.type === "ProjectV2SingleSelectField"));
34948
+ if (!statusField) return {
34949
+ ok: false,
34950
+ error: "Status field not found on project board. This is unexpected for a Projects v2 board.",
34951
+ code: "NOT_FOUND"
34952
+ };
34953
+ const statusOptions = statusField.options ?? [];
34954
+ const statusOptionsMap = {};
34955
+ for (const opt of statusOptions) statusOptionsMap[opt.name] = opt.id;
34956
+ if (!statusOptionsMap["In Review"]) {
34957
+ const addResult = await addStatusOption(projectId, statusField.id, "In Review", statusOptions);
34958
+ if (!addResult.ok) return fail$2(addResult);
34959
+ statusOptionsMap["In Review"] = addResult.data;
34960
+ }
34961
+ if (statusOptionsMap["Todo"] && !statusOptionsMap["To Do"]) statusOptionsMap["To Do"] = statusOptionsMap["Todo"];
34962
+ const estimateField = fields.find((f) => f.name === "Estimate");
34963
+ let estimateFieldId = estimateField?.id ?? "";
34964
+ if (!estimateField) {
34965
+ const createFieldResult = await ghExec([
34966
+ "project",
34967
+ "field-create",
34968
+ String(projectNum),
34969
+ "--owner",
34970
+ "@me",
34971
+ "--name",
34972
+ "Estimate",
34973
+ "--data-type",
34974
+ "NUMBER"
34975
+ ]);
34976
+ if (!createFieldResult.ok) return fail$2(createFieldResult);
34977
+ const refetch = await getProjectFields(projectNum);
34978
+ if (refetch.ok) {
34979
+ const est = refetch.data.find((f) => f.name === "Estimate");
34980
+ if (est) estimateFieldId = est.id;
34981
+ }
34982
+ }
34983
+ const repoResult = await ghExec([
34984
+ "repo",
34985
+ "view",
34986
+ "--json",
34987
+ "nameWithOwner",
34988
+ "--jq",
34989
+ ".nameWithOwner"
34990
+ ]);
34991
+ const repo = repoResult.ok ? repoResult.data.trim() : "";
34992
+ const mapping = loadMapping(cwd) ?? createEmptyMapping(repo);
34993
+ mapping.project_number = projectNum;
34994
+ mapping.project_id = projectId;
34995
+ mapping.status_field_id = statusField.id;
34996
+ mapping.status_options = statusOptionsMap;
34997
+ mapping.estimate_field_id = estimateFieldId;
34998
+ if (repo && !mapping.repo) mapping.repo = repo;
34999
+ saveMapping(cwd, mapping);
35000
+ return {
35001
+ ok: true,
35002
+ data: void 0
35003
+ };
35004
+ }
35005
+ /**
35006
+ * Add an issue to a project board.
35007
+ *
35008
+ * Runs `gh project item-add {num} --owner @me --url {issueUrl} --format json`.
35009
+ * Returns the project item ID (different from the issue ID).
35010
+ */
35011
+ async function addItemToProject(projectNum, issueUrl) {
35012
+ const result = await ghExec([
35013
+ "project",
35014
+ "item-add",
35015
+ String(projectNum),
35016
+ "--owner",
35017
+ "@me",
35018
+ "--url",
35019
+ issueUrl,
35020
+ "--format",
35021
+ "json"
35022
+ ], { parseJson: true });
35023
+ if (!result.ok) return fail$2(result);
35024
+ return {
35025
+ ok: true,
35026
+ data: { item_id: result.data.id }
35027
+ };
35028
+ }
35029
+ /**
35030
+ * Move a project item to a specific status column.
35031
+ *
35032
+ * Runs `gh project item-edit` with single-select-option-id for the
35033
+ * Status field.
35034
+ */
35035
+ async function moveItemToStatus(projectId, itemId, statusFieldId, statusOptionId) {
35036
+ const result = await ghExec([
35037
+ "project",
35038
+ "item-edit",
35039
+ "--project-id",
35040
+ projectId,
35041
+ "--id",
35042
+ itemId,
35043
+ "--field-id",
35044
+ statusFieldId,
35045
+ "--single-select-option-id",
35046
+ statusOptionId
35047
+ ]);
35048
+ if (!result.ok) return fail$2(result);
35049
+ return {
35050
+ ok: true,
35051
+ data: void 0
35052
+ };
35053
+ }
35054
+ /**
35055
+ * Set the Estimate (story points) field on a project item.
35056
+ *
35057
+ * Runs `gh project item-edit` with --number flag for the Estimate field.
35058
+ */
35059
+ async function setEstimate(projectId, itemId, estimateFieldId, points) {
35060
+ const result = await ghExec([
35061
+ "project",
35062
+ "item-edit",
35063
+ "--project-id",
35064
+ projectId,
35065
+ "--id",
35066
+ itemId,
35067
+ "--field-id",
35068
+ estimateFieldId,
35069
+ "--number",
35070
+ String(points)
35071
+ ]);
35072
+ if (!result.ok) return fail$2(result);
35073
+ return {
35074
+ ok: true,
35075
+ data: void 0
35076
+ };
35077
+ }
35078
+
35079
+ //#endregion
35080
+ //#region src/github/milestones.ts
35081
+ /**
35082
+ * Re-wrap a failed GhResult for a different generic type.
35083
+ */
35084
+ function fail$1(result) {
35085
+ return {
35086
+ ok: false,
35087
+ error: result.error,
35088
+ code: result.code
35089
+ };
35090
+ }
35091
+ /**
35092
+ * Create a new GitHub milestone.
35093
+ *
35094
+ * Uses the REST API: `POST /repos/{owner}/{repo}/milestones`.
35095
+ * The `{owner}` and `{repo}` placeholders are auto-resolved by `gh api`.
35096
+ *
35097
+ * Returns the milestone number and internal ID.
35098
+ */
35099
+ async function createMilestone(title, description) {
35100
+ const args = [
35101
+ "api",
35102
+ "repos/{owner}/{repo}/milestones",
35103
+ "-X",
35104
+ "POST",
35105
+ "-f",
35106
+ `title=${title}`,
35107
+ "-f",
35108
+ "state=open"
35109
+ ];
35110
+ if (description) args.push("-f", `description=${description}`);
35111
+ const result = await ghExec(args, { parseJson: true });
35112
+ if (!result.ok) return result;
35113
+ return {
35114
+ ok: true,
35115
+ data: {
35116
+ number: result.data.number,
35117
+ id: result.data.id
35118
+ }
35119
+ };
35120
+ }
35121
+ /**
35122
+ * Find an existing milestone by title.
35123
+ *
35124
+ * Uses the REST API: `GET /repos/{owner}/{repo}/milestones`.
35125
+ * Fetches all open milestones and filters by exact title match.
35126
+ *
35127
+ * Returns null if no milestone with the given title exists.
35128
+ */
35129
+ async function findMilestone(title) {
35130
+ const result = await ghExec([
35131
+ "api",
35132
+ "repos/{owner}/{repo}/milestones",
35133
+ "--paginate"
35134
+ ], { parseJson: true });
35135
+ if (!result.ok) return fail$1(result);
35136
+ const match = result.data.find((m) => m.title === title);
35137
+ if (!match) return {
35138
+ ok: true,
35139
+ data: null
35140
+ };
35141
+ return {
35142
+ ok: true,
35143
+ data: {
35144
+ number: match.number,
35145
+ id: match.id
35146
+ }
35147
+ };
35148
+ }
35149
+ /**
35150
+ * Ensure a milestone exists, creating it if needed. Idempotent.
35151
+ *
35152
+ * First attempts to find an existing milestone with the given title.
35153
+ * If not found, creates a new one. Returns whether it was newly created.
35154
+ */
35155
+ async function ensureMilestone(title, description) {
35156
+ const findResult = await findMilestone(title);
35157
+ if (!findResult.ok) return fail$1(findResult);
35158
+ if (findResult.data) return {
35159
+ ok: true,
35160
+ data: {
35161
+ ...findResult.data,
35162
+ created: false
35163
+ }
35164
+ };
35165
+ const createResult = await createMilestone(title, description);
35166
+ if (!createResult.ok) return fail$1(createResult);
35167
+ return {
35168
+ ok: true,
35169
+ data: {
35170
+ ...createResult.data,
35171
+ created: true
35172
+ }
35173
+ };
35174
+ }
35175
+ /**
35176
+ * Close a milestone if all its issues are closed.
35177
+ *
35178
+ * Fetches milestone details via REST API to check `open_issues` count.
35179
+ * If open_issues === 0, patches the milestone state to "closed".
35180
+ *
35181
+ * This implements AC-12: milestones auto-close when all issues are closed.
35182
+ */
35183
+ async function closeMilestoneIfComplete(milestoneNumber) {
35184
+ const detailResult = await ghExec(["api", `repos/{owner}/{repo}/milestones/${milestoneNumber}`], { parseJson: true });
35185
+ if (!detailResult.ok) return fail$1(detailResult);
35186
+ const milestone = detailResult.data;
35187
+ if (milestone.state === "closed") return {
35188
+ ok: true,
35189
+ data: { closed: true }
35190
+ };
35191
+ if (milestone.open_issues > 0) return {
35192
+ ok: true,
35193
+ data: { closed: false }
35194
+ };
35195
+ const closeResult = await ghExec([
35196
+ "api",
35197
+ `repos/{owner}/{repo}/milestones/${milestoneNumber}`,
35198
+ "-X",
35199
+ "PATCH",
35200
+ "-f",
35201
+ "state=closed"
35202
+ ]);
35203
+ if (!closeResult.ok) return fail$1(closeResult);
35204
+ return {
35205
+ ok: true,
35206
+ data: { closed: true }
35207
+ };
35208
+ }
35209
+
35210
+ //#endregion
35211
+ //#region src/github/sync.ts
35212
+ /**
35213
+ * Re-wrap a failed GhResult for a different generic type.
35214
+ */
35215
+ function fail(result) {
35216
+ return {
35217
+ ok: false,
35218
+ error: result.error,
35219
+ code: result.code
35220
+ };
35221
+ }
35222
+ /**
35223
+ * Batch-fetch issue details via a single GraphQL query.
35224
+ *
35225
+ * Fetches up to 100 issues per query using node ID lookups.
35226
+ * Falls back to sequential `gh issue view` if GraphQL fails.
35227
+ */
35228
+ async function batchFetchIssues(repo, issueNumbers) {
35229
+ if (issueNumbers.length === 0) return {
35230
+ ok: true,
35231
+ data: /* @__PURE__ */ new Map()
35232
+ };
35233
+ const [owner, name] = repo.split("/");
35234
+ if (!owner || !name) return {
35235
+ ok: false,
35236
+ error: `Invalid repo format: ${repo}. Expected "owner/repo".`,
35237
+ code: "UNKNOWN"
35238
+ };
35239
+ const BATCH_SIZE = 100;
35240
+ const resultMap = /* @__PURE__ */ new Map();
35241
+ for (let i = 0; i < issueNumbers.length; i += BATCH_SIZE) {
35242
+ const batch = issueNumbers.slice(i, i + BATCH_SIZE);
35243
+ const result = await ghGraphQL(`
35244
+ query {
35245
+ repository(owner: "${owner}", name: "${name}") {
35246
+ ${batch.map((num, idx) => `issue_${idx}: issue(number: ${num}) { number state title labels(first: 20) { nodes { name } } }`).join("\n ")}
35247
+ }
35248
+ }
35249
+ `);
35250
+ if (!result.ok) return batchFetchIssuesSequential(issueNumbers);
35251
+ const repoData = result.data.repository;
35252
+ for (let idx = 0; idx < batch.length; idx++) {
35253
+ const issueData = repoData[`issue_${idx}`];
35254
+ if (issueData) resultMap.set(issueData.number, {
35255
+ state: issueData.state.toLowerCase(),
35256
+ title: issueData.title,
35257
+ labels: issueData.labels.nodes.map((l) => l.name)
35258
+ });
35259
+ }
35260
+ }
35261
+ return {
35262
+ ok: true,
35263
+ data: resultMap
35264
+ };
35265
+ }
35266
+ /**
35267
+ * Sequential fallback: fetch issues one at a time via `gh issue view`.
35268
+ */
35269
+ async function batchFetchIssuesSequential(issueNumbers) {
35270
+ const resultMap = /* @__PURE__ */ new Map();
35271
+ for (const num of issueNumbers) {
35272
+ const result = await ghExec([
35273
+ "issue",
35274
+ "view",
35275
+ String(num),
35276
+ "--json",
35277
+ "state,title,labels"
35278
+ ], { parseJson: true });
35279
+ if (result.ok) resultMap.set(num, {
35280
+ state: result.data.state.toLowerCase(),
35281
+ title: result.data.title,
35282
+ labels: result.data.labels.map((l) => l.name)
35283
+ });
35284
+ }
35285
+ return {
35286
+ ok: true,
35287
+ data: resultMap
35288
+ };
35289
+ }
35290
+ /**
35291
+ * Compare local mapping file against GitHub reality.
35292
+ *
35293
+ * For each tracked issue (phases + todos), fetches current GitHub state
35294
+ * and compares against the local mapping. Reports discrepancies in
35295
+ * state, title, and labels.
35296
+ *
35297
+ * Uses batched GraphQL for efficiency (single query for up to 100 issues).
35298
+ */
35299
+ async function syncCheck(cwd) {
35300
+ const mapping = loadMapping(cwd);
35301
+ if (!mapping) return {
35302
+ ok: false,
35303
+ error: "github-issues.json does not exist. Run project setup first.",
35304
+ code: "NOT_FOUND"
35305
+ };
35306
+ if (!mapping.repo) return {
35307
+ ok: false,
35308
+ error: "No repo configured in github-issues.json.",
35309
+ code: "NOT_FOUND"
35310
+ };
35311
+ const trackedIssues = [];
35312
+ for (const [phaseNum, phase] of Object.entries(mapping.phases)) {
35313
+ if (phase.tracking_issue.number > 0) trackedIssues.push({
35314
+ issueNumber: phase.tracking_issue.number,
35315
+ localStatus: phase.tracking_issue.status,
35316
+ source: `phase ${phaseNum} tracking`
35317
+ });
35318
+ for (const [taskId, task] of Object.entries(phase.tasks)) if (task.number > 0) trackedIssues.push({
35319
+ issueNumber: task.number,
35320
+ localStatus: task.status,
35321
+ source: `phase ${phaseNum}, task ${taskId}`
35322
+ });
35323
+ }
35324
+ if (mapping.todos) {
35325
+ for (const [todoId, todo] of Object.entries(mapping.todos)) if (todo.number > 0) trackedIssues.push({
35326
+ issueNumber: todo.number,
35327
+ localStatus: todo.status,
35328
+ source: `todo ${todoId}`
35329
+ });
35330
+ }
35331
+ if (trackedIssues.length === 0) return {
35332
+ ok: true,
35333
+ data: {
35334
+ inSync: true,
35335
+ changes: []
35336
+ }
35337
+ };
35338
+ const issueNumbers = trackedIssues.map((t) => t.issueNumber);
35339
+ const fetchResult = await batchFetchIssues(mapping.repo, issueNumbers);
35340
+ if (!fetchResult.ok) return fail(fetchResult);
35341
+ const remoteStates = fetchResult.data;
35342
+ const changes = [];
35343
+ for (const tracked of trackedIssues) {
35344
+ const remote = remoteStates.get(tracked.issueNumber);
35345
+ if (!remote) {
35346
+ changes.push({
35347
+ issueNumber: tracked.issueNumber,
35348
+ field: "existence",
35349
+ localValue: "exists",
35350
+ remoteValue: "not found"
35351
+ });
35352
+ continue;
35353
+ }
35354
+ const isRemoteClosed = remote.state === "closed";
35355
+ const isLocalDone = tracked.localStatus === "Done";
35356
+ if (isRemoteClosed && !isLocalDone) changes.push({
35357
+ issueNumber: tracked.issueNumber,
35358
+ field: "state",
35359
+ localValue: tracked.localStatus,
35360
+ remoteValue: "closed (Done)"
35361
+ });
35362
+ else if (!isRemoteClosed && isLocalDone) changes.push({
35363
+ issueNumber: tracked.issueNumber,
35364
+ field: "state",
35365
+ localValue: "Done",
35366
+ remoteValue: `open (${remote.state})`
35367
+ });
35368
+ }
35369
+ return {
35370
+ ok: true,
35371
+ data: {
35372
+ inSync: changes.length === 0,
35373
+ changes
35374
+ }
35375
+ };
35376
+ }
35377
+
33983
35378
  //#endregion
33984
35379
  //#region src/mcp/utils.ts
33985
35380
  /**
@@ -34049,6 +35444,10 @@ function mcpError(error, summary) {
34049
35444
  /**
34050
35445
  * Phase CRUD MCP Tools — Phase operations exposed as MCP tools
34051
35446
  *
35447
+ * Integrates with GitHub: phase completion triggers sync check, issue close,
35448
+ * board move to Done, and milestone completion check. Find/list enrich
35449
+ * responses with GitHub issue data when available.
35450
+ *
34052
35451
  * CRITICAL: Never import output() or error() from core — they call process.exit().
34053
35452
  * CRITICAL: Never write to stdout — it is reserved for MCP JSON-RPC protocol.
34054
35453
  * CRITICAL: Never call process.exit() — the server must stay alive after every tool call.
@@ -34061,8 +35460,33 @@ function registerPhaseTools(server) {
34061
35460
  try {
34062
35461
  const cwd = detectProjectRoot();
34063
35462
  if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
34064
- const result = findPhaseInternal(cwd, phase);
35463
+ const result = await findPhaseInternal(cwd, phase);
34065
35464
  if (!result) return mcpError(`Phase ${phase} not found`, "Phase not found");
35465
+ let githubTracking = null;
35466
+ let githubTaskIssues = null;
35467
+ let githubWarning;
35468
+ try {
35469
+ const mapping = loadMapping(cwd);
35470
+ if (mapping && result.phase_number) {
35471
+ const phaseMapping = mapping.phases[result.phase_number];
35472
+ if (phaseMapping) {
35473
+ if (phaseMapping.tracking_issue.number > 0) githubTracking = {
35474
+ number: phaseMapping.tracking_issue.number,
35475
+ status: phaseMapping.tracking_issue.status
35476
+ };
35477
+ const taskEntries = Object.entries(phaseMapping.tasks);
35478
+ if (taskEntries.length > 0) {
35479
+ githubTaskIssues = {};
35480
+ for (const [taskId, task] of taskEntries) if (task.number > 0) githubTaskIssues[taskId] = {
35481
+ number: task.number,
35482
+ status: task.status
35483
+ };
35484
+ }
35485
+ }
35486
+ }
35487
+ } catch (e) {
35488
+ githubWarning = `GitHub data enrichment failed: ${e.message}`;
35489
+ }
34066
35490
  return mcpSuccess({
34067
35491
  found: result.found,
34068
35492
  directory: result.directory,
@@ -34075,7 +35499,10 @@ function registerPhaseTools(server) {
34075
35499
  has_research: result.has_research,
34076
35500
  has_context: result.has_context,
34077
35501
  has_verification: result.has_verification,
34078
- archived: result.archived ?? null
35502
+ archived: result.archived ?? null,
35503
+ github_tracking_issue: githubTracking,
35504
+ github_task_issues: githubTaskIssues,
35505
+ ...githubWarning ? { github_warning: githubWarning } : {}
34079
35506
  }, `Found phase ${result.phase_number}: ${result.phase_name ?? "unnamed"}`);
34080
35507
  } catch (e) {
34081
35508
  return mcpError(e.message, "Operation failed");
@@ -34098,22 +35525,46 @@ function registerPhaseTools(server) {
34098
35525
  limit,
34099
35526
  has_more: false
34100
35527
  }, "No phases directory found");
34101
- let dirs = listSubDirs(phasesDir);
35528
+ let dirs = await listSubDirs(phasesDir);
34102
35529
  if (include_archived) {
34103
- const archived = getArchivedPhaseDirs(cwd);
35530
+ const archived = await getArchivedPhaseDirs(cwd);
34104
35531
  for (const a of archived) dirs.push(`${a.name} [${a.milestone}]`);
34105
35532
  }
34106
35533
  dirs.sort((a, b) => comparePhaseNum(a, b));
34107
35534
  const total_count = dirs.length;
34108
35535
  const paginated = dirs.slice(offset, offset + limit);
34109
35536
  const has_more = offset + limit < total_count;
35537
+ let githubIssueCounts = null;
35538
+ let githubWarning;
35539
+ try {
35540
+ if (await detectGitHubMode() === "full") {
35541
+ const mapping = loadMapping(cwd);
35542
+ if (mapping && Object.keys(mapping.phases).length > 0) {
35543
+ githubIssueCounts = {};
35544
+ for (const [phaseNum, phaseData] of Object.entries(mapping.phases)) {
35545
+ let open = 0;
35546
+ let closed = 0;
35547
+ for (const task of Object.values(phaseData.tasks)) if (task.number > 0) if (task.status === "Done") closed++;
35548
+ else open++;
35549
+ githubIssueCounts[phaseNum] = {
35550
+ open,
35551
+ closed
35552
+ };
35553
+ }
35554
+ }
35555
+ }
35556
+ } catch (e) {
35557
+ githubWarning = `GitHub enrichment failed: ${e.message}`;
35558
+ }
34110
35559
  return mcpSuccess({
34111
35560
  directories: paginated,
34112
35561
  count: paginated.length,
34113
35562
  total_count,
34114
35563
  offset,
34115
35564
  limit,
34116
- has_more
35565
+ has_more,
35566
+ github_issue_counts: githubIssueCounts,
35567
+ ...githubWarning ? { github_warning: githubWarning } : {}
34117
35568
  }, `Showing ${paginated.length} of ${total_count} phase(s)`);
34118
35569
  } catch (e) {
34119
35570
  return mcpError(e.message, "Operation failed");
@@ -34160,7 +35611,50 @@ function registerPhaseTools(server) {
34160
35611
  try {
34161
35612
  const cwd = detectProjectRoot();
34162
35613
  if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
35614
+ let syncDiscrepancies = [];
35615
+ let githubWarning;
35616
+ try {
35617
+ if (await detectGitHubMode() === "full") {
35618
+ const syncResult = await syncCheck(cwd);
35619
+ if (syncResult.ok && !syncResult.data.inSync) syncDiscrepancies = syncResult.data.changes;
35620
+ }
35621
+ } catch (e) {
35622
+ githubWarning = `Sync check failed: ${e.message}`;
35623
+ }
34163
35624
  const result = await phaseCompleteCore(cwd, phase);
35625
+ let githubClosed = false;
35626
+ let milestoneClosed = false;
35627
+ try {
35628
+ if (await detectGitHubMode() === "full") {
35629
+ const mapping = loadMapping(cwd);
35630
+ if (mapping) {
35631
+ const phaseMapping = mapping.phases[phase];
35632
+ if (phaseMapping) {
35633
+ if (phaseMapping.tracking_issue.number > 0) {
35634
+ if ((await closeIssue(phaseMapping.tracking_issue.number, "completed")).ok) {
35635
+ githubClosed = true;
35636
+ phaseMapping.tracking_issue.status = "Done";
35637
+ if (phaseMapping.tracking_issue.item_id && mapping.status_field_id && mapping.status_options["Done"]) await moveItemToStatus(mapping.project_id, phaseMapping.tracking_issue.item_id, mapping.status_field_id, mapping.status_options["Done"]);
35638
+ }
35639
+ }
35640
+ for (const [_taskId, task] of Object.entries(phaseMapping.tasks)) if (task.number > 0 && task.status !== "Done") {
35641
+ if ((await closeIssue(task.number, "completed")).ok) {
35642
+ task.status = "Done";
35643
+ if (task.item_id && mapping.status_field_id && mapping.status_options["Done"]) await moveItemToStatus(mapping.project_id, task.item_id, mapping.status_field_id, mapping.status_options["Done"]);
35644
+ if (phaseMapping.tracking_issue.number > 0) await updateParentTaskList(phaseMapping.tracking_issue.number, task.number, true);
35645
+ }
35646
+ }
35647
+ saveMapping(cwd, mapping);
35648
+ if (mapping.milestone_id > 0) {
35649
+ const msResult = await closeMilestoneIfComplete(mapping.milestone_id);
35650
+ if (msResult.ok) milestoneClosed = msResult.data.closed;
35651
+ }
35652
+ }
35653
+ }
35654
+ }
35655
+ } catch (e) {
35656
+ githubWarning = (githubWarning ? githubWarning + "; " : "") + `GitHub completion operations failed: ${e.message}`;
35657
+ }
34164
35658
  return mcpSuccess({
34165
35659
  completed_phase: result.completed_phase,
34166
35660
  phase_name: result.phase_name,
@@ -34170,12 +35664,118 @@ function registerPhaseTools(server) {
34170
35664
  is_last_phase: result.is_last_phase,
34171
35665
  date: result.date,
34172
35666
  roadmap_updated: result.roadmap_updated,
34173
- state_updated: result.state_updated
35667
+ state_updated: result.state_updated,
35668
+ sync_discrepancies: syncDiscrepancies.length > 0 ? syncDiscrepancies : null,
35669
+ github_closed: githubClosed,
35670
+ milestone_closed: milestoneClosed,
35671
+ ...githubWarning ? { github_warning: githubWarning } : {}
34174
35672
  }, `Phase ${phase} marked as complete${result.next_phase ? `, next: Phase ${result.next_phase}` : ""}`);
34175
35673
  } catch (e) {
34176
35674
  return mcpError(e.message, "Operation failed");
34177
35675
  }
34178
35676
  });
35677
+ server.tool("mcp_bounce_issue", "Bounce a task back from In Review to In Progress with a detailed comment explaining what failed. Implements reviewer feedback loop (AC-05).", {
35678
+ issue_number: numberType().describe("GitHub issue number to bounce back"),
35679
+ reason: stringType().describe("Detailed reason why the task is being bounced back (reviewer feedback)")
35680
+ }, async ({ issue_number, reason }) => {
35681
+ try {
35682
+ const cwd = detectProjectRoot();
35683
+ if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
35684
+ if (await detectGitHubMode() === "local-only") {
35685
+ const mapping = loadMapping(cwd);
35686
+ if (mapping) {
35687
+ if (updateLocalMappingStatus$1(mapping, issue_number, "In Progress")) {
35688
+ saveMapping(cwd, mapping);
35689
+ return mcpSuccess({
35690
+ mode: "local-only",
35691
+ issue_number,
35692
+ status: "In Progress",
35693
+ local_updated: true,
35694
+ reason
35695
+ }, `Local-only: issue #${issue_number} bounced to In Progress (reason recorded locally)`);
35696
+ }
35697
+ }
35698
+ return mcpSuccess({
35699
+ mode: "local-only",
35700
+ issue_number,
35701
+ reason,
35702
+ note: "Bounce recorded locally. GitHub operations skipped."
35703
+ }, `Local-only: bounce for issue #${issue_number} recorded`);
35704
+ }
35705
+ const mapping = loadMapping(cwd);
35706
+ let githubWarning;
35707
+ let moved = false;
35708
+ let commented = false;
35709
+ try {
35710
+ const commentResult = await postComment(issue_number, `## Bounced Back to In Progress\n\n**Reason:** ${reason}\n\n---\n*Review feedback posted by MAXSIM*`);
35711
+ commented = commentResult.ok;
35712
+ if (!commentResult.ok) githubWarning = `Comment failed: ${commentResult.error}`;
35713
+ } catch (e) {
35714
+ githubWarning = `Comment failed: ${e.message}`;
35715
+ }
35716
+ try {
35717
+ if (mapping) {
35718
+ const issueEntry = findIssueInMapping$2(mapping, issue_number);
35719
+ if (issueEntry?.item_id && mapping.status_field_id && mapping.status_options["In Progress"]) {
35720
+ const moveResult = await moveItemToStatus(mapping.project_id, issueEntry.item_id, mapping.status_field_id, mapping.status_options["In Progress"]);
35721
+ moved = moveResult.ok;
35722
+ if (!moveResult.ok) githubWarning = (githubWarning ? githubWarning + "; " : "") + `Board move failed: ${moveResult.error}`;
35723
+ }
35724
+ updateLocalMappingStatus$1(mapping, issue_number, "In Progress");
35725
+ saveMapping(cwd, mapping);
35726
+ }
35727
+ } catch (e) {
35728
+ githubWarning = (githubWarning ? githubWarning + "; " : "") + `Board move failed: ${e.message}`;
35729
+ }
35730
+ return mcpSuccess({
35731
+ mode: "full",
35732
+ issue_number,
35733
+ status: "In Progress",
35734
+ commented,
35735
+ moved,
35736
+ reason,
35737
+ ...githubWarning ? { github_warning: githubWarning } : {}
35738
+ }, `Issue #${issue_number} bounced to In Progress${commented ? " with feedback comment" : ""}`);
35739
+ } catch (e) {
35740
+ return mcpError(e.message, "Operation failed");
35741
+ }
35742
+ });
35743
+ }
35744
+ /**
35745
+ * Find an issue entry in the mapping file (searches phases and todos).
35746
+ */
35747
+ function findIssueInMapping$2(mapping, issueNumber) {
35748
+ for (const phase of Object.values(mapping.phases)) {
35749
+ if (phase.tracking_issue.number === issueNumber) return phase.tracking_issue;
35750
+ for (const task of Object.values(phase.tasks)) if (task.number === issueNumber) return task;
35751
+ }
35752
+ if (mapping.todos) {
35753
+ for (const todo of Object.values(mapping.todos)) if (todo.number === issueNumber) return todo;
35754
+ }
35755
+ return null;
35756
+ }
35757
+ /**
35758
+ * Update local mapping status for an issue (mutates mapping in-place).
35759
+ * Returns true if the issue was found and updated.
35760
+ */
35761
+ function updateLocalMappingStatus$1(mapping, issueNumber, status) {
35762
+ for (const phase of Object.values(mapping.phases)) {
35763
+ if (phase.tracking_issue.number === issueNumber) {
35764
+ phase.tracking_issue.status = status;
35765
+ return true;
35766
+ }
35767
+ for (const task of Object.values(phase.tasks)) if (task.number === issueNumber) {
35768
+ task.status = status;
35769
+ return true;
35770
+ }
35771
+ }
35772
+ if (mapping.todos) {
35773
+ for (const todo of Object.values(mapping.todos)) if (todo.number === issueNumber) {
35774
+ todo.status = status;
35775
+ return true;
35776
+ }
35777
+ }
35778
+ return false;
34179
35779
  }
34180
35780
 
34181
35781
  //#endregion
@@ -34203,6 +35803,10 @@ function parseTodoFrontmatter(content) {
34203
35803
  /**
34204
35804
  * Todo CRUD MCP Tools — Todo operations exposed as MCP tools
34205
35805
  *
35806
+ * Integrates with GitHub: todo add creates GitHub issue in 'full' mode,
35807
+ * todo complete closes GitHub issue and moves to Done on board,
35808
+ * todo list enriches with GitHub issue data when available.
35809
+ *
34206
35810
  * CRITICAL: Never import output() or error() from core — they call process.exit().
34207
35811
  * CRITICAL: Never write to stdout — it is reserved for MCP JSON-RPC protocol.
34208
35812
  * CRITICAL: Never call process.exit() — the server must stay alive after every tool call.
@@ -34228,12 +35832,55 @@ function registerTodoTools(server) {
34228
35832
  const filePath = node_path.default.join(pendingDir, filename);
34229
35833
  const content = `---\ncreated: ${today}\ntitle: ${title}\narea: ${area || "general"}\nphase: ${phase || "unassigned"}\n---\n${description || ""}\n`;
34230
35834
  node_fs.default.writeFileSync(filePath, content, "utf-8");
35835
+ let githubIssueNumber = null;
35836
+ let githubIssueUrl = null;
35837
+ let githubWarning;
35838
+ try {
35839
+ if (await detectGitHubMode() === "full") {
35840
+ const mapping = loadMapping(cwd);
35841
+ const issueResult = await createTodoIssue({
35842
+ title,
35843
+ description: description || void 0,
35844
+ milestone: mapping?.milestone_title || void 0
35845
+ });
35846
+ if (issueResult.ok) {
35847
+ githubIssueNumber = issueResult.data.number;
35848
+ githubIssueUrl = issueResult.data.url;
35849
+ if (mapping && mapping.project_number > 0) {
35850
+ const issueUrl = `https://github.com/${mapping.repo}/issues/${issueResult.data.number}`;
35851
+ const addResult = await addItemToProject(mapping.project_number, issueUrl);
35852
+ if (addResult.ok) {
35853
+ updateTodoMapping(cwd, filename, {
35854
+ number: issueResult.data.number,
35855
+ node_id: issueResult.data.node_id,
35856
+ item_id: addResult.data.item_id,
35857
+ status: "To Do"
35858
+ });
35859
+ if (mapping.status_field_id && mapping.status_options["To Do"]) await moveItemToStatus(mapping.project_id, addResult.data.item_id, mapping.status_field_id, mapping.status_options["To Do"]);
35860
+ } else {
35861
+ updateTodoMapping(cwd, filename, {
35862
+ number: issueResult.data.number,
35863
+ node_id: issueResult.data.node_id,
35864
+ item_id: "",
35865
+ status: "To Do"
35866
+ });
35867
+ githubWarning = `Issue created but board add failed: ${addResult.error}`;
35868
+ }
35869
+ } else githubWarning = "Issue created but no project board configured for board tracking.";
35870
+ } else githubWarning = `GitHub issue creation failed: ${issueResult.error}`;
35871
+ }
35872
+ } catch (e) {
35873
+ githubWarning = `GitHub operation failed: ${e.message}`;
35874
+ }
34231
35875
  return mcpSuccess({
34232
35876
  file: filename,
34233
35877
  path: `.planning/todos/pending/${filename}`,
34234
35878
  title,
34235
- area: area || "general"
34236
- }, `Todo created: ${title}`);
35879
+ area: area || "general",
35880
+ github_issue_number: githubIssueNumber,
35881
+ github_issue_url: githubIssueUrl,
35882
+ ...githubWarning ? { github_warning: githubWarning } : {}
35883
+ }, `Todo created: ${title}${githubIssueNumber ? ` (GitHub #${githubIssueNumber})` : ""}`);
34237
35884
  } catch (e) {
34238
35885
  return mcpError(e.message, "Operation failed");
34239
35886
  }
@@ -34252,11 +35899,33 @@ function registerTodoTools(server) {
34252
35899
  content = `completed: ${today}\n` + content;
34253
35900
  node_fs.default.writeFileSync(node_path.default.join(completedDir, todo_id), content, "utf-8");
34254
35901
  node_fs.default.unlinkSync(sourcePath);
35902
+ let githubClosed = false;
35903
+ let githubWarning;
35904
+ try {
35905
+ if (await detectGitHubMode() === "full") {
35906
+ const mapping = loadMapping(cwd);
35907
+ if (mapping?.todos?.[todo_id]) {
35908
+ const todoMapping = mapping.todos[todo_id];
35909
+ if (todoMapping.number > 0) {
35910
+ const closeResult = await closeIssue(todoMapping.number, "completed");
35911
+ githubClosed = closeResult.ok;
35912
+ if (!closeResult.ok) githubWarning = `GitHub issue close failed: ${closeResult.error}`;
35913
+ if (todoMapping.item_id && mapping.status_field_id && mapping.status_options["Done"]) await moveItemToStatus(mapping.project_id, todoMapping.item_id, mapping.status_field_id, mapping.status_options["Done"]);
35914
+ todoMapping.status = "Done";
35915
+ saveMapping(cwd, mapping);
35916
+ }
35917
+ }
35918
+ }
35919
+ } catch (e) {
35920
+ githubWarning = `GitHub operation failed: ${e.message}`;
35921
+ }
34255
35922
  return mcpSuccess({
34256
35923
  completed: true,
34257
35924
  file: todo_id,
34258
- date: today
34259
- }, `Todo completed: ${todo_id}`);
35925
+ date: today,
35926
+ github_closed: githubClosed,
35927
+ ...githubWarning ? { github_warning: githubWarning } : {}
35928
+ }, `Todo completed: ${todo_id}${githubClosed ? " (GitHub issue closed)" : ""}`);
34260
35929
  } catch (e) {
34261
35930
  return mcpError(e.message, "Operation failed");
34262
35931
  }
@@ -34277,6 +35946,22 @@ function registerTodoTools(server) {
34277
35946
  if (status === "pending" || status === "all") dirs.push(node_path.default.join(todosBase, "pending"));
34278
35947
  if (status === "completed" || status === "all") dirs.push(node_path.default.join(todosBase, "completed"));
34279
35948
  const todos = [];
35949
+ let todoMappings = null;
35950
+ let githubWarning;
35951
+ try {
35952
+ if (await detectGitHubMode() === "full") {
35953
+ const mapping = loadMapping(cwd);
35954
+ if (mapping?.todos) {
35955
+ todoMappings = {};
35956
+ for (const [todoId, data] of Object.entries(mapping.todos)) if (data.number > 0) todoMappings[todoId] = {
35957
+ number: data.number,
35958
+ status: data.status
35959
+ };
35960
+ }
35961
+ }
35962
+ } catch (e) {
35963
+ githubWarning = `GitHub enrichment failed: ${e.message}`;
35964
+ }
34280
35965
  for (const dir of dirs) {
34281
35966
  const dirStatus = dir.endsWith("pending") ? "pending" : "completed";
34282
35967
  let files = [];
@@ -34288,19 +35973,25 @@ function registerTodoTools(server) {
34288
35973
  for (const file of files) try {
34289
35974
  const fm = parseTodoFrontmatter(node_fs.default.readFileSync(node_path.default.join(dir, file), "utf-8"));
34290
35975
  if (area && fm.area !== area) continue;
34291
- todos.push({
35976
+ const todoEntry = {
34292
35977
  file,
34293
35978
  created: fm.created,
34294
35979
  title: fm.title,
34295
35980
  area: fm.area,
34296
35981
  status: dirStatus,
34297
35982
  path: `.planning/todos/${dirStatus}/${file}`
34298
- });
35983
+ };
35984
+ if (todoMappings?.[file]) {
35985
+ todoEntry.github_issue_number = todoMappings[file].number;
35986
+ todoEntry.github_status = todoMappings[file].status;
35987
+ }
35988
+ todos.push(todoEntry);
34299
35989
  } catch {}
34300
35990
  }
34301
35991
  return mcpSuccess({
34302
35992
  count: todos.length,
34303
- todos
35993
+ todos,
35994
+ ...githubWarning ? { github_warning: githubWarning } : {}
34304
35995
  }, `${todos.length} todos found`);
34305
35996
  } catch (e) {
34306
35997
  return mcpError(e.message, "Operation failed");
@@ -34362,11 +36053,29 @@ function appendToStateSection(content, sectionPattern, entry, placeholderPattern
34362
36053
  /**
34363
36054
  * State Management MCP Tools — STATE.md operations exposed as MCP tools
34364
36055
  *
36056
+ * Integrates with GitHub: blocker add/resolve uses best-effort GitHub
36057
+ * issue linking when blocker text references issue numbers.
36058
+ *
34365
36059
  * CRITICAL: Never import output() or error() from core — they call process.exit().
34366
36060
  * CRITICAL: Never write to stdout — it is reserved for MCP JSON-RPC protocol.
34367
36061
  * CRITICAL: Never call process.exit() — the server must stay alive after every tool call.
34368
36062
  */
34369
36063
  /**
36064
+ * Extract GitHub issue numbers from text.
36065
+ *
36066
+ * Matches patterns like "#42", "issue 42", "issue #42", "blocked by #42".
36067
+ * Returns unique issue numbers found.
36068
+ */
36069
+ function extractIssueNumbers(text) {
36070
+ const matches = text.matchAll(/#(\d+)|issue\s+#?(\d+)/gi);
36071
+ const numbers = /* @__PURE__ */ new Set();
36072
+ for (const match of matches) {
36073
+ const num = parseInt(match[1] || match[2], 10);
36074
+ if (!Number.isNaN(num) && num > 0) numbers.add(num);
36075
+ }
36076
+ return Array.from(numbers);
36077
+ }
36078
+ /**
34370
36079
  * Register all state management tools on the MCP server.
34371
36080
  */
34372
36081
  function registerStateTools(server) {
@@ -34448,10 +36157,24 @@ function registerStateTools(server) {
34448
36157
  const updated = appendToStateSection(node_fs.default.readFileSync(stPath, "utf-8"), /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i, `- ${text}`, [/None\.?\s*\n?/gi, /None yet\.?\s*\n?/gi]);
34449
36158
  if (!updated) return mcpError("Blockers section not found in STATE.md", "Section not found");
34450
36159
  node_fs.default.writeFileSync(stPath, updated, "utf-8");
36160
+ let githubLinked = [];
36161
+ let githubWarning;
36162
+ try {
36163
+ if (await detectGitHubMode() === "full") {
36164
+ const issueNumbers = extractIssueNumbers(text);
36165
+ if (issueNumbers.length > 0) {
36166
+ for (const issueNum of issueNumbers) if ((await postComment(issueNum, `**Blocker added in MAXSIM:**\n\n${text}\n\n---\n*Posted by MAXSIM blocker tracking*`)).ok) githubLinked.push(issueNum);
36167
+ }
36168
+ }
36169
+ } catch (e) {
36170
+ githubWarning = `GitHub linking failed: ${e.message}`;
36171
+ }
34451
36172
  return mcpSuccess({
34452
36173
  added: true,
34453
- blocker: text
34454
- }, "Blocker added");
36174
+ blocker: text,
36175
+ github_linked_issues: githubLinked.length > 0 ? githubLinked : null,
36176
+ ...githubWarning ? { github_warning: githubWarning } : {}
36177
+ }, `Blocker added${githubLinked.length > 0 ? ` (linked to ${githubLinked.map((n) => `#${n}`).join(", ")})` : ""}`);
34455
36178
  } catch (e) {
34456
36179
  return mcpError(e.message, "Operation failed");
34457
36180
  }
@@ -34466,17 +36189,37 @@ function registerStateTools(server) {
34466
36189
  const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
34467
36190
  const match = content.match(sectionPattern);
34468
36191
  if (!match) return mcpError("Blockers section not found in STATE.md", "Section not found");
34469
- let newBody = match[2].split("\n").filter((line) => {
36192
+ const lines = match[2].split("\n");
36193
+ const matchingLines = [];
36194
+ let newBody = lines.filter((line) => {
34470
36195
  if (!line.startsWith("- ")) return true;
34471
- return !line.toLowerCase().includes(text.toLowerCase());
36196
+ if (line.toLowerCase().includes(text.toLowerCase())) {
36197
+ matchingLines.push(line);
36198
+ return false;
36199
+ }
36200
+ return true;
34472
36201
  }).join("\n");
34473
36202
  if (!newBody.trim() || !newBody.includes("- ")) newBody = "None\n";
34474
36203
  content = content.replace(sectionPattern, (_match, header) => `${header}${newBody}`);
34475
36204
  node_fs.default.writeFileSync(stPath, content, "utf-8");
36205
+ let githubCommented = [];
36206
+ let githubWarning;
36207
+ try {
36208
+ if (await detectGitHubMode() === "full") {
36209
+ const issueNumbers = extractIssueNumbers(matchingLines.join(" ") + " " + text);
36210
+ if (issueNumbers.length > 0) {
36211
+ for (const issueNum of issueNumbers) if ((await postComment(issueNum, `**Blocker resolved in MAXSIM:**\n\nResolved blocker matching: "${text}"\n\n---\n*Posted by MAXSIM blocker tracking*`)).ok) githubCommented.push(issueNum);
36212
+ }
36213
+ }
36214
+ } catch (e) {
36215
+ githubWarning = `GitHub comment failed: ${e.message}`;
36216
+ }
34476
36217
  return mcpSuccess({
34477
36218
  resolved: true,
34478
- blocker: text
34479
- }, "Blocker resolved");
36219
+ blocker: text,
36220
+ github_commented_issues: githubCommented.length > 0 ? githubCommented : null,
36221
+ ...githubWarning ? { github_warning: githubWarning } : {}
36222
+ }, `Blocker resolved${githubCommented.length > 0 ? ` (commented on ${githubCommented.map((n) => `#${n}`).join(", ")})` : ""}`);
34480
36223
  } catch (e) {
34481
36224
  return mcpError(e.message, "Operation failed");
34482
36225
  }
@@ -34491,7 +36234,7 @@ function registerStateTools(server) {
34491
36234
  * Ported from maxsim/bin/lib/roadmap.cjs
34492
36235
  */
34493
36236
  async function cmdRoadmapAnalyze(cwd) {
34494
- const content = await safeReadFileAsync(roadmapPath(cwd));
36237
+ const content = await safeReadFile(roadmapPath(cwd));
34495
36238
  if (!content) return cmdOk({
34496
36239
  error: "ROADMAP.md not found",
34497
36240
  milestones: [],
@@ -34524,7 +36267,7 @@ async function cmdRoadmapAnalyze(cwd) {
34524
36267
  }
34525
36268
  let allDirs = [];
34526
36269
  try {
34527
- allDirs = await listSubDirsAsync(phasesDir);
36270
+ allDirs = await listSubDirs(phasesDir);
34528
36271
  } catch {}
34529
36272
  const phases = await Promise.all(parsedPhases.map(async (p) => {
34530
36273
  let diskStatus = "no_directory";
@@ -34664,9 +36407,9 @@ function loadRoadmapContext(cwd) {
34664
36407
  addIfExists(files, cwd, ".planning/ROADMAP.md", "roadmap");
34665
36408
  return files;
34666
36409
  }
34667
- function loadPhaseContext(cwd, phase) {
36410
+ async function loadPhaseContext(cwd, phase) {
34668
36411
  const files = [];
34669
- const phaseInfo = findPhaseInternal(cwd, phase);
36412
+ const phaseInfo = await findPhaseInternal(cwd, phase);
34670
36413
  if (!phaseInfo?.directory) return files;
34671
36414
  const phaseDir = phaseInfo.directory;
34672
36415
  try {
@@ -34684,7 +36427,7 @@ function loadPhaseContext(cwd, phase) {
34684
36427
  }
34685
36428
  return files;
34686
36429
  }
34687
- function loadArtefakteContext(cwd, phase) {
36430
+ async function loadArtefakteContext(cwd, phase) {
34688
36431
  const files = [];
34689
36432
  for (const filename of [
34690
36433
  "DECISIONS.md",
@@ -34692,7 +36435,7 @@ function loadArtefakteContext(cwd, phase) {
34692
36435
  "NO-GOS.md"
34693
36436
  ]) {
34694
36437
  if (phase) {
34695
- const phaseInfo = findPhaseInternal(cwd, phase);
36438
+ const phaseInfo = await findPhaseInternal(cwd, phase);
34696
36439
  if (phaseInfo?.directory) addIfExists(files, cwd, node_path.default.join(phaseInfo.directory, filename), `artefakt-${filename.toLowerCase()}`);
34697
36440
  }
34698
36441
  addIfExists(files, cwd, `.planning/${filename}`, `artefakt-${filename.toLowerCase()}`);
@@ -34709,11 +36452,11 @@ function loadCodebaseContext(cwd, topic) {
34709
36452
  } catch {}
34710
36453
  return files;
34711
36454
  }
34712
- function loadHistoryContext(cwd, currentPhase) {
36455
+ async function loadHistoryContext(cwd, currentPhase) {
34713
36456
  const files = [];
34714
36457
  const pd = phasesPath(cwd);
34715
36458
  try {
34716
- const dirs = listSubDirs(pd, true);
36459
+ const dirs = await listSubDirs(pd, true);
34717
36460
  for (const dir of dirs) {
34718
36461
  if (currentPhase) {
34719
36462
  if (dir.match(/^(\d+[A-Z]?(?:\.\d+)?)/i)?.[1] === currentPhase) continue;
@@ -34727,15 +36470,15 @@ function loadHistoryContext(cwd, currentPhase) {
34727
36470
  }
34728
36471
  return files;
34729
36472
  }
34730
- function cmdContextLoad(cwd, phase, topic, includeHistory) {
36473
+ async function cmdContextLoad(cwd, phase, topic, includeHistory) {
34731
36474
  const allFiles = [];
34732
36475
  allFiles.push(...loadProjectContext(cwd));
34733
36476
  allFiles.push(...loadRoadmapContext(cwd));
34734
- allFiles.push(...loadArtefakteContext(cwd, phase));
36477
+ allFiles.push(...await loadArtefakteContext(cwd, phase));
34735
36478
  const selectedDocs = selectCodebaseDocs(topic);
34736
36479
  allFiles.push(...loadCodebaseContext(cwd, topic));
34737
- if (phase) allFiles.push(...loadPhaseContext(cwd, phase));
34738
- if (includeHistory) allFiles.push(...loadHistoryContext(cwd, phase));
36480
+ if (phase) allFiles.push(...await loadPhaseContext(cwd, phase));
36481
+ if (includeHistory) allFiles.push(...await loadHistoryContext(cwd, phase));
34739
36482
  const seen = /* @__PURE__ */ new Set();
34740
36483
  const deduped = allFiles.filter((f) => {
34741
36484
  if (seen.has(f.path)) return false;
@@ -34778,7 +36521,7 @@ function registerContextTools(server) {
34778
36521
  current_phase = data.current_phase ?? null;
34779
36522
  next_phase = data.next_phase ?? null;
34780
36523
  }
34781
- const stateContent = safeReadFile(planningPath(cwd, "STATE.md"));
36524
+ const stateContent = await safeReadFile(planningPath(cwd, "STATE.md"));
34782
36525
  if (stateContent) {
34783
36526
  const statePhase = stateExtractField(stateContent, "Current Phase");
34784
36527
  if (statePhase) phase_name = statePhase;
@@ -34799,12 +36542,12 @@ function registerContextTools(server) {
34799
36542
  try {
34800
36543
  const cwd = detectProjectRoot();
34801
36544
  if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
34802
- const project_vision = safeReadFile(planningPath(cwd, "PROJECT.md"));
34803
- const config = loadConfig(cwd);
36545
+ const project_vision = await safeReadFile(planningPath(cwd, "PROJECT.md"));
36546
+ const config = await loadConfig(cwd);
34804
36547
  let phase_context = null;
34805
36548
  if (phase) {
34806
- const phaseInfo = findPhaseInternal(cwd, phase);
34807
- if (phaseInfo) phase_context = safeReadFile(node_path.default.join(phaseInfo.directory, `${phaseInfo.phase_number}-CONTEXT.md`));
36549
+ const phaseInfo = await findPhaseInternal(cwd, phase);
36550
+ if (phaseInfo) phase_context = await safeReadFile(node_path.default.join(phaseInfo.directory, `${phaseInfo.phase_number}-CONTEXT.md`));
34808
36551
  }
34809
36552
  return mcpSuccess({
34810
36553
  project_vision,
@@ -34822,7 +36565,7 @@ function registerContextTools(server) {
34822
36565
  try {
34823
36566
  const cwd = detectProjectRoot();
34824
36567
  if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
34825
- const result = cmdContextLoad(cwd, phase, topic, true);
36568
+ const result = await cmdContextLoad(cwd, phase, topic, true);
34826
36569
  if (!result.ok) return mcpError(result.error, "Context load failed");
34827
36570
  return mcpSuccess({ context: result.result }, `Context loaded${phase ? ` for phase ${phase}` : ""}${topic ? ` topic "${topic}"` : ""}`);
34828
36571
  } catch (e) {
@@ -34834,9 +36577,9 @@ function registerContextTools(server) {
34834
36577
  const cwd = detectProjectRoot();
34835
36578
  if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
34836
36579
  return mcpSuccess({
34837
- project: safeReadFile(planningPath(cwd, "PROJECT.md")),
34838
- requirements: safeReadFile(planningPath(cwd, "REQUIREMENTS.md")),
34839
- state: safeReadFile(planningPath(cwd, "STATE.md"))
36580
+ project: await safeReadFile(planningPath(cwd, "PROJECT.md")),
36581
+ requirements: await safeReadFile(planningPath(cwd, "REQUIREMENTS.md")),
36582
+ state: await safeReadFile(planningPath(cwd, "STATE.md"))
34840
36583
  }, "Project overview loaded");
34841
36584
  } catch (e) {
34842
36585
  return mcpError("Failed: " + e.message, "Error occurred");
@@ -34846,7 +36589,7 @@ function registerContextTools(server) {
34846
36589
  try {
34847
36590
  const cwd = detectProjectRoot();
34848
36591
  if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
34849
- const phaseInfo = findPhaseInternal(cwd, phase);
36592
+ const phaseInfo = await findPhaseInternal(cwd, phase);
34850
36593
  if (!phaseInfo) return mcpError(`Phase ${phase} not found`, "Phase not found");
34851
36594
  const files = [];
34852
36595
  try {
@@ -34855,7 +36598,7 @@ function registerContextTools(server) {
34855
36598
  const fullPath = node_path.default.join(phaseInfo.directory, entry);
34856
36599
  if (node_fs.default.statSync(fullPath).isFile()) files.push({
34857
36600
  name: entry,
34858
- content: safeReadFile(fullPath)
36601
+ content: await safeReadFile(fullPath)
34859
36602
  });
34860
36603
  }
34861
36604
  } catch {}
@@ -35006,7 +36749,7 @@ function registerConfigTools(server) {
35006
36749
  value: result.rawValue ?? result.result
35007
36750
  }, `Config value for "${key}"`);
35008
36751
  }
35009
- return mcpSuccess({ config: loadConfig(cwd) }, "Full configuration loaded");
36752
+ return mcpSuccess({ config: await loadConfig(cwd) }, "Full configuration loaded");
35010
36753
  } catch (e) {
35011
36754
  return mcpError("Failed: " + e.message, "Error occurred");
35012
36755
  }
@@ -35031,6 +36774,897 @@ function registerConfigTools(server) {
35031
36774
  });
35032
36775
  }
35033
36776
 
36777
+ //#endregion
36778
+ //#region src/github/types.ts
36779
+ const MAXSIM_LABELS = [
36780
+ {
36781
+ name: "maxsim",
36782
+ color: "6f42c1",
36783
+ description: "MAXSIM managed issue"
36784
+ },
36785
+ {
36786
+ name: "phase-task",
36787
+ color: "0075ca",
36788
+ description: "MAXSIM phase task"
36789
+ },
36790
+ {
36791
+ name: "todo",
36792
+ color: "fbca04",
36793
+ description: "MAXSIM todo item"
36794
+ },
36795
+ {
36796
+ name: "imported",
36797
+ color: "e4e669",
36798
+ description: "Imported into MAXSIM tracking"
36799
+ },
36800
+ {
36801
+ name: "superseded",
36802
+ color: "d73a4a",
36803
+ description: "Superseded by newer plan"
36804
+ }
36805
+ ];
36806
+ const FIBONACCI_POINTS = [
36807
+ 1,
36808
+ 2,
36809
+ 3,
36810
+ 5,
36811
+ 8,
36812
+ 13,
36813
+ 21,
36814
+ 34
36815
+ ];
36816
+
36817
+ //#endregion
36818
+ //#region src/github/labels.ts
36819
+ /**
36820
+ * Ensure all MAXSIM labels exist on the repository.
36821
+ *
36822
+ * Iterates over MAXSIM_LABELS and runs `gh label create` with `--force`
36823
+ * for each label. The `--force` flag updates existing labels with the
36824
+ * specified color and description.
36825
+ *
36826
+ * Continues on individual label failures (logs to stderr).
36827
+ * Only fails if ALL labels fail to create.
36828
+ */
36829
+ async function ensureLabels() {
36830
+ let successCount = 0;
36831
+ const errors = [];
36832
+ for (const label of MAXSIM_LABELS) {
36833
+ const result = await ghExec([
36834
+ "label",
36835
+ "create",
36836
+ label.name,
36837
+ "--color",
36838
+ label.color,
36839
+ "--description",
36840
+ label.description,
36841
+ "--force"
36842
+ ]);
36843
+ if (result.ok) successCount++;
36844
+ else {
36845
+ const errMsg = result.error;
36846
+ console.error(`[maxsim] Failed to create label "${label.name}": ${errMsg}`);
36847
+ errors.push(`${label.name}: ${errMsg}`);
36848
+ }
36849
+ }
36850
+ if (successCount === 0 && errors.length > 0) return {
36851
+ ok: false,
36852
+ error: `All labels failed to create: ${errors.join("; ")}`,
36853
+ code: "UNKNOWN"
36854
+ };
36855
+ return {
36856
+ ok: true,
36857
+ data: void 0
36858
+ };
36859
+ }
36860
+
36861
+ //#endregion
36862
+ //#region src/github/templates.ts
36863
+ /**
36864
+ * GitHub Issue Templates — Template file generation
36865
+ *
36866
+ * Installs GitHub Issue Form YAML templates into `.github/ISSUE_TEMPLATE/`
36867
+ * for the MAXSIM-managed issue types: phase tasks and todos.
36868
+ *
36869
+ * These are file-system operations only (no gh CLI needed).
36870
+ * Uses synchronous fs to match existing core module patterns.
36871
+ *
36872
+ * CRITICAL: Never call process.exit().
36873
+ */
36874
+ /**
36875
+ * Phase task issue template (GitHub Issue Forms YAML format).
36876
+ *
36877
+ * Used for issues created from MAXSIM phase plans.
36878
+ * Labels: maxsim, phase-task
36879
+ */
36880
+ const PHASE_TASK_TEMPLATE = `name: "MAXSIM Phase Task"
36881
+ description: "Task generated by MAXSIM phase planning"
36882
+ labels: ["maxsim", "phase-task"]
36883
+ body:
36884
+ - type: markdown
36885
+ attributes:
36886
+ value: |
36887
+ This issue was auto-generated by MAXSIM.
36888
+
36889
+ - type: textarea
36890
+ id: summary
36891
+ attributes:
36892
+ label: Summary
36893
+ description: Task summary
36894
+ validations:
36895
+ required: true
36896
+
36897
+ - type: textarea
36898
+ id: spec
36899
+ attributes:
36900
+ label: Full Specification
36901
+ description: Detailed task specification including actions, criteria, and dependencies
36902
+ `;
36903
+ /**
36904
+ * Todo issue template (GitHub Issue Forms YAML format).
36905
+ *
36906
+ * Used for issues created from MAXSIM todo items.
36907
+ * Labels: maxsim, todo
36908
+ */
36909
+ const TODO_TEMPLATE = `name: "MAXSIM Todo"
36910
+ description: "Todo item tracked by MAXSIM"
36911
+ labels: ["maxsim", "todo"]
36912
+ body:
36913
+ - type: textarea
36914
+ id: description
36915
+ attributes:
36916
+ label: Description
36917
+ description: Brief description of the todo item
36918
+ validations:
36919
+ required: true
36920
+
36921
+ - type: textarea
36922
+ id: acceptance
36923
+ attributes:
36924
+ label: Acceptance Criteria
36925
+ description: What defines "done" for this todo?
36926
+ `;
36927
+ /**
36928
+ * Install MAXSIM issue templates into the project's `.github/ISSUE_TEMPLATE/` directory.
36929
+ *
36930
+ * Creates the directory recursively if it does not exist.
36931
+ * Writes two YAML files:
36932
+ * - phase-task.yml (for phase plan tasks)
36933
+ * - todo.yml (for todo items)
36934
+ *
36935
+ * Overwrites existing templates if present (to ensure latest version).
36936
+ * This is a synchronous file write operation (no gh CLI needed).
36937
+ */
36938
+ function installIssueTemplates(cwd) {
36939
+ const templateDir = node_path.default.join(cwd, ".github", "ISSUE_TEMPLATE");
36940
+ node_fs.default.mkdirSync(templateDir, { recursive: true });
36941
+ node_fs.default.writeFileSync(node_path.default.join(templateDir, "phase-task.yml"), PHASE_TASK_TEMPLATE, "utf-8");
36942
+ node_fs.default.writeFileSync(node_path.default.join(templateDir, "todo.yml"), TODO_TEMPLATE, "utf-8");
36943
+ }
36944
+
36945
+ //#endregion
36946
+ //#region src/mcp/github-tools.ts
36947
+ /**
36948
+ * GitHub Issue Lifecycle MCP Tools — GitHub operations exposed as MCP tools
36949
+ *
36950
+ * Provides MCP tools for issue CRUD, PR creation with auto-close linking (AC-08),
36951
+ * sync checking (AC-09), and issue import. Every tool checks detectGitHubMode()
36952
+ * and degrades gracefully to local-only behavior when GitHub is not configured.
36953
+ *
36954
+ * CRITICAL: Never import output() or error() from core — they call process.exit().
36955
+ * CRITICAL: Never write to stdout — it is reserved for MCP JSON-RPC protocol.
36956
+ * CRITICAL: Never call process.exit() — the server must stay alive after every tool call.
36957
+ */
36958
+ /**
36959
+ * Register all GitHub issue lifecycle tools on the MCP server.
36960
+ */
36961
+ function registerGitHubTools(server) {
36962
+ server.tool("mcp_github_setup", "Set up GitHub integration: create project board, labels, milestone, and issue templates.", { milestone_title: stringType().optional().describe("Milestone title (defaults to current milestone from STATE.md)") }, async ({ milestone_title }) => {
36963
+ try {
36964
+ const cwd = detectProjectRoot();
36965
+ if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
36966
+ if (await detectGitHubMode() === "local-only") {
36967
+ installIssueTemplates(cwd);
36968
+ return mcpSuccess({
36969
+ mode: "local-only",
36970
+ templates_installed: true,
36971
+ board_created: false,
36972
+ labels_created: false,
36973
+ milestone_created: false
36974
+ }, "Local-only mode: installed issue templates only. Run `gh auth login` with project scope for full GitHub integration.");
36975
+ }
36976
+ const boardResult = await ensureProjectBoard("MAXSIM Task Board", cwd);
36977
+ if (!boardResult.ok) return mcpError(`Board setup failed: ${boardResult.error}`, "Setup failed");
36978
+ const labelsResult = await ensureLabels();
36979
+ if (!labelsResult.ok) return mcpError(`Label setup failed: ${labelsResult.error}`, "Setup failed");
36980
+ let milestoneData = null;
36981
+ if (milestone_title) {
36982
+ const msResult = await ensureMilestone(milestone_title);
36983
+ if (msResult.ok) {
36984
+ milestoneData = msResult.data;
36985
+ const mapping = loadMapping(cwd);
36986
+ if (mapping) {
36987
+ mapping.milestone_id = msResult.data.number;
36988
+ mapping.milestone_title = milestone_title;
36989
+ saveMapping(cwd, mapping);
36990
+ }
36991
+ }
36992
+ }
36993
+ installIssueTemplates(cwd);
36994
+ return mcpSuccess({
36995
+ mode: "full",
36996
+ board: {
36997
+ number: boardResult.data.number,
36998
+ created: boardResult.data.created
36999
+ },
37000
+ labels_created: true,
37001
+ milestone: milestoneData ? {
37002
+ number: milestoneData.number,
37003
+ title: milestone_title,
37004
+ created: milestoneData.created
37005
+ } : null,
37006
+ templates_installed: true
37007
+ }, `GitHub integration set up: board #${boardResult.data.number}, labels, ${milestoneData ? `milestone "${milestone_title}"` : "no milestone"}, templates`);
37008
+ } catch (e) {
37009
+ return mcpError(e.message, "Operation failed");
37010
+ }
37011
+ });
37012
+ server.tool("mcp_create_plan_issues", "Create GitHub issues for all tasks in a finalized plan. Creates task issues and parent tracking issue.", {
37013
+ phase: stringType().describe("Phase number (e.g. \"01\")"),
37014
+ plan: stringType().describe("Plan number (e.g. \"01\")"),
37015
+ phase_name: stringType().describe("Phase description for the tracking issue title"),
37016
+ tasks: arrayType(objectType({
37017
+ taskId: stringType(),
37018
+ title: stringType(),
37019
+ summary: stringType(),
37020
+ actions: arrayType(stringType()),
37021
+ acceptanceCriteria: arrayType(stringType()),
37022
+ dependencies: arrayType(stringType()).optional(),
37023
+ estimate: numberType().optional()
37024
+ })).describe("Array of task objects to create issues for"),
37025
+ milestone: stringType().optional().describe("Milestone title to assign")
37026
+ }, async ({ phase, plan, phase_name, tasks, milestone }) => {
37027
+ try {
37028
+ const cwd = detectProjectRoot();
37029
+ if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
37030
+ if (await detectGitHubMode() === "local-only") return mcpSuccess({
37031
+ mode: "local-only",
37032
+ warning: "GitHub not configured, issues not created",
37033
+ tasks_count: tasks.length
37034
+ }, "Local-only mode: GitHub issues not created. Run `gh auth login` for full integration.");
37035
+ const mapping = loadMapping(cwd);
37036
+ const result = await createAllPlanIssues({
37037
+ phaseNum: phase,
37038
+ planNum: plan,
37039
+ phaseName: phase_name,
37040
+ tasks,
37041
+ milestone,
37042
+ projectTitle: mapping?.project_number ? void 0 : void 0,
37043
+ cwd
37044
+ });
37045
+ if (!result.ok) return mcpError(`Issue creation failed: ${result.error}`, "Creation failed");
37046
+ if (mapping && mapping.project_number > 0) {
37047
+ const repo = mapping.repo;
37048
+ const allIssueNumbers = [result.data.parentIssue, ...result.data.taskIssues.map((t) => t.issueNumber)];
37049
+ for (const issueNum of allIssueNumbers) {
37050
+ const issueUrl = `https://github.com/${repo}/issues/${issueNum}`;
37051
+ const addResult = await addItemToProject(mapping.project_number, issueUrl);
37052
+ if (addResult.ok) {
37053
+ const taskEntry = result.data.taskIssues.find((t) => t.issueNumber === issueNum);
37054
+ if (taskEntry) updateTaskMapping(cwd, phase, taskEntry.taskId, { item_id: addResult.data.item_id });
37055
+ if (mapping.status_options["To Do"] && mapping.status_field_id) await moveItemToStatus(mapping.project_id, addResult.data.item_id, mapping.status_field_id, mapping.status_options["To Do"]);
37056
+ if (taskEntry && mapping.estimate_field_id) {
37057
+ const taskDef = tasks.find((t) => t.taskId === taskEntry.taskId);
37058
+ if (taskDef?.estimate) await setEstimate(mapping.project_id, addResult.data.item_id, mapping.estimate_field_id, taskDef.estimate);
37059
+ }
37060
+ }
37061
+ }
37062
+ }
37063
+ return mcpSuccess({
37064
+ mode: "full",
37065
+ parent_issue: result.data.parentIssue,
37066
+ task_issues: result.data.taskIssues,
37067
+ total_created: result.data.taskIssues.length + 1
37068
+ }, `Created ${result.data.taskIssues.length} task issues + parent tracking issue #${result.data.parentIssue}`);
37069
+ } catch (e) {
37070
+ return mcpError(e.message, "Operation failed");
37071
+ }
37072
+ });
37073
+ server.tool("mcp_create_todo_issue", "Create a GitHub issue for a todo item.", {
37074
+ title: stringType().describe("Todo title"),
37075
+ description: stringType().optional().describe("Todo description"),
37076
+ acceptance_criteria: arrayType(stringType()).optional().describe("Acceptance criteria list")
37077
+ }, async ({ title, description, acceptance_criteria }) => {
37078
+ try {
37079
+ const cwd = detectProjectRoot();
37080
+ if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
37081
+ if (await detectGitHubMode() === "local-only") return mcpSuccess({
37082
+ mode: "local-only",
37083
+ warning: "GitHub not configured. Use mcp_add_todo for local todo tracking.",
37084
+ title
37085
+ }, "Local-only mode: GitHub todo issue not created.");
37086
+ const mapping = loadMapping(cwd);
37087
+ const result = await createTodoIssue({
37088
+ title,
37089
+ description,
37090
+ acceptanceCriteria: acceptance_criteria,
37091
+ milestone: mapping?.milestone_title || void 0
37092
+ });
37093
+ if (!result.ok) return mcpError(`Todo issue creation failed: ${result.error}`, "Creation failed");
37094
+ if (mapping && mapping.project_number > 0) {
37095
+ const issueUrl = `https://github.com/${mapping.repo}/issues/${result.data.number}`;
37096
+ const addResult = await addItemToProject(mapping.project_number, issueUrl);
37097
+ if (addResult.ok && mapping) {
37098
+ if (!mapping.todos) mapping.todos = {};
37099
+ mapping.todos[`todo-${result.data.number}`] = {
37100
+ number: result.data.number,
37101
+ node_id: result.data.node_id,
37102
+ item_id: addResult.data.item_id,
37103
+ status: "To Do"
37104
+ };
37105
+ saveMapping(cwd, mapping);
37106
+ }
37107
+ }
37108
+ return mcpSuccess({
37109
+ mode: "full",
37110
+ issue_number: result.data.number,
37111
+ url: result.data.url
37112
+ }, `Created todo issue #${result.data.number}: ${title}`);
37113
+ } catch (e) {
37114
+ return mcpError(e.message, "Operation failed");
37115
+ }
37116
+ });
37117
+ server.tool("mcp_move_issue", "Move a GitHub issue to a new status column (To Do, In Progress, In Review, Done).", {
37118
+ issue_number: numberType().describe("GitHub issue number"),
37119
+ status: enumType([
37120
+ "To Do",
37121
+ "In Progress",
37122
+ "In Review",
37123
+ "Done"
37124
+ ]).describe("Target status column")
37125
+ }, async ({ issue_number, status }) => {
37126
+ try {
37127
+ const cwd = detectProjectRoot();
37128
+ if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
37129
+ const mode = await detectGitHubMode();
37130
+ const mapping = loadMapping(cwd);
37131
+ if (mode === "local-only") {
37132
+ if (mapping) {
37133
+ if (updateLocalMappingStatus(mapping, issue_number, status)) {
37134
+ saveMapping(cwd, mapping);
37135
+ return mcpSuccess({
37136
+ mode: "local-only",
37137
+ issue_number,
37138
+ status,
37139
+ local_updated: true
37140
+ }, `Local mapping updated: issue #${issue_number} -> ${status}`);
37141
+ }
37142
+ }
37143
+ return mcpError(`Issue #${issue_number} not found in local mapping`, "Issue not tracked");
37144
+ }
37145
+ if (!mapping) return mcpError("github-issues.json not found. Run mcp_github_setup first.", "Setup required");
37146
+ const issueEntry = findIssueInMapping$1(mapping, issue_number);
37147
+ if (!issueEntry) return mcpError(`Issue #${issue_number} not found in local mapping`, "Issue not tracked");
37148
+ if (!issueEntry.item_id) return mcpError(`Issue #${issue_number} has no project item_id. It may not have been added to the board.`, "Not on board");
37149
+ const statusOptionId = mapping.status_options[status];
37150
+ if (!statusOptionId) return mcpError(`Status "${status}" not found in project board options`, "Invalid status");
37151
+ const moveResult = await moveItemToStatus(mapping.project_id, issueEntry.item_id, mapping.status_field_id, statusOptionId);
37152
+ if (!moveResult.ok) return mcpError(`Move failed: ${moveResult.error}`, "Move failed");
37153
+ updateLocalMappingStatus(mapping, issue_number, status);
37154
+ saveMapping(cwd, mapping);
37155
+ return mcpSuccess({
37156
+ mode: "full",
37157
+ issue_number,
37158
+ status,
37159
+ moved: true
37160
+ }, `Issue #${issue_number} moved to "${status}"`);
37161
+ } catch (e) {
37162
+ return mcpError(e.message, "Operation failed");
37163
+ }
37164
+ });
37165
+ server.tool("mcp_close_issue", "Close a GitHub issue as completed or not planned.", {
37166
+ issue_number: numberType().describe("GitHub issue number"),
37167
+ reason: enumType(["completed", "not_planned"]).optional().default("completed").describe("Close reason")
37168
+ }, async ({ issue_number, reason }) => {
37169
+ try {
37170
+ const cwd = detectProjectRoot();
37171
+ if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
37172
+ if (await detectGitHubMode() === "local-only") return mcpSuccess({
37173
+ mode: "local-only",
37174
+ warning: "GitHub not configured. Cannot close remote issue.",
37175
+ issue_number
37176
+ }, "Local-only mode: cannot close GitHub issue.");
37177
+ const result = await closeIssue(issue_number, reason);
37178
+ if (!result.ok) return mcpError(`Close failed: ${result.error}`, "Close failed");
37179
+ const mapping = loadMapping(cwd);
37180
+ if (mapping) {
37181
+ const issueEntry = findIssueInMapping$1(mapping, issue_number);
37182
+ if (issueEntry?.item_id && mapping.status_options["Done"] && mapping.status_field_id) await moveItemToStatus(mapping.project_id, issueEntry.item_id, mapping.status_field_id, mapping.status_options["Done"]);
37183
+ updateLocalMappingStatus(mapping, issue_number, "Done");
37184
+ saveMapping(cwd, mapping);
37185
+ }
37186
+ return mcpSuccess({
37187
+ mode: "full",
37188
+ issue_number,
37189
+ reason,
37190
+ closed: true
37191
+ }, `Issue #${issue_number} closed (${reason})`);
37192
+ } catch (e) {
37193
+ return mcpError(e.message, "Operation failed");
37194
+ }
37195
+ });
37196
+ server.tool("mcp_post_comment", "Post a progress comment on a GitHub issue.", {
37197
+ issue_number: numberType().describe("GitHub issue number"),
37198
+ body: stringType().describe("Comment body (markdown supported)")
37199
+ }, async ({ issue_number, body }) => {
37200
+ try {
37201
+ if (await detectGitHubMode() === "local-only") return mcpSuccess({
37202
+ mode: "local-only",
37203
+ warning: "GitHub not configured. Cannot post comment.",
37204
+ issue_number
37205
+ }, "Local-only mode: cannot post comment on GitHub issue.");
37206
+ const result = await postComment(issue_number, body);
37207
+ if (!result.ok) return mcpError(`Comment failed: ${result.error}`, "Comment failed");
37208
+ return mcpSuccess({
37209
+ mode: "full",
37210
+ issue_number,
37211
+ commented: true
37212
+ }, `Comment posted on issue #${issue_number}`);
37213
+ } catch (e) {
37214
+ return mcpError(e.message, "Operation failed");
37215
+ }
37216
+ });
37217
+ server.tool("mcp_import_issue", "Import an external GitHub issue into MAXSIM tracking.", { issue_number: numberType().describe("GitHub issue number to import") }, async ({ issue_number }) => {
37218
+ try {
37219
+ if (await detectGitHubMode() === "local-only") return mcpSuccess({
37220
+ mode: "local-only",
37221
+ warning: "GitHub not configured. Cannot import issue.",
37222
+ issue_number
37223
+ }, "Local-only mode: cannot import GitHub issue.");
37224
+ const result = await importExternalIssue(issue_number);
37225
+ if (!result.ok) return mcpError(`Import failed: ${result.error}`, "Import failed");
37226
+ return mcpSuccess({
37227
+ mode: "full",
37228
+ issue_number: result.data.number,
37229
+ title: result.data.title,
37230
+ labels: result.data.labels,
37231
+ imported: true
37232
+ }, `Imported issue #${result.data.number}: "${result.data.title}". Assign to a phase or todo for tracking.`);
37233
+ } catch (e) {
37234
+ return mcpError(e.message, "Operation failed");
37235
+ }
37236
+ });
37237
+ server.tool("mcp_sync_check", "Check for external changes to tracked GitHub issues.", {}, async () => {
37238
+ try {
37239
+ const cwd = detectProjectRoot();
37240
+ if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
37241
+ if (await detectGitHubMode() === "local-only") return mcpSuccess({
37242
+ mode: "local-only",
37243
+ warning: "GitHub not configured. Sync check not available.",
37244
+ in_sync: true,
37245
+ changes: []
37246
+ }, "Local-only mode: sync check skipped.");
37247
+ const result = await syncCheck(cwd);
37248
+ if (!result.ok) return mcpError(`Sync check failed: ${result.error}`, "Sync failed");
37249
+ return mcpSuccess({
37250
+ mode: "full",
37251
+ in_sync: result.data.inSync,
37252
+ changes: result.data.changes,
37253
+ change_count: result.data.changes.length
37254
+ }, result.data.inSync ? "All tracked issues are in sync with GitHub." : `${result.data.changes.length} discrepancies found between local mapping and GitHub.`);
37255
+ } catch (e) {
37256
+ return mcpError(e.message, "Operation failed");
37257
+ }
37258
+ });
37259
+ server.tool("mcp_supersede_plan", "Close old plan issues and link to new plan issues.", {
37260
+ phase: stringType().describe("Phase number"),
37261
+ old_plan: stringType().describe("Old plan number to supersede"),
37262
+ new_plan: stringType().describe("New plan number that replaces it")
37263
+ }, async ({ phase, old_plan, new_plan }) => {
37264
+ try {
37265
+ const cwd = detectProjectRoot();
37266
+ if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
37267
+ if (await detectGitHubMode() === "local-only") return mcpSuccess({
37268
+ mode: "local-only",
37269
+ warning: "GitHub not configured. Cannot supersede plan issues.",
37270
+ phase,
37271
+ old_plan,
37272
+ new_plan
37273
+ }, "Local-only mode: plan supersession skipped.");
37274
+ const mapping = loadMapping(cwd);
37275
+ if (!mapping) return mcpError("github-issues.json not found. Run mcp_github_setup first.", "Setup required");
37276
+ const newPhaseMapping = mapping.phases[phase];
37277
+ if (!newPhaseMapping) return mcpError(`Phase ${phase} not found in mapping. Create new plan issues first.`, "Phase not found");
37278
+ const result = await supersedePlanIssues({
37279
+ phaseNum: phase,
37280
+ oldPlanNum: old_plan,
37281
+ newPlanNum: new_plan,
37282
+ newIssueNumbers: Object.entries(newPhaseMapping.tasks).map(([taskId, task]) => ({
37283
+ taskId,
37284
+ issueNumber: task.number
37285
+ })),
37286
+ cwd
37287
+ });
37288
+ if (!result.ok) return mcpError(`Supersession failed: ${result.error}`, "Supersession failed");
37289
+ return mcpSuccess({
37290
+ mode: "full",
37291
+ phase,
37292
+ old_plan,
37293
+ new_plan,
37294
+ superseded: true
37295
+ }, `Plan ${phase}-${old_plan} superseded by ${phase}-${new_plan}`);
37296
+ } catch (e) {
37297
+ return mcpError(e.message, "Operation failed");
37298
+ }
37299
+ });
37300
+ server.tool("mcp_create_pr", "Create a pull request with auto-close linking for tracked GitHub issues. Generates PR description with Closes #N lines (AC-08).", {
37301
+ issue_numbers: arrayType(numberType()).describe("Issue numbers to auto-close when PR merges"),
37302
+ branch: stringType().describe("Source branch name for the PR"),
37303
+ title: stringType().describe("PR title"),
37304
+ base: stringType().optional().default("main").describe("Base branch (default: main)"),
37305
+ additional_context: stringType().optional().describe("Additional context to include in PR body"),
37306
+ draft: booleanType().optional().default(false).describe("Create as draft PR")
37307
+ }, async ({ issue_numbers, branch, title, base, additional_context, draft }) => {
37308
+ try {
37309
+ const mode = await detectGitHubMode();
37310
+ const prBody = buildPrBody(issue_numbers, additional_context);
37311
+ if (mode === "local-only") return mcpSuccess({
37312
+ mode: "local-only",
37313
+ warning: "GitHub not configured. PR not created. Use the body below to create manually.",
37314
+ pr_body: prBody,
37315
+ issues_linked: issue_numbers
37316
+ }, "Local-only mode: PR body generated but PR not created.");
37317
+ const args = [
37318
+ "pr",
37319
+ "create",
37320
+ "--title",
37321
+ title,
37322
+ "--body",
37323
+ prBody,
37324
+ "--head",
37325
+ branch
37326
+ ];
37327
+ if (base) args.push("--base", base);
37328
+ if (draft) args.push("--draft");
37329
+ const createResult = await ghExec(args);
37330
+ if (!createResult.ok) return mcpError(`PR creation failed: ${createResult.error}`, "PR creation failed");
37331
+ const prUrl = createResult.data.trim();
37332
+ const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
37333
+ return mcpSuccess({
37334
+ mode: "full",
37335
+ pr_number: prNumberMatch ? parseInt(prNumberMatch[1], 10) : null,
37336
+ pr_url: prUrl,
37337
+ issues_linked: issue_numbers,
37338
+ draft
37339
+ }, `PR${draft ? " (draft)" : ""} created: ${prUrl} — auto-closes ${issue_numbers.map((n) => `#${n}`).join(", ")}`);
37340
+ } catch (e) {
37341
+ return mcpError(e.message, "Operation failed");
37342
+ }
37343
+ });
37344
+ }
37345
+ /**
37346
+ * Find an issue entry in the mapping file (searches phases and todos).
37347
+ */
37348
+ function findIssueInMapping$1(mapping, issueNumber) {
37349
+ for (const phase of Object.values(mapping.phases)) {
37350
+ if (phase.tracking_issue.number === issueNumber) return phase.tracking_issue;
37351
+ for (const task of Object.values(phase.tasks)) if (task.number === issueNumber) return task;
37352
+ }
37353
+ if (mapping.todos) {
37354
+ for (const todo of Object.values(mapping.todos)) if (todo.number === issueNumber) return todo;
37355
+ }
37356
+ return null;
37357
+ }
37358
+ /**
37359
+ * Update local mapping status for an issue (mutates mapping in-place).
37360
+ * Returns true if the issue was found and updated.
37361
+ */
37362
+ function updateLocalMappingStatus(mapping, issueNumber, status) {
37363
+ for (const phase of Object.values(mapping.phases)) {
37364
+ if (phase.tracking_issue.number === issueNumber) {
37365
+ phase.tracking_issue.status = status;
37366
+ return true;
37367
+ }
37368
+ for (const task of Object.values(phase.tasks)) if (task.number === issueNumber) {
37369
+ task.status = status;
37370
+ return true;
37371
+ }
37372
+ }
37373
+ if (mapping.todos) {
37374
+ for (const todo of Object.values(mapping.todos)) if (todo.number === issueNumber) {
37375
+ todo.status = status;
37376
+ return true;
37377
+ }
37378
+ }
37379
+ return false;
37380
+ }
37381
+
37382
+ //#endregion
37383
+ //#region src/mcp/board-tools.ts
37384
+ /**
37385
+ * Board Query MCP Tools — Project board operations exposed as MCP tools
37386
+ *
37387
+ * Provides MCP tools for querying the GitHub project board, searching issues,
37388
+ * getting issue details, and setting estimates. Every tool checks detectGitHubMode()
37389
+ * and degrades gracefully to local-only behavior when GitHub is not configured.
37390
+ *
37391
+ * CRITICAL: Never import output() or error() from core — they call process.exit().
37392
+ * CRITICAL: Never write to stdout — it is reserved for MCP JSON-RPC protocol.
37393
+ * CRITICAL: Never call process.exit() — the server must stay alive after every tool call.
37394
+ */
37395
+ /**
37396
+ * Register all board query tools on the MCP server.
37397
+ */
37398
+ function registerBoardTools(server) {
37399
+ server.tool("mcp_query_board", "Query the GitHub project board. Returns all items with their status, estimates, and issue details.", {
37400
+ status: enumType([
37401
+ "To Do",
37402
+ "In Progress",
37403
+ "In Review",
37404
+ "Done"
37405
+ ]).optional().describe("Filter by status column"),
37406
+ phase: stringType().optional().describe("Filter by phase number (matches issue title prefix)")
37407
+ }, async ({ status, phase }) => {
37408
+ try {
37409
+ const cwd = detectProjectRoot();
37410
+ if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
37411
+ const mode = await detectGitHubMode();
37412
+ const mapping = loadMapping(cwd);
37413
+ if (mode === "local-only") {
37414
+ if (!mapping) return mcpSuccess({
37415
+ mode: "local-only",
37416
+ items: [],
37417
+ count: 0
37418
+ }, "Local-only mode: no mapping file found.");
37419
+ const items = buildLocalBoardItems(mapping, status, phase);
37420
+ return mcpSuccess({
37421
+ mode: "local-only",
37422
+ items,
37423
+ count: items.length
37424
+ }, `Local-only mode: ${items.length} items from local mapping.`);
37425
+ }
37426
+ if (!mapping || !mapping.project_number) return mcpError("No project board configured. Run mcp_github_setup first.", "Setup required");
37427
+ const result = await ghExec([
37428
+ "project",
37429
+ "item-list",
37430
+ String(mapping.project_number),
37431
+ "--owner",
37432
+ "@me",
37433
+ "--format",
37434
+ "json"
37435
+ ], { parseJson: true });
37436
+ if (!result.ok) return mcpError(`Board query failed: ${result.error}`, "Query failed");
37437
+ let items = result.data.items || [];
37438
+ if (status) items = items.filter((item) => item.status === status);
37439
+ if (phase) {
37440
+ const phasePrefix = `[P${phase}]`;
37441
+ const phasePrefixAlt = `[Phase ${phase}]`;
37442
+ items = items.filter((item) => item.title?.includes(phasePrefix) || item.title?.includes(phasePrefixAlt) || item.content?.title?.includes(phasePrefix) || item.content?.title?.includes(phasePrefixAlt));
37443
+ }
37444
+ const formatted = items.map((item) => ({
37445
+ item_id: item.id,
37446
+ title: item.content?.title ?? item.title,
37447
+ issue_number: item.content?.number ?? null,
37448
+ status: item.status ?? "No Status",
37449
+ url: item.content?.url ?? null
37450
+ }));
37451
+ return mcpSuccess({
37452
+ mode: "full",
37453
+ items: formatted,
37454
+ count: formatted.length
37455
+ }, `Board query: ${formatted.length} items${status ? ` in "${status}"` : ""}${phase ? ` for phase ${phase}` : ""}`);
37456
+ } catch (e) {
37457
+ return mcpError(e.message, "Operation failed");
37458
+ }
37459
+ });
37460
+ server.tool("mcp_search_issues", "Search GitHub issues by label, milestone, state, or text query.", {
37461
+ labels: arrayType(stringType()).optional().describe("Filter by label names"),
37462
+ milestone: stringType().optional().describe("Filter by milestone title"),
37463
+ state: enumType([
37464
+ "open",
37465
+ "closed",
37466
+ "all"
37467
+ ]).optional().default("open").describe("Filter by issue state"),
37468
+ query: stringType().optional().describe("Text search query")
37469
+ }, async ({ labels, milestone, state, query }) => {
37470
+ try {
37471
+ const cwd = detectProjectRoot();
37472
+ if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
37473
+ if (await detectGitHubMode() === "local-only") {
37474
+ const mapping = loadMapping(cwd);
37475
+ if (!mapping) return mcpSuccess({
37476
+ mode: "local-only",
37477
+ issues: [],
37478
+ count: 0
37479
+ }, "Local-only mode: no mapping file found.");
37480
+ const items = buildLocalSearchResults(mapping, state);
37481
+ return mcpSuccess({
37482
+ mode: "local-only",
37483
+ issues: items,
37484
+ count: items.length
37485
+ }, `Local-only mode: ${items.length} items from local mapping.`);
37486
+ }
37487
+ const args = [
37488
+ "issue",
37489
+ "list",
37490
+ "--json",
37491
+ "number,title,state,labels,milestone",
37492
+ "--limit",
37493
+ "100"
37494
+ ];
37495
+ if (state && state !== "all") args.push("--state", state);
37496
+ else if (state === "all") args.push("--state", "all");
37497
+ if (labels && labels.length > 0) for (const label of labels) args.push("--label", label);
37498
+ if (milestone) args.push("--milestone", milestone);
37499
+ if (query) args.push("--search", query);
37500
+ const result = await ghExec(args, { parseJson: true });
37501
+ if (!result.ok) return mcpError(`Search failed: ${result.error}`, "Search failed");
37502
+ const issues = result.data.map((issue) => ({
37503
+ number: issue.number,
37504
+ title: issue.title,
37505
+ state: issue.state,
37506
+ labels: issue.labels.map((l) => l.name),
37507
+ milestone: issue.milestone?.title ?? null
37508
+ }));
37509
+ return mcpSuccess({
37510
+ mode: "full",
37511
+ issues,
37512
+ count: issues.length
37513
+ }, `Found ${issues.length} issues`);
37514
+ } catch (e) {
37515
+ return mcpError(e.message, "Operation failed");
37516
+ }
37517
+ });
37518
+ server.tool("mcp_get_issue_detail", "Get full details of a specific GitHub issue including comments.", { issue_number: numberType().describe("GitHub issue number") }, async ({ issue_number }) => {
37519
+ try {
37520
+ if (await detectGitHubMode() === "local-only") return mcpSuccess({
37521
+ mode: "local-only",
37522
+ warning: "GitHub not configured. Cannot fetch issue details.",
37523
+ issue_number
37524
+ }, "Local-only mode: cannot fetch issue details.");
37525
+ const result = await ghExec([
37526
+ "issue",
37527
+ "view",
37528
+ String(issue_number),
37529
+ "--json",
37530
+ "number,title,body,state,labels,comments,assignees"
37531
+ ], { parseJson: true });
37532
+ if (!result.ok) return mcpError(`Fetch failed: ${result.error}`, "Fetch failed");
37533
+ const issue = result.data;
37534
+ return mcpSuccess({
37535
+ mode: "full",
37536
+ number: issue.number,
37537
+ title: issue.title,
37538
+ body: issue.body,
37539
+ state: issue.state,
37540
+ labels: issue.labels.map((l) => l.name),
37541
+ assignees: issue.assignees.map((a) => a.login),
37542
+ comments: issue.comments.map((c) => ({
37543
+ author: c.author.login,
37544
+ body: c.body,
37545
+ created_at: c.createdAt
37546
+ })),
37547
+ comment_count: issue.comments.length
37548
+ }, `Issue #${issue.number}: ${issue.title} (${issue.state})`);
37549
+ } catch (e) {
37550
+ return mcpError(e.message, "Operation failed");
37551
+ }
37552
+ });
37553
+ server.tool("mcp_set_estimate", "Set Fibonacci story points on a GitHub issue.", {
37554
+ issue_number: numberType().describe("GitHub issue number"),
37555
+ points: numberType().describe("Fibonacci story points (1, 2, 3, 5, 8, 13, 21, 34)")
37556
+ }, async ({ issue_number, points }) => {
37557
+ try {
37558
+ const cwd = detectProjectRoot();
37559
+ if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
37560
+ if (!FIBONACCI_POINTS.includes(points)) return mcpError(`Invalid points: ${points}. Must be one of: ${FIBONACCI_POINTS.join(", ")}`, "Validation failed");
37561
+ if (await detectGitHubMode() === "local-only") return mcpSuccess({
37562
+ mode: "local-only",
37563
+ warning: "GitHub not configured. Cannot set estimate.",
37564
+ issue_number,
37565
+ points
37566
+ }, "Local-only mode: cannot set estimate on GitHub project.");
37567
+ const mapping = loadMapping(cwd);
37568
+ if (!mapping) return mcpError("github-issues.json not found. Run mcp_github_setup first.", "Setup required");
37569
+ if (!mapping.estimate_field_id) return mcpError("Estimate field not configured. Re-run mcp_github_setup.", "Setup required");
37570
+ const issueEntry = findIssueInMapping(mapping, issue_number);
37571
+ if (!issueEntry) return mcpError(`Issue #${issue_number} not found in local mapping`, "Issue not tracked");
37572
+ if (!issueEntry.item_id) return mcpError(`Issue #${issue_number} has no project item_id. It may not have been added to the board.`, "Not on board");
37573
+ const result = await setEstimate(mapping.project_id, issueEntry.item_id, mapping.estimate_field_id, points);
37574
+ if (!result.ok) return mcpError(`Set estimate failed: ${result.error}`, "Estimate failed");
37575
+ return mcpSuccess({
37576
+ mode: "full",
37577
+ issue_number,
37578
+ points,
37579
+ set: true
37580
+ }, `Estimate set: issue #${issue_number} = ${points} points`);
37581
+ } catch (e) {
37582
+ return mcpError(e.message, "Operation failed");
37583
+ }
37584
+ });
37585
+ }
37586
+ /**
37587
+ * Find an issue entry in the mapping file (searches phases and todos).
37588
+ */
37589
+ function findIssueInMapping(mapping, issueNumber) {
37590
+ for (const phase of Object.values(mapping.phases)) {
37591
+ if (phase.tracking_issue.number === issueNumber) return phase.tracking_issue;
37592
+ for (const task of Object.values(phase.tasks)) if (task.number === issueNumber) return task;
37593
+ }
37594
+ if (mapping.todos) {
37595
+ for (const todo of Object.values(mapping.todos)) if (todo.number === issueNumber) return todo;
37596
+ }
37597
+ return null;
37598
+ }
37599
+ /**
37600
+ * Build local board items from the mapping file (for local-only mode).
37601
+ */
37602
+ function buildLocalBoardItems(mapping, statusFilter, phaseFilter) {
37603
+ const items = [];
37604
+ for (const [phaseNum, phase] of Object.entries(mapping.phases)) {
37605
+ if (phaseFilter && phaseNum !== phaseFilter) continue;
37606
+ if (phase.tracking_issue.number > 0) {
37607
+ const entry = {
37608
+ issue_number: phase.tracking_issue.number,
37609
+ title: `[Phase ${phaseNum}] Tracking`,
37610
+ status: phase.tracking_issue.status,
37611
+ source: `phase ${phaseNum}`
37612
+ };
37613
+ if (!statusFilter || entry.status === statusFilter) items.push(entry);
37614
+ }
37615
+ for (const [taskId, task] of Object.entries(phase.tasks)) if (task.number > 0) {
37616
+ const entry = {
37617
+ issue_number: task.number,
37618
+ title: `[P${phaseNum}] Task ${taskId}`,
37619
+ status: task.status,
37620
+ source: `phase ${phaseNum}, task ${taskId}`
37621
+ };
37622
+ if (!statusFilter || entry.status === statusFilter) items.push(entry);
37623
+ }
37624
+ }
37625
+ if (!phaseFilter && mapping.todos) {
37626
+ for (const [todoId, todo] of Object.entries(mapping.todos)) if (todo.number > 0) {
37627
+ const entry = {
37628
+ issue_number: todo.number,
37629
+ title: `Todo: ${todoId}`,
37630
+ status: todo.status,
37631
+ source: `todo ${todoId}`
37632
+ };
37633
+ if (!statusFilter || entry.status === statusFilter) items.push(entry);
37634
+ }
37635
+ }
37636
+ return items;
37637
+ }
37638
+ /**
37639
+ * Build local search results from the mapping file (for local-only mode).
37640
+ */
37641
+ function buildLocalSearchResults(mapping, stateFilter) {
37642
+ const items = [];
37643
+ for (const [phaseNum, phase] of Object.entries(mapping.phases)) for (const [taskId, task] of Object.entries(phase.tasks)) if (task.number > 0) {
37644
+ const state = task.status === "Done" ? "closed" : "open";
37645
+ if (stateFilter && stateFilter !== "all" && state !== stateFilter) continue;
37646
+ items.push({
37647
+ issue_number: task.number,
37648
+ title: `[P${phaseNum}] Task ${taskId}`,
37649
+ state,
37650
+ source: `phase ${phaseNum}`
37651
+ });
37652
+ }
37653
+ if (mapping.todos) {
37654
+ for (const [todoId, todo] of Object.entries(mapping.todos)) if (todo.number > 0) {
37655
+ const state = todo.status === "Done" ? "closed" : "open";
37656
+ if (stateFilter && stateFilter !== "all" && state !== stateFilter) continue;
37657
+ items.push({
37658
+ issue_number: todo.number,
37659
+ title: `Todo: ${todoId}`,
37660
+ state,
37661
+ source: "todo"
37662
+ });
37663
+ }
37664
+ }
37665
+ return items;
37666
+ }
37667
+
35034
37668
  //#endregion
35035
37669
  //#region src/mcp/index.ts
35036
37670
  /**
@@ -35043,6 +37677,8 @@ function registerAllTools(server) {
35043
37677
  registerContextTools(server);
35044
37678
  registerRoadmapTools(server);
35045
37679
  registerConfigTools(server);
37680
+ registerGitHubTools(server);
37681
+ registerBoardTools(server);
35046
37682
  }
35047
37683
 
35048
37684
  //#endregion