gsd-pi 2.66.1-dev.3c26b49 → 2.66.1-dev.3cea7ac

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 (230) hide show
  1. package/dist/resources/extensions/ask-user-questions.js +79 -11
  2. package/dist/resources/extensions/claude-code-cli/partial-builder.js +4 -3
  3. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +10 -3
  4. package/dist/resources/extensions/gsd/auto/loop.js +13 -1
  5. package/dist/resources/extensions/gsd/auto/phases.js +10 -4
  6. package/dist/resources/extensions/gsd/auto/run-unit.js +10 -2
  7. package/dist/resources/extensions/gsd/auto/session.js +1 -1
  8. package/dist/resources/extensions/gsd/auto-dashboard.js +65 -15
  9. package/dist/resources/extensions/gsd/auto-dispatch.js +30 -28
  10. package/dist/resources/extensions/gsd/auto-prompts.js +6 -6
  11. package/dist/resources/extensions/gsd/auto-recovery.js +11 -12
  12. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +18 -6
  13. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +59 -5
  14. package/dist/resources/extensions/gsd/bootstrap/register-shortcuts.js +8 -5
  15. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +186 -14
  16. package/dist/resources/extensions/gsd/codebase-generator.js +4 -0
  17. package/dist/resources/extensions/gsd/commands/handlers/core.js +3 -3
  18. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +10 -4
  19. package/dist/resources/extensions/gsd/custom-workflow-engine.js +3 -1
  20. package/dist/resources/extensions/gsd/detection.js +6 -0
  21. package/dist/resources/extensions/gsd/files.js +19 -2
  22. package/dist/resources/extensions/gsd/guided-flow.js +12 -8
  23. package/dist/resources/extensions/gsd/index.js +1 -1
  24. package/dist/resources/extensions/gsd/parallel-monitor-overlay.js +2 -0
  25. package/dist/resources/extensions/gsd/parsers-legacy.js +3 -1
  26. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  27. package/dist/resources/extensions/gsd/prompts/discuss-prepared.md +7 -7
  28. package/dist/resources/extensions/gsd/prompts/discuss.md +3 -3
  29. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -3
  30. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  31. package/dist/resources/extensions/gsd/prompts/rethink.md +6 -2
  32. package/dist/resources/extensions/gsd/prompts/system.md +1 -1
  33. package/dist/resources/extensions/gsd/prompts/triage-captures.md +1 -1
  34. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +4 -4
  35. package/dist/resources/extensions/gsd/prompts/worktree-merge.md +3 -1
  36. package/dist/resources/extensions/gsd/safety/file-change-validator.js +2 -1
  37. package/dist/resources/extensions/gsd/state.js +2 -1
  38. package/dist/resources/extensions/gsd/visualizer-overlay.js +27 -26
  39. package/dist/resources/extensions/gsd/workflow-reconcile.js +46 -7
  40. package/dist/resources/extensions/remote-questions/manager.js +8 -0
  41. package/dist/resources/extensions/shared/interview-ui.js +10 -0
  42. package/dist/web/standalone/.next/BUILD_ID +1 -1
  43. package/dist/web/standalone/.next/app-path-routes-manifest.json +20 -20
  44. package/dist/web/standalone/.next/build-manifest.json +2 -2
  45. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  46. package/dist/web/standalone/.next/required-server-files.json +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  48. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/index.html +1 -1
  64. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app-paths-manifest.json +20 -20
  71. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  72. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  73. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  74. package/dist/web/standalone/server.js +1 -1
  75. package/package.json +1 -1
  76. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -1
  77. package/packages/pi-ai/dist/providers/anthropic-shared.js +4 -3
  78. package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -1
  79. package/packages/pi-ai/dist/utils/json-parse.d.ts.map +1 -1
  80. package/packages/pi-ai/dist/utils/json-parse.js +11 -1
  81. package/packages/pi-ai/dist/utils/json-parse.js.map +1 -1
  82. package/packages/pi-ai/dist/utils/repair-tool-json.d.ts.map +1 -1
  83. package/packages/pi-ai/dist/utils/repair-tool-json.js +60 -1
  84. package/packages/pi-ai/dist/utils/repair-tool-json.js.map +1 -1
  85. package/packages/pi-ai/dist/utils/tests/json-parse.test.d.ts +2 -0
  86. package/packages/pi-ai/dist/utils/tests/json-parse.test.d.ts.map +1 -0
  87. package/packages/pi-ai/dist/utils/tests/json-parse.test.js +14 -0
  88. package/packages/pi-ai/dist/utils/tests/json-parse.test.js.map +1 -0
  89. package/packages/pi-ai/dist/utils/tests/repair-tool-json.test.js +10 -0
  90. package/packages/pi-ai/dist/utils/tests/repair-tool-json.test.js.map +1 -1
  91. package/packages/pi-ai/src/providers/anthropic-shared.ts +4 -3
  92. package/packages/pi-ai/src/utils/json-parse.ts +11 -1
  93. package/packages/pi-ai/src/utils/repair-tool-json.ts +69 -1
  94. package/packages/pi-ai/src/utils/tests/json-parse.test.ts +17 -0
  95. package/packages/pi-ai/src/utils/tests/repair-tool-json.test.ts +13 -0
  96. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/provider-display-name.test.d.ts +2 -0
  97. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/provider-display-name.test.d.ts.map +1 -0
  98. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/provider-display-name.test.js +17 -0
  99. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/provider-display-name.test.js.map +1 -0
  100. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.d.ts.map +1 -1
  101. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js +2 -1
  102. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js.map +1 -1
  103. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.d.ts +1 -0
  104. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  105. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js +9 -2
  106. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js.map +1 -1
  107. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.d.ts.map +1 -1
  108. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.js +2 -1
  109. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.js.map +1 -1
  110. package/packages/pi-coding-agent/dist/modes/interactive/components/scoped-models-selector.d.ts.map +1 -1
  111. package/packages/pi-coding-agent/dist/modes/interactive/components/scoped-models-selector.js +2 -1
  112. package/packages/pi-coding-agent/dist/modes/interactive/components/scoped-models-selector.js.map +1 -1
  113. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +2 -2
  114. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  115. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/provider-display-name.test.ts +18 -0
  116. package/packages/pi-coding-agent/src/modes/interactive/components/footer.ts +2 -1
  117. package/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +11 -2
  118. package/packages/pi-coding-agent/src/modes/interactive/components/provider-manager.ts +2 -1
  119. package/packages/pi-coding-agent/src/modes/interactive/components/scoped-models-selector.ts +2 -1
  120. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +2 -2
  121. package/packages/pi-tui/dist/__tests__/autocomplete.test.js +13 -0
  122. package/packages/pi-tui/dist/__tests__/autocomplete.test.js.map +1 -1
  123. package/packages/pi-tui/dist/__tests__/stdin-buffer.test.d.ts +2 -0
  124. package/packages/pi-tui/dist/__tests__/stdin-buffer.test.d.ts.map +1 -0
  125. package/packages/pi-tui/dist/__tests__/stdin-buffer.test.js +35 -0
  126. package/packages/pi-tui/dist/__tests__/stdin-buffer.test.js.map +1 -0
  127. package/packages/pi-tui/dist/__tests__/tui.test.d.ts +2 -0
  128. package/packages/pi-tui/dist/__tests__/tui.test.d.ts.map +1 -0
  129. package/packages/pi-tui/dist/__tests__/tui.test.js +43 -0
  130. package/packages/pi-tui/dist/__tests__/tui.test.js.map +1 -0
  131. package/packages/pi-tui/dist/autocomplete.d.ts.map +1 -1
  132. package/packages/pi-tui/dist/autocomplete.js +9 -7
  133. package/packages/pi-tui/dist/autocomplete.js.map +1 -1
  134. package/packages/pi-tui/dist/components/__tests__/editor.test.d.ts +2 -0
  135. package/packages/pi-tui/dist/components/__tests__/editor.test.d.ts.map +1 -0
  136. package/packages/pi-tui/dist/components/__tests__/editor.test.js +54 -0
  137. package/packages/pi-tui/dist/components/__tests__/editor.test.js.map +1 -0
  138. package/packages/pi-tui/dist/components/editor.d.ts +3 -1
  139. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  140. package/packages/pi-tui/dist/components/editor.js +14 -3
  141. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  142. package/packages/pi-tui/dist/stdin-buffer.d.ts.map +1 -1
  143. package/packages/pi-tui/dist/stdin-buffer.js +6 -0
  144. package/packages/pi-tui/dist/stdin-buffer.js.map +1 -1
  145. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  146. package/packages/pi-tui/dist/tui.js +8 -0
  147. package/packages/pi-tui/dist/tui.js.map +1 -1
  148. package/packages/pi-tui/src/__tests__/autocomplete.test.ts +15 -0
  149. package/packages/pi-tui/src/__tests__/stdin-buffer.test.ts +43 -0
  150. package/packages/pi-tui/src/__tests__/tui.test.ts +50 -0
  151. package/packages/pi-tui/src/autocomplete.ts +9 -7
  152. package/packages/pi-tui/src/components/__tests__/editor.test.ts +64 -0
  153. package/packages/pi-tui/src/components/editor.ts +14 -3
  154. package/packages/pi-tui/src/stdin-buffer.ts +7 -0
  155. package/packages/pi-tui/src/tui.ts +9 -0
  156. package/src/resources/extensions/ask-user-questions.ts +103 -11
  157. package/src/resources/extensions/claude-code-cli/partial-builder.ts +4 -3
  158. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +12 -3
  159. package/src/resources/extensions/claude-code-cli/tests/partial-builder.test.ts +17 -0
  160. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +18 -0
  161. package/src/resources/extensions/gsd/auto/loop-deps.ts +2 -1
  162. package/src/resources/extensions/gsd/auto/loop.ts +14 -1
  163. package/src/resources/extensions/gsd/auto/phases.ts +10 -5
  164. package/src/resources/extensions/gsd/auto/run-unit.ts +14 -2
  165. package/src/resources/extensions/gsd/auto/session.ts +1 -1
  166. package/src/resources/extensions/gsd/auto-dashboard.ts +76 -16
  167. package/src/resources/extensions/gsd/auto-dispatch.ts +36 -35
  168. package/src/resources/extensions/gsd/auto-prompts.ts +5 -6
  169. package/src/resources/extensions/gsd/auto-recovery.ts +15 -15
  170. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +27 -6
  171. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +67 -6
  172. package/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts +11 -8
  173. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +209 -16
  174. package/src/resources/extensions/gsd/codebase-generator.ts +4 -0
  175. package/src/resources/extensions/gsd/commands/handlers/core.ts +6 -6
  176. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +11 -4
  177. package/src/resources/extensions/gsd/custom-workflow-engine.ts +3 -1
  178. package/src/resources/extensions/gsd/detection.ts +6 -0
  179. package/src/resources/extensions/gsd/files.ts +21 -2
  180. package/src/resources/extensions/gsd/guided-flow.ts +15 -8
  181. package/src/resources/extensions/gsd/index.ts +6 -0
  182. package/src/resources/extensions/gsd/parallel-monitor-overlay.ts +2 -0
  183. package/src/resources/extensions/gsd/parsers-legacy.ts +3 -1
  184. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  185. package/src/resources/extensions/gsd/prompts/discuss-prepared.md +7 -7
  186. package/src/resources/extensions/gsd/prompts/discuss.md +3 -3
  187. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -3
  188. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  189. package/src/resources/extensions/gsd/prompts/rethink.md +6 -2
  190. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  191. package/src/resources/extensions/gsd/prompts/triage-captures.md +1 -1
  192. package/src/resources/extensions/gsd/prompts/validate-milestone.md +4 -4
  193. package/src/resources/extensions/gsd/prompts/worktree-merge.md +3 -1
  194. package/src/resources/extensions/gsd/safety/file-change-validator.ts +4 -1
  195. package/src/resources/extensions/gsd/state.ts +2 -1
  196. package/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +52 -1
  197. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +50 -2
  198. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +48 -0
  199. package/src/resources/extensions/gsd/tests/codebase-generator.test.ts +22 -0
  200. package/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts +44 -0
  201. package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +7 -1
  202. package/src/resources/extensions/gsd/tests/custom-workflow-engine.test.ts +31 -0
  203. package/src/resources/extensions/gsd/tests/detection.test.ts +37 -0
  204. package/src/resources/extensions/gsd/tests/file-change-validator.test.ts +50 -0
  205. package/src/resources/extensions/gsd/tests/gsd-tools.test.ts +35 -0
  206. package/src/resources/extensions/gsd/tests/guided-flow-session-isolation.test.ts +34 -0
  207. package/src/resources/extensions/gsd/tests/health-widget.test.ts +45 -0
  208. package/src/resources/extensions/gsd/tests/integration/auto-recovery.test.ts +53 -13
  209. package/src/resources/extensions/gsd/tests/integration/state-machine-runtime-failures.test.ts +2 -2
  210. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +1 -1
  211. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +3 -4
  212. package/src/resources/extensions/gsd/tests/parallel-monitor-overlay.test.ts +21 -0
  213. package/src/resources/extensions/gsd/tests/parallel-research-dispatch.test.ts +71 -2
  214. package/src/resources/extensions/gsd/tests/parsers.test.ts +25 -0
  215. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +8 -1
  216. package/src/resources/extensions/gsd/tests/queue-execution-guard.test.ts +9 -0
  217. package/src/resources/extensions/gsd/tests/reactive-graph.test.ts +19 -0
  218. package/src/resources/extensions/gsd/tests/register-shortcuts.test.ts +73 -0
  219. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +98 -0
  220. package/src/resources/extensions/gsd/tests/smart-entry-complete.test.ts +2 -2
  221. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +26 -0
  222. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +59 -0
  223. package/src/resources/extensions/gsd/tests/workflow-reconcile.test.ts +91 -0
  224. package/src/resources/extensions/gsd/tests/write-gate.test.ts +210 -35
  225. package/src/resources/extensions/gsd/visualizer-overlay.ts +31 -27
  226. package/src/resources/extensions/gsd/workflow-reconcile.ts +59 -8
  227. package/src/resources/extensions/remote-questions/manager.ts +9 -0
  228. package/src/resources/extensions/shared/interview-ui.ts +13 -0
  229. /package/dist/web/standalone/.next/static/{ZzNRjwBFLOhqEu4BYCQi9 → HxFcJ8GrYNPsg9ARz7GPz}/_buildManifest.js +0 -0
  230. /package/dist/web/standalone/.next/static/{ZzNRjwBFLOhqEu4BYCQi9 → HxFcJ8GrYNPsg9ARz7GPz}/_ssgManifest.js +0 -0
@@ -58,6 +58,51 @@ export function questionSignature(questions) {
58
58
  export function resetAskUserQuestionsCache() {
59
59
  turnCache.clear();
60
60
  }
61
+ /**
62
+ * Race a remote channel dispatch against the local TUI. The first to produce
63
+ * a valid (non-error, non-timeout) result wins. The loser is cancelled via
64
+ * the shared AbortController.
65
+ *
66
+ * If the local TUI responds first, the remote poll is aborted (the message
67
+ * stays in Discord/Slack but polling stops). If remote responds first, the
68
+ * local TUI prompt is cancelled.
69
+ *
70
+ * Returns null only when both sides fail or are cancelled.
71
+ */
72
+ async function raceRemoteAndLocal(startRemote, startLocal, controller, questions) {
73
+ // Wrap local TUI result into the same shape as remote results
74
+ const localPromise = startLocal().then((result) => {
75
+ if (!result || Object.keys(result.answers).length === 0)
76
+ return null;
77
+ return {
78
+ content: [{ type: "text", text: formatForLLM(result) }],
79
+ details: { questions, response: result, cancelled: false },
80
+ };
81
+ }).catch(() => null);
82
+ const remotePromise = startRemote().then((result) => {
83
+ if (!result)
84
+ return null;
85
+ const details = result.details;
86
+ // Treat timeouts and errors as non-wins — let the local TUI win instead
87
+ if (details?.timed_out || details?.error)
88
+ return null;
89
+ return result;
90
+ }).catch(() => null);
91
+ // Race: first non-null result wins
92
+ const winner = await Promise.race([
93
+ localPromise.then((r) => r ? { source: "local", result: r } : null),
94
+ remotePromise.then((r) => r ? { source: "remote", result: r } : null),
95
+ ]);
96
+ if (winner) {
97
+ // Cancel the loser
98
+ controller.abort();
99
+ return winner.result;
100
+ }
101
+ // First to resolve was null — wait for the other
102
+ const [localResult, remoteResult] = await Promise.all([localPromise, remotePromise]);
103
+ controller.abort();
104
+ return localResult ?? remoteResult;
105
+ }
61
106
  // ─── Helpers ──────────────────────────────────────────────────────────────────
62
107
  const OTHER_OPTION_LABEL = "None of the above";
63
108
  function errorResult(message, questions = []) {
@@ -116,19 +161,42 @@ export default function AskUserQuestions(pi) {
116
161
  return errorResult(`Error: ask_user_questions requires non-empty options for every question (question "${q.id}" has none)`, params.questions);
117
162
  }
118
163
  }
119
- // Try remote first if configured (works in both interactive and headless modes).
120
- // tryRemoteQuestions returns null when no remote channel is configured, so
121
- // this is a no-op when the user has not set up Slack/Discord/Telegram.
122
- const { tryRemoteQuestions } = await import("./remote-questions/manager.js");
123
- const remoteResult = await tryRemoteQuestions(params.questions, signal);
124
- if (remoteResult) {
125
- // Cache successful remote results to prevent duplicate Discord dispatches
126
- const remoteDetails = remoteResult.details;
127
- if (remoteDetails && !remoteDetails.timed_out && !remoteDetails.error) {
128
- turnCache.set(sig, remoteResult);
164
+ // ── Routing: race remote + local, remote-only, or local-only ────────
165
+ const { tryRemoteQuestions, isRemoteConfigured } = await import("./remote-questions/manager.js");
166
+ const hasRemote = isRemoteConfigured();
167
+ // Case 1: Both remote and local UI available — race them.
168
+ // The first response wins; the loser is cancelled via AbortController.
169
+ if (hasRemote && ctx.hasUI) {
170
+ const raceController = new AbortController();
171
+ // Merge the parent signal so external cancellation propagates.
172
+ const onParentAbort = () => raceController.abort();
173
+ signal?.addEventListener("abort", onParentAbort, { once: true });
174
+ const raceSignal = raceController.signal;
175
+ const raceResult = await raceRemoteAndLocal(() => tryRemoteQuestions(params.questions, raceSignal), () => showInterviewRound(params.questions, { signal: raceSignal }, ctx), raceController, params.questions);
176
+ signal?.removeEventListener("abort", onParentAbort);
177
+ if (raceResult) {
178
+ const details = raceResult.details;
179
+ if (details && !details.timed_out && !details.error && !details.cancelled) {
180
+ turnCache.set(sig, raceResult);
181
+ }
182
+ return { ...raceResult, details: raceResult.details };
183
+ }
184
+ // Both sides failed/cancelled — fall through to error
185
+ return errorResult("ask_user_questions: no response received from local UI or remote channel", params.questions);
186
+ }
187
+ // Case 2: Remote configured but no local UI (headless) — remote only.
188
+ if (hasRemote && !ctx.hasUI) {
189
+ const remoteResult = await tryRemoteQuestions(params.questions, signal);
190
+ if (remoteResult) {
191
+ const remoteDetails = remoteResult.details;
192
+ if (remoteDetails && !remoteDetails.timed_out && !remoteDetails.error) {
193
+ turnCache.set(sig, remoteResult);
194
+ }
195
+ return { ...remoteResult, details: remoteResult.details };
129
196
  }
130
- return { ...remoteResult, details: remoteResult.details };
197
+ return errorResult("Error: remote channel configured but returned no result", params.questions);
131
198
  }
199
+ // Case 3: No remote — local UI only.
132
200
  if (!ctx.hasUI) {
133
201
  return errorResult("Error: UI not available (non-interactive mode)", params.questions);
134
202
  }
@@ -4,7 +4,7 @@
4
4
  * Translates the Claude Agent SDK's `BetaRawMessageStreamEvent` sequence
5
5
  * into GSD's `AssistantMessageEvent` deltas for incremental TUI rendering.
6
6
  */
7
- import { repairToolJson } from "@gsd/pi-ai";
7
+ import { hasXmlParameterTags, repairToolJson } from "@gsd/pi-ai";
8
8
  // ---------------------------------------------------------------------------
9
9
  // Content-block mapping helpers
10
10
  // ---------------------------------------------------------------------------
@@ -207,14 +207,15 @@ export class PartialMessageBuilder {
207
207
  }
208
208
  if (block.type === "toolCall") {
209
209
  const jsonStr = this.toolJsonAccum.get(streamIndex) ?? "{}";
210
+ const jsonForParse = hasXmlParameterTags(jsonStr) ? repairToolJson(jsonStr) : jsonStr;
210
211
  try {
211
- block.arguments = JSON.parse(jsonStr);
212
+ block.arguments = JSON.parse(jsonForParse);
212
213
  }
213
214
  catch {
214
215
  // JSON.parse failed — attempt repair for YAML-style bullet
215
216
  // lists that LLMs copy from template formatting (#2660).
216
217
  try {
217
- block.arguments = JSON.parse(repairToolJson(jsonStr));
218
+ block.arguments = JSON.parse(repairToolJson(jsonForParse));
218
219
  }
219
220
  catch {
220
221
  // Repair also failed — stream was truncated or garbage.
@@ -29,6 +29,15 @@ function createAssistantStream() {
29
29
  // Claude binary resolution
30
30
  // ---------------------------------------------------------------------------
31
31
  let cachedClaudePath = null;
32
+ export function getClaudeLookupCommand(platform = process.platform) {
33
+ return platform === "win32" ? "where claude" : "which claude";
34
+ }
35
+ export function parseClaudeLookupOutput(output) {
36
+ return output
37
+ .toString()
38
+ .trim()
39
+ .split(/\r?\n/)[0] ?? "";
40
+ }
32
41
  /**
33
42
  * Resolve the path to the system-installed `claude` binary.
34
43
  * The SDK defaults to a bundled cli.js which doesn't exist when
@@ -38,9 +47,7 @@ function getClaudePath() {
38
47
  if (cachedClaudePath)
39
48
  return cachedClaudePath;
40
49
  try {
41
- cachedClaudePath = execSync("which claude", { timeout: 5_000, stdio: "pipe" })
42
- .toString()
43
- .trim();
50
+ cachedClaudePath = parseClaudeLookupOutput(execSync(getClaudeLookupCommand(), { timeout: 5_000, stdio: "pipe" }));
44
51
  }
45
52
  catch {
46
53
  cachedClaudePath = "claude"; // fall back to PATH resolution
@@ -150,7 +150,7 @@ export async function autoLoop(ctx, pi, s, deps) {
150
150
  }
151
151
  // Verification passed — mark step complete
152
152
  debugLog("autoLoop", { phase: "custom-engine-reconcile", iteration, unitId: iterData.unitId });
153
- await engine.reconcile(engineState, {
153
+ const reconcileResult = await engine.reconcile(engineState, {
154
154
  unitType: iterData.unitType,
155
155
  unitId: iterData.unitId,
156
156
  startedAt: s.currentUnit?.startedAt ?? Date.now(),
@@ -161,6 +161,18 @@ export async function autoLoop(ctx, pi, s, deps) {
161
161
  recentErrorMessages.length = 0;
162
162
  deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-end", data: { iteration } });
163
163
  debugLog("autoLoop", { phase: "iteration-complete", iteration });
164
+ if (reconcileResult.outcome === "milestone-complete") {
165
+ await deps.stopAuto(ctx, pi, "Workflow complete");
166
+ break;
167
+ }
168
+ if (reconcileResult.outcome === "pause") {
169
+ await deps.pauseAuto(ctx, pi);
170
+ break;
171
+ }
172
+ if (reconcileResult.outcome === "stop") {
173
+ await deps.stopAuto(ctx, pi, reconcileResult.reason ?? "Engine stopped");
174
+ break;
175
+ }
164
176
  continue;
165
177
  }
166
178
  if (!sidecarItem) {
@@ -338,7 +338,13 @@ export async function runPreDispatch(ic, loopState) {
338
338
  ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning");
339
339
  }
340
340
  // Mid-merge safety check
341
- if (deps.reconcileMergeState(s.basePath, ctx)) {
341
+ const mergeReconcileResult = deps.reconcileMergeState(s.basePath, ctx);
342
+ if (mergeReconcileResult === "blocked") {
343
+ await deps.pauseAuto(ctx, pi);
344
+ debugLog("autoLoop", { phase: "exit", reason: "merge-reconciliation-blocked" });
345
+ return { action: "break", reason: "merge-reconciliation-blocked" };
346
+ }
347
+ if (mergeReconcileResult === "reconciled") {
342
348
  deps.invalidateAllCaches();
343
349
  state = await deps.deriveState(s.basePath);
344
350
  mid = state.activeMilestone?.id;
@@ -948,13 +954,13 @@ export async function runUnitPhase(ic, iterData, loopState, sidecarItem) {
948
954
  return { action: "break", reason: "provider-pause" };
949
955
  }
950
956
  // Session creation timeout (not a structural error): pause auto-mode
951
- // and let the provider-error-resume timer handle recovery. This matches
952
- // the provider-pause path — break out cleanly, don't hard-stop.
957
+ // and let the provider-error-resume timer handle recovery (#3767). This
958
+ // matches the provider-pause path — break out cleanly, don't hard-stop.
953
959
  // Structural errors (TypeError, is not a function) are NOT transient
954
960
  // and must hard-stop to avoid infinite retry loops.
955
961
  if (unitResult.errorContext?.isTransient &&
956
962
  unitResult.errorContext?.category === "timeout") {
957
- ctx.ui.notify(`Session creation timed out for ${unitType} ${unitId}. Will retry.`, "warning");
963
+ ctx.ui.notify(`Session creation timed out for ${unitType} ${unitId}. Pausing auto-mode (recoverable).`, "warning");
958
964
  debugLog("autoLoop", { phase: "session-timeout-pause", unitType, unitId });
959
965
  await deps.pauseAuto(ctx, pi);
960
966
  return { action: "break", reason: "session-timeout" };
@@ -7,6 +7,10 @@ import { NEW_SESSION_TIMEOUT_MS } from "./session.js";
7
7
  import { _setCurrentResolve, _setSessionSwitchInFlight } from "./resolve.js";
8
8
  import { debugLog } from "../debug-logger.js";
9
9
  import { logWarning } from "../workflow-logger.js";
10
+ import { resolveAutoSupervisorConfig } from "../preferences.js";
11
+ // Tracks the latest session-switch attempt so a late timeout settlement from an
12
+ // older runUnit() call cannot clear the guard for a newer one.
13
+ let sessionSwitchGeneration = 0;
10
14
  /**
11
15
  * Execute a single unit: create a new session, send the prompt, and await
12
16
  * the agent_end promise. Returns a UnitResult describing what happened.
@@ -21,10 +25,13 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
21
25
  debugLog("runUnit", { phase: "session-create", unitType, unitId });
22
26
  let sessionResult;
23
27
  let sessionTimeoutHandle;
28
+ const mySessionSwitchGeneration = ++sessionSwitchGeneration;
24
29
  _setSessionSwitchInFlight(true);
25
30
  try {
26
31
  const sessionPromise = s.cmdCtx.newSession().finally(() => {
27
- _setSessionSwitchInFlight(false);
32
+ if (sessionSwitchGeneration === mySessionSwitchGeneration) {
33
+ _setSessionSwitchInFlight(false);
34
+ }
28
35
  });
29
36
  const timeoutPromise = new Promise((resolve) => {
30
37
  sessionTimeoutHandle = setTimeout(() => resolve({ cancelled: true }), NEW_SESSION_TIMEOUT_MS);
@@ -83,7 +90,8 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
83
90
  // If supervision fails to resolve unitPromise within 30s, treat as cancelled.
84
91
  // Without this, a crashed agent that never emits agent_end hangs the loop (#3161).
85
92
  debugLog("runUnit", { phase: "awaiting-agent-end", unitType, unitId });
86
- const UNIT_HARD_TIMEOUT_MS = 30_000;
93
+ const supervisor = resolveAutoSupervisorConfig();
94
+ const UNIT_HARD_TIMEOUT_MS = Math.max(30_000, ((supervisor.hard_timeout_minutes ?? 30) * 60 * 1000) + 30_000);
87
95
  let unitTimeoutHandle;
88
96
  const timeoutResult = new Promise((resolve) => {
89
97
  unitTimeoutHandle = setTimeout(() => {
@@ -19,7 +19,7 @@
19
19
  export const MAX_UNIT_DISPATCHES = 3;
20
20
  export const STUB_RECOVERY_THRESHOLD = 2;
21
21
  export const MAX_LIFETIME_DISPATCHES = 6;
22
- export const NEW_SESSION_TIMEOUT_MS = 30_000;
22
+ export const NEW_SESSION_TIMEOUT_MS = 120_000;
23
23
  // ─── AutoSession ─────────────────────────────────────────────────────────────
24
24
  export class AutoSession {
25
25
  // ── Lifecycle ────────────────────────────────────────────────────────────
@@ -8,6 +8,7 @@
8
8
  import { getCurrentBranch } from "./worktree.js";
9
9
  import { getActiveHook } from "./post-unit-hooks.js";
10
10
  import { getLedger, getProjectTotals } from "./metrics.js";
11
+ import { getErrorMessage } from "./error-utils.js";
11
12
  import { isDbAvailable, getMilestoneSlices, getSliceTasks } from "./gsd-db.js";
12
13
  import { formatShortcut } from "./files.js";
13
14
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
@@ -17,7 +18,7 @@ import { makeUI } from "../shared/tui.js";
17
18
  import { GLYPH, INDENT } from "../shared/mod.js";
18
19
  import { computeProgressScore } from "./progress-score.js";
19
20
  import { getActiveWorktreeName } from "./worktree-command.js";
20
- import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
21
+ import { getGlobalGSDPreferencesPath, getProjectGSDPreferencesPath, parsePreferencesMarkdown, } from "./preferences.js";
21
22
  import { resolveServiceTierIcon, getEffectiveServiceTier } from "./service-tier.js";
22
23
  import { parseUnitId } from "./unit-id.js";
23
24
  import { formatRtkSavingsLabel, getRtkSessionSavings, } from "../shared/rtk-session-stats.js";
@@ -293,26 +294,68 @@ export const hideFooter = () => ({
293
294
  const WIDGET_MODES = ["full", "small", "min", "off"];
294
295
  let widgetMode = "full";
295
296
  let widgetModeInitialized = false;
297
+ let widgetModePreferencePath = null;
298
+ function safeReadTextFile(path) {
299
+ try {
300
+ if (!existsSync(path))
301
+ return null;
302
+ return readFileSync(path, "utf-8");
303
+ }
304
+ catch {
305
+ return null;
306
+ }
307
+ }
308
+ function readWidgetModeFromFile(path) {
309
+ const raw = safeReadTextFile(path);
310
+ if (!raw)
311
+ return undefined;
312
+ const prefs = parsePreferencesMarkdown(raw);
313
+ const saved = prefs?.widget_mode;
314
+ if (saved && WIDGET_MODES.includes(saved)) {
315
+ return saved;
316
+ }
317
+ return undefined;
318
+ }
319
+ function resolveWidgetModePreferencePath(projectPath = getProjectGSDPreferencesPath(), globalPath = getGlobalGSDPreferencesPath()) {
320
+ if (readWidgetModeFromFile(projectPath)) {
321
+ return projectPath;
322
+ }
323
+ if (readWidgetModeFromFile(globalPath)) {
324
+ return globalPath;
325
+ }
326
+ if (safeReadTextFile(projectPath) !== null)
327
+ return projectPath;
328
+ if (safeReadTextFile(globalPath) !== null)
329
+ return globalPath;
330
+ return getGlobalGSDPreferencesPath();
331
+ }
296
332
  /** Load widget mode from preferences (once). */
297
- function ensureWidgetModeLoaded() {
333
+ function ensureWidgetModeLoaded(projectPath, globalPath) {
298
334
  if (widgetModeInitialized)
299
335
  return;
300
336
  widgetModeInitialized = true;
301
337
  try {
302
- const loaded = loadEffectiveGSDPreferences();
303
- const saved = loaded?.preferences?.widget_mode;
338
+ const resolvedProjectPath = projectPath ?? getProjectGSDPreferencesPath();
339
+ const resolvedGlobalPath = globalPath ?? getGlobalGSDPreferencesPath();
340
+ const saved = readWidgetModeFromFile(resolvedProjectPath) ?? readWidgetModeFromFile(resolvedGlobalPath);
304
341
  if (saved && WIDGET_MODES.includes(saved)) {
305
342
  widgetMode = saved;
306
343
  }
344
+ widgetModePreferencePath = resolveWidgetModePreferencePath(resolvedProjectPath, resolvedGlobalPath);
307
345
  }
308
346
  catch (err) { /* non-fatal — use default */
309
- logWarning("dashboard", `operation failed: ${err instanceof Error ? err.message : String(err)}`);
347
+ logWarning("dashboard", `operation failed: ${getErrorMessage(err)}`);
348
+ widgetModePreferencePath = getGlobalGSDPreferencesPath();
310
349
  }
311
350
  }
312
- /** Persist widget mode to global preferences YAML. */
313
- function persistWidgetMode(mode) {
351
+ /**
352
+ * Persist widget mode to the preference file that owns the effective value.
353
+ * Project-scoped widget_mode wins over global; if neither scope defines it,
354
+ * we prefer an existing project preferences file and otherwise fall back to
355
+ * the global preferences file.
356
+ */
357
+ function persistWidgetMode(mode, prefsPath = widgetModePreferencePath ?? resolveWidgetModePreferencePath()) {
314
358
  try {
315
- const prefsPath = getGlobalGSDPreferencesPath();
316
359
  let content = "";
317
360
  if (existsSync(prefsPath)) {
318
361
  content = readFileSync(prefsPath, "utf-8");
@@ -332,23 +375,30 @@ function persistWidgetMode(mode) {
332
375
  }
333
376
  }
334
377
  /** Cycle to the next widget mode. Returns the new mode. */
335
- export function cycleWidgetMode() {
336
- ensureWidgetModeLoaded();
378
+ export function cycleWidgetMode(projectPath, globalPath) {
379
+ ensureWidgetModeLoaded(projectPath, globalPath);
337
380
  const idx = WIDGET_MODES.indexOf(widgetMode);
338
381
  widgetMode = WIDGET_MODES[(idx + 1) % WIDGET_MODES.length];
339
- persistWidgetMode(widgetMode);
382
+ persistWidgetMode(widgetMode, widgetModePreferencePath ?? resolveWidgetModePreferencePath(projectPath, globalPath));
340
383
  return widgetMode;
341
384
  }
342
385
  /** Set widget mode directly. */
343
- export function setWidgetMode(mode) {
386
+ export function setWidgetMode(mode, projectPath, globalPath) {
387
+ ensureWidgetModeLoaded(projectPath, globalPath);
344
388
  widgetMode = mode;
345
- persistWidgetMode(widgetMode);
389
+ persistWidgetMode(widgetMode, widgetModePreferencePath ?? resolveWidgetModePreferencePath(projectPath, globalPath));
346
390
  }
347
391
  /** Get current widget mode. */
348
- export function getWidgetMode() {
349
- ensureWidgetModeLoaded();
392
+ export function getWidgetMode(projectPath, globalPath) {
393
+ ensureWidgetModeLoaded(projectPath, globalPath);
350
394
  return widgetMode;
351
395
  }
396
+ /** Test-only reset for widget mode caching. */
397
+ export function _resetWidgetModeForTests() {
398
+ widgetMode = "full";
399
+ widgetModeInitialized = false;
400
+ widgetModePreferencePath = null;
401
+ }
352
402
  export function updateProgressWidget(ctx, unitType, unitId, state, accessors, tierBadge) {
353
403
  if (!ctx.hasUI)
354
404
  return;
@@ -291,34 +291,8 @@ export const DISPATCH_RULES = [
291
291
  },
292
292
  },
293
293
  {
294
- name: "planning (no research, not S01) research-slice",
295
- match: async ({ state, mid, midTitle, basePath, prefs }) => {
296
- if (state.phase !== "planning")
297
- return null;
298
- // Phase skip: skip research when preference or profile says so
299
- if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research)
300
- return null;
301
- if (!state.activeSlice)
302
- return missingSliceStop(mid, state.phase);
303
- const sid = state.activeSlice.id;
304
- const sTitle = state.activeSlice.title;
305
- const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
306
- if (researchFile)
307
- return null; // has research, fall through
308
- // Skip slice research for S01 when milestone research already exists —
309
- // the milestone research already covers the same ground for the first slice.
310
- const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
311
- if (milestoneResearchFile && sid === "S01")
312
- return null; // fall through to plan-slice
313
- return {
314
- action: "dispatch",
315
- unitType: "research-slice",
316
- unitId: `${mid}/${sid}`,
317
- prompt: await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, basePath),
318
- };
319
- },
320
- },
321
- {
294
+ // Keep this rule before the single-slice research rule so the multi-slice
295
+ // path wins whenever 2+ slices are ready.
322
296
  name: "planning (multiple slices need research) → parallel-research-slices",
323
297
  match: async ({ state, mid, midTitle, basePath, prefs }) => {
324
298
  if (state.phase !== "planning")
@@ -360,6 +334,34 @@ export const DISPATCH_RULES = [
360
334
  };
361
335
  },
362
336
  },
337
+ {
338
+ name: "planning (no research, not S01) → research-slice",
339
+ match: async ({ state, mid, midTitle, basePath, prefs }) => {
340
+ if (state.phase !== "planning")
341
+ return null;
342
+ // Phase skip: skip research when preference or profile says so
343
+ if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research)
344
+ return null;
345
+ if (!state.activeSlice)
346
+ return missingSliceStop(mid, state.phase);
347
+ const sid = state.activeSlice.id;
348
+ const sTitle = state.activeSlice.title;
349
+ const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
350
+ if (researchFile)
351
+ return null; // has research, fall through
352
+ // Skip slice research for S01 when milestone research already exists —
353
+ // the milestone research already covers the same ground for the first slice.
354
+ const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
355
+ if (milestoneResearchFile && sid === "S01")
356
+ return null; // fall through to plan-slice
357
+ return {
358
+ action: "dispatch",
359
+ unitType: "research-slice",
360
+ unitId: `${mid}/${sid}`,
361
+ prompt: await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, basePath),
362
+ };
363
+ },
364
+ },
363
365
  {
364
366
  name: "planning → plan-slice",
365
367
  match: async ({ state, mid, midTitle, basePath }) => {
@@ -1410,7 +1410,7 @@ export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
1410
1410
  catch (err) {
1411
1411
  logWarning("prompt", `buildValidateMilestonePrompt verification classes lookup failed: ${err instanceof Error ? err.message : String(err)}`);
1412
1412
  }
1413
- // Inline all slice summaries and UAT results
1413
+ // Inline all slice summaries and assessment results
1414
1414
  let valSliceIds = [];
1415
1415
  try {
1416
1416
  const { isDbAvailable, getMilestoneSlices } = await import("./gsd-db.js");
@@ -1436,11 +1436,11 @@ export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
1436
1436
  const summaryPath = resolveSliceFile(base, mid, sid, "SUMMARY");
1437
1437
  const summaryRel = relSliceFile(base, mid, sid, "SUMMARY");
1438
1438
  inlined.push(await inlineFile(summaryPath, summaryRel, `${sid} Summary`));
1439
- const uatPath = resolveSliceFile(base, mid, sid, "UAT");
1440
- const uatRel = relSliceFile(base, mid, sid, "UAT");
1441
- const uatInline = await inlineFileOptional(uatPath, uatRel, `${sid} UAT Result`);
1442
- if (uatInline)
1443
- inlined.push(uatInline);
1439
+ const assessmentPath = resolveSliceFile(base, mid, sid, "ASSESSMENT");
1440
+ const assessmentRel = relSliceFile(base, mid, sid, "ASSESSMENT");
1441
+ const assessmentInline = await inlineFileOptional(assessmentPath, assessmentRel, `${sid} Assessment`);
1442
+ if (assessmentInline)
1443
+ inlined.push(assessmentInline);
1444
1444
  }
1445
1445
  // Aggregate unresolved follow-ups and known limitations across slices
1446
1446
  const outstandingItems = [];
@@ -10,7 +10,7 @@ import { parseUnitId } from "./unit-id.js";
10
10
  import { appendEvent } from "./workflow-events.js";
11
11
  import { clearParseCache } from "./files.js";
12
12
  import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js";
13
- import { isDbAvailable, getTask, getSlice, getSliceTasks, updateTaskStatus, updateSliceStatus } from "./gsd-db.js";
13
+ import { isDbAvailable, getTask, getSlice, getSliceTasks, getPendingGates, updateTaskStatus, updateSliceStatus } from "./gsd-db.js";
14
14
  import { isValidationTerminal } from "./state.js";
15
15
  import { getErrorMessage } from "./error-utils.js";
16
16
  import { logWarning, logError } from "./workflow-logger.js";
@@ -198,8 +198,7 @@ export function verifyExpectedArtifact(unitType, unitId, base) {
198
198
  if (gateIds.length === 0)
199
199
  return true;
200
200
  try {
201
- const { getPendingGates: getPending } = require("./gsd-db.js");
202
- const pending = getPending(mid, sid, "slice");
201
+ const pending = getPendingGates(mid, sid, "slice");
203
202
  const pendingIds = new Set(pending.map((g) => g.gate_id));
204
203
  // All dispatched gates must no longer be pending
205
204
  for (const gid of gateIds) {
@@ -454,9 +453,8 @@ function abortAndResetMerge(basePath, hasMergeHead, squashMsgPath) {
454
453
  /**
455
454
  * Detect leftover merge state from a prior session and reconcile it.
456
455
  * If MERGE_HEAD or SQUASH_MSG exists, check whether conflicts are resolved.
457
- * If resolved: finalize the commit. If still conflicted: abort and reset.
458
- *
459
- * Returns true if state was dirty and re-derivation is needed.
456
+ * If resolved: finalize the commit. If only .gsd conflicts remain: auto-resolve.
457
+ * If code conflicts remain: fail safe without modifying the worktree.
460
458
  */
461
459
  export function reconcileMergeState(basePath, ctx) {
462
460
  const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD");
@@ -464,7 +462,7 @@ export function reconcileMergeState(basePath, ctx) {
464
462
  const hasMergeHead = existsSync(mergeHeadPath);
465
463
  const hasSquashMsg = existsSync(squashMsgPath);
466
464
  if (!hasMergeHead && !hasSquashMsg)
467
- return false;
465
+ return "clean";
468
466
  const conflictedFiles = nativeConflictFiles(basePath);
469
467
  if (conflictedFiles.length === 0) {
470
468
  // All conflicts resolved — finalize the merge/squash commit
@@ -481,7 +479,7 @@ export function reconcileMergeState(basePath, ctx) {
481
479
  catch (err) {
482
480
  const errorMessage = getErrorMessage(err);
483
481
  ctx.ui.notify(`Failed to finalize leftover merge/squash commit: ${errorMessage}`, "error");
484
- return false;
482
+ return "blocked";
485
483
  }
486
484
  }
487
485
  else {
@@ -515,12 +513,13 @@ export function reconcileMergeState(basePath, ctx) {
515
513
  }
516
514
  }
517
515
  else {
518
- // Code conflicts present — abort and reset
519
- abortAndResetMerge(basePath, hasMergeHead, squashMsgPath);
520
- ctx.ui.notify("Detected leftover merge state with unresolved conflicts cleaned up. Re-deriving state.", "warning");
516
+ // Code conflicts present — fail safe and preserve any manual resolution
517
+ // work instead of discarding it with merge --abort/reset --hard.
518
+ ctx.ui.notify("Detected leftover merge state with unresolved code conflicts. Auto-mode will pause without modifying the worktree so manual conflict resolution is preserved.", "error");
519
+ return "blocked";
521
520
  }
522
521
  }
523
- return true;
522
+ return "reconciled";
524
523
  }
525
524
  // ─── Loop Remediation ─────────────────────────────────────────────────────────
526
525
  /**
@@ -5,6 +5,11 @@ import { loadEffectiveGSDPreferences } from "../preferences.js";
5
5
  import { ensureDbOpen } from "./dynamic-tools.js";
6
6
  import { StringEnum } from "@gsd/pi-ai";
7
7
  import { logError } from "../workflow-logger.js";
8
+ import { shouldBlockContextArtifactSave } from "./write-gate.js";
9
+ const SUPPORTED_SUMMARY_ARTIFACT_TYPES = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT", "CONTEXT-DRAFT"];
10
+ export function isSupportedSummaryArtifactType(artifactType) {
11
+ return SUPPORTED_SUMMARY_ARTIFACT_TYPES.includes(artifactType);
12
+ }
8
13
  /**
9
14
  * Register an alias tool that shares the same execute function as its canonical counterpart.
10
15
  * The alias description and promptGuidelines direct the LLM to prefer the canonical name.
@@ -274,13 +279,19 @@ export function registerDbTools(pi) {
274
279
  details: { operation: "save_summary", error: "db_unavailable" },
275
280
  };
276
281
  }
277
- const validTypes = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT"];
278
- if (!validTypes.includes(params.artifact_type)) {
282
+ if (!isSupportedSummaryArtifactType(params.artifact_type)) {
279
283
  return {
280
- content: [{ type: "text", text: `Error: Invalid artifact_type "${params.artifact_type}". Must be one of: ${validTypes.join(", ")}` }],
284
+ content: [{ type: "text", text: `Error: Invalid artifact_type "${params.artifact_type}". Must be one of: ${SUPPORTED_SUMMARY_ARTIFACT_TYPES.join(", ")}` }],
281
285
  details: { operation: "save_summary", error: "invalid_artifact_type" },
282
286
  };
283
287
  }
288
+ const contextGuard = shouldBlockContextArtifactSave(params.artifact_type, params.milestone_id ?? null, params.slice_id ?? null);
289
+ if (contextGuard.block) {
290
+ return {
291
+ content: [{ type: "text", text: `Error saving artifact: ${contextGuard.reason ?? "context write blocked"}` }],
292
+ details: { operation: "save_summary", error: "context_write_blocked" },
293
+ };
294
+ }
284
295
  try {
285
296
  let relativePath;
286
297
  if (params.task_id && params.slice_id) {
@@ -322,16 +333,17 @@ export function registerDbTools(pi) {
322
333
  "Computes the file path from milestone/slice/task IDs automatically.",
323
334
  promptSnippet: "Save a GSD artifact (summary/research/context/assessment) to DB and disk",
324
335
  promptGuidelines: [
325
- "Use gsd_summary_save to persist structured artifacts (SUMMARY, RESEARCH, CONTEXT, ASSESSMENT).",
336
+ "Use gsd_summary_save to persist structured artifacts (SUMMARY, RESEARCH, CONTEXT, ASSESSMENT, CONTEXT-DRAFT).",
326
337
  "milestone_id is required. slice_id and task_id are optional — they determine the file path.",
327
338
  "The tool computes the relative path automatically: milestones/M001/M001-SUMMARY.md, milestones/M001/slices/S01/S01-SUMMARY.md, etc.",
328
- "artifact_type must be one of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT.",
339
+ "artifact_type must be one of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT, CONTEXT-DRAFT.",
340
+ "Use CONTEXT-DRAFT for incremental draft persistence; use CONTEXT for the final milestone context after depth verification.",
329
341
  ],
330
342
  parameters: Type.Object({
331
343
  milestone_id: Type.String({ description: "Milestone ID (e.g. M001)" }),
332
344
  slice_id: Type.Optional(Type.String({ description: "Slice ID (e.g. S01)" })),
333
345
  task_id: Type.Optional(Type.String({ description: "Task ID (e.g. T01)" })),
334
- artifact_type: Type.String({ description: "One of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT" }),
346
+ artifact_type: Type.String({ description: "One of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT, CONTEXT-DRAFT" }),
335
347
  content: Type.String({ description: "The full markdown content of the artifact" }),
336
348
  }),
337
349
  execute: summarySaveExecute,