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
@@ -110,6 +110,16 @@ test("isValidationTerminal returns true for verdict: passed (#1429)", () => {
110
110
  assert.equal(isValidationTerminal(content), true);
111
111
  });
112
112
 
113
+ test("isValidationTerminal returns true for verdict: fail (#2769)", () => {
114
+ const content = "---\nverdict: fail\nremediation_round: 1\n---\n\n# Validation";
115
+ assert.equal(isValidationTerminal(content), true);
116
+ });
117
+
118
+ test("isValidationTerminal returns true for any arbitrary verdict string (#2769)", () => {
119
+ const content = "---\nverdict: custom-verdict\nremediation_round: 0\n---\n\n# Validation";
120
+ assert.equal(isValidationTerminal(content), true);
121
+ });
122
+
113
123
  test("isValidationTerminal returns false for missing frontmatter", () => {
114
124
  const content = "# Validation\nNo frontmatter here.";
115
125
  assert.equal(isValidationTerminal(content), false);
@@ -327,14 +337,14 @@ test("verifyExpectedArtifact rejects VALIDATION with missing verdict field", ()
327
337
  }
328
338
  });
329
339
 
330
- test("verifyExpectedArtifact rejects VALIDATION with unrecognized verdict", () => {
340
+ test("verifyExpectedArtifact accepts VALIDATION with any extracted verdict", () => {
331
341
  const base = makeTmpBase();
332
342
  try {
333
343
  writeValidation(base, "M001", "---\nverdict: unknown-value\nremediation_round: 0\n---\n\n# Validation");
334
344
  clearPathCache();
335
345
  clearParseCache();
336
346
  const result = verifyExpectedArtifact("validate-milestone", "M001", base);
337
- assert.equal(result, false, "VALIDATION with unrecognized verdict should fail verification");
347
+ assert.equal(result, true, "VALIDATION with any extracted verdict should pass verification");
338
348
  } finally {
339
349
  cleanup(base);
340
350
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Unit tests for the milestone completion validation gate pattern matching.
3
+ *
4
+ * The gate in auto-dispatch accepts two evidence formats:
5
+ * 1. Structured template: content contains "Operational" AND ("MET" or "N/A")
6
+ * 2. Prose evidence: matches /[Oo]perational[\s:][^\n]*(?:pass|verified|...)/i
7
+ *
8
+ * These tests exercise the exact same expressions used in auto-dispatch.ts
9
+ * to ensure both formats are correctly recognized, and that content without
10
+ * operational evidence is properly rejected.
11
+ */
12
+
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+
16
+ // ─── Replicate the gate matching logic from auto-dispatch.ts ─────────────────
17
+
18
+ /**
19
+ * Returns true when validation content contains acceptable operational
20
+ * verification evidence (structured or prose). Mirrors the inline checks
21
+ * in the "execute → complete-milestone" dispatch rule.
22
+ */
23
+ function hasOperationalEvidence(validationContent: string): boolean {
24
+ const structuredMatch =
25
+ validationContent.includes("Operational") &&
26
+ (validationContent.includes("MET") || validationContent.includes("N/A"));
27
+ const proseMatch =
28
+ /[Oo]perational[\s:][^\n]*(?:pass|verified|confirmed|met|complete|true|yes|addressed|covered|n\/a|not\s+applicable)/i.test(
29
+ validationContent,
30
+ );
31
+ return structuredMatch || proseMatch;
32
+ }
33
+
34
+ // ─── Structured format ───────────────────────────────────────────────────────
35
+
36
+ test("structured: Operational + MET passes", () => {
37
+ const content = `| Criteria | Status |
38
+ | Operational | MET |
39
+ | Functional | MET |`;
40
+ assert.ok(hasOperationalEvidence(content));
41
+ });
42
+
43
+ test("structured: Operational + N/A passes", () => {
44
+ const content = `| Criteria | Status |
45
+ | Operational | N/A |
46
+ | Functional | MET |`;
47
+ assert.ok(hasOperationalEvidence(content));
48
+ });
49
+
50
+ test("structured: Operational present with MET on another row still passes (includes is content-wide)", () => {
51
+ // The structured check uses .includes() across the entire content,
52
+ // so "MET" on the Functional row satisfies the condition alongside
53
+ // "Operational" anywhere in the document.
54
+ const content = `| Criteria | Status |
55
+ | Operational | PENDING |
56
+ | Functional | MET |`;
57
+ assert.ok(hasOperationalEvidence(content));
58
+ });
59
+
60
+ test("structured: Operational alone without any MET or N/A anywhere fails", () => {
61
+ const content = `| Criteria | Status |
62
+ | Operational | PENDING |
63
+ | Functional | PENDING |`;
64
+ assert.ok(!hasOperationalEvidence(content));
65
+ });
66
+
67
+ // ─── Prose format ────────────────────────────────────────────────────────────
68
+
69
+ test('prose: "Operational: verified" passes', () => {
70
+ const content = `## Validation Report
71
+ Operational: verified — all endpoints responsive.
72
+ Functional: tests pass.`;
73
+ assert.ok(hasOperationalEvidence(content));
74
+ });
75
+
76
+ test('prose: "Operational checks confirmed" passes', () => {
77
+ const content = `## Validation Report
78
+ Operational checks confirmed by smoke test suite.`;
79
+ assert.ok(hasOperationalEvidence(content));
80
+ });
81
+
82
+ test('prose: "Operational — pass" passes', () => {
83
+ const content = `Operational — pass (all services healthy)`;
84
+ assert.ok(hasOperationalEvidence(content));
85
+ });
86
+
87
+ test('prose: "operational: addressed" passes (case-insensitive)', () => {
88
+ const content = `operational: addressed in CI pipeline run #42.`;
89
+ assert.ok(hasOperationalEvidence(content));
90
+ });
91
+
92
+ test('prose: "Operational: not applicable" passes', () => {
93
+ const content = `Operational: not applicable for this library-only change.`;
94
+ assert.ok(hasOperationalEvidence(content));
95
+ });
96
+
97
+ test('prose: "Operational: n/a" passes', () => {
98
+ const content = `Operational: n/a — no runtime components.`;
99
+ assert.ok(hasOperationalEvidence(content));
100
+ });
101
+
102
+ test('prose: "Operational: complete" passes', () => {
103
+ const content = `Operational: complete — all health checks green.`;
104
+ assert.ok(hasOperationalEvidence(content));
105
+ });
106
+
107
+ // ─── Rejection cases ─────────────────────────────────────────────────────────
108
+
109
+ test("no operational evidence: unrelated content fails", () => {
110
+ const content = `## Validation Report
111
+ All functional tests pass.
112
+ Code coverage at 92%.`;
113
+ assert.ok(!hasOperationalEvidence(content));
114
+ });
115
+
116
+ test("no operational evidence: word 'operational' buried without qualifying keyword fails", () => {
117
+ const content = `## Validation Report
118
+ The operational aspects were not evaluated in this round.`;
119
+ assert.ok(!hasOperationalEvidence(content));
120
+ });
121
+
122
+ test("no operational evidence: empty content fails", () => {
123
+ assert.ok(!hasOperationalEvidence(""));
124
+ });
@@ -0,0 +1,72 @@
1
+ // GSD — validation unit tests
2
+
3
+ import test from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+
6
+ import { isNonEmptyString, validateStringArray } from '../validation.ts';
7
+
8
+ // ─── isNonEmptyString ────────────────────────────────────────────────────────
9
+
10
+ test('isNonEmptyString: "hello" returns true', () => {
11
+ assert.equal(isNonEmptyString('hello'), true);
12
+ });
13
+
14
+ test('isNonEmptyString: " " (whitespace only) returns false', () => {
15
+ assert.equal(isNonEmptyString(' '), false);
16
+ });
17
+
18
+ test('isNonEmptyString: "" (empty string) returns false', () => {
19
+ assert.equal(isNonEmptyString(''), false);
20
+ });
21
+
22
+ test('isNonEmptyString: null returns false', () => {
23
+ assert.equal(isNonEmptyString(null), false);
24
+ });
25
+
26
+ test('isNonEmptyString: undefined returns false', () => {
27
+ assert.equal(isNonEmptyString(undefined), false);
28
+ });
29
+
30
+ test('isNonEmptyString: 42 (number) returns false', () => {
31
+ assert.equal(isNonEmptyString(42), false);
32
+ });
33
+
34
+ // ─── validateStringArray ─────────────────────────────────────────────────────
35
+
36
+ test('validateStringArray: ["a", "b"] returns ["a", "b"]', () => {
37
+ assert.deepEqual(validateStringArray(['a', 'b'], 'items'), ['a', 'b']);
38
+ });
39
+
40
+ test('validateStringArray: [] (empty array) returns []', () => {
41
+ assert.deepEqual(validateStringArray([], 'items'), []);
42
+ });
43
+
44
+ test('validateStringArray: "not an array" throws with "must be an array"', () => {
45
+ assert.throws(
46
+ () => validateStringArray('not an array', 'items'),
47
+ (err: Error) => {
48
+ assert.ok(err.message.includes('must be an array'));
49
+ return true;
50
+ },
51
+ );
52
+ });
53
+
54
+ test('validateStringArray: ["a", 42] throws with "must contain only non-empty strings"', () => {
55
+ assert.throws(
56
+ () => validateStringArray(['a', 42], 'items'),
57
+ (err: Error) => {
58
+ assert.ok(err.message.includes('must contain only non-empty strings'));
59
+ return true;
60
+ },
61
+ );
62
+ });
63
+
64
+ test('validateStringArray: ["a", ""] throws with "must contain only non-empty strings"', () => {
65
+ assert.throws(
66
+ () => validateStringArray(['a', ''], 'items'),
67
+ (err: Error) => {
68
+ assert.ok(err.message.includes('must contain only non-empty strings'));
69
+ return true;
70
+ },
71
+ );
72
+ });
@@ -17,6 +17,7 @@ import {
17
17
  updateMilestoneStatus,
18
18
  } from "../gsd-db.js";
19
19
  import { resolveMilestonePath, clearPathCache } from "../paths.js";
20
+ import { isClosedStatus } from "../status-guards.js";
20
21
  import { saveFile, clearParseCache } from "../files.js";
21
22
  import { invalidateStateCache } from "../state.js";
22
23
  import { renderAllProjections } from "../workflow-projections.js";
@@ -134,7 +135,7 @@ export async function handleCompleteMilestone(
134
135
  guardError = `milestone not found: ${params.milestoneId}`;
135
136
  return;
136
137
  }
137
- if (milestone.status === "complete" || milestone.status === "done") {
138
+ if (isClosedStatus(milestone.status)) {
138
139
  guardError = `milestone ${params.milestoneId} is already complete`;
139
140
  return;
140
141
  }
@@ -146,7 +147,7 @@ export async function handleCompleteMilestone(
146
147
  return;
147
148
  }
148
149
 
149
- const incompleteSlices = slices.filter(s => s.status !== "complete" && s.status !== "done");
150
+ const incompleteSlices = slices.filter(s => !isClosedStatus(s.status));
150
151
  if (incompleteSlices.length > 0) {
151
152
  const incompleteIds = incompleteSlices.map(s => `${s.id} (status: ${s.status})`).join(", ");
152
153
  guardError = `incomplete slices: ${incompleteIds}`;
@@ -156,7 +157,7 @@ export async function handleCompleteMilestone(
156
157
  // Deep check: verify all tasks in all slices are complete
157
158
  for (const slice of slices) {
158
159
  const tasks = getSliceTasks(params.milestoneId, slice.id);
159
- const incompleteTasks = tasks.filter(t => t.status !== "complete" && t.status !== "done");
160
+ const incompleteTasks = tasks.filter(t => !isClosedStatus(t.status));
160
161
  if (incompleteTasks.length > 0) {
161
162
  const ids = incompleteTasks.map(t => `${t.id} (status: ${t.status})`).join(", ");
162
163
  guardError = `slice ${slice.id} has incomplete tasks: ${ids}`;
@@ -11,6 +11,7 @@ import { join } from "node:path";
11
11
  import { mkdirSync } from "node:fs";
12
12
 
13
13
  import type { CompleteSliceParams } from "../types.js";
14
+ import { isClosedStatus } from "../status-guards.js";
14
15
  import {
15
16
  transaction,
16
17
  insertMilestone,
@@ -225,13 +226,13 @@ export async function handleCompleteSlice(
225
226
  // Milestone/slice not existing is OK — insertMilestone/insertSlice below will auto-create.
226
227
  // Only block if they exist and are closed.
227
228
  const milestone = getMilestone(params.milestoneId);
228
- if (milestone && (milestone.status === "complete" || milestone.status === "done")) {
229
+ if (milestone && isClosedStatus(milestone.status)) {
229
230
  guardError = `cannot complete slice in a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
230
231
  return;
231
232
  }
232
233
 
233
234
  const slice = getSlice(params.milestoneId, params.sliceId);
234
- if (slice && (slice.status === "complete" || slice.status === "done")) {
235
+ if (slice && isClosedStatus(slice.status)) {
235
236
  guardError = `slice ${params.sliceId} is already complete — use gsd_slice_reopen first if you need to redo it`;
236
237
  return;
237
238
  }
@@ -243,7 +244,7 @@ export async function handleCompleteSlice(
243
244
  return;
244
245
  }
245
246
 
246
- const incompleteTasks = tasks.filter(t => t.status !== "complete" && t.status !== "done");
247
+ const incompleteTasks = tasks.filter(t => !isClosedStatus(t.status));
247
248
  if (incompleteTasks.length > 0) {
248
249
  const incompleteIds = incompleteTasks.map(t => `${t.id} (status: ${t.status})`).join(", ");
249
250
  guardError = `incomplete tasks: ${incompleteIds}`;
@@ -11,6 +11,7 @@ import { join } from "node:path";
11
11
  import { mkdirSync, existsSync } from "node:fs";
12
12
 
13
13
  import type { CompleteTaskParams } from "../types.js";
14
+ import { isClosedStatus } from "../status-guards.js";
14
15
  import {
15
16
  transaction,
16
17
  insertMilestone,
@@ -159,19 +160,19 @@ export async function handleCompleteTask(
159
160
  // Milestone/slice not existing is OK — insertMilestone/insertSlice below will auto-create.
160
161
  // Only block if they exist and are closed.
161
162
  const milestone = getMilestone(params.milestoneId);
162
- if (milestone && (milestone.status === "complete" || milestone.status === "done")) {
163
+ if (milestone && isClosedStatus(milestone.status)) {
163
164
  guardError = `cannot complete task in a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
164
165
  return;
165
166
  }
166
167
 
167
168
  const slice = getSlice(params.milestoneId, params.sliceId);
168
- if (slice && (slice.status === "complete" || slice.status === "done")) {
169
+ if (slice && isClosedStatus(slice.status)) {
169
170
  guardError = `cannot complete task in a closed slice: ${params.sliceId} (status: ${slice.status})`;
170
171
  return;
171
172
  }
172
173
 
173
174
  const existingTask = getTask(params.milestoneId, params.sliceId, params.taskId);
174
- if (existingTask && (existingTask.status === "complete" || existingTask.status === "done")) {
175
+ if (existingTask && isClosedStatus(existingTask.status)) {
175
176
  guardError = `task ${params.taskId} is already complete — use gsd_task_reopen first if you need to redo it`;
176
177
  return;
177
178
  }
@@ -1,4 +1,6 @@
1
1
  import { clearParseCache } from "../files.js";
2
+ import { isClosedStatus } from "../status-guards.js";
3
+ import { isNonEmptyString, validateStringArray } from "../validation.js";
2
4
  import {
3
5
  transaction,
4
6
  getMilestone,
@@ -54,20 +56,6 @@ export interface PlanMilestoneResult {
54
56
  roadmapPath: string;
55
57
  }
56
58
 
57
- function isNonEmptyString(value: unknown): value is string {
58
- return typeof value === "string" && value.trim().length > 0;
59
- }
60
-
61
- function validateStringArray(value: unknown, field: string): string[] {
62
- if (!Array.isArray(value)) {
63
- throw new Error(`${field} must be an array`);
64
- }
65
- if (value.some((item) => !isNonEmptyString(item))) {
66
- throw new Error(`${field} must contain only non-empty strings`);
67
- }
68
- return value;
69
- }
70
-
71
59
  function validateRiskEntries(value: unknown): Array<{ risk: string; whyItMatters: string }> {
72
60
  if (!Array.isArray(value)) {
73
61
  throw new Error("keyRisks must be an array");
@@ -196,7 +184,7 @@ export async function handlePlanMilestone(
196
184
  try {
197
185
  transaction(() => {
198
186
  const existingMilestone = getMilestone(params.milestoneId);
199
- if (existingMilestone && (existingMilestone.status === "complete" || existingMilestone.status === "done")) {
187
+ if (existingMilestone && isClosedStatus(existingMilestone.status)) {
200
188
  guardError = `cannot re-plan milestone ${params.milestoneId}: it is already complete`;
201
189
  return;
202
190
  }
@@ -209,7 +197,7 @@ export async function handlePlanMilestone(
209
197
  guardError = `depends_on references unknown milestone: ${depId}`;
210
198
  return;
211
199
  }
212
- if (dep.status !== "complete" && dep.status !== "done") {
200
+ if (!isClosedStatus(dep.status)) {
213
201
  guardError = `depends_on milestone ${depId} is not yet complete (status: ${dep.status})`;
214
202
  return;
215
203
  }
@@ -1,4 +1,6 @@
1
1
  import { clearParseCache } from "../files.js";
2
+ import { isClosedStatus } from "../status-guards.js";
3
+ import { isNonEmptyString, validateStringArray } from "../validation.js";
2
4
  import {
3
5
  transaction,
4
6
  getMilestone,
@@ -50,20 +52,6 @@ export interface PlanSliceResult {
50
52
  taskPlanPaths: string[];
51
53
  }
52
54
 
53
- function isNonEmptyString(value: unknown): value is string {
54
- return typeof value === "string" && value.trim().length > 0;
55
- }
56
-
57
- function validateStringArray(value: unknown, field: string): string[] {
58
- if (!Array.isArray(value)) {
59
- throw new Error(`${field} must be an array`);
60
- }
61
- if (value.some((item) => !isNonEmptyString(item))) {
62
- throw new Error(`${field} must contain only non-empty strings`);
63
- }
64
- return value;
65
- }
66
-
67
55
  function validateTasks(value: unknown): PlanSliceTaskInput[] {
68
56
  if (!Array.isArray(value) || value.length === 0) {
69
57
  throw new Error("tasks must be a non-empty array");
@@ -157,7 +145,7 @@ export async function handlePlanSlice(
157
145
  guardError = `milestone not found: ${params.milestoneId}`;
158
146
  return;
159
147
  }
160
- if (parentMilestone.status === "complete" || parentMilestone.status === "done") {
148
+ if (isClosedStatus(parentMilestone.status)) {
161
149
  guardError = `cannot plan slice in a closed milestone: ${params.milestoneId} (status: ${parentMilestone.status})`;
162
150
  return;
163
151
  }
@@ -167,7 +155,7 @@ export async function handlePlanSlice(
167
155
  guardError = `missing parent slice: ${params.milestoneId}/${params.sliceId}`;
168
156
  return;
169
157
  }
170
- if (parentSlice.status === "complete" || parentSlice.status === "done") {
158
+ if (isClosedStatus(parentSlice.status)) {
171
159
  guardError = `cannot re-plan slice ${params.sliceId}: it is already complete — use gsd_slice_reopen first`;
172
160
  return;
173
161
  }
@@ -1,4 +1,6 @@
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";
@@ -32,20 +34,6 @@ export interface PlanTaskResult {
32
34
  taskPlanPath: string;
33
35
  }
34
36
 
35
- function isNonEmptyString(value: unknown): value is string {
36
- return typeof value === "string" && value.trim().length > 0;
37
- }
38
-
39
- function validateStringArray(value: unknown, field: string): string[] {
40
- if (!Array.isArray(value)) {
41
- throw new Error(`${field} must be an array`);
42
- }
43
- if (value.some((item) => !isNonEmptyString(item))) {
44
- throw new Error(`${field} must contain only non-empty strings`);
45
- }
46
- return value;
47
- }
48
-
49
37
  function validateParams(params: PlanTaskParams): PlanTaskParams {
50
38
  if (!isNonEmptyString(params?.milestoneId)) throw new Error("milestoneId is required");
51
39
  if (!isNonEmptyString(params?.sliceId)) throw new Error("sliceId is required");
@@ -89,13 +77,13 @@ export async function handlePlanTask(
89
77
  guardError = `missing parent slice: ${params.milestoneId}/${params.sliceId}`;
90
78
  return;
91
79
  }
92
- if (parentSlice.status === "complete" || parentSlice.status === "done") {
80
+ if (isClosedStatus(parentSlice.status)) {
93
81
  guardError = `cannot plan task in a closed slice: ${params.sliceId} (status: ${parentSlice.status})`;
94
82
  return;
95
83
  }
96
84
 
97
85
  const existingTask = getTask(params.milestoneId, params.sliceId, params.taskId);
98
- if (existingTask && (existingTask.status === "complete" || existingTask.status === "done")) {
86
+ if (existingTask && isClosedStatus(existingTask.status)) {
99
87
  guardError = `cannot re-plan task ${params.taskId}: it is already complete — use gsd_task_reopen first`;
100
88
  return;
101
89
  }
@@ -1,4 +1,7 @@
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 {
3
6
  transaction,
4
7
  getMilestone,
@@ -14,7 +17,6 @@ import { renderRoadmapFromDb, renderAssessmentFromDb } from "../markdown-rendere
14
17
  import { renderAllProjections } from "../workflow-projections.js";
15
18
  import { writeManifest } from "../workflow-manifest.js";
16
19
  import { appendEvent } from "../workflow-events.js";
17
- import { join } from "node:path";
18
20
 
19
21
  export interface SliceChangeInput {
20
22
  sliceId: string;
@@ -47,9 +49,6 @@ export interface ReassessRoadmapResult {
47
49
  roadmapPath: string;
48
50
  }
49
51
 
50
- function isNonEmptyString(value: unknown): value is string {
51
- return typeof value === "string" && value.trim().length > 0;
52
- }
53
52
 
54
53
  function validateParams(params: ReassessRoadmapParams): ReassessRoadmapParams {
55
54
  if (!isNonEmptyString(params?.milestoneId)) throw new Error("milestoneId is required");
@@ -125,7 +124,7 @@ export async function handleReassessRoadmap(
125
124
  guardError = `milestone not found: ${params.milestoneId}`;
126
125
  return;
127
126
  }
128
- if (milestone.status === "complete" || milestone.status === "done") {
127
+ if (isClosedStatus(milestone.status)) {
129
128
  guardError = `cannot reassess a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
130
129
  return;
131
130
  }
@@ -136,7 +135,7 @@ export async function handleReassessRoadmap(
136
135
  guardError = `completedSliceId not found: ${params.milestoneId}/${params.completedSliceId}`;
137
136
  return;
138
137
  }
139
- if (completedSlice.status !== "complete" && completedSlice.status !== "done") {
138
+ if (!isClosedStatus(completedSlice.status)) {
140
139
  guardError = `completedSliceId ${params.completedSliceId} is not complete (status: ${completedSlice.status}) — reassess can only be called after a slice finishes`;
141
140
  return;
142
141
  }
@@ -145,7 +144,7 @@ export async function handleReassessRoadmap(
145
144
  const existingSlices = getMilestoneSlices(params.milestoneId);
146
145
  const completedSliceIds = new Set<string>();
147
146
  for (const slice of existingSlices) {
148
- if (slice.status === "complete" || slice.status === "done") {
147
+ if (isClosedStatus(slice.status)) {
149
148
  completedSliceIds.add(slice.id);
150
149
  }
151
150
  }
@@ -20,6 +20,7 @@ import {
20
20
  transaction,
21
21
  } from "../gsd-db.js";
22
22
  import { invalidateStateCache } from "../state.js";
23
+ import { isClosedStatus } from "../status-guards.js";
23
24
  import { renderAllProjections } from "../workflow-projections.js";
24
25
  import { writeManifest } from "../workflow-manifest.js";
25
26
  import { appendEvent } from "../workflow-events.js";
@@ -62,8 +63,8 @@ export async function handleReopenSlice(
62
63
  guardError = `milestone not found: ${params.milestoneId}`;
63
64
  return;
64
65
  }
65
- if (milestone.status === "complete" || milestone.status === "done") {
66
- guardError = `cannot reopen slice inside a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
66
+ if (isClosedStatus(milestone.status)) {
67
+ guardError = `cannot reopen slice in a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
67
68
  return;
68
69
  }
69
70
 
@@ -72,7 +73,7 @@ export async function handleReopenSlice(
72
73
  guardError = `slice not found: ${params.milestoneId}/${params.sliceId}`;
73
74
  return;
74
75
  }
75
- if (slice.status !== "complete" && slice.status !== "done") {
76
+ if (!isClosedStatus(slice.status)) {
76
77
  guardError = `slice ${params.sliceId} is not complete (status: ${slice.status}) — nothing to reopen`;
77
78
  return;
78
79
  }
@@ -18,6 +18,7 @@ import {
18
18
  transaction,
19
19
  } from "../gsd-db.js";
20
20
  import { invalidateStateCache } from "../state.js";
21
+ import { isClosedStatus } from "../status-guards.js";
21
22
  import { renderAllProjections } from "../workflow-projections.js";
22
23
  import { writeManifest } from "../workflow-manifest.js";
23
24
  import { appendEvent } from "../workflow-events.js";
@@ -63,7 +64,7 @@ export async function handleReopenTask(
63
64
  guardError = `milestone not found: ${params.milestoneId}`;
64
65
  return;
65
66
  }
66
- if (milestone.status === "complete" || milestone.status === "done") {
67
+ if (isClosedStatus(milestone.status)) {
67
68
  guardError = `cannot reopen task in a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
68
69
  return;
69
70
  }
@@ -73,8 +74,8 @@ export async function handleReopenTask(
73
74
  guardError = `slice not found: ${params.milestoneId}/${params.sliceId}`;
74
75
  return;
75
76
  }
76
- if (slice.status === "complete" || slice.status === "done") {
77
- guardError = `cannot reopen task inside a closed slice: ${params.sliceId} (status: ${slice.status}) — use gsd_slice_reopen first`;
77
+ if (isClosedStatus(slice.status)) {
78
+ guardError = `cannot reopen task in a closed slice: ${params.sliceId} (status: ${slice.status}) — use gsd_slice_reopen first`;
78
79
  return;
79
80
  }
80
81
 
@@ -83,7 +84,7 @@ export async function handleReopenTask(
83
84
  guardError = `task not found: ${params.milestoneId}/${params.sliceId}/${params.taskId}`;
84
85
  return;
85
86
  }
86
- if (task.status !== "complete" && task.status !== "done") {
87
+ if (!isClosedStatus(task.status)) {
87
88
  guardError = `task ${params.taskId} is not complete (status: ${task.status}) — nothing to reopen`;
88
89
  return;
89
90
  }
@@ -10,6 +10,8 @@ import {
10
10
  deleteTask,
11
11
  } from "../gsd-db.js";
12
12
  import { invalidateStateCache } from "../state.js";
13
+ import { isClosedStatus } from "../status-guards.js";
14
+ import { isNonEmptyString } from "../validation.js";
13
15
  import { renderPlanFromDb, renderReplanFromDb } from "../markdown-renderer.js";
14
16
  import { renderAllProjections } from "../workflow-projections.js";
15
17
  import { writeManifest } from "../workflow-manifest.js";
@@ -48,10 +50,6 @@ export interface ReplanSliceResult {
48
50
  planPath: string;
49
51
  }
50
52
 
51
- function isNonEmptyString(value: unknown): value is string {
52
- return typeof value === "string" && value.trim().length > 0;
53
- }
54
-
55
53
  function validateParams(params: ReplanSliceParams): ReplanSliceParams {
56
54
  if (!isNonEmptyString(params?.milestoneId)) throw new Error("milestoneId is required");
57
55
  if (!isNonEmptyString(params?.sliceId)) throw new Error("sliceId is required");
@@ -104,7 +102,7 @@ export async function handleReplanSlice(
104
102
  guardError = `missing parent slice: ${params.milestoneId}/${params.sliceId}`;
105
103
  return;
106
104
  }
107
- if (parentSlice.status === "complete" || parentSlice.status === "done") {
105
+ if (isClosedStatus(parentSlice.status)) {
108
106
  guardError = `cannot replan a closed slice: ${params.sliceId} (status: ${parentSlice.status})`;
109
107
  return;
110
108
  }
@@ -115,7 +113,7 @@ export async function handleReplanSlice(
115
113
  guardError = `blockerTaskId not found: ${params.milestoneId}/${params.sliceId}/${params.blockerTaskId}`;
116
114
  return;
117
115
  }
118
- if (blockerTask.status !== "complete" && blockerTask.status !== "done") {
116
+ if (!isClosedStatus(blockerTask.status)) {
119
117
  guardError = `blockerTaskId ${params.blockerTaskId} is not complete (status: ${blockerTask.status}) — the blocker task must be finished before a replan is triggered`;
120
118
  return;
121
119
  }
@@ -124,7 +122,7 @@ export async function handleReplanSlice(
124
122
  const existingTasks = getSliceTasks(params.milestoneId, params.sliceId);
125
123
  const completedTaskIds = new Set<string>();
126
124
  for (const task of existingTasks) {
127
- if (task.status === "complete" || task.status === "done") {
125
+ if (isClosedStatus(task.status)) {
128
126
  completedTaskIds.add(task.id);
129
127
  }
130
128
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared input-validation primitives for GSD tool handlers.
3
+ */
4
+
5
+ /** Type guard: value is a string with at least one non-whitespace character. */
6
+ export function isNonEmptyString(value: unknown): value is string {
7
+ return typeof value === "string" && value.trim().length > 0;
8
+ }
9
+
10
+ /**
11
+ * Validate that `value` is an array of non-empty strings.
12
+ * Throws with a message referencing `field` on failure.
13
+ * Returns the validated array (narrowed to string[]).
14
+ */
15
+ export function validateStringArray(value: unknown, field: string): string[] {
16
+ if (!Array.isArray(value)) {
17
+ throw new Error(`${field} must be an array`);
18
+ }
19
+ if (value.some((item) => !isNonEmptyString(item))) {
20
+ throw new Error(`${field} must contain only non-empty strings`);
21
+ }
22
+ return value;
23
+ }