gsd-pi 2.52.0 → 2.53.0-dev.07ffe51

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 (226) hide show
  1. package/README.md +55 -32
  2. package/dist/headless-query.js +1 -1
  3. package/dist/resources/extensions/get-secrets-from-user.js +7 -0
  4. package/dist/resources/extensions/gsd/auto/phases.js +28 -8
  5. package/dist/resources/extensions/gsd/auto-dispatch.js +5 -1
  6. package/dist/resources/extensions/gsd/auto-worktree.js +70 -14
  7. package/dist/resources/extensions/gsd/auto.js +22 -0
  8. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +4 -10
  9. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +3 -3
  10. package/dist/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  11. package/dist/resources/extensions/gsd/git-service.js +4 -3
  12. package/dist/resources/extensions/gsd/guided-flow.js +4 -3
  13. package/dist/resources/extensions/gsd/markdown-renderer.js +5 -4
  14. package/dist/resources/extensions/gsd/parallel-orchestrator.js +18 -2
  15. package/dist/resources/extensions/gsd/preferences-types.js +1 -1
  16. package/dist/resources/extensions/gsd/state.js +18 -29
  17. package/dist/resources/extensions/gsd/status-guards.js +12 -0
  18. package/dist/resources/extensions/gsd/tools/complete-milestone.js +4 -3
  19. package/dist/resources/extensions/gsd/tools/complete-slice.js +4 -3
  20. package/dist/resources/extensions/gsd/tools/complete-task.js +4 -3
  21. package/dist/resources/extensions/gsd/tools/plan-milestone.js +4 -14
  22. package/dist/resources/extensions/gsd/tools/plan-slice.js +4 -14
  23. package/dist/resources/extensions/gsd/tools/plan-task.js +4 -14
  24. package/dist/resources/extensions/gsd/tools/reassess-roadmap.js +6 -7
  25. package/dist/resources/extensions/gsd/tools/reopen-slice.js +4 -3
  26. package/dist/resources/extensions/gsd/tools/reopen-task.js +5 -4
  27. package/dist/resources/extensions/gsd/tools/replan-slice.js +5 -6
  28. package/dist/resources/extensions/gsd/validation.js +21 -0
  29. package/dist/resources/extensions/shared/rtk.js +14 -4
  30. package/dist/rtk.js +3 -1
  31. package/dist/web/standalone/.next/BUILD_ID +1 -1
  32. package/dist/web/standalone/.next/app-path-routes-manifest.json +16 -16
  33. package/dist/web/standalone/.next/build-manifest.json +4 -4
  34. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  35. package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
  36. package/dist/web/standalone/.next/required-server-files.json +4 -4
  37. package/dist/web/standalone/.next/server/app/_global-error/page.js +3 -3
  38. package/dist/web/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  40. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found/page.js +2 -2
  48. package/dist/web/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.rsc +3 -3
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +3 -3
  54. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
  58. package/dist/web/standalone/.next/server/app/api/boot/route_client-reference-manifest.js +1 -1
  59. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js +1 -1
  60. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route_client-reference-manifest.js +1 -1
  61. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js +1 -1
  62. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route_client-reference-manifest.js +1 -1
  63. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js +2 -2
  64. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route_client-reference-manifest.js +1 -1
  65. package/dist/web/standalone/.next/server/app/api/browse-directories/route.js +1 -1
  66. package/dist/web/standalone/.next/server/app/api/browse-directories/route_client-reference-manifest.js +1 -1
  67. package/dist/web/standalone/.next/server/app/api/captures/route.js +1 -1
  68. package/dist/web/standalone/.next/server/app/api/captures/route_client-reference-manifest.js +1 -1
  69. package/dist/web/standalone/.next/server/app/api/cleanup/route.js +1 -1
  70. package/dist/web/standalone/.next/server/app/api/cleanup/route_client-reference-manifest.js +1 -1
  71. package/dist/web/standalone/.next/server/app/api/dev-mode/route.js +1 -1
  72. package/dist/web/standalone/.next/server/app/api/dev-mode/route_client-reference-manifest.js +1 -1
  73. package/dist/web/standalone/.next/server/app/api/doctor/route.js +1 -1
  74. package/dist/web/standalone/.next/server/app/api/doctor/route_client-reference-manifest.js +1 -1
  75. package/dist/web/standalone/.next/server/app/api/experimental/route.js +2 -2
  76. package/dist/web/standalone/.next/server/app/api/experimental/route_client-reference-manifest.js +1 -1
  77. package/dist/web/standalone/.next/server/app/api/export-data/route.js +1 -1
  78. package/dist/web/standalone/.next/server/app/api/export-data/route_client-reference-manifest.js +1 -1
  79. package/dist/web/standalone/.next/server/app/api/files/route.js +1 -1
  80. package/dist/web/standalone/.next/server/app/api/files/route_client-reference-manifest.js +1 -1
  81. package/dist/web/standalone/.next/server/app/api/forensics/route.js +1 -1
  82. package/dist/web/standalone/.next/server/app/api/forensics/route_client-reference-manifest.js +1 -1
  83. package/dist/web/standalone/.next/server/app/api/git/route.js +1 -1
  84. package/dist/web/standalone/.next/server/app/api/git/route_client-reference-manifest.js +1 -1
  85. package/dist/web/standalone/.next/server/app/api/history/route.js +1 -1
  86. package/dist/web/standalone/.next/server/app/api/history/route_client-reference-manifest.js +1 -1
  87. package/dist/web/standalone/.next/server/app/api/hooks/route.js +1 -1
  88. package/dist/web/standalone/.next/server/app/api/hooks/route_client-reference-manifest.js +1 -1
  89. package/dist/web/standalone/.next/server/app/api/inspect/route.js +1 -1
  90. package/dist/web/standalone/.next/server/app/api/inspect/route_client-reference-manifest.js +1 -1
  91. package/dist/web/standalone/.next/server/app/api/knowledge/route.js +1 -1
  92. package/dist/web/standalone/.next/server/app/api/knowledge/route_client-reference-manifest.js +1 -1
  93. package/dist/web/standalone/.next/server/app/api/live-state/route.js +1 -1
  94. package/dist/web/standalone/.next/server/app/api/live-state/route_client-reference-manifest.js +1 -1
  95. package/dist/web/standalone/.next/server/app/api/onboarding/route.js +1 -1
  96. package/dist/web/standalone/.next/server/app/api/onboarding/route_client-reference-manifest.js +1 -1
  97. package/dist/web/standalone/.next/server/app/api/preferences/route.js +1 -1
  98. package/dist/web/standalone/.next/server/app/api/preferences/route_client-reference-manifest.js +1 -1
  99. package/dist/web/standalone/.next/server/app/api/projects/route.js +1 -1
  100. package/dist/web/standalone/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
  101. package/dist/web/standalone/.next/server/app/api/recovery/route.js +1 -1
  102. package/dist/web/standalone/.next/server/app/api/recovery/route_client-reference-manifest.js +1 -1
  103. package/dist/web/standalone/.next/server/app/api/remote-questions/route.js +2 -2
  104. package/dist/web/standalone/.next/server/app/api/remote-questions/route_client-reference-manifest.js +1 -1
  105. package/dist/web/standalone/.next/server/app/api/session/browser/route.js +1 -1
  106. package/dist/web/standalone/.next/server/app/api/session/browser/route_client-reference-manifest.js +1 -1
  107. package/dist/web/standalone/.next/server/app/api/session/command/route.js +1 -1
  108. package/dist/web/standalone/.next/server/app/api/session/command/route_client-reference-manifest.js +1 -1
  109. package/dist/web/standalone/.next/server/app/api/session/events/route.js +2 -2
  110. package/dist/web/standalone/.next/server/app/api/session/events/route_client-reference-manifest.js +1 -1
  111. package/dist/web/standalone/.next/server/app/api/session/manage/route.js +1 -1
  112. package/dist/web/standalone/.next/server/app/api/session/manage/route_client-reference-manifest.js +1 -1
  113. package/dist/web/standalone/.next/server/app/api/settings-data/route.js +1 -1
  114. package/dist/web/standalone/.next/server/app/api/settings-data/route_client-reference-manifest.js +1 -1
  115. package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
  116. package/dist/web/standalone/.next/server/app/api/shutdown/route_client-reference-manifest.js +1 -1
  117. package/dist/web/standalone/.next/server/app/api/skill-health/route.js +1 -1
  118. package/dist/web/standalone/.next/server/app/api/skill-health/route_client-reference-manifest.js +1 -1
  119. package/dist/web/standalone/.next/server/app/api/steer/route.js +1 -1
  120. package/dist/web/standalone/.next/server/app/api/steer/route_client-reference-manifest.js +1 -1
  121. package/dist/web/standalone/.next/server/app/api/switch-root/route.js +1 -1
  122. package/dist/web/standalone/.next/server/app/api/switch-root/route_client-reference-manifest.js +1 -1
  123. package/dist/web/standalone/.next/server/app/api/terminal/input/route.js +2 -2
  124. package/dist/web/standalone/.next/server/app/api/terminal/input/route_client-reference-manifest.js +1 -1
  125. package/dist/web/standalone/.next/server/app/api/terminal/resize/route.js +2 -2
  126. package/dist/web/standalone/.next/server/app/api/terminal/resize/route_client-reference-manifest.js +1 -1
  127. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js +2 -2
  128. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route_client-reference-manifest.js +1 -1
  129. package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js +4 -4
  130. package/dist/web/standalone/.next/server/app/api/terminal/stream/route_client-reference-manifest.js +1 -1
  131. package/dist/web/standalone/.next/server/app/api/terminal/upload/route.js +1 -1
  132. package/dist/web/standalone/.next/server/app/api/terminal/upload/route_client-reference-manifest.js +1 -1
  133. package/dist/web/standalone/.next/server/app/api/undo/route.js +1 -1
  134. package/dist/web/standalone/.next/server/app/api/undo/route_client-reference-manifest.js +1 -1
  135. package/dist/web/standalone/.next/server/app/api/update/route.js +1 -1
  136. package/dist/web/standalone/.next/server/app/api/update/route_client-reference-manifest.js +1 -1
  137. package/dist/web/standalone/.next/server/app/api/visualizer/route.js +1 -1
  138. package/dist/web/standalone/.next/server/app/api/visualizer/route_client-reference-manifest.js +1 -1
  139. package/dist/web/standalone/.next/server/app/index.html +1 -1
  140. package/dist/web/standalone/.next/server/app/index.rsc +4 -4
  141. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  142. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +4 -4
  143. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  144. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +3 -3
  145. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  146. package/dist/web/standalone/.next/server/app/page.js +2 -2
  147. package/dist/web/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  148. package/dist/web/standalone/.next/server/app-paths-manifest.json +16 -16
  149. package/dist/web/standalone/.next/server/chunks/2229.js +1 -1
  150. package/dist/web/standalone/.next/server/chunks/7471.js +3 -3
  151. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  152. package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
  153. package/dist/web/standalone/.next/server/middleware.js +2 -2
  154. package/dist/web/standalone/.next/server/next-font-manifest.js +1 -1
  155. package/dist/web/standalone/.next/server/next-font-manifest.json +1 -1
  156. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  157. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  158. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  159. package/dist/web/standalone/.next/static/chunks/4024.87fd909ae0110f50.js +9 -0
  160. package/dist/web/standalone/.next/static/chunks/app/_not-found/{page-2f24283c162b6ab3.js → page-f2a7482d42a5614b.js} +1 -1
  161. package/dist/web/standalone/.next/static/chunks/app/{layout-9ecfd95f343793f0.js → layout-a16c7a7ecdf0c2cf.js} +1 -1
  162. package/dist/web/standalone/.next/static/chunks/app/page-b950e4e384cc62b3.js +1 -0
  163. package/dist/web/standalone/.next/static/chunks/main-app-fdab67f7802d7832.js +1 -0
  164. package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-459824ffb8c323dd.js +1 -0
  165. package/dist/web/standalone/.next/static/chunks/{webpack-024d82be84800e52.js → webpack-bca0e732db0dcec3.js} +1 -1
  166. package/dist/web/standalone/node_modules/node-pty/build/Makefile +2 -2
  167. package/dist/web/standalone/node_modules/node-pty/build/Release/pty.node +0 -0
  168. package/dist/web/standalone/node_modules/node-pty/build/pty.target.mk +14 -14
  169. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api.target.mk +14 -14
  170. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api_except.target.mk +14 -14
  171. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api_maybe.target.mk +14 -14
  172. package/dist/web/standalone/server.js +1 -1
  173. package/package.json +1 -1
  174. package/packages/pi-coding-agent/package.json +1 -1
  175. package/pkg/package.json +1 -1
  176. package/scripts/ensure-workspace-builds.cjs +36 -8
  177. package/src/resources/extensions/get-secrets-from-user.ts +8 -0
  178. package/src/resources/extensions/gsd/auto/phases.ts +38 -7
  179. package/src/resources/extensions/gsd/auto-dispatch.ts +6 -1
  180. package/src/resources/extensions/gsd/auto-worktree.ts +73 -14
  181. package/src/resources/extensions/gsd/auto.ts +21 -0
  182. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +4 -11
  183. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +3 -3
  184. package/src/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  185. package/src/resources/extensions/gsd/git-service.ts +4 -3
  186. package/src/resources/extensions/gsd/guided-flow.ts +4 -3
  187. package/src/resources/extensions/gsd/markdown-renderer.ts +5 -4
  188. package/src/resources/extensions/gsd/parallel-orchestrator.ts +23 -1
  189. package/src/resources/extensions/gsd/preferences-types.ts +1 -1
  190. package/src/resources/extensions/gsd/state.ts +18 -29
  191. package/src/resources/extensions/gsd/status-guards.ts +13 -0
  192. package/src/resources/extensions/gsd/tests/active-milestone-id-guard.test.ts +91 -0
  193. package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +87 -0
  194. package/src/resources/extensions/gsd/tests/auto-worktree-auto-resolve.test.ts +80 -0
  195. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +1 -1
  196. package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +39 -0
  197. package/src/resources/extensions/gsd/tests/git-service.test.ts +64 -30
  198. package/src/resources/extensions/gsd/tests/milestone-report-path.test.ts +51 -0
  199. package/src/resources/extensions/gsd/tests/parallel-orchestrator-zombie-cleanup.test.ts +277 -0
  200. package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +103 -0
  201. package/src/resources/extensions/gsd/tests/preferences.test.ts +1 -1
  202. package/src/resources/extensions/gsd/tests/rate-limit-model-fallback.test.ts +90 -0
  203. package/src/resources/extensions/gsd/tests/session-lock-transient-read.test.ts +9 -8
  204. package/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts +125 -0
  205. package/src/resources/extensions/gsd/tests/status-guards.test.ts +30 -0
  206. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +12 -2
  207. package/src/resources/extensions/gsd/tests/validation-gate-patterns.test.ts +124 -0
  208. package/src/resources/extensions/gsd/tests/validation.test.ts +72 -0
  209. package/src/resources/extensions/gsd/tools/complete-milestone.ts +4 -3
  210. package/src/resources/extensions/gsd/tools/complete-slice.ts +4 -3
  211. package/src/resources/extensions/gsd/tools/complete-task.ts +4 -3
  212. package/src/resources/extensions/gsd/tools/plan-milestone.ts +4 -16
  213. package/src/resources/extensions/gsd/tools/plan-slice.ts +4 -16
  214. package/src/resources/extensions/gsd/tools/plan-task.ts +4 -16
  215. package/src/resources/extensions/gsd/tools/reassess-roadmap.ts +6 -7
  216. package/src/resources/extensions/gsd/tools/reopen-slice.ts +4 -3
  217. package/src/resources/extensions/gsd/tools/reopen-task.ts +5 -4
  218. package/src/resources/extensions/gsd/tools/replan-slice.ts +5 -7
  219. package/src/resources/extensions/gsd/validation.ts +23 -0
  220. package/src/resources/extensions/shared/rtk.ts +22 -4
  221. package/dist/web/standalone/.next/static/chunks/4024.21054f459af5cc78.js +0 -9
  222. package/dist/web/standalone/.next/static/chunks/app/page-fbecd1237e2d6d1f.js +0 -1
  223. package/dist/web/standalone/.next/static/chunks/main-app-d3d4c336195465f9.js +0 -1
  224. package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-ab5a8926e07ec673.js +0 -1
  225. /package/dist/web/standalone/.next/static/{vlgS2rkXjxeKhgXhdp4lh → Q5pfrfJIvgUKR3LJLVB0T}/_buildManifest.js +0 -0
  226. /package/dist/web/standalone/.next/static/{vlgS2rkXjxeKhgXhdp4lh → Q5pfrfJIvgUKR3LJLVB0T}/_ssgManifest.js +0 -0
@@ -6,6 +6,7 @@ import { parseSummary, loadFile, parseRequirementCounts, parseContextDependsOn,
6
6
  import { resolveMilestoneFile, resolveSlicePath, resolveSliceFile, resolveTaskFile, resolveTasksDir, resolveGsdRootFile, gsdRoot, } from './paths.js';
7
7
  import { findMilestoneIds } from './milestone-ids.js';
8
8
  import { loadQueueOrder, sortByQueueOrder } from './queue-order.js';
9
+ import { isClosedStatus } from './status-guards.js';
9
10
  import { nativeBatchParseGsdFiles } from './native-parser-bridge.js';
10
11
  import { join, resolve } from 'path';
11
12
  import { existsSync, readdirSync } from 'node:fs';
@@ -39,19 +40,13 @@ export function isMilestoneComplete(roadmap) {
39
40
  return roadmap.slices.length > 0 && roadmap.slices.every(s => s.done);
40
41
  }
41
42
  /**
42
- * Check whether a VALIDATION file's verdict is terminal (pass or needs-attention).
43
- * A non-terminal verdict (needs-remediation) means validation must re-run
44
- * after remediation slices are executed.
43
+ * Check whether a VALIDATION file's verdict is terminal.
44
+ * Any successfully extracted verdict (pass, needs-attention, needs-remediation,
45
+ * fail, etc.) means validation completed. Only return false when no verdict
46
+ * could be parsed — i.e. extractVerdict() returns undefined (#2769).
45
47
  */
46
48
  export function isValidationTerminal(validationContent) {
47
- const v = extractVerdict(validationContent);
48
- if (!v)
49
- return false;
50
- // 'pass' and 'needs-attention' are always terminal.
51
- // 'needs-remediation' is treated as terminal to prevent infinite loops
52
- // when no remediation slices exist in the roadmap (#832). The validation
53
- // report is preserved on disk for manual review.
54
- return v === 'pass' || v === 'needs-attention' || v === 'needs-remediation';
49
+ return extractVerdict(validationContent) != null;
55
50
  }
56
51
  const CACHE_TTL_MS = 100;
57
52
  let _stateCache = null;
@@ -203,12 +198,6 @@ function extractContextTitle(content, fallback) {
203
198
  return stripMilestonePrefix(h1.slice(2).trim()) || fallback;
204
199
  }
205
200
  // ─── DB-backed State Derivation ────────────────────────────────────────────
206
- /**
207
- * Helper: check if a DB status counts as "done" (handles K002 ambiguity).
208
- */
209
- function isStatusDone(status) {
210
- return status === 'complete' || status === 'done';
211
- }
212
201
  /**
213
202
  * Derive GSD state from the milestones/slices/tasks DB tables.
214
203
  * Flag files (PARKED, VALIDATION, CONTINUE, REPLAN, REPLAN-TRIGGER, CONTEXT-DRAFT)
@@ -292,7 +281,7 @@ export async function deriveStateFromDb(basePath) {
292
281
  parkedMilestoneIds.add(m.id);
293
282
  continue;
294
283
  }
295
- if (isStatusDone(m.status)) {
284
+ if (isClosedStatus(m.status)) {
296
285
  completeMilestoneIds.add(m.id);
297
286
  continue;
298
287
  }
@@ -304,7 +293,7 @@ export async function deriveStateFromDb(basePath) {
304
293
  }
305
294
  // Check roadmap: all slices done means milestone is complete
306
295
  const slices = getMilestoneSlices(m.id);
307
- if (slices.length > 0 && slices.every(s => isStatusDone(s.status))) {
296
+ if (slices.length > 0 && slices.every(s => isClosedStatus(s.status))) {
308
297
  // All slices done but no summary — still counts as complete for dep resolution
309
298
  // if a summary file exists
310
299
  // Note: without summary file, the milestone is in validating/completing state, not complete
@@ -323,7 +312,7 @@ export async function deriveStateFromDb(basePath) {
323
312
  }
324
313
  // Ghost milestone check: no slices in DB AND no substantive files on disk
325
314
  const slices = getMilestoneSlices(m.id);
326
- if (slices.length === 0 && !isStatusDone(m.status)) {
315
+ if (slices.length === 0 && !isClosedStatus(m.status)) {
327
316
  // Check disk for ghost detection
328
317
  if (isGhostMilestone(basePath, m.id))
329
318
  continue;
@@ -344,7 +333,7 @@ export async function deriveStateFromDb(basePath) {
344
333
  continue;
345
334
  }
346
335
  // Not complete — determine if it should be active
347
- const allSlicesDone = slices.length > 0 && slices.every(s => isStatusDone(s.status));
336
+ const allSlicesDone = slices.length > 0 && slices.every(s => isClosedStatus(s.status));
348
337
  // Get title — prefer DB, fall back to context file extraction
349
338
  let title = stripMilestonePrefix(m.title) || m.id;
350
339
  if (title === m.id) {
@@ -484,7 +473,7 @@ export async function deriveStateFromDb(basePath) {
484
473
  // Guard: [].every() === true (vacuous truth). Without the length check,
485
474
  // an empty slice array causes a premature phase transition to
486
475
  // validating-milestone. See: https://github.com/gsd-build/gsd-2/issues/2667
487
- const allSlicesDone = activeMilestoneSlices.length > 0 && activeMilestoneSlices.every(s => isStatusDone(s.status));
476
+ const allSlicesDone = activeMilestoneSlices.length > 0 && activeMilestoneSlices.every(s => isClosedStatus(s.status));
488
477
  if (allSlicesDone) {
489
478
  const validationFile = resolveMilestoneFile(basePath, activeMilestone.id, "VALIDATION");
490
479
  const validationContent = validationFile ? await loadFile(validationFile) : null;
@@ -514,14 +503,14 @@ export async function deriveStateFromDb(basePath) {
514
503
  }
515
504
  // ── Find active slice (first incomplete with deps satisfied) ─────────
516
505
  const sliceProgress = {
517
- done: activeMilestoneSlices.filter(s => isStatusDone(s.status)).length,
506
+ done: activeMilestoneSlices.filter(s => isClosedStatus(s.status)).length,
518
507
  total: activeMilestoneSlices.length,
519
508
  };
520
- const doneSliceIds = new Set(activeMilestoneSlices.filter(s => isStatusDone(s.status)).map(s => s.id));
509
+ const doneSliceIds = new Set(activeMilestoneSlices.filter(s => isClosedStatus(s.status)).map(s => s.id));
521
510
  let activeSlice = null;
522
511
  let activeSliceRow = null;
523
512
  for (const s of activeMilestoneSlices) {
524
- if (isStatusDone(s.status))
513
+ if (isClosedStatus(s.status))
525
514
  continue;
526
515
  if (s.depends.every(dep => doneSliceIds.has(dep))) {
527
516
  activeSlice = { id: s.id, title: s.title };
@@ -561,7 +550,7 @@ export async function deriveStateFromDb(basePath) {
561
550
  // causing the dispatcher to re-dispatch the same completed task forever.
562
551
  let reconciled = false;
563
552
  for (const t of tasks) {
564
- if (isStatusDone(t.status))
553
+ if (isClosedStatus(t.status))
565
554
  continue;
566
555
  const summaryPath = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, t.id, "SUMMARY");
567
556
  if (summaryPath && existsSync(summaryPath)) {
@@ -581,10 +570,10 @@ export async function deriveStateFromDb(basePath) {
581
570
  tasks = getSliceTasks(activeMilestone.id, activeSlice.id);
582
571
  }
583
572
  const taskProgress = {
584
- done: tasks.filter(t => isStatusDone(t.status)).length,
573
+ done: tasks.filter(t => isClosedStatus(t.status)).length,
585
574
  total: tasks.length,
586
575
  };
587
- const activeTaskRow = tasks.find(t => !isStatusDone(t.status));
576
+ const activeTaskRow = tasks.find(t => !isClosedStatus(t.status));
588
577
  if (!activeTaskRow && tasks.length > 0) {
589
578
  // All tasks done but slice not marked complete → summarizing
590
579
  return {
@@ -639,7 +628,7 @@ export async function deriveStateFromDb(basePath) {
639
628
  };
640
629
  }
641
630
  // ── Blocker detection: check completed tasks for blocker_discovered ──
642
- const completedTasks = tasks.filter(t => isStatusDone(t.status));
631
+ const completedTasks = tasks.filter(t => isClosedStatus(t.status));
643
632
  let blockerTaskId = null;
644
633
  for (const ct of completedTasks) {
645
634
  if (ct.blocker_discovered) {
@@ -0,0 +1,12 @@
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
+ /** Returns true when a milestone, slice, or task status indicates closure. */
10
+ export function isClosedStatus(status) {
11
+ return status === "complete" || status === "done";
12
+ }
@@ -9,6 +9,7 @@ import { join } from "node:path";
9
9
  import { mkdirSync } from "node:fs";
10
10
  import { transaction, getMilestone, getMilestoneSlices, getSliceTasks, updateMilestoneStatus, } from "../gsd-db.js";
11
11
  import { resolveMilestonePath, clearPathCache } from "../paths.js";
12
+ import { isClosedStatus } from "../status-guards.js";
12
13
  import { saveFile, clearParseCache } from "../files.js";
13
14
  import { invalidateStateCache } from "../state.js";
14
15
  import { renderAllProjections } from "../workflow-projections.js";
@@ -89,7 +90,7 @@ export async function handleCompleteMilestone(params, basePath) {
89
90
  guardError = `milestone not found: ${params.milestoneId}`;
90
91
  return;
91
92
  }
92
- if (milestone.status === "complete" || milestone.status === "done") {
93
+ if (isClosedStatus(milestone.status)) {
93
94
  guardError = `milestone ${params.milestoneId} is already complete`;
94
95
  return;
95
96
  }
@@ -99,7 +100,7 @@ export async function handleCompleteMilestone(params, basePath) {
99
100
  guardError = `no slices found for milestone ${params.milestoneId}`;
100
101
  return;
101
102
  }
102
- const incompleteSlices = slices.filter(s => s.status !== "complete" && s.status !== "done");
103
+ const incompleteSlices = slices.filter(s => !isClosedStatus(s.status));
103
104
  if (incompleteSlices.length > 0) {
104
105
  const incompleteIds = incompleteSlices.map(s => `${s.id} (status: ${s.status})`).join(", ");
105
106
  guardError = `incomplete slices: ${incompleteIds}`;
@@ -108,7 +109,7 @@ export async function handleCompleteMilestone(params, basePath) {
108
109
  // Deep check: verify all tasks in all slices are complete
109
110
  for (const slice of slices) {
110
111
  const tasks = getSliceTasks(params.milestoneId, slice.id);
111
- const incompleteTasks = tasks.filter(t => t.status !== "complete" && t.status !== "done");
112
+ const incompleteTasks = tasks.filter(t => !isClosedStatus(t.status));
112
113
  if (incompleteTasks.length > 0) {
113
114
  const ids = incompleteTasks.map(t => `${t.id} (status: ${t.status})`).join(", ");
114
115
  guardError = `slice ${slice.id} has incomplete tasks: ${ids}`;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { join } from "node:path";
10
10
  import { mkdirSync } from "node:fs";
11
+ import { isClosedStatus } from "../status-guards.js";
11
12
  import { transaction, insertMilestone, insertSlice, getSlice, getSliceTasks, getMilestone, updateSliceStatus, setSliceSummaryMd, } from "../gsd-db.js";
12
13
  import { resolveSlicePath, clearPathCache } from "../paths.js";
13
14
  import { checkOwnership, sliceUnitKey } from "../unit-ownership.js";
@@ -179,12 +180,12 @@ export async function handleCompleteSlice(params, basePath) {
179
180
  // Milestone/slice not existing is OK — insertMilestone/insertSlice below will auto-create.
180
181
  // Only block if they exist and are closed.
181
182
  const milestone = getMilestone(params.milestoneId);
182
- if (milestone && (milestone.status === "complete" || milestone.status === "done")) {
183
+ if (milestone && isClosedStatus(milestone.status)) {
183
184
  guardError = `cannot complete slice in a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
184
185
  return;
185
186
  }
186
187
  const slice = getSlice(params.milestoneId, params.sliceId);
187
- if (slice && (slice.status === "complete" || slice.status === "done")) {
188
+ if (slice && isClosedStatus(slice.status)) {
188
189
  guardError = `slice ${params.sliceId} is already complete — use gsd_slice_reopen first if you need to redo it`;
189
190
  return;
190
191
  }
@@ -194,7 +195,7 @@ export async function handleCompleteSlice(params, basePath) {
194
195
  guardError = `no tasks found for slice ${params.sliceId} in milestone ${params.milestoneId}`;
195
196
  return;
196
197
  }
197
- const incompleteTasks = tasks.filter(t => t.status !== "complete" && t.status !== "done");
198
+ const incompleteTasks = tasks.filter(t => !isClosedStatus(t.status));
198
199
  if (incompleteTasks.length > 0) {
199
200
  const incompleteIds = incompleteTasks.map(t => `${t.id} (status: ${t.status})`).join(", ");
200
201
  guardError = `incomplete tasks: ${incompleteIds}`;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { join } from "node:path";
10
10
  import { mkdirSync } from "node:fs";
11
+ import { isClosedStatus } from "../status-guards.js";
11
12
  import { transaction, insertMilestone, insertSlice, insertTask, insertVerificationEvidence, getMilestone, getSlice, getTask, updateTaskStatus, setTaskSummaryMd, deleteVerificationEvidence, } from "../gsd-db.js";
12
13
  import { resolveSliceFile, resolveTasksDir, clearPathCache } from "../paths.js";
13
14
  import { checkOwnership, taskUnitKey } from "../unit-ownership.js";
@@ -122,17 +123,17 @@ export async function handleCompleteTask(params, basePath) {
122
123
  // Milestone/slice not existing is OK — insertMilestone/insertSlice below will auto-create.
123
124
  // Only block if they exist and are closed.
124
125
  const milestone = getMilestone(params.milestoneId);
125
- if (milestone && (milestone.status === "complete" || milestone.status === "done")) {
126
+ if (milestone && isClosedStatus(milestone.status)) {
126
127
  guardError = `cannot complete task in a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
127
128
  return;
128
129
  }
129
130
  const slice = getSlice(params.milestoneId, params.sliceId);
130
- if (slice && (slice.status === "complete" || slice.status === "done")) {
131
+ if (slice && isClosedStatus(slice.status)) {
131
132
  guardError = `cannot complete task in a closed slice: ${params.sliceId} (status: ${slice.status})`;
132
133
  return;
133
134
  }
134
135
  const existingTask = getTask(params.milestoneId, params.sliceId, params.taskId);
135
- if (existingTask && (existingTask.status === "complete" || existingTask.status === "done")) {
136
+ if (existingTask && isClosedStatus(existingTask.status)) {
136
137
  guardError = `task ${params.taskId} is already complete — use gsd_task_reopen first if you need to redo it`;
137
138
  return;
138
139
  }
@@ -1,22 +1,12 @@
1
1
  import { clearParseCache } from "../files.js";
2
+ import { isClosedStatus } from "../status-guards.js";
3
+ import { isNonEmptyString, validateStringArray } from "../validation.js";
2
4
  import { transaction, getMilestone, insertMilestone, insertSlice, upsertMilestonePlanning, upsertSlicePlanning, } from "../gsd-db.js";
3
5
  import { invalidateStateCache } from "../state.js";
4
6
  import { renderRoadmapFromDb } from "../markdown-renderer.js";
5
7
  import { renderAllProjections } from "../workflow-projections.js";
6
8
  import { writeManifest } from "../workflow-manifest.js";
7
9
  import { appendEvent } from "../workflow-events.js";
8
- function isNonEmptyString(value) {
9
- return typeof value === "string" && value.trim().length > 0;
10
- }
11
- function validateStringArray(value, field) {
12
- if (!Array.isArray(value)) {
13
- throw new Error(`${field} must be an array`);
14
- }
15
- if (value.some((item) => !isNonEmptyString(item))) {
16
- throw new Error(`${field} must contain only non-empty strings`);
17
- }
18
- return value;
19
- }
20
10
  function validateRiskEntries(value) {
21
11
  if (!Array.isArray(value)) {
22
12
  throw new Error("keyRisks must be an array");
@@ -152,7 +142,7 @@ export async function handlePlanMilestone(rawParams, basePath) {
152
142
  try {
153
143
  transaction(() => {
154
144
  const existingMilestone = getMilestone(params.milestoneId);
155
- if (existingMilestone && (existingMilestone.status === "complete" || existingMilestone.status === "done")) {
145
+ if (existingMilestone && isClosedStatus(existingMilestone.status)) {
156
146
  guardError = `cannot re-plan milestone ${params.milestoneId}: it is already complete`;
157
147
  return;
158
148
  }
@@ -164,7 +154,7 @@ export async function handlePlanMilestone(rawParams, basePath) {
164
154
  guardError = `depends_on references unknown milestone: ${depId}`;
165
155
  return;
166
156
  }
167
- if (dep.status !== "complete" && dep.status !== "done") {
157
+ if (!isClosedStatus(dep.status)) {
168
158
  guardError = `depends_on milestone ${depId} is not yet complete (status: ${dep.status})`;
169
159
  return;
170
160
  }
@@ -1,22 +1,12 @@
1
1
  import { clearParseCache } from "../files.js";
2
+ import { isClosedStatus } from "../status-guards.js";
3
+ import { isNonEmptyString } from "../validation.js";
2
4
  import { transaction, getMilestone, getSlice, insertTask, upsertSlicePlanning, upsertTaskPlanning, insertGateRow, } from "../gsd-db.js";
3
5
  import { invalidateStateCache } from "../state.js";
4
6
  import { renderPlanFromDb } from "../markdown-renderer.js";
5
7
  import { renderAllProjections } from "../workflow-projections.js";
6
8
  import { writeManifest } from "../workflow-manifest.js";
7
9
  import { appendEvent } from "../workflow-events.js";
8
- function isNonEmptyString(value) {
9
- return typeof value === "string" && value.trim().length > 0;
10
- }
11
- function validateStringArray(value, field) {
12
- if (!Array.isArray(value)) {
13
- throw new Error(`${field} must be an array`);
14
- }
15
- if (value.some((item) => !isNonEmptyString(item))) {
16
- throw new Error(`${field} must contain only non-empty strings`);
17
- }
18
- return value;
19
- }
20
10
  function validateTasks(value) {
21
11
  if (!Array.isArray(value) || value.length === 0) {
22
12
  throw new Error("tasks must be a non-empty array");
@@ -113,7 +103,7 @@ export async function handlePlanSlice(rawParams, basePath) {
113
103
  guardError = `milestone not found: ${params.milestoneId}`;
114
104
  return;
115
105
  }
116
- if (parentMilestone.status === "complete" || parentMilestone.status === "done") {
106
+ if (isClosedStatus(parentMilestone.status)) {
117
107
  guardError = `cannot plan slice in a closed milestone: ${params.milestoneId} (status: ${parentMilestone.status})`;
118
108
  return;
119
109
  }
@@ -122,7 +112,7 @@ export async function handlePlanSlice(rawParams, basePath) {
122
112
  guardError = `missing parent slice: ${params.milestoneId}/${params.sliceId}`;
123
113
  return;
124
114
  }
125
- if (parentSlice.status === "complete" || parentSlice.status === "done") {
115
+ if (isClosedStatus(parentSlice.status)) {
126
116
  guardError = `cannot re-plan slice ${params.sliceId}: it is already complete — use gsd_slice_reopen first`;
127
117
  return;
128
118
  }
@@ -1,22 +1,12 @@
1
1
  import { clearParseCache } from "../files.js";
2
+ import { isClosedStatus } from "../status-guards.js";
3
+ import { isNonEmptyString, validateStringArray } from "../validation.js";
2
4
  import { transaction, getSlice, getTask, insertTask, upsertTaskPlanning } from "../gsd-db.js";
3
5
  import { invalidateStateCache } from "../state.js";
4
6
  import { renderTaskPlanFromDb } from "../markdown-renderer.js";
5
7
  import { renderAllProjections } from "../workflow-projections.js";
6
8
  import { writeManifest } from "../workflow-manifest.js";
7
9
  import { appendEvent } from "../workflow-events.js";
8
- function isNonEmptyString(value) {
9
- return typeof value === "string" && value.trim().length > 0;
10
- }
11
- function validateStringArray(value, field) {
12
- if (!Array.isArray(value)) {
13
- throw new Error(`${field} must be an array`);
14
- }
15
- if (value.some((item) => !isNonEmptyString(item))) {
16
- throw new Error(`${field} must contain only non-empty strings`);
17
- }
18
- return value;
19
- }
20
10
  function validateParams(params) {
21
11
  if (!isNonEmptyString(params?.milestoneId))
22
12
  throw new Error("milestoneId is required");
@@ -61,12 +51,12 @@ export async function handlePlanTask(rawParams, basePath) {
61
51
  guardError = `missing parent slice: ${params.milestoneId}/${params.sliceId}`;
62
52
  return;
63
53
  }
64
- if (parentSlice.status === "complete" || parentSlice.status === "done") {
54
+ if (isClosedStatus(parentSlice.status)) {
65
55
  guardError = `cannot plan task in a closed slice: ${params.sliceId} (status: ${parentSlice.status})`;
66
56
  return;
67
57
  }
68
58
  const existingTask = getTask(params.milestoneId, params.sliceId, params.taskId);
69
- if (existingTask && (existingTask.status === "complete" || existingTask.status === "done")) {
59
+ if (existingTask && isClosedStatus(existingTask.status)) {
70
60
  guardError = `cannot re-plan task ${params.taskId}: it is already complete — use gsd_task_reopen first`;
71
61
  return;
72
62
  }
@@ -1,14 +1,13 @@
1
+ import { join } from "node:path";
1
2
  import { clearParseCache } from "../files.js";
3
+ import { isClosedStatus } from "../status-guards.js";
4
+ import { isNonEmptyString } from "../validation.js";
2
5
  import { transaction, getMilestone, getMilestoneSlices, getSlice, insertSlice, updateSliceFields, insertAssessment, deleteSlice, } from "../gsd-db.js";
3
6
  import { invalidateStateCache } from "../state.js";
4
7
  import { renderRoadmapFromDb, renderAssessmentFromDb } from "../markdown-renderer.js";
5
8
  import { renderAllProjections } from "../workflow-projections.js";
6
9
  import { writeManifest } from "../workflow-manifest.js";
7
10
  import { appendEvent } from "../workflow-events.js";
8
- import { join } from "node:path";
9
- function isNonEmptyString(value) {
10
- return typeof value === "string" && value.trim().length > 0;
11
- }
12
11
  function validateParams(params) {
13
12
  if (!isNonEmptyString(params?.milestoneId))
14
13
  throw new Error("milestoneId is required");
@@ -76,7 +75,7 @@ export async function handleReassessRoadmap(rawParams, basePath) {
76
75
  guardError = `milestone not found: ${params.milestoneId}`;
77
76
  return;
78
77
  }
79
- if (milestone.status === "complete" || milestone.status === "done") {
78
+ if (isClosedStatus(milestone.status)) {
80
79
  guardError = `cannot reassess a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
81
80
  return;
82
81
  }
@@ -86,7 +85,7 @@ export async function handleReassessRoadmap(rawParams, basePath) {
86
85
  guardError = `completedSliceId not found: ${params.milestoneId}/${params.completedSliceId}`;
87
86
  return;
88
87
  }
89
- if (completedSlice.status !== "complete" && completedSlice.status !== "done") {
88
+ if (!isClosedStatus(completedSlice.status)) {
90
89
  guardError = `completedSliceId ${params.completedSliceId} is not complete (status: ${completedSlice.status}) — reassess can only be called after a slice finishes`;
91
90
  return;
92
91
  }
@@ -94,7 +93,7 @@ export async function handleReassessRoadmap(rawParams, basePath) {
94
93
  const existingSlices = getMilestoneSlices(params.milestoneId);
95
94
  const completedSliceIds = new Set();
96
95
  for (const slice of existingSlices) {
97
- if (slice.status === "complete" || slice.status === "done") {
96
+ if (isClosedStatus(slice.status)) {
98
97
  completedSliceIds.add(slice.id);
99
98
  }
100
99
  }
@@ -11,6 +11,7 @@
11
11
  // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
12
12
  import { getMilestone, getSlice, getSliceTasks, updateSliceStatus, updateTaskStatus, transaction, } from "../gsd-db.js";
13
13
  import { invalidateStateCache } from "../state.js";
14
+ import { isClosedStatus } from "../status-guards.js";
14
15
  import { renderAllProjections } from "../workflow-projections.js";
15
16
  import { writeManifest } from "../workflow-manifest.js";
16
17
  import { appendEvent } from "../workflow-events.js";
@@ -31,8 +32,8 @@ export async function handleReopenSlice(params, basePath) {
31
32
  guardError = `milestone not found: ${params.milestoneId}`;
32
33
  return;
33
34
  }
34
- if (milestone.status === "complete" || milestone.status === "done") {
35
- guardError = `cannot reopen slice inside a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
35
+ if (isClosedStatus(milestone.status)) {
36
+ guardError = `cannot reopen slice in a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
36
37
  return;
37
38
  }
38
39
  const slice = getSlice(params.milestoneId, params.sliceId);
@@ -40,7 +41,7 @@ export async function handleReopenSlice(params, basePath) {
40
41
  guardError = `slice not found: ${params.milestoneId}/${params.sliceId}`;
41
42
  return;
42
43
  }
43
- if (slice.status !== "complete" && slice.status !== "done") {
44
+ if (!isClosedStatus(slice.status)) {
44
45
  guardError = `slice ${params.sliceId} is not complete (status: ${slice.status}) — nothing to reopen`;
45
46
  return;
46
47
  }
@@ -10,6 +10,7 @@
10
10
  // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
11
11
  import { getMilestone, getSlice, getTask, updateTaskStatus, transaction, } from "../gsd-db.js";
12
12
  import { invalidateStateCache } from "../state.js";
13
+ import { isClosedStatus } from "../status-guards.js";
13
14
  import { renderAllProjections } from "../workflow-projections.js";
14
15
  import { writeManifest } from "../workflow-manifest.js";
15
16
  import { appendEvent } from "../workflow-events.js";
@@ -32,7 +33,7 @@ export async function handleReopenTask(params, basePath) {
32
33
  guardError = `milestone not found: ${params.milestoneId}`;
33
34
  return;
34
35
  }
35
- if (milestone.status === "complete" || milestone.status === "done") {
36
+ if (isClosedStatus(milestone.status)) {
36
37
  guardError = `cannot reopen task in a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
37
38
  return;
38
39
  }
@@ -41,8 +42,8 @@ export async function handleReopenTask(params, basePath) {
41
42
  guardError = `slice not found: ${params.milestoneId}/${params.sliceId}`;
42
43
  return;
43
44
  }
44
- if (slice.status === "complete" || slice.status === "done") {
45
- guardError = `cannot reopen task inside a closed slice: ${params.sliceId} (status: ${slice.status}) — use gsd_slice_reopen first`;
45
+ if (isClosedStatus(slice.status)) {
46
+ guardError = `cannot reopen task in a closed slice: ${params.sliceId} (status: ${slice.status}) — use gsd_slice_reopen first`;
46
47
  return;
47
48
  }
48
49
  const task = getTask(params.milestoneId, params.sliceId, params.taskId);
@@ -50,7 +51,7 @@ export async function handleReopenTask(params, basePath) {
50
51
  guardError = `task not found: ${params.milestoneId}/${params.sliceId}/${params.taskId}`;
51
52
  return;
52
53
  }
53
- if (task.status !== "complete" && task.status !== "done") {
54
+ if (!isClosedStatus(task.status)) {
54
55
  guardError = `task ${params.taskId} is not complete (status: ${task.status}) — nothing to reopen`;
55
56
  return;
56
57
  }
@@ -1,13 +1,12 @@
1
1
  import { clearParseCache } from "../files.js";
2
2
  import { transaction, getSlice, getSliceTasks, getTask, insertTask, upsertTaskPlanning, insertReplanHistory, deleteTask, } from "../gsd-db.js";
3
3
  import { invalidateStateCache } from "../state.js";
4
+ import { isClosedStatus } from "../status-guards.js";
5
+ import { isNonEmptyString } from "../validation.js";
4
6
  import { renderPlanFromDb, renderReplanFromDb } from "../markdown-renderer.js";
5
7
  import { renderAllProjections } from "../workflow-projections.js";
6
8
  import { writeManifest } from "../workflow-manifest.js";
7
9
  import { appendEvent } from "../workflow-events.js";
8
- function isNonEmptyString(value) {
9
- return typeof value === "string" && value.trim().length > 0;
10
- }
11
10
  function validateParams(params) {
12
11
  if (!isNonEmptyString(params?.milestoneId))
13
12
  throw new Error("milestoneId is required");
@@ -59,7 +58,7 @@ export async function handleReplanSlice(rawParams, basePath) {
59
58
  guardError = `missing parent slice: ${params.milestoneId}/${params.sliceId}`;
60
59
  return;
61
60
  }
62
- if (parentSlice.status === "complete" || parentSlice.status === "done") {
61
+ if (isClosedStatus(parentSlice.status)) {
63
62
  guardError = `cannot replan a closed slice: ${params.sliceId} (status: ${parentSlice.status})`;
64
63
  return;
65
64
  }
@@ -69,7 +68,7 @@ export async function handleReplanSlice(rawParams, basePath) {
69
68
  guardError = `blockerTaskId not found: ${params.milestoneId}/${params.sliceId}/${params.blockerTaskId}`;
70
69
  return;
71
70
  }
72
- if (blockerTask.status !== "complete" && blockerTask.status !== "done") {
71
+ if (!isClosedStatus(blockerTask.status)) {
73
72
  guardError = `blockerTaskId ${params.blockerTaskId} is not complete (status: ${blockerTask.status}) — the blocker task must be finished before a replan is triggered`;
74
73
  return;
75
74
  }
@@ -77,7 +76,7 @@ export async function handleReplanSlice(rawParams, basePath) {
77
76
  const existingTasks = getSliceTasks(params.milestoneId, params.sliceId);
78
77
  const completedTaskIds = new Set();
79
78
  for (const task of existingTasks) {
80
- if (task.status === "complete" || task.status === "done") {
79
+ if (isClosedStatus(task.status)) {
81
80
  completedTaskIds.add(task.id);
82
81
  }
83
82
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared input-validation primitives for GSD tool handlers.
3
+ */
4
+ /** Type guard: value is a string with at least one non-whitespace character. */
5
+ export function isNonEmptyString(value) {
6
+ return typeof value === "string" && value.trim().length > 0;
7
+ }
8
+ /**
9
+ * Validate that `value` is an array of non-empty strings.
10
+ * Throws with a message referencing `field` on failure.
11
+ * Returns the validated array (narrowed to string[]).
12
+ */
13
+ export function validateStringArray(value, field) {
14
+ if (!Array.isArray(value)) {
15
+ throw new Error(`${field} must be an array`);
16
+ }
17
+ if (value.some((item) => !isNonEmptyString(item))) {
18
+ throw new Error(`${field} must contain only non-empty strings`);
19
+ }
20
+ return value;
21
+ }
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { delimiter, join } from "node:path";
5
5
  const GSD_RTK_PATH_ENV = "GSD_RTK_PATH";
6
6
  const GSD_RTK_DISABLED_ENV = "GSD_RTK_DISABLED";
7
+ const GSD_RTK_REWRITE_TIMEOUT_MS_ENV = "GSD_RTK_REWRITE_TIMEOUT_MS";
7
8
  const RTK_TELEMETRY_DISABLED_ENV = "RTK_TELEMETRY_DISABLED";
8
9
  const RTK_REWRITE_TIMEOUT_MS = 5_000;
9
10
  function isTruthy(value) {
@@ -12,6 +13,13 @@ function isTruthy(value) {
12
13
  const normalized = value.trim().toLowerCase();
13
14
  return normalized === "1" || normalized === "true" || normalized === "yes";
14
15
  }
16
+ function getRewriteTimeoutMs(env = process.env) {
17
+ const configured = Number.parseInt(env[GSD_RTK_REWRITE_TIMEOUT_MS_ENV] ?? "", 10);
18
+ if (Number.isFinite(configured) && configured > 0) {
19
+ return configured;
20
+ }
21
+ return RTK_REWRITE_TIMEOUT_MS;
22
+ }
15
23
  export function isRtkEnabled(env = process.env) {
16
24
  return !isTruthy(env[GSD_RTK_DISABLED_ENV]);
17
25
  }
@@ -75,19 +83,21 @@ export function resolveRtkBinaryPath(options = {}) {
75
83
  }
76
84
  return resolveSystemRtkPath(options.pathValue ?? getPathValue(env), platform);
77
85
  }
78
- export function rewriteCommandWithRtk(command, env = process.env) {
86
+ export function rewriteCommandWithRtk(command, options = {}) {
87
+ const env = options.env ?? process.env;
79
88
  if (!command.trim())
80
89
  return command;
81
90
  if (!isRtkEnabled(env))
82
91
  return command;
83
- const binaryPath = resolveRtkBinaryPath({ env });
92
+ const binaryPath = options.binaryPath ?? resolveRtkBinaryPath({ env });
84
93
  if (!binaryPath)
85
94
  return command;
86
- const result = spawnSync(binaryPath, ["rewrite", command], {
95
+ const run = options.spawnSyncImpl ?? spawnSync;
96
+ const result = run(binaryPath, ["rewrite", command], {
87
97
  encoding: "utf-8",
88
98
  env: buildRtkEnv(env),
89
99
  stdio: ["ignore", "pipe", "ignore"],
90
- timeout: RTK_REWRITE_TIMEOUT_MS,
100
+ timeout: getRewriteTimeoutMs(env),
91
101
  // .cmd/.bat wrappers (used by fake-rtk in tests) require shell:true on Windows
92
102
  shell: /\.(cmd|bat)$/i.test(binaryPath),
93
103
  });
package/dist/rtk.js CHANGED
@@ -175,7 +175,9 @@ function resolveSystemRtkPath(pathValue, platform = process.platform) {
175
175
  export function resolveRtkBinaryPath(options = {}) {
176
176
  const env = options.env ?? process.env;
177
177
  const platform = options.platform ?? process.platform;
178
- const explicitPath = options.binaryPath ?? env[GSD_RTK_PATH_ENV];
178
+ if (options.binaryPath)
179
+ return options.binaryPath;
180
+ const explicitPath = env[GSD_RTK_PATH_ENV];
179
181
  if (explicitPath && existsSync(explicitPath)) {
180
182
  return explicitPath;
181
183
  }
@@ -1 +1 @@
1
- vlgS2rkXjxeKhgXhdp4lh
1
+ Q5pfrfJIvgUKR3LJLVB0T