gsd-pi 2.52.0 → 2.53.0-dev.a67436f

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 (247) hide show
  1. package/README.md +55 -32
  2. package/dist/headless-query.js +1 -1
  3. package/dist/headless-ui.d.ts +2 -2
  4. package/dist/headless-ui.js +18 -15
  5. package/dist/headless.d.ts +11 -0
  6. package/dist/headless.js +178 -38
  7. package/dist/resources/extensions/get-secrets-from-user.js +7 -0
  8. package/dist/resources/extensions/gsd/auto/phases.js +28 -8
  9. package/dist/resources/extensions/gsd/auto-dispatch.js +5 -1
  10. package/dist/resources/extensions/gsd/auto-worktree.js +70 -14
  11. package/dist/resources/extensions/gsd/auto.js +22 -0
  12. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +4 -10
  13. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +3 -3
  14. package/dist/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  15. package/dist/resources/extensions/gsd/git-service.js +4 -3
  16. package/dist/resources/extensions/gsd/guided-flow.js +4 -3
  17. package/dist/resources/extensions/gsd/markdown-renderer.js +5 -4
  18. package/dist/resources/extensions/gsd/parallel-orchestrator.js +18 -2
  19. package/dist/resources/extensions/gsd/preferences-types.js +1 -1
  20. package/dist/resources/extensions/gsd/state.js +18 -29
  21. package/dist/resources/extensions/gsd/status-guards.js +12 -0
  22. package/dist/resources/extensions/gsd/tools/complete-milestone.js +4 -3
  23. package/dist/resources/extensions/gsd/tools/complete-slice.js +4 -3
  24. package/dist/resources/extensions/gsd/tools/complete-task.js +4 -3
  25. package/dist/resources/extensions/gsd/tools/plan-milestone.js +4 -14
  26. package/dist/resources/extensions/gsd/tools/plan-slice.js +4 -14
  27. package/dist/resources/extensions/gsd/tools/plan-task.js +4 -14
  28. package/dist/resources/extensions/gsd/tools/reassess-roadmap.js +6 -7
  29. package/dist/resources/extensions/gsd/tools/reopen-slice.js +4 -3
  30. package/dist/resources/extensions/gsd/tools/reopen-task.js +5 -4
  31. package/dist/resources/extensions/gsd/tools/replan-slice.js +5 -6
  32. package/dist/resources/extensions/gsd/validation.js +21 -0
  33. package/dist/resources/extensions/shared/rtk.js +14 -4
  34. package/dist/rtk.js +3 -1
  35. package/dist/web/standalone/.next/BUILD_ID +1 -1
  36. package/dist/web/standalone/.next/app-path-routes-manifest.json +20 -20
  37. package/dist/web/standalone/.next/build-manifest.json +4 -4
  38. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  39. package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
  40. package/dist/web/standalone/.next/required-server-files.json +3 -3
  41. package/dist/web/standalone/.next/server/app/_global-error/page.js +3 -3
  42. package/dist/web/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  44. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found/page.js +2 -2
  52. package/dist/web/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  53. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.rsc +3 -3
  55. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +3 -3
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
  62. package/dist/web/standalone/.next/server/app/api/boot/route_client-reference-manifest.js +1 -1
  63. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js +1 -1
  64. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route_client-reference-manifest.js +1 -1
  65. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js +1 -1
  66. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route_client-reference-manifest.js +1 -1
  67. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js +2 -2
  68. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route_client-reference-manifest.js +1 -1
  69. package/dist/web/standalone/.next/server/app/api/browse-directories/route.js +1 -1
  70. package/dist/web/standalone/.next/server/app/api/browse-directories/route_client-reference-manifest.js +1 -1
  71. package/dist/web/standalone/.next/server/app/api/captures/route.js +1 -1
  72. package/dist/web/standalone/.next/server/app/api/captures/route_client-reference-manifest.js +1 -1
  73. package/dist/web/standalone/.next/server/app/api/cleanup/route.js +1 -1
  74. package/dist/web/standalone/.next/server/app/api/cleanup/route_client-reference-manifest.js +1 -1
  75. package/dist/web/standalone/.next/server/app/api/dev-mode/route.js +1 -1
  76. package/dist/web/standalone/.next/server/app/api/dev-mode/route_client-reference-manifest.js +1 -1
  77. package/dist/web/standalone/.next/server/app/api/doctor/route.js +1 -1
  78. package/dist/web/standalone/.next/server/app/api/doctor/route_client-reference-manifest.js +1 -1
  79. package/dist/web/standalone/.next/server/app/api/experimental/route.js +2 -2
  80. package/dist/web/standalone/.next/server/app/api/experimental/route_client-reference-manifest.js +1 -1
  81. package/dist/web/standalone/.next/server/app/api/export-data/route.js +1 -1
  82. package/dist/web/standalone/.next/server/app/api/export-data/route_client-reference-manifest.js +1 -1
  83. package/dist/web/standalone/.next/server/app/api/files/route.js +1 -1
  84. package/dist/web/standalone/.next/server/app/api/files/route_client-reference-manifest.js +1 -1
  85. package/dist/web/standalone/.next/server/app/api/forensics/route.js +1 -1
  86. package/dist/web/standalone/.next/server/app/api/forensics/route_client-reference-manifest.js +1 -1
  87. package/dist/web/standalone/.next/server/app/api/git/route.js +1 -1
  88. package/dist/web/standalone/.next/server/app/api/git/route_client-reference-manifest.js +1 -1
  89. package/dist/web/standalone/.next/server/app/api/history/route.js +1 -1
  90. package/dist/web/standalone/.next/server/app/api/history/route_client-reference-manifest.js +1 -1
  91. package/dist/web/standalone/.next/server/app/api/hooks/route.js +1 -1
  92. package/dist/web/standalone/.next/server/app/api/hooks/route_client-reference-manifest.js +1 -1
  93. package/dist/web/standalone/.next/server/app/api/inspect/route.js +1 -1
  94. package/dist/web/standalone/.next/server/app/api/inspect/route_client-reference-manifest.js +1 -1
  95. package/dist/web/standalone/.next/server/app/api/knowledge/route.js +1 -1
  96. package/dist/web/standalone/.next/server/app/api/knowledge/route_client-reference-manifest.js +1 -1
  97. package/dist/web/standalone/.next/server/app/api/live-state/route.js +1 -1
  98. package/dist/web/standalone/.next/server/app/api/live-state/route_client-reference-manifest.js +1 -1
  99. package/dist/web/standalone/.next/server/app/api/onboarding/route.js +1 -1
  100. package/dist/web/standalone/.next/server/app/api/onboarding/route_client-reference-manifest.js +1 -1
  101. package/dist/web/standalone/.next/server/app/api/preferences/route.js +1 -1
  102. package/dist/web/standalone/.next/server/app/api/preferences/route_client-reference-manifest.js +1 -1
  103. package/dist/web/standalone/.next/server/app/api/projects/route.js +1 -1
  104. package/dist/web/standalone/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
  105. package/dist/web/standalone/.next/server/app/api/recovery/route.js +1 -1
  106. package/dist/web/standalone/.next/server/app/api/recovery/route_client-reference-manifest.js +1 -1
  107. package/dist/web/standalone/.next/server/app/api/remote-questions/route.js +2 -2
  108. package/dist/web/standalone/.next/server/app/api/remote-questions/route_client-reference-manifest.js +1 -1
  109. package/dist/web/standalone/.next/server/app/api/session/browser/route.js +1 -1
  110. package/dist/web/standalone/.next/server/app/api/session/browser/route_client-reference-manifest.js +1 -1
  111. package/dist/web/standalone/.next/server/app/api/session/command/route.js +1 -1
  112. package/dist/web/standalone/.next/server/app/api/session/command/route_client-reference-manifest.js +1 -1
  113. package/dist/web/standalone/.next/server/app/api/session/events/route.js +2 -2
  114. package/dist/web/standalone/.next/server/app/api/session/events/route_client-reference-manifest.js +1 -1
  115. package/dist/web/standalone/.next/server/app/api/session/manage/route.js +1 -1
  116. package/dist/web/standalone/.next/server/app/api/session/manage/route_client-reference-manifest.js +1 -1
  117. package/dist/web/standalone/.next/server/app/api/settings-data/route.js +1 -1
  118. package/dist/web/standalone/.next/server/app/api/settings-data/route_client-reference-manifest.js +1 -1
  119. package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
  120. package/dist/web/standalone/.next/server/app/api/shutdown/route_client-reference-manifest.js +1 -1
  121. package/dist/web/standalone/.next/server/app/api/skill-health/route.js +1 -1
  122. package/dist/web/standalone/.next/server/app/api/skill-health/route_client-reference-manifest.js +1 -1
  123. package/dist/web/standalone/.next/server/app/api/steer/route.js +1 -1
  124. package/dist/web/standalone/.next/server/app/api/steer/route_client-reference-manifest.js +1 -1
  125. package/dist/web/standalone/.next/server/app/api/switch-root/route.js +1 -1
  126. package/dist/web/standalone/.next/server/app/api/switch-root/route_client-reference-manifest.js +1 -1
  127. package/dist/web/standalone/.next/server/app/api/terminal/input/route.js +2 -2
  128. package/dist/web/standalone/.next/server/app/api/terminal/input/route_client-reference-manifest.js +1 -1
  129. package/dist/web/standalone/.next/server/app/api/terminal/resize/route.js +2 -2
  130. package/dist/web/standalone/.next/server/app/api/terminal/resize/route_client-reference-manifest.js +1 -1
  131. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js +2 -2
  132. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route_client-reference-manifest.js +1 -1
  133. package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js +4 -4
  134. package/dist/web/standalone/.next/server/app/api/terminal/stream/route_client-reference-manifest.js +1 -1
  135. package/dist/web/standalone/.next/server/app/api/terminal/upload/route.js +1 -1
  136. package/dist/web/standalone/.next/server/app/api/terminal/upload/route_client-reference-manifest.js +1 -1
  137. package/dist/web/standalone/.next/server/app/api/undo/route.js +1 -1
  138. package/dist/web/standalone/.next/server/app/api/undo/route_client-reference-manifest.js +1 -1
  139. package/dist/web/standalone/.next/server/app/api/update/route.js +1 -1
  140. package/dist/web/standalone/.next/server/app/api/update/route_client-reference-manifest.js +1 -1
  141. package/dist/web/standalone/.next/server/app/api/visualizer/route.js +1 -1
  142. package/dist/web/standalone/.next/server/app/api/visualizer/route_client-reference-manifest.js +1 -1
  143. package/dist/web/standalone/.next/server/app/index.html +1 -1
  144. package/dist/web/standalone/.next/server/app/index.rsc +4 -4
  145. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  146. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +4 -4
  147. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  148. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +3 -3
  149. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  150. package/dist/web/standalone/.next/server/app/page.js +2 -2
  151. package/dist/web/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  152. package/dist/web/standalone/.next/server/app-paths-manifest.json +20 -20
  153. package/dist/web/standalone/.next/server/chunks/2229.js +1 -1
  154. package/dist/web/standalone/.next/server/chunks/7471.js +3 -3
  155. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  156. package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
  157. package/dist/web/standalone/.next/server/middleware.js +2 -2
  158. package/dist/web/standalone/.next/server/next-font-manifest.js +1 -1
  159. package/dist/web/standalone/.next/server/next-font-manifest.json +1 -1
  160. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  161. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  162. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  163. package/dist/web/standalone/.next/static/chunks/4024.87fd909ae0110f50.js +9 -0
  164. package/dist/web/standalone/.next/static/chunks/app/_not-found/{page-2f24283c162b6ab3.js → page-f2a7482d42a5614b.js} +1 -1
  165. package/dist/web/standalone/.next/static/chunks/app/{layout-9ecfd95f343793f0.js → layout-a16c7a7ecdf0c2cf.js} +1 -1
  166. package/dist/web/standalone/.next/static/chunks/app/page-b950e4e384cc62b3.js +1 -0
  167. package/dist/web/standalone/.next/static/chunks/main-app-fdab67f7802d7832.js +1 -0
  168. package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-459824ffb8c323dd.js +1 -0
  169. package/dist/web/standalone/.next/static/chunks/{webpack-024d82be84800e52.js → webpack-bca0e732db0dcec3.js} +1 -1
  170. package/dist/web/standalone/node_modules/node-pty/build/Makefile +2 -2
  171. package/dist/web/standalone/node_modules/node-pty/build/Release/pty.node +0 -0
  172. package/dist/web/standalone/node_modules/node-pty/build/pty.target.mk +14 -14
  173. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api.target.mk +14 -14
  174. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api_except.target.mk +14 -14
  175. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api_maybe.target.mk +14 -14
  176. package/dist/web/standalone/server.js +1 -1
  177. package/package.json +1 -1
  178. package/packages/mcp-server/README.md +6 -6
  179. package/packages/mcp-server/package.json +14 -4
  180. package/packages/mcp-server/src/cli.ts +1 -1
  181. package/packages/mcp-server/src/index.ts +1 -1
  182. package/packages/mcp-server/src/mcp-server.test.ts +2 -2
  183. package/packages/mcp-server/src/session-manager.ts +2 -2
  184. package/packages/mcp-server/src/types.ts +1 -1
  185. package/packages/pi-coding-agent/package.json +1 -1
  186. package/packages/rpc-client/README.md +125 -0
  187. package/packages/rpc-client/examples/basic-usage.ts +13 -0
  188. package/packages/rpc-client/package.json +17 -3
  189. package/packages/rpc-client/src/index.ts +10 -0
  190. package/packages/rpc-client/src/jsonl.ts +64 -0
  191. package/packages/rpc-client/src/rpc-client.test.ts +568 -0
  192. package/packages/rpc-client/src/rpc-client.ts +666 -0
  193. package/packages/rpc-client/src/rpc-types.ts +399 -0
  194. package/packages/rpc-client/tsconfig.examples.json +17 -0
  195. package/packages/rpc-client/tsconfig.json +24 -0
  196. package/pkg/package.json +1 -1
  197. package/scripts/ensure-workspace-builds.cjs +36 -8
  198. package/src/resources/extensions/get-secrets-from-user.ts +8 -0
  199. package/src/resources/extensions/gsd/auto/phases.ts +38 -7
  200. package/src/resources/extensions/gsd/auto-dispatch.ts +6 -1
  201. package/src/resources/extensions/gsd/auto-worktree.ts +73 -14
  202. package/src/resources/extensions/gsd/auto.ts +21 -0
  203. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +4 -11
  204. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +3 -3
  205. package/src/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  206. package/src/resources/extensions/gsd/git-service.ts +4 -3
  207. package/src/resources/extensions/gsd/guided-flow.ts +4 -3
  208. package/src/resources/extensions/gsd/markdown-renderer.ts +5 -4
  209. package/src/resources/extensions/gsd/parallel-orchestrator.ts +23 -1
  210. package/src/resources/extensions/gsd/preferences-types.ts +1 -1
  211. package/src/resources/extensions/gsd/state.ts +18 -29
  212. package/src/resources/extensions/gsd/status-guards.ts +13 -0
  213. package/src/resources/extensions/gsd/tests/active-milestone-id-guard.test.ts +91 -0
  214. package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +87 -0
  215. package/src/resources/extensions/gsd/tests/auto-worktree-auto-resolve.test.ts +80 -0
  216. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +1 -1
  217. package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +39 -0
  218. package/src/resources/extensions/gsd/tests/git-service.test.ts +64 -30
  219. package/src/resources/extensions/gsd/tests/milestone-report-path.test.ts +51 -0
  220. package/src/resources/extensions/gsd/tests/parallel-orchestrator-zombie-cleanup.test.ts +277 -0
  221. package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +103 -0
  222. package/src/resources/extensions/gsd/tests/preferences.test.ts +1 -1
  223. package/src/resources/extensions/gsd/tests/rate-limit-model-fallback.test.ts +90 -0
  224. package/src/resources/extensions/gsd/tests/session-lock-transient-read.test.ts +9 -8
  225. package/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts +125 -0
  226. package/src/resources/extensions/gsd/tests/status-guards.test.ts +30 -0
  227. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +12 -2
  228. package/src/resources/extensions/gsd/tests/validation-gate-patterns.test.ts +124 -0
  229. package/src/resources/extensions/gsd/tests/validation.test.ts +72 -0
  230. package/src/resources/extensions/gsd/tools/complete-milestone.ts +4 -3
  231. package/src/resources/extensions/gsd/tools/complete-slice.ts +4 -3
  232. package/src/resources/extensions/gsd/tools/complete-task.ts +4 -3
  233. package/src/resources/extensions/gsd/tools/plan-milestone.ts +4 -16
  234. package/src/resources/extensions/gsd/tools/plan-slice.ts +4 -16
  235. package/src/resources/extensions/gsd/tools/plan-task.ts +4 -16
  236. package/src/resources/extensions/gsd/tools/reassess-roadmap.ts +6 -7
  237. package/src/resources/extensions/gsd/tools/reopen-slice.ts +4 -3
  238. package/src/resources/extensions/gsd/tools/reopen-task.ts +5 -4
  239. package/src/resources/extensions/gsd/tools/replan-slice.ts +5 -7
  240. package/src/resources/extensions/gsd/validation.ts +23 -0
  241. package/src/resources/extensions/shared/rtk.ts +22 -4
  242. package/dist/web/standalone/.next/static/chunks/4024.21054f459af5cc78.js +0 -9
  243. package/dist/web/standalone/.next/static/chunks/app/page-fbecd1237e2d6d1f.js +0 -1
  244. package/dist/web/standalone/.next/static/chunks/main-app-d3d4c336195465f9.js +0 -1
  245. package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-ab5a8926e07ec673.js +0 -1
  246. /package/dist/web/standalone/.next/static/{vlgS2rkXjxeKhgXhdp4lh → YO-PWFRitlHM-L-dotlmm}/_buildManifest.js +0 -0
  247. /package/dist/web/standalone/.next/static/{vlgS2rkXjxeKhgXhdp4lh → YO-PWFRitlHM-L-dotlmm}/_ssgManifest.js +0 -0
@@ -157,6 +157,25 @@ function clearProjectRootStateFiles(basePath: string, milestoneId: string): void
157
157
  }
158
158
  }
159
159
 
160
+ // ─── Build Artifact Auto-Resolve ─────────────────────────────────────────────
161
+
162
+ /** Patterns for machine-generated build artifacts that can be safely
163
+ * auto-resolved by accepting --theirs during merge. These files are
164
+ * regenerable and never contain meaningful manual edits. */
165
+ export const SAFE_AUTO_RESOLVE_PATTERNS: RegExp[] = [
166
+ /\.tsbuildinfo$/,
167
+ /\.pyc$/,
168
+ /\/__pycache__\//,
169
+ /\.DS_Store$/,
170
+ /\.map$/,
171
+ ];
172
+
173
+ /** Returns true if the file path is safe to auto-resolve during merge.
174
+ * Covers `.gsd/` state files and common build artifacts. */
175
+ export const isSafeToAutoResolve = (filePath: string): boolean =>
176
+ filePath.startsWith(".gsd/") ||
177
+ SAFE_AUTO_RESOLVE_PATTERNS.some((re) => re.test(filePath));
178
+
160
179
  // ─── Dispatch-Level Sync (project root ↔ worktree) ──────────────────────────
161
180
 
162
181
  /**
@@ -1408,30 +1427,30 @@ export function mergeMilestoneToMain(
1408
1427
  : nativeConflictFiles(originalBasePath_);
1409
1428
 
1410
1429
  if (conflictedFiles.length > 0) {
1411
- // Separate .gsd/ state file conflicts from real code conflicts.
1412
- // GSD state files (STATE.md, auto.lock, etc.)
1413
- // diverge between branches during normal operation always prefer the
1414
- // milestone branch version since it has the latest execution state.
1415
- const gsdConflicts = conflictedFiles.filter((f) => f.startsWith(".gsd/"));
1430
+ // Separate auto-resolvable conflicts (GSD state files + build artifacts)
1431
+ // from real code conflicts. GSD state files diverge between branches
1432
+ // during normal operation. Build artifacts are machine-generated and
1433
+ // regenerable. Both are safe to accept from the milestone branch.
1434
+ const autoResolvable = conflictedFiles.filter(isSafeToAutoResolve);
1416
1435
  const codeConflicts = conflictedFiles.filter(
1417
- (f) => !f.startsWith(".gsd/"),
1436
+ (f) => !isSafeToAutoResolve(f),
1418
1437
  );
1419
1438
 
1420
- // Auto-resolve .gsd/ conflicts by accepting the milestone branch version
1421
- if (gsdConflicts.length > 0) {
1422
- for (const gsdFile of gsdConflicts) {
1439
+ // Auto-resolve safe conflicts by accepting the milestone branch version
1440
+ if (autoResolvable.length > 0) {
1441
+ for (const safeFile of autoResolvable) {
1423
1442
  try {
1424
- nativeCheckoutTheirs(originalBasePath_, [gsdFile]);
1425
- nativeAddPaths(originalBasePath_, [gsdFile]);
1443
+ nativeCheckoutTheirs(originalBasePath_, [safeFile]);
1444
+ nativeAddPaths(originalBasePath_, [safeFile]);
1426
1445
  } catch {
1427
1446
  // If checkout --theirs fails, try removing the file from the merge
1428
1447
  // (it's a runtime file that shouldn't be committed anyway)
1429
- nativeRmForce(originalBasePath_, [gsdFile]);
1448
+ nativeRmForce(originalBasePath_, [safeFile]);
1430
1449
  }
1431
1450
  }
1432
1451
  }
1433
1452
 
1434
- // If there are still non-.gsd conflicts, escalate
1453
+ // If there are still real code conflicts, escalate
1435
1454
  if (codeConflicts.length > 0) {
1436
1455
  // Pop stash before throwing so local work is not lost (#2151).
1437
1456
  if (stashed) {
@@ -1480,7 +1499,47 @@ export function mergeMilestoneToMain(
1480
1499
  encoding: "utf-8",
1481
1500
  });
1482
1501
  } catch {
1483
- // Stash pop conflict is non-fatal stash entry persists for manual resolution.
1502
+ // Stash pop after squash merge can conflict on .gsd/ state files that
1503
+ // diverged between branches. Left unresolved, these UU entries block
1504
+ // every subsequent merge. Auto-resolve them the same way we handle
1505
+ // .gsd/ conflicts during the merge itself: accept HEAD (the just-committed
1506
+ // version) and drop the now-applied stash.
1507
+ const uu = nativeConflictFiles(originalBasePath_);
1508
+ const gsdUU = uu.filter((f) => f.startsWith(".gsd/"));
1509
+ const nonGsdUU = uu.filter((f) => !f.startsWith(".gsd/"));
1510
+
1511
+ if (gsdUU.length > 0) {
1512
+ for (const f of gsdUU) {
1513
+ try {
1514
+ // Accept the committed (HEAD) version of the state file
1515
+ execFileSync("git", ["checkout", "HEAD", "--", f], {
1516
+ cwd: originalBasePath_,
1517
+ stdio: ["ignore", "pipe", "pipe"],
1518
+ encoding: "utf-8",
1519
+ });
1520
+ nativeAddPaths(originalBasePath_, [f]);
1521
+ } catch {
1522
+ // Last resort: remove the conflicted state file
1523
+ nativeRmForce(originalBasePath_, [f]);
1524
+ }
1525
+ }
1526
+ }
1527
+
1528
+ if (nonGsdUU.length === 0) {
1529
+ // All conflicts were .gsd/ files — safe to drop the stash
1530
+ try {
1531
+ execFileSync("git", ["stash", "drop"], {
1532
+ cwd: originalBasePath_,
1533
+ stdio: ["ignore", "pipe", "pipe"],
1534
+ encoding: "utf-8",
1535
+ });
1536
+ } catch { /* stash may already be consumed */ }
1537
+ } else {
1538
+ // Non-.gsd conflicts remain — leave stash for manual resolution
1539
+ logWarning("reconcile", "Stash pop conflict on non-.gsd files after merge", {
1540
+ files: nonGsdUU.join(", "),
1541
+ });
1542
+ }
1484
1543
  }
1485
1544
  }
1486
1545
 
@@ -414,6 +414,13 @@ export function stopAutoRemote(projectRoot: string): {
414
414
  const lock = readCrashLock(projectRoot);
415
415
  if (!lock) return { found: false };
416
416
 
417
+ // Never SIGTERM ourselves — a stale lock with our own PID is not a remote
418
+ // session, it is leftover from a prior loop exit in this process. (#2730)
419
+ if (lock.pid === process.pid) {
420
+ clearLock(projectRoot);
421
+ return { found: false };
422
+ }
423
+
417
424
  if (!isLockProcessAlive(lock)) {
418
425
  // Stale lock — clean it up
419
426
  clearLock(projectRoot);
@@ -445,6 +452,10 @@ export function checkRemoteAutoSession(projectRoot: string): {
445
452
  const lock = readCrashLock(projectRoot);
446
453
  if (!lock) return { running: false };
447
454
 
455
+ // Our own PID is not a "remote" session — it is a stale lock left by this
456
+ // process (e.g. after step-mode exit without full cleanup). (#2730)
457
+ if (lock.pid === process.pid) return { running: false };
458
+
448
459
  if (!isLockProcessAlive(lock)) {
449
460
  // Stale lock from a dead process — not a live remote session
450
461
  return { running: false };
@@ -548,6 +559,16 @@ function cleanupAfterLoopExit(ctx: ExtensionContext): void {
548
559
  s.active = false;
549
560
  clearUnitTimeout();
550
561
 
562
+ // Clear crash lock and release session lock so the next `/gsd next` does
563
+ // not see a stale lock with the current PID and treat it as a "remote"
564
+ // session (which would cause it to SIGTERM itself). (#2730)
565
+ try {
566
+ if (lockBase()) clearLock(lockBase());
567
+ if (lockBase()) releaseSessionLock(lockBase());
568
+ } catch {
569
+ /* best-effort — mirror stopAuto cleanup */
570
+ }
571
+
551
572
  ctx.ui.setStatus("gsd-auto", undefined);
552
573
  ctx.ui.setWidget("gsd-progress", undefined);
553
574
  ctx.ui.setFooter(undefined);
@@ -107,16 +107,9 @@ export async function handleAgentEnd(
107
107
  ctx.ui.notify(`Network retries exhausted for ${currentModelId}. Attempting model fallback.`, "warning");
108
108
  }
109
109
 
110
- // --- Rate limit: skip model fallback, go straight to pause ---
111
- // Rate-limiting is a provider issue, not a model issue.
112
- // Switching models won't help if the provider is throttling you.
113
- if (cls.kind === "rate-limit") {
114
- await pauseTransientWithBackoff(cls, pi, ctx, errorDetail, true);
115
- return;
116
- }
117
-
118
- // --- Server/connection/stream errors: try model fallback first ---
119
- if (cls.kind === "network" || cls.kind === "server" || cls.kind === "connection" || cls.kind === "stream") {
110
+ // --- Transient errors: try model fallback first, then pause ---
111
+ // Rate limits are often per-model, so switching models can bypass them.
112
+ if (cls.kind === "rate-limit" || cls.kind === "network" || cls.kind === "server" || cls.kind === "connection" || cls.kind === "stream") {
120
113
  // Try model fallback
121
114
  const dash = getAutoDashboardData();
122
115
  if (dash.currentUnit) {
@@ -161,7 +154,7 @@ export async function handleAgentEnd(
161
154
 
162
155
  // --- Transient fallback: pause with auto-resume ---
163
156
  if (isTransient(cls)) {
164
- await pauseTransientWithBackoff(cls, pi, ctx, errorDetail, false);
157
+ await pauseTransientWithBackoff(cls, pi, ctx, errorDetail, cls.kind === "rate-limit");
165
158
  return;
166
159
  }
167
160
 
@@ -390,7 +390,7 @@ async function configureGit(ctx: ExtensionCommandContext, prefs: Record<string,
390
390
  const gitBooleanFields = [
391
391
  { key: "auto_push", label: "Auto-push commits after committing", defaultVal: false },
392
392
  { key: "push_branches", label: "Push milestone branches to remote", defaultVal: false },
393
- { key: "snapshots", label: "Create WIP snapshot commits during long tasks", defaultVal: false },
393
+ { key: "snapshots", label: "Create WIP snapshot commits during long tasks", defaultVal: true },
394
394
  ] as const;
395
395
 
396
396
  for (const field of gitBooleanFields) {
@@ -423,7 +423,7 @@ async function configureGit(ctx: ExtensionCommandContext, prefs: Record<string,
423
423
  // pre_merge_check
424
424
  const currentPreMerge = git.pre_merge_check !== undefined ? String(git.pre_merge_check) : "";
425
425
  const preMergeChoice = await ctx.ui.select(
426
- `Pre-merge check${currentPreMerge ? ` (current: ${currentPreMerge})` : " (default: false)"}:`,
426
+ `Pre-merge check${currentPreMerge ? ` (current: ${currentPreMerge})` : " (default: auto)"}:`,
427
427
  ["true", "false", "auto", "(keep current)"],
428
428
  );
429
429
  if (preMergeChoice && preMergeChoice !== "(keep current)") {
@@ -588,7 +588,7 @@ export async function configureMode(ctx: ExtensionCommandContext, prefs: Record<
588
588
  if (modeStr.startsWith("solo")) {
589
589
  prefs.mode = "solo";
590
590
  ctx.ui.notify(
591
- "Mode: solo — defaults: auto_push=true, push_branches=false, pre_merge_check=false, merge_strategy=squash, isolation=worktree, unique_milestone_ids=false",
591
+ "Mode: solo — defaults: auto_push=true, push_branches=false, pre_merge_check=auto, merge_strategy=squash, isolation=worktree, unique_milestone_ids=false",
592
592
  "info",
593
593
  );
594
594
  } else if (modeStr.startsWith("team")) {
@@ -126,8 +126,8 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
126
126
  - `auto_push`: boolean — automatically push commits to the remote after committing. Default: `false`.
127
127
  - `push_branches`: boolean — push the milestone branch to the remote after commits. Default: `false`.
128
128
  - `remote`: string — git remote name to push to. Default: `"origin"`.
129
- - `snapshots`: boolean — create snapshot commits (WIP saves) during long-running tasks. Default: `false`.
130
- - `pre_merge_check`: boolean or `"auto"` — run pre-merge checks before merging a worktree back to the integration branch. `true` always runs, `false` never runs, `"auto"` runs when CI is detected. Default: `false`.
129
+ - `snapshots`: boolean — create snapshot commits (WIP saves) during long-running tasks. Default: `true`.
130
+ - `pre_merge_check`: boolean or `"auto"` — run pre-merge checks before merging a worktree back to the integration branch. `true` always runs, `false` never runs, `"auto"` runs when CI is detected. Default: `"auto"`.
131
131
  - `commit_type`: string — override the conventional commit type prefix. Must be one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`. Default: inferred from diff content.
132
132
  - `main_branch`: string — the primary branch name for new git repos (e.g., `"main"`, `"master"`, `"trunk"`). Also used by `getMainBranch()` as the preferred branch when auto-detection is ambiguous. Default: `"main"`.
133
133
  - `merge_strategy`: `"squash"` or `"merge"` — controls how worktree branches are merged back. `"squash"` combines all commits into one; `"merge"` preserves individual commits. Default: `"squash"`.
@@ -605,11 +605,12 @@ export class GitServiceImpl {
605
605
 
606
606
  /**
607
607
  * Create a snapshot ref for the given label (typically a slice branch name).
608
- * Gated on prefs.snapshots === true. Ref path: refs/gsd/snapshots/<label>/<timestamp>
608
+ * Enabled by default; opt out with prefs.snapshots === false.
609
+ * Ref path: refs/gsd/snapshots/<label>/<timestamp>
609
610
  * The ref points at HEAD, capturing the current commit before destructive operations.
610
611
  */
611
612
  createSnapshot(label: string): void {
612
- if (this.prefs.snapshots !== true) return;
613
+ if (this.prefs.snapshots === false) return;
613
614
 
614
615
  const now = new Date();
615
616
  const ts = now.getFullYear().toString()
@@ -631,7 +632,7 @@ export class GitServiceImpl {
631
632
  * Stub: to be implemented in T03.
632
633
  */
633
634
  runPreMergeCheck(): PreMergeCheckResult {
634
- if (this.prefs.pre_merge_check === false || this.prefs.pre_merge_check === undefined) {
635
+ if (this.prefs.pre_merge_check === false) {
635
636
  return { passed: true, skipped: true };
636
637
  }
637
638
 
@@ -517,8 +517,9 @@ export async function showDiscuss(
517
517
 
518
518
  const state = await deriveState(basePath);
519
519
 
520
- // No active milestone check for pending milestones to discuss instead
521
- if (!state.activeMilestone) {
520
+ // No active milestone (or corrupted milestone with undefined id)
521
+ // check for pending milestones to discuss instead
522
+ if (!state.activeMilestone?.id) {
522
523
  const pendingMilestones = state.registry.filter(m => m.status === "pending");
523
524
  if (pendingMilestones.length === 0) {
524
525
  ctx.ui.notify("No active milestone. Run /gsd to create one first.", "warning");
@@ -1043,7 +1044,7 @@ export async function showSmartEntry(
1043
1044
 
1044
1045
  const state = await deriveState(basePath);
1045
1046
 
1046
- if (!state.activeMilestone) {
1047
+ if (!state.activeMilestone?.id) {
1047
1048
  // Guard: if a discuss session is already in flight, don't re-inject the prompt.
1048
1049
  // Both /gsd and /gsd auto reach this branch when no milestone exists yet.
1049
1050
  // Without this guard, every subsequent /gsd call overwrites pendingAutoStart
@@ -9,6 +9,7 @@
9
9
  // parseRoadmap(), parsePlan(), parseSummary() in files.ts.
10
10
 
11
11
  import { readFileSync, existsSync, mkdirSync } from "node:fs";
12
+ import { isClosedStatus } from "./status-guards.js";
12
13
  import { join, relative } from "node:path";
13
14
  import { createRequire } from "node:module";
14
15
  import {
@@ -337,7 +338,7 @@ function renderSlicePlanMarkdown(slice: SliceRow, tasks: TaskRow[], gates: GateR
337
338
  lines.push("## Tasks");
338
339
  lines.push("");
339
340
  for (const task of tasks) {
340
- const done = task.status === "done" || task.status === "complete" ? "x" : " ";
341
+ const done = isClosedStatus(task.status) ? "x" : " ";
341
342
  const estimate = task.estimate.trim() ? ` \`est:${task.estimate.trim()}\`` : "";
342
343
  lines.push(`- [${done}] **${task.id}: ${task.title || task.id}**${estimate}`);
343
344
  if (task.description.trim()) {
@@ -573,7 +574,7 @@ export async function renderPlanCheckboxes(
573
574
  // Apply checkbox patches for each task
574
575
  let updated = content;
575
576
  for (const task of tasks) {
576
- const isDone = task.status === "done" || task.status === "complete";
577
+ const isDone = isClosedStatus(task.status);
577
578
  const tid = task.id;
578
579
 
579
580
  if (isDone) {
@@ -857,7 +858,7 @@ export function detectStaleRenders(basePath: string): StaleEntry[] {
857
858
  const parsed = parsePlan(content);
858
859
 
859
860
  for (const task of tasks) {
860
- const isDoneInDb = task.status === "done" || task.status === "complete";
861
+ const isDoneInDb = isClosedStatus(task.status);
861
862
  const planTask = parsed.tasks.find((t: { id: string }) => t.id === task.id);
862
863
  if (!planTask) continue;
863
864
 
@@ -880,7 +881,7 @@ export function detectStaleRenders(basePath: string): StaleEntry[] {
880
881
 
881
882
  // Check missing task summary files
882
883
  for (const task of tasks) {
883
- if ((task.status === "done" || task.status === "complete") && task.full_summary_md) {
884
+ if (isClosedStatus(task.status) && task.full_summary_md) {
884
885
  const slicePath = resolveSlicePath(basePath, milestone.id, slice.id);
885
886
  if (slicePath) {
886
887
  const tasksDir = join(slicePath, "tasks");
@@ -196,7 +196,17 @@ function appendWorkerLog(basePath: string, milestoneId: string, chunk: string):
196
196
  }
197
197
 
198
198
  function restoreRuntimeState(basePath: string): boolean {
199
- if (state?.active) return true;
199
+ if (state?.active) {
200
+ // Verify at least one worker is alive — if all are in terminal states,
201
+ // the cached state is stale and we should fall through to cleanup.
202
+ const hasLiveWorker = [...state.workers.values()].some(
203
+ (w) => w.state !== "error" && w.state !== "stopped",
204
+ );
205
+ if (hasLiveWorker) return true;
206
+
207
+ // All workers dead — clear stale state so restoreState() can clean up.
208
+ state = null;
209
+ }
200
210
 
201
211
  const restored = restoreState(basePath);
202
212
  if (restored && restored.workers.length > 0) {
@@ -932,6 +942,18 @@ export function refreshWorkerStatuses(
932
942
  state.totalCost += worker.cost;
933
943
  }
934
944
 
945
+ // If all workers are in a terminal state (error/stopped), the orchestration
946
+ // is finished — deactivate and clean up so zombie workers don't persist.
947
+ const allDead = [...state.workers.values()].every(
948
+ (w) => w.state === "error" || w.state === "stopped",
949
+ );
950
+ if (allDead) {
951
+ state.active = false;
952
+ removeStateFile(basePath);
953
+ state = null;
954
+ return;
955
+ }
956
+
935
957
  // Persist updated state for crash recovery
936
958
  persistState(basePath);
937
959
  }
@@ -33,7 +33,7 @@ export const MODE_DEFAULTS: Record<WorkflowMode, Partial<GSDPreferences>> = {
33
33
  git: {
34
34
  auto_push: true,
35
35
  push_branches: false,
36
- pre_merge_check: false,
36
+ pre_merge_check: "auto",
37
37
  merge_strategy: "squash",
38
38
  isolation: "none",
39
39
  },
@@ -36,6 +36,7 @@ import {
36
36
 
37
37
  import { findMilestoneIds } from './milestone-ids.js';
38
38
  import { loadQueueOrder, sortByQueueOrder } from './queue-order.js';
39
+ import { isClosedStatus } from './status-guards.js';
39
40
  import { nativeBatchParseGsdFiles, type BatchParsedFile } from './native-parser-bridge.js';
40
41
 
41
42
  import { join, resolve } from 'path';
@@ -89,18 +90,13 @@ export function isMilestoneComplete(roadmap: Roadmap): boolean {
89
90
  }
90
91
 
91
92
  /**
92
- * Check whether a VALIDATION file's verdict is terminal (pass or needs-attention).
93
- * A non-terminal verdict (needs-remediation) means validation must re-run
94
- * after remediation slices are executed.
93
+ * Check whether a VALIDATION file's verdict is terminal.
94
+ * Any successfully extracted verdict (pass, needs-attention, needs-remediation,
95
+ * fail, etc.) means validation completed. Only return false when no verdict
96
+ * could be parsed — i.e. extractVerdict() returns undefined (#2769).
95
97
  */
96
98
  export function isValidationTerminal(validationContent: string): boolean {
97
- const v = extractVerdict(validationContent);
98
- if (!v) return false;
99
- // 'pass' and 'needs-attention' are always terminal.
100
- // 'needs-remediation' is treated as terminal to prevent infinite loops
101
- // when no remediation slices exist in the roadmap (#832). The validation
102
- // report is preserved on disk for manual review.
103
- return v === 'pass' || v === 'needs-attention' || v === 'needs-remediation';
99
+ return extractVerdict(validationContent) != null;
104
100
  }
105
101
 
106
102
  // ─── State Derivation ──────────────────────────────────────────────────────
@@ -272,13 +268,6 @@ function extractContextTitle(content: string | null, fallback: string): string {
272
268
 
273
269
  // ─── DB-backed State Derivation ────────────────────────────────────────────
274
270
 
275
- /**
276
- * Helper: check if a DB status counts as "done" (handles K002 ambiguity).
277
- */
278
- function isStatusDone(status: string): boolean {
279
- return status === 'complete' || status === 'done';
280
- }
281
-
282
271
  /**
283
272
  * Derive GSD state from the milestones/slices/tasks DB tables.
284
273
  * Flag files (PARKED, VALIDATION, CONTINUE, REPLAN, REPLAN-TRIGGER, CONTEXT-DRAFT)
@@ -368,7 +357,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
368
357
  continue;
369
358
  }
370
359
 
371
- if (isStatusDone(m.status)) {
360
+ if (isClosedStatus(m.status)) {
372
361
  completeMilestoneIds.add(m.id);
373
362
  continue;
374
363
  }
@@ -382,7 +371,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
382
371
 
383
372
  // Check roadmap: all slices done means milestone is complete
384
373
  const slices = getMilestoneSlices(m.id);
385
- if (slices.length > 0 && slices.every(s => isStatusDone(s.status))) {
374
+ if (slices.length > 0 && slices.every(s => isClosedStatus(s.status))) {
386
375
  // All slices done but no summary — still counts as complete for dep resolution
387
376
  // if a summary file exists
388
377
  // Note: without summary file, the milestone is in validating/completing state, not complete
@@ -404,7 +393,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
404
393
 
405
394
  // Ghost milestone check: no slices in DB AND no substantive files on disk
406
395
  const slices = getMilestoneSlices(m.id);
407
- if (slices.length === 0 && !isStatusDone(m.status)) {
396
+ if (slices.length === 0 && !isClosedStatus(m.status)) {
408
397
  // Check disk for ghost detection
409
398
  if (isGhostMilestone(basePath, m.id)) continue;
410
399
  }
@@ -427,7 +416,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
427
416
  }
428
417
 
429
418
  // Not complete — determine if it should be active
430
- const allSlicesDone = slices.length > 0 && slices.every(s => isStatusDone(s.status));
419
+ const allSlicesDone = slices.length > 0 && slices.every(s => isClosedStatus(s.status));
431
420
 
432
421
  // Get title — prefer DB, fall back to context file extraction
433
422
  let title = stripMilestonePrefix(m.title) || m.id;
@@ -582,7 +571,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
582
571
  // Guard: [].every() === true (vacuous truth). Without the length check,
583
572
  // an empty slice array causes a premature phase transition to
584
573
  // validating-milestone. See: https://github.com/gsd-build/gsd-2/issues/2667
585
- const allSlicesDone = activeMilestoneSlices.length > 0 && activeMilestoneSlices.every(s => isStatusDone(s.status));
574
+ const allSlicesDone = activeMilestoneSlices.length > 0 && activeMilestoneSlices.every(s => isClosedStatus(s.status));
586
575
  if (allSlicesDone) {
587
576
  const validationFile = resolveMilestoneFile(basePath, activeMilestone.id, "VALIDATION");
588
577
  const validationContent = validationFile ? await loadFile(validationFile) : null;
@@ -615,19 +604,19 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
615
604
 
616
605
  // ── Find active slice (first incomplete with deps satisfied) ─────────
617
606
  const sliceProgress = {
618
- done: activeMilestoneSlices.filter(s => isStatusDone(s.status)).length,
607
+ done: activeMilestoneSlices.filter(s => isClosedStatus(s.status)).length,
619
608
  total: activeMilestoneSlices.length,
620
609
  };
621
610
 
622
611
  const doneSliceIds = new Set(
623
- activeMilestoneSlices.filter(s => isStatusDone(s.status)).map(s => s.id)
612
+ activeMilestoneSlices.filter(s => isClosedStatus(s.status)).map(s => s.id)
624
613
  );
625
614
 
626
615
  let activeSlice: ActiveRef | null = null;
627
616
  let activeSliceRow: SliceRow | null = null;
628
617
 
629
618
  for (const s of activeMilestoneSlices) {
630
- if (isStatusDone(s.status)) continue;
619
+ if (isClosedStatus(s.status)) continue;
631
620
  if (s.depends.every(dep => doneSliceIds.has(dep))) {
632
621
  activeSlice = { id: s.id, title: s.title };
633
622
  activeSliceRow = s;
@@ -670,7 +659,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
670
659
  // causing the dispatcher to re-dispatch the same completed task forever.
671
660
  let reconciled = false;
672
661
  for (const t of tasks) {
673
- if (isStatusDone(t.status)) continue;
662
+ if (isClosedStatus(t.status)) continue;
674
663
  const summaryPath = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, t.id, "SUMMARY");
675
664
  if (summaryPath && existsSync(summaryPath)) {
676
665
  try {
@@ -693,11 +682,11 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
693
682
  }
694
683
 
695
684
  const taskProgress = {
696
- done: tasks.filter(t => isStatusDone(t.status)).length,
685
+ done: tasks.filter(t => isClosedStatus(t.status)).length,
697
686
  total: tasks.length,
698
687
  };
699
688
 
700
- const activeTaskRow = tasks.find(t => !isStatusDone(t.status));
689
+ const activeTaskRow = tasks.find(t => !isClosedStatus(t.status));
701
690
 
702
691
  if (!activeTaskRow && tasks.length > 0) {
703
692
  // All tasks done but slice not marked complete → summarizing
@@ -758,7 +747,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
758
747
  }
759
748
 
760
749
  // ── Blocker detection: check completed tasks for blocker_discovered ──
761
- const completedTasks = tasks.filter(t => isStatusDone(t.status));
750
+ const completedTasks = tasks.filter(t => isClosedStatus(t.status));
762
751
  let blockerTaskId: string | null = null;
763
752
  for (const ct of completedTasks) {
764
753
  if (ct.blocker_discovered) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Status predicates for GSD state-machine guards.
3
+ *
4
+ * The DB stores status as free-form strings. Two values indicate
5
+ * "closed": "complete" (canonical) and "done" (legacy / alias).
6
+ * Every inline `status === "complete" || status === "done"` should
7
+ * use isClosedStatus() instead.
8
+ */
9
+
10
+ /** Returns true when a milestone, slice, or task status indicates closure. */
11
+ export function isClosedStatus(status: string): boolean {
12
+ return status === "complete" || status === "done";
13
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Regression test for #2773 — activeMilestone.id guard
3
+ *
4
+ * When activeMilestone is a non-null object with `id: undefined` (corrupted
5
+ * state), the old `!state.activeMilestone` truthiness check passed through,
6
+ * causing a downstream crash when code assumed `.id` was a valid string.
7
+ *
8
+ * The fix uses optional chaining (`!state.activeMilestone?.id`) so all three
9
+ * "no usable milestone" shapes are caught:
10
+ * 1. activeMilestone === null
11
+ * 2. activeMilestone === undefined
12
+ * 3. activeMilestone === { id: undefined, title: "..." }
13
+ */
14
+
15
+ import { describe, it } from 'node:test'
16
+ import assert from 'node:assert/strict'
17
+
18
+ import type { GSDState, ActiveRef } from '../types.ts'
19
+
20
+ // ─── Guard Under Test ────────────────────────────────────────────────────────
21
+ // Extracted guard logic identical to headless-query.ts (line 74) and
22
+ // guided-flow.ts (lines 522, 1047).
23
+
24
+ function activeMilestoneIsUsable(activeMilestone: ActiveRef | null | undefined): boolean {
25
+ return !!activeMilestone?.id
26
+ }
27
+
28
+ // ─── Tests ───────────────────────────────────────────────────────────────────
29
+
30
+ describe('activeMilestone?.id guard (#2773)', () => {
31
+ it('rejects null activeMilestone', () => {
32
+ assert.equal(activeMilestoneIsUsable(null), false)
33
+ })
34
+
35
+ it('rejects undefined activeMilestone', () => {
36
+ assert.equal(activeMilestoneIsUsable(undefined), false)
37
+ })
38
+
39
+ it('rejects malformed activeMilestone with id: undefined', () => {
40
+ // This is the crash case from #2773 — object exists but id is undefined
41
+ const malformed = { id: undefined, title: 'Ghost Milestone' } as unknown as ActiveRef
42
+ assert.equal(activeMilestoneIsUsable(malformed), false)
43
+ })
44
+
45
+ it('rejects malformed activeMilestone with id: empty string', () => {
46
+ const malformed = { id: '', title: 'Empty ID Milestone' } as unknown as ActiveRef
47
+ assert.equal(activeMilestoneIsUsable(malformed), false)
48
+ })
49
+
50
+ it('accepts valid activeMilestone with a real id', () => {
51
+ const valid: ActiveRef = { id: 'M001', title: 'Real Milestone' }
52
+ assert.equal(activeMilestoneIsUsable(valid), true)
53
+ })
54
+ })
55
+
56
+ describe('headless-query stop behavior with corrupted milestone', () => {
57
+ // Simulates the decision logic from handleQuery (headless-query.ts:74-78)
58
+ function deriveNextAction(activeMilestone: ActiveRef | null | undefined, phase: string) {
59
+ if (!activeMilestone?.id) {
60
+ return {
61
+ action: 'stop' as const,
62
+ reason: phase === 'complete' ? 'All milestones complete.' : 'No active milestone.',
63
+ }
64
+ }
65
+ return { action: 'dispatch' as const, unitId: activeMilestone.id }
66
+ }
67
+
68
+ it('returns stop when activeMilestone is null', () => {
69
+ const result = deriveNextAction(null, 'pre-planning')
70
+ assert.equal(result.action, 'stop')
71
+ })
72
+
73
+ it('returns stop when activeMilestone has undefined id', () => {
74
+ const corrupted = { id: undefined, title: 'Corrupted' } as unknown as ActiveRef
75
+ const result = deriveNextAction(corrupted, 'executing')
76
+ assert.equal(result.action, 'stop')
77
+ assert.equal(result.reason, 'No active milestone.')
78
+ })
79
+
80
+ it('returns dispatch with valid milestone id', () => {
81
+ const valid: ActiveRef = { id: 'M001', title: 'Valid' }
82
+ const result = deriveNextAction(valid, 'executing')
83
+ assert.equal(result.action, 'dispatch')
84
+ })
85
+
86
+ it('returns correct stop reason when phase is complete', () => {
87
+ const result = deriveNextAction(null, 'complete')
88
+ assert.equal(result.action, 'stop')
89
+ assert.equal(result.reason, 'All milestones complete.')
90
+ })
91
+ })