gsd-pi 2.77.0-dev.c9c8932c6 → 2.77.0-dev.eaa4973bc

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 (245) hide show
  1. package/dist/cli-web-branch.d.ts +1 -0
  2. package/dist/cli-web-branch.js +3 -0
  3. package/dist/cli.js +24 -2
  4. package/dist/provider-migrations.d.ts +18 -0
  5. package/dist/provider-migrations.js +14 -0
  6. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +14 -10
  7. package/dist/resources/extensions/gsd/abandon-detect.js +44 -0
  8. package/dist/resources/extensions/gsd/auto/resolve.js +24 -0
  9. package/dist/resources/extensions/gsd/auto/run-unit.js +10 -2
  10. package/dist/resources/extensions/gsd/auto/turn-epoch.js +95 -0
  11. package/dist/resources/extensions/gsd/auto-dispatch.js +24 -0
  12. package/dist/resources/extensions/gsd/auto-loop.js +1 -1
  13. package/dist/resources/extensions/gsd/auto-post-unit.js +31 -0
  14. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +11 -5
  15. package/dist/resources/extensions/gsd/auto-unit-closeout.js +11 -2
  16. package/dist/resources/extensions/gsd/auto-worktree.js +58 -8
  17. package/dist/resources/extensions/gsd/auto.js +4 -0
  18. package/dist/resources/extensions/gsd/bootstrap/provider-error-resume.js +4 -2
  19. package/dist/resources/extensions/gsd/commands/handlers/workflow.js +31 -4
  20. package/dist/resources/extensions/gsd/file-lock.js +49 -9
  21. package/dist/resources/extensions/gsd/git-service.js +1 -0
  22. package/dist/resources/extensions/gsd/gitignore.js +1 -0
  23. package/dist/resources/extensions/gsd/guided-flow-queue.js +4 -1
  24. package/dist/resources/extensions/gsd/journal.js +17 -2
  25. package/dist/resources/extensions/gsd/milestone-actions.js +15 -0
  26. package/dist/resources/extensions/gsd/reports.js +5 -4
  27. package/dist/resources/extensions/gsd/tools/complete-slice.js +21 -0
  28. package/dist/resources/extensions/gsd/tools/complete-task.js +31 -0
  29. package/dist/resources/extensions/gsd/uok/audit.js +18 -2
  30. package/dist/resources/extensions/gsd/workflow-logger.js +10 -2
  31. package/dist/resources/extensions/gsd/worktree-manager.js +1 -0
  32. package/dist/resources/extensions/mcp-client/auth.js +10 -1
  33. package/dist/resources/extensions/mcp-client/index.js +118 -9
  34. package/dist/resources/skills/create-skill/SKILL.md +2 -2
  35. package/dist/resources/skills/create-skill/references/gsd-skill-ecosystem.md +4 -4
  36. package/dist/resources/skills/create-skill/workflows/audit-skill.md +4 -4
  37. package/dist/resources/skills/create-skill/workflows/create-new-skill.md +5 -5
  38. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  39. package/dist/web/standalone/.next/BUILD_ID +1 -1
  40. package/dist/web/standalone/.next/app-path-routes-manifest.json +13 -13
  41. package/dist/web/standalone/.next/build-manifest.json +3 -3
  42. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  43. package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  45. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  53. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
  61. package/dist/web/standalone/.next/server/app/api/boot/route.js.nft.json +1 -1
  62. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js +1 -1
  63. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js.nft.json +1 -1
  64. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js +1 -1
  65. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js.nft.json +1 -1
  66. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js +1 -1
  67. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js.nft.json +1 -1
  68. package/dist/web/standalone/.next/server/app/api/captures/route.js +1 -1
  69. package/dist/web/standalone/.next/server/app/api/captures/route.js.nft.json +1 -1
  70. package/dist/web/standalone/.next/server/app/api/cleanup/route.js +1 -1
  71. package/dist/web/standalone/.next/server/app/api/cleanup/route.js.nft.json +1 -1
  72. package/dist/web/standalone/.next/server/app/api/doctor/route.js +1 -1
  73. package/dist/web/standalone/.next/server/app/api/doctor/route.js.nft.json +1 -1
  74. package/dist/web/standalone/.next/server/app/api/export-data/route.js +1 -1
  75. package/dist/web/standalone/.next/server/app/api/export-data/route.js.nft.json +1 -1
  76. package/dist/web/standalone/.next/server/app/api/files/route.js +1 -1
  77. package/dist/web/standalone/.next/server/app/api/files/route.js.nft.json +1 -1
  78. package/dist/web/standalone/.next/server/app/api/forensics/route.js +1 -1
  79. package/dist/web/standalone/.next/server/app/api/forensics/route.js.nft.json +1 -1
  80. package/dist/web/standalone/.next/server/app/api/git/route.js +1 -1
  81. package/dist/web/standalone/.next/server/app/api/git/route.js.nft.json +1 -1
  82. package/dist/web/standalone/.next/server/app/api/history/route.js +1 -1
  83. package/dist/web/standalone/.next/server/app/api/history/route.js.nft.json +1 -1
  84. package/dist/web/standalone/.next/server/app/api/hooks/route.js +1 -1
  85. package/dist/web/standalone/.next/server/app/api/hooks/route.js.nft.json +1 -1
  86. package/dist/web/standalone/.next/server/app/api/inspect/route.js +1 -1
  87. package/dist/web/standalone/.next/server/app/api/inspect/route.js.nft.json +1 -1
  88. package/dist/web/standalone/.next/server/app/api/knowledge/route.js +1 -1
  89. package/dist/web/standalone/.next/server/app/api/knowledge/route.js.nft.json +1 -1
  90. package/dist/web/standalone/.next/server/app/api/live-state/route.js +1 -1
  91. package/dist/web/standalone/.next/server/app/api/live-state/route.js.nft.json +1 -1
  92. package/dist/web/standalone/.next/server/app/api/notifications/route.js +1 -1
  93. package/dist/web/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
  94. package/dist/web/standalone/.next/server/app/api/onboarding/route.js +1 -1
  95. package/dist/web/standalone/.next/server/app/api/onboarding/route.js.nft.json +1 -1
  96. package/dist/web/standalone/.next/server/app/api/projects/route.js +1 -1
  97. package/dist/web/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
  98. package/dist/web/standalone/.next/server/app/api/recovery/route.js +1 -1
  99. package/dist/web/standalone/.next/server/app/api/recovery/route.js.nft.json +1 -1
  100. package/dist/web/standalone/.next/server/app/api/session/browser/route.js +1 -1
  101. package/dist/web/standalone/.next/server/app/api/session/browser/route.js.nft.json +1 -1
  102. package/dist/web/standalone/.next/server/app/api/session/command/route.js +1 -1
  103. package/dist/web/standalone/.next/server/app/api/session/command/route.js.nft.json +1 -1
  104. package/dist/web/standalone/.next/server/app/api/session/events/route.js +1 -1
  105. package/dist/web/standalone/.next/server/app/api/session/events/route.js.nft.json +1 -1
  106. package/dist/web/standalone/.next/server/app/api/session/manage/route.js +1 -1
  107. package/dist/web/standalone/.next/server/app/api/session/manage/route.js.nft.json +1 -1
  108. package/dist/web/standalone/.next/server/app/api/settings-data/route.js +1 -1
  109. package/dist/web/standalone/.next/server/app/api/settings-data/route.js.nft.json +1 -1
  110. package/dist/web/standalone/.next/server/app/api/skill-health/route.js +1 -1
  111. package/dist/web/standalone/.next/server/app/api/skill-health/route.js.nft.json +1 -1
  112. package/dist/web/standalone/.next/server/app/api/steer/route.js +1 -1
  113. package/dist/web/standalone/.next/server/app/api/steer/route.js.nft.json +1 -1
  114. package/dist/web/standalone/.next/server/app/api/switch-root/route.js +1 -1
  115. package/dist/web/standalone/.next/server/app/api/switch-root/route.js.nft.json +1 -1
  116. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js +1 -1
  117. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js.nft.json +1 -1
  118. package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js +1 -1
  119. package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js.nft.json +1 -1
  120. package/dist/web/standalone/.next/server/app/api/undo/route.js +1 -1
  121. package/dist/web/standalone/.next/server/app/api/undo/route.js.nft.json +1 -1
  122. package/dist/web/standalone/.next/server/app/api/visualizer/route.js +1 -1
  123. package/dist/web/standalone/.next/server/app/api/visualizer/route.js.nft.json +1 -1
  124. package/dist/web/standalone/.next/server/app/index.html +1 -1
  125. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  126. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  127. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  128. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  129. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  130. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  131. package/dist/web/standalone/.next/server/app-paths-manifest.json +13 -13
  132. package/dist/web/standalone/.next/server/chunks/1926.js +1 -0
  133. package/dist/web/standalone/.next/server/chunks/6897.js +1 -1
  134. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  135. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  136. package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
  137. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  138. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  139. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  140. package/dist/web/standalone/.next/static/chunks/2826.e9f5195e91f9cad2.js +11 -0
  141. package/dist/web/standalone/.next/static/chunks/{webpack-1832629448831fdc.js → webpack-2e68521d7c82f7c2.js} +1 -1
  142. package/package.json +3 -3
  143. package/packages/mcp-server/README.md +3 -3
  144. package/packages/mcp-server/dist/env-writer.d.ts +1 -0
  145. package/packages/mcp-server/dist/env-writer.d.ts.map +1 -1
  146. package/packages/mcp-server/dist/env-writer.js +74 -6
  147. package/packages/mcp-server/dist/env-writer.js.map +1 -1
  148. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  149. package/packages/mcp-server/dist/server.js +25 -2
  150. package/packages/mcp-server/dist/server.js.map +1 -1
  151. package/packages/mcp-server/src/env-writer.test.ts +79 -1
  152. package/packages/mcp-server/src/env-writer.ts +76 -6
  153. package/packages/mcp-server/src/server.ts +29 -2
  154. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  155. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.d.ts +1 -0
  156. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.d.ts.map +1 -1
  157. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.js +21 -16
  158. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.js.map +1 -1
  159. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.test.d.ts +2 -0
  160. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.test.d.ts.map +1 -0
  161. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.test.js +130 -0
  162. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.test.js.map +1 -0
  163. package/packages/pi-coding-agent/dist/core/sdk.d.ts +1 -0
  164. package/packages/pi-coding-agent/dist/core/sdk.d.ts.map +1 -1
  165. package/packages/pi-coding-agent/dist/core/sdk.js +4 -1
  166. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  167. package/packages/pi-coding-agent/dist/core/sdk.test.js +19 -1
  168. package/packages/pi-coding-agent/dist/core/sdk.test.js.map +1 -1
  169. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/provider-display-name.test.js +15 -6
  170. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/provider-display-name.test.js.map +1 -1
  171. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.d.ts.map +1 -1
  172. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js +14 -5
  173. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js.map +1 -1
  174. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.d.ts +7 -1
  175. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  176. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js +31 -9
  177. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js.map +1 -1
  178. package/packages/pi-coding-agent/src/core/compaction-orchestrator.test.ts +154 -0
  179. package/packages/pi-coding-agent/src/core/compaction-orchestrator.ts +24 -17
  180. package/packages/pi-coding-agent/src/core/sdk.test.ts +25 -1
  181. package/packages/pi-coding-agent/src/core/sdk.ts +10 -3
  182. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/provider-display-name.test.ts +17 -7
  183. package/packages/pi-coding-agent/src/modes/interactive/components/footer.ts +14 -5
  184. package/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +45 -11
  185. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  186. package/packages/pi-tui/dist/__tests__/stdin-buffer.test.js +19 -0
  187. package/packages/pi-tui/dist/__tests__/stdin-buffer.test.js.map +1 -1
  188. package/packages/pi-tui/dist/stdin-buffer.d.ts +7 -0
  189. package/packages/pi-tui/dist/stdin-buffer.d.ts.map +1 -1
  190. package/packages/pi-tui/dist/stdin-buffer.js +20 -0
  191. package/packages/pi-tui/dist/stdin-buffer.js.map +1 -1
  192. package/packages/pi-tui/src/__tests__/stdin-buffer.test.ts +25 -0
  193. package/packages/pi-tui/src/stdin-buffer.ts +26 -0
  194. package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
  195. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +14 -10
  196. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +22 -16
  197. package/src/resources/extensions/gsd/abandon-detect.ts +62 -0
  198. package/src/resources/extensions/gsd/auto/resolve.ts +29 -0
  199. package/src/resources/extensions/gsd/auto/run-unit.ts +16 -2
  200. package/src/resources/extensions/gsd/auto/turn-epoch.ts +108 -0
  201. package/src/resources/extensions/gsd/auto-dispatch.ts +20 -0
  202. package/src/resources/extensions/gsd/auto-loop.ts +1 -1
  203. package/src/resources/extensions/gsd/auto-post-unit.ts +30 -0
  204. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +12 -5
  205. package/src/resources/extensions/gsd/auto-unit-closeout.ts +14 -3
  206. package/src/resources/extensions/gsd/auto-worktree.ts +60 -5
  207. package/src/resources/extensions/gsd/auto.ts +5 -0
  208. package/src/resources/extensions/gsd/bootstrap/provider-error-resume.ts +6 -2
  209. package/src/resources/extensions/gsd/commands/handlers/workflow.ts +27 -8
  210. package/src/resources/extensions/gsd/file-lock.ts +84 -11
  211. package/src/resources/extensions/gsd/git-service.ts +1 -0
  212. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  213. package/src/resources/extensions/gsd/guided-flow-queue.ts +4 -1
  214. package/src/resources/extensions/gsd/journal.ts +27 -2
  215. package/src/resources/extensions/gsd/milestone-actions.ts +18 -0
  216. package/src/resources/extensions/gsd/reports.ts +5 -4
  217. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +11 -2
  218. package/src/resources/extensions/gsd/tests/auto-mode-guards.test.ts +79 -0
  219. package/src/resources/extensions/gsd/tests/file-lock.test.ts +86 -12
  220. package/src/resources/extensions/gsd/tests/integration/auto-worktree-milestone-merge.test.ts +30 -0
  221. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +3 -2
  222. package/src/resources/extensions/gsd/tests/mcp-client-security.test.ts +76 -0
  223. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +39 -25
  224. package/src/resources/extensions/gsd/tests/queue-auto-guard.test.ts +181 -0
  225. package/src/resources/extensions/gsd/tests/quick-auto-guard.test.ts +13 -7
  226. package/src/resources/extensions/gsd/tests/require-slice-discussion-dispatch.test.ts +170 -0
  227. package/src/resources/extensions/gsd/tests/rewrite-docs-abandon-detect.test.ts +195 -0
  228. package/src/resources/extensions/gsd/tests/stuck-detection-coverage.test.ts +2 -2
  229. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +50 -2
  230. package/src/resources/extensions/gsd/tests/turn-epoch.test.ts +162 -0
  231. package/src/resources/extensions/gsd/tools/complete-slice.ts +38 -0
  232. package/src/resources/extensions/gsd/tools/complete-task.ts +49 -0
  233. package/src/resources/extensions/gsd/uok/audit.ts +20 -2
  234. package/src/resources/extensions/gsd/workflow-logger.ts +22 -3
  235. package/src/resources/extensions/gsd/worktree-manager.ts +1 -0
  236. package/src/resources/extensions/mcp-client/auth.ts +12 -1
  237. package/src/resources/extensions/mcp-client/index.ts +129 -10
  238. package/src/resources/skills/create-skill/SKILL.md +2 -2
  239. package/src/resources/skills/create-skill/references/gsd-skill-ecosystem.md +4 -4
  240. package/src/resources/skills/create-skill/workflows/audit-skill.md +4 -4
  241. package/src/resources/skills/create-skill/workflows/create-new-skill.md +5 -5
  242. package/dist/web/standalone/.next/server/chunks/7461.js +0 -1
  243. package/dist/web/standalone/.next/static/chunks/2826.d445fb428ef41fa1.js +0 -9
  244. /package/dist/web/standalone/.next/static/{H51SzzS_EEgdBsDVhrLBv → 5wbu35_C2_MQ3Jj1lEVDx}/_buildManifest.js +0 -0
  245. /package/dist/web/standalone/.next/static/{H51SzzS_EEgdBsDVhrLBv → 5wbu35_C2_MQ3Jj1lEVDx}/_ssgManifest.js +0 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Regression tests for #3454: auto-dispatch must honour
3
+ * require_slice_discussion and pause before plan-slice when:
4
+ * 1. state.phase === "planning"
5
+ * 2. require_slice_discussion is enabled in preferences
6
+ * 3. state.activeSlice is non-null
7
+ * 4. the slice has no CONTEXT file on disk
8
+ *
9
+ * Exercises the dispatch rule from DISPATCH_RULES directly with a
10
+ * DispatchContext built against a real temp directory — no source-string
11
+ * matching, no brittle rename dependencies.
12
+ */
13
+ import { test, describe } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { tmpdir } from "node:os";
18
+ import { DISPATCH_RULES, type DispatchContext } from "../auto-dispatch.ts";
19
+ import type { GSDState } from "../types.ts";
20
+ import type { GSDPreferences } from "../preferences.ts";
21
+
22
+ // Use a stable token (the preference name) for rule lookup instead of the
23
+ // full human-readable title — copy edits to the rule name will not break
24
+ // these tests as long as the rule still references the preference it gates.
25
+ const RULE_NAME_TOKEN = "require_slice_discussion";
26
+
27
+ function findRule() {
28
+ const matches = DISPATCH_RULES.filter(r => r.name.includes(RULE_NAME_TOKEN));
29
+ if (matches.length !== 1) {
30
+ throw new Error(`expected exactly one dispatch rule containing "${RULE_NAME_TOKEN}", found ${matches.length}`);
31
+ }
32
+ return matches[0];
33
+ }
34
+
35
+ function buildState(overrides: Partial<GSDState> = {}): GSDState {
36
+ return {
37
+ activeMilestone: { id: "M001", title: "Test milestone" },
38
+ activeSlice: { id: "S01", title: "Test slice" },
39
+ activeTask: null,
40
+ phase: "planning",
41
+ recentDecisions: [],
42
+ blockers: [],
43
+ nextAction: "",
44
+ registry: [],
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ function makeBasePath(prefix: string): string {
50
+ const dir = mkdtempSync(join(tmpdir(), `gsd-req-slice-${prefix}-`));
51
+ mkdirSync(join(dir, ".gsd", "milestones", "M001", "slices", "S01"), { recursive: true });
52
+ return dir;
53
+ }
54
+
55
+ function buildCtx(
56
+ basePath: string,
57
+ prefs: GSDPreferences | undefined,
58
+ state: GSDState = buildState(),
59
+ ): DispatchContext {
60
+ return {
61
+ basePath,
62
+ mid: "M001",
63
+ midTitle: "Test milestone",
64
+ state,
65
+ prefs,
66
+ };
67
+ }
68
+
69
+ describe("require_slice_discussion dispatch rule (#3454)", () => {
70
+ // ─── Positive case: rule fires ────────────────────────────────────────
71
+
72
+ test("returns stop action when preference enabled and CONTEXT missing", async () => {
73
+ const basePath = makeBasePath("fire");
74
+ try {
75
+ const prefs = { phases: { require_slice_discussion: true } } as unknown as GSDPreferences;
76
+ const action = await findRule().match(buildCtx(basePath, prefs));
77
+ assert.ok(action, "rule must return a non-null action when pausing is required");
78
+ assert.strictEqual(action!.action, "stop");
79
+ if (action!.action === "stop") {
80
+ assert.match(action!.reason, /S01/);
81
+ assert.match(action!.reason, /require_slice_discussion/);
82
+ assert.match(action!.reason, /\/gsd discuss/);
83
+ assert.strictEqual(action!.level, "info");
84
+ }
85
+ } finally {
86
+ rmSync(basePath, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ // ─── Negative cases: rule falls through ───────────────────────────────
91
+
92
+ test("falls through (null) when preference is disabled", async () => {
93
+ const basePath = makeBasePath("pref-disabled");
94
+ try {
95
+ const prefs = { phases: { require_slice_discussion: false } } as unknown as GSDPreferences;
96
+ const action = await findRule().match(buildCtx(basePath, prefs));
97
+ assert.strictEqual(action, null);
98
+ } finally {
99
+ rmSync(basePath, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test("falls through (null) when preference is absent / undefined", async () => {
104
+ const basePath = makeBasePath("pref-absent");
105
+ try {
106
+ const action = await findRule().match(buildCtx(basePath, undefined));
107
+ assert.strictEqual(action, null);
108
+ } finally {
109
+ rmSync(basePath, { recursive: true, force: true });
110
+ }
111
+ });
112
+
113
+ test("falls through (null) when phase is not 'planning'", async () => {
114
+ const basePath = makeBasePath("wrong-phase");
115
+ try {
116
+ const prefs = { phases: { require_slice_discussion: true } } as unknown as GSDPreferences;
117
+ const state = buildState({ phase: "executing" });
118
+ const action = await findRule().match(buildCtx(basePath, prefs, state));
119
+ assert.strictEqual(action, null);
120
+ } finally {
121
+ rmSync(basePath, { recursive: true, force: true });
122
+ }
123
+ });
124
+
125
+ test("falls through (null) when no active slice", async () => {
126
+ const basePath = makeBasePath("no-slice");
127
+ try {
128
+ const prefs = { phases: { require_slice_discussion: true } } as unknown as GSDPreferences;
129
+ const state = buildState({ activeSlice: null });
130
+ const action = await findRule().match(buildCtx(basePath, prefs, state));
131
+ assert.strictEqual(action, null);
132
+ } finally {
133
+ rmSync(basePath, { recursive: true, force: true });
134
+ }
135
+ });
136
+
137
+ // ─── Context-file present: should not pause ───────────────────────────
138
+
139
+ test("falls through (null) when CONTEXT file already exists on disk", async () => {
140
+ const basePath = makeBasePath("ctx-present");
141
+ try {
142
+ // Seed the CONTEXT file that /gsd discuss would have written.
143
+ const sliceDir = join(basePath, ".gsd", "milestones", "M001", "slices", "S01");
144
+ writeFileSync(join(sliceDir, "S01-CONTEXT.md"), "# Discussion notes\n", "utf-8");
145
+
146
+ const prefs = { phases: { require_slice_discussion: true } } as unknown as GSDPreferences;
147
+ const action = await findRule().match(buildCtx(basePath, prefs));
148
+ assert.strictEqual(
149
+ action,
150
+ null,
151
+ "once the slice has a CONTEXT file, the rule must fall through so planning proceeds",
152
+ );
153
+ } finally {
154
+ rmSync(basePath, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ // ─── Rule ordering: must run before the plan-slice rule ──────────────
159
+
160
+ test("rule is ordered before the 'planning → plan-slice' rule", () => {
161
+ const discussIdx = DISPATCH_RULES.findIndex(r => r.name.includes(RULE_NAME_TOKEN));
162
+ const planIdx = DISPATCH_RULES.findIndex(r => r.name.startsWith("planning → plan-slice"));
163
+ assert.ok(discussIdx >= 0, "require_slice_discussion rule must be registered");
164
+ assert.ok(planIdx >= 0, "plan-slice rule must be registered");
165
+ assert.ok(
166
+ discussIdx < planIdx,
167
+ "require_slice_discussion rule must be ordered before plan-slice so it preempts dispatch",
168
+ );
169
+ });
170
+ });
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Regression tests for #3490: post-unit handler for rewrite-docs must
3
+ * detect abandon/descope overrides that target the current milestone and
4
+ * park it — without false-positive parking on unrelated scope-change
5
+ * overrides that merely contain an abandon-family verb.
6
+ *
7
+ * Exercises detectAbandonMilestone() directly — a pure function over
8
+ * Override objects, no I/O and no production-code import chain.
9
+ * parkMilestone() end-to-end behavior is covered by the existing
10
+ * park-milestone.test.ts / park-edge-cases.test.ts / park-db-sync.test.ts
11
+ * suites; here we only validate the decision layer that feeds it.
12
+ */
13
+ import { test, describe } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { detectAbandonMilestone } from "../abandon-detect.ts";
16
+ import type { Override } from "../files.ts";
17
+
18
+ function mkOverride(change: string): Override {
19
+ return {
20
+ timestamp: "2026-04-21T00:00:00.000Z",
21
+ change,
22
+ scope: "active",
23
+ appliedAt: "M001/S01/T01",
24
+ };
25
+ }
26
+
27
+ describe("detectAbandonMilestone (#3490)", () => {
28
+ test("parks on explicit abandon-milestone phrasing", () => {
29
+ const d = detectAbandonMilestone(
30
+ [mkOverride("abandon this milestone, it's no longer relevant")],
31
+ "M001",
32
+ );
33
+ assert.strictEqual(d.shouldPark, true);
34
+ assert.strictEqual(d.matched.length, 1);
35
+ assert.match(d.reason, /abandon this milestone/);
36
+ });
37
+
38
+ test("parks on descope-milestone phrasing", () => {
39
+ const d = detectAbandonMilestone(
40
+ [mkOverride("descope the entire milestone")],
41
+ "M001",
42
+ );
43
+ assert.strictEqual(d.shouldPark, true);
44
+ });
45
+
46
+ test("parks when override references milestone by ID instead of the word", () => {
47
+ const d = detectAbandonMilestone(
48
+ [mkOverride("shelve M003 for now, coming back next quarter")],
49
+ "M003",
50
+ );
51
+ assert.strictEqual(d.shouldPark, true);
52
+ assert.strictEqual(d.matched[0], "shelve M003 for now, coming back next quarter");
53
+ });
54
+
55
+ test("parks on past-tense / gerund verb forms (UK spelling)", () => {
56
+ const d = detectAbandonMilestone(
57
+ [mkOverride("this milestone was cancelled by product")],
58
+ "M001",
59
+ );
60
+ assert.strictEqual(d.shouldPark, true);
61
+ });
62
+
63
+ test("parks on US spelling 'canceled' (single-l)", () => {
64
+ const d = detectAbandonMilestone(
65
+ [mkOverride("the milestone was canceled by the PM")],
66
+ "M001",
67
+ );
68
+ assert.strictEqual(d.shouldPark, true);
69
+ });
70
+
71
+ test("parks on hyphenated 'de-scope' variant", () => {
72
+ const d = detectAbandonMilestone(
73
+ [mkOverride("de-scope this milestone — moved to v2")],
74
+ "M001",
75
+ );
76
+ assert.strictEqual(d.shouldPark, true);
77
+ });
78
+
79
+ test("parks on space-separated 'de scope' variant", () => {
80
+ const d = detectAbandonMilestone(
81
+ [mkOverride("de scope the milestone entirely")],
82
+ "M001",
83
+ );
84
+ assert.strictEqual(d.shouldPark, true);
85
+ });
86
+
87
+ test("parks on 'de-scoped' past-tense hyphen variant", () => {
88
+ const d = detectAbandonMilestone(
89
+ [mkOverride("M003 was de-scoped last week")],
90
+ "M003",
91
+ );
92
+ assert.strictEqual(d.shouldPark, true);
93
+ });
94
+
95
+ // ─── False-positive guards ────────────────────────────────────────────
96
+
97
+ test("does NOT park on 'cancel the standup reminder' (no milestone ref)", () => {
98
+ const d = detectAbandonMilestone(
99
+ [mkOverride("cancel the daily standup reminder")],
100
+ "M001",
101
+ );
102
+ assert.strictEqual(d.shouldPark, false);
103
+ assert.deepStrictEqual(d.matched, []);
104
+ });
105
+
106
+ test("does NOT park on 'drop the dependency on X' (no milestone ref)", () => {
107
+ const d = detectAbandonMilestone(
108
+ [mkOverride("drop the dependency on X and use Y instead")],
109
+ "M001",
110
+ );
111
+ assert.strictEqual(d.shouldPark, false);
112
+ });
113
+
114
+ test("does NOT park on 'scrap the v1 design' (no milestone ref)", () => {
115
+ const d = detectAbandonMilestone(
116
+ [mkOverride("scrap the v1 design for the landing page")],
117
+ "M001",
118
+ );
119
+ assert.strictEqual(d.shouldPark, false);
120
+ });
121
+
122
+ test("does NOT park on override with milestone word but no abandon verb", () => {
123
+ const d = detectAbandonMilestone(
124
+ [mkOverride("change the milestone title to something clearer")],
125
+ "M001",
126
+ );
127
+ assert.strictEqual(d.shouldPark, false);
128
+ });
129
+
130
+ test("does NOT park when a different milestone ID is referenced", () => {
131
+ // The abandon verb is present and an MID is present, but the MID is
132
+ // not the current milestone. We require either the literal word
133
+ // "milestone" OR the current MID — a reference to a different MID
134
+ // (M007) with neither the word "milestone" nor the current ID (M001)
135
+ // should not trigger.
136
+ const d = detectAbandonMilestone(
137
+ [mkOverride("drop M007")],
138
+ "M001",
139
+ );
140
+ assert.strictEqual(d.shouldPark, false);
141
+ });
142
+
143
+ // ─── Edge cases ───────────────────────────────────────────────────────
144
+
145
+ test("empty overrides list returns no-park", () => {
146
+ const d = detectAbandonMilestone([], "M001");
147
+ assert.strictEqual(d.shouldPark, false);
148
+ assert.deepStrictEqual(d.matched, []);
149
+ });
150
+
151
+ test("null/undefined currentMilestoneId returns no-park even with matching text", () => {
152
+ const d1 = detectAbandonMilestone(
153
+ [mkOverride("abandon this milestone")],
154
+ null,
155
+ );
156
+ assert.strictEqual(d1.shouldPark, false);
157
+
158
+ const d2 = detectAbandonMilestone(
159
+ [mkOverride("abandon this milestone")],
160
+ undefined,
161
+ );
162
+ assert.strictEqual(d2.shouldPark, false);
163
+ });
164
+
165
+ test("multiple abandon overrides are concatenated in reason", () => {
166
+ const d = detectAbandonMilestone(
167
+ [
168
+ mkOverride("abandon this milestone"),
169
+ mkOverride("cancel the standup"), // filtered out (no ref)
170
+ mkOverride("descope the milestone entirely"),
171
+ ],
172
+ "M001",
173
+ );
174
+ assert.strictEqual(d.shouldPark, true);
175
+ assert.strictEqual(d.matched.length, 2, "only the two milestone-scoped overrides match");
176
+ assert.match(d.reason, /abandon this milestone/);
177
+ assert.match(d.reason, /descope the milestone entirely/);
178
+ assert.doesNotMatch(d.reason, /cancel the standup/);
179
+ });
180
+
181
+ // ─── Regex-injection guard on milestone ID ────────────────────────────
182
+
183
+ test("milestone ID with regex metacharacters is escaped, not interpreted", () => {
184
+ // A pathological MID should not break the matcher — the function
185
+ // escapes regex metacharacters before building the pattern.
186
+ const d = detectAbandonMilestone(
187
+ [mkOverride("abandon M.01 the milestone")],
188
+ "M.01",
189
+ );
190
+ // 'milestone' is present, so this parks regardless of escaping,
191
+ // but the test confirms the RegExp construction does not throw.
192
+ assert.strictEqual(d.shouldPark, true);
193
+ });
194
+ });
195
+
@@ -142,9 +142,9 @@ test("Rule 3: A-A-A-A triggers Rule 2 not Rule 3", () => {
142
142
 
143
143
  test("Rule 4: same ENOENT path in two entries triggers stuck", () => {
144
144
  const result = detectStuck([
145
- { key: "A", error: "ENOENT: no such file or directory, access '/home/user/.gsd/agent/skills/debug-like-expert/SKILL.md'" },
145
+ { key: "A", error: "ENOENT: no such file or directory, access '/home/user/.agents/skills/debug-like-expert/SKILL.md'" },
146
146
  { key: "B" },
147
- { key: "A", error: "ENOENT: no such file or directory, access '/home/user/.gsd/agent/skills/debug-like-expert/SKILL.md'" },
147
+ { key: "A", error: "ENOENT: no such file or directory, access '/home/user/.agents/skills/debug-like-expert/SKILL.md'" },
148
148
  ]);
149
149
  assert.notEqual(result, null);
150
150
  assert.equal(result!.stuck, true);
@@ -258,6 +258,55 @@ test("resolution: markCaptureExecuted is idempotent", () => {
258
258
  }
259
259
  });
260
260
 
261
+ // ─── executeTriageResolutions + note execution (#3578) ──────────────────────
262
+
263
+ test("resolution: executeTriageResolutions stamps note captures as executed", () => {
264
+ const tmp = makeTempDir("res-exec-note");
265
+ try {
266
+ const id = appendCapture(tmp, "FYI the API changed");
267
+ markCaptureResolved(tmp, id, "note", "acknowledged", "informational");
268
+
269
+ const result = executeTriageResolutions(tmp, "M001", "S01");
270
+
271
+ // The note should now be marked as executed
272
+ const all = loadAllCaptures(tmp);
273
+ assert.strictEqual(all.length, 1);
274
+ assert.strictEqual(all[0].executed, true, "note capture should be marked as executed");
275
+
276
+ // It should appear in the actions log
277
+ assert.ok(
278
+ result.actions.some(a => a.includes(id) && a.includes("Note acknowledged")),
279
+ "actions should include a note-acknowledged entry",
280
+ );
281
+ } finally {
282
+ rmSync(tmp, { recursive: true, force: true });
283
+ }
284
+ });
285
+
286
+ test("resolution: executeTriageResolutions does not double-stamp already-executed notes", () => {
287
+ const tmp = makeTempDir("res-exec-note-idem");
288
+ try {
289
+ const id = appendCapture(tmp, "informational note");
290
+ markCaptureResolved(tmp, id, "note", "acknowledged", "info");
291
+
292
+ // First execution — stamps the note
293
+ executeTriageResolutions(tmp, "M001", "S01");
294
+
295
+ // Second execution — should be a no-op for the note
296
+ const result2 = executeTriageResolutions(tmp, "M001", "S01");
297
+
298
+ assert.strictEqual(result2.actions.length, 0, "second call should produce no actions");
299
+
300
+ // Verify the Executed field was not duplicated in the file
301
+ const filePath = join(tmp, ".gsd", "CAPTURES.md");
302
+ const content = readFileSync(filePath, "utf-8");
303
+ const executedMatches = content.match(/\*\*Executed:\*\*/g);
304
+ assert.strictEqual(executedMatches?.length, 1, "should have exactly one Executed field");
305
+ } finally {
306
+ rmSync(tmp, { recursive: true, force: true });
307
+ }
308
+ });
309
+
261
310
  // ─── loadActionableCaptures ──────────────────────────────────────────────────
262
311
 
263
312
  test("resolution: loadActionableCaptures returns only unexecuted actionable captures", () => {
@@ -387,8 +436,7 @@ test("resolution: executeTriageResolutions handles mixed classifications", () =>
387
436
  assert.strictEqual(result.injected, 1, "should inject 1 task");
388
437
  assert.strictEqual(result.replanned, 0);
389
438
  assert.strictEqual(result.quickTasks.length, 1, "should queue 1 quick-task");
390
- // inject + quick-task + note acknowledged = 3 actions (defer still excluded)
391
- assert.strictEqual(result.actions.length, 3, "should have 3 action entries (defer excluded, note now included)");
439
+ assert.strictEqual(result.actions.length, 3, "should have 3 action entries (inject + quick-task + note acknowledged; defer excluded)");
392
440
  } finally {
393
441
  rmSync(tmp, { recursive: true, force: true });
394
442
  }
@@ -0,0 +1,162 @@
1
+ // Project: gsd-pi — Tests for the auto/turn-epoch stale-write guard.
2
+
3
+ import test from "node:test";
4
+ import assert from "node:assert/strict";
5
+
6
+ import {
7
+ _resetTurnEpoch,
8
+ bumpTurnGeneration,
9
+ describeTurnEpoch,
10
+ getCurrentTurnGeneration,
11
+ isStaleWrite,
12
+ runWithTurnGeneration,
13
+ } from "../auto/turn-epoch.ts";
14
+
15
+ test("turn-epoch: generation starts at 0 and bumps monotonically", () => {
16
+ _resetTurnEpoch();
17
+ assert.equal(getCurrentTurnGeneration(), 0);
18
+ assert.equal(bumpTurnGeneration("test-a"), 1);
19
+ assert.equal(bumpTurnGeneration("test-b"), 2);
20
+ assert.equal(getCurrentTurnGeneration(), 2);
21
+ });
22
+
23
+ test("turn-epoch: isStaleWrite returns false when no turn context captured", () => {
24
+ _resetTurnEpoch();
25
+ bumpTurnGeneration("no-context");
26
+ // Called outside runWithTurnGeneration — safe default is false.
27
+ assert.equal(isStaleWrite("out-of-band"), false);
28
+ });
29
+
30
+ test("turn-epoch: isStaleWrite returns false inside a fresh turn", () => {
31
+ _resetTurnEpoch();
32
+ const captured = getCurrentTurnGeneration();
33
+ runWithTurnGeneration(captured, () => {
34
+ assert.equal(isStaleWrite("fresh"), false);
35
+ });
36
+ });
37
+
38
+ test("turn-epoch: isStaleWrite returns true after the epoch bumps mid-turn", () => {
39
+ _resetTurnEpoch();
40
+ const captured = getCurrentTurnGeneration();
41
+ runWithTurnGeneration(captured, () => {
42
+ bumpTurnGeneration("recovery-fires");
43
+ assert.equal(isStaleWrite("stale"), true);
44
+ });
45
+ });
46
+
47
+ test("turn-epoch: nested turns each see their own captured generation", () => {
48
+ _resetTurnEpoch();
49
+ const outerGen = getCurrentTurnGeneration();
50
+ runWithTurnGeneration(outerGen, () => {
51
+ assert.equal(isStaleWrite("outer-fresh"), false);
52
+ bumpTurnGeneration("bump-between");
53
+ const innerGen = getCurrentTurnGeneration();
54
+ runWithTurnGeneration(innerGen, () => {
55
+ // Inner context saw the bumped generation at capture time — fresh.
56
+ assert.equal(isStaleWrite("inner-fresh"), false);
57
+ });
58
+ // Back to outer context — still stale because outerGen < current.
59
+ assert.equal(isStaleWrite("outer-after-bump"), true);
60
+ });
61
+ });
62
+
63
+ test("turn-epoch: describeTurnEpoch surfaces captured vs current", () => {
64
+ _resetTurnEpoch();
65
+ bumpTurnGeneration("seed");
66
+ const captured = getCurrentTurnGeneration();
67
+ runWithTurnGeneration(captured, () => {
68
+ let snapshot = describeTurnEpoch();
69
+ assert.equal(snapshot.captured, captured);
70
+ assert.equal(snapshot.current, captured);
71
+ assert.equal(snapshot.stale, false);
72
+
73
+ bumpTurnGeneration("supersede");
74
+ snapshot = describeTurnEpoch();
75
+ assert.equal(snapshot.captured, captured);
76
+ assert.equal(snapshot.current, captured + 1);
77
+ assert.equal(snapshot.stale, true);
78
+ });
79
+
80
+ // Outside the turn — captured is null, stale is false.
81
+ const outside = describeTurnEpoch();
82
+ assert.equal(outside.captured, null);
83
+ assert.equal(outside.stale, false);
84
+ });
85
+
86
+ test("turn-epoch: AsyncLocalStorage propagates across awaits", async () => {
87
+ _resetTurnEpoch();
88
+ const captured = getCurrentTurnGeneration();
89
+ await runWithTurnGeneration(captured, async () => {
90
+ await Promise.resolve();
91
+ await new Promise((r) => setTimeout(r, 1));
92
+ bumpTurnGeneration("async-bump");
93
+ await Promise.resolve();
94
+ assert.equal(isStaleWrite("post-await"), true);
95
+ });
96
+ });
97
+
98
+ // ─── Source-level invariant checks for recoverTimedOutUnit ──────────────────
99
+ //
100
+ // The recoverTimedOutUnit function has two branch families:
101
+ // - ADVANCE branches: the unit is done, loop moves on — these MUST bump.
102
+ // - STEERING branches: the same LLM turn is kept alive with a steering
103
+ // message — these MUST NOT bump (otherwise the retry's legitimate writes
104
+ // get marked stale).
105
+ //
106
+ // The whole function must contain zero raw `resolveAgentEnd` calls with the
107
+ // "timeout-recovery" _synthetic marker — all advance paths go through
108
+ // bumpAndResolveSynthetic. And there must be no top-level bump call.
109
+
110
+ test("recoverTimedOutUnit: no raw `resolveAgentEnd({ _synthetic: \"timeout-recovery\" })` calls remain", async () => {
111
+ const fs = await import("node:fs");
112
+ const path = await import("node:path");
113
+ const url = await import("node:url");
114
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
115
+ const src = fs.readFileSync(
116
+ path.join(here, "..", "auto-timeout-recovery.ts"),
117
+ "utf-8",
118
+ );
119
+ const rawSyntheticResolve =
120
+ /resolveAgentEnd\s*\(\s*\{\s*messages:\s*\[\s*\]\s*,\s*_synthetic:\s*["']timeout-recovery/;
121
+ assert.equal(
122
+ rawSyntheticResolve.test(src),
123
+ false,
124
+ "auto-timeout-recovery.ts must funnel advance paths through bumpAndResolveSynthetic — a raw resolveAgentEnd with _synthetic:\"timeout-recovery\" would leak orphan writes",
125
+ );
126
+ });
127
+
128
+ test("recoverTimedOutUnit: no top-level bumpTurnGeneration — steering branches must not supersede", async () => {
129
+ const fs = await import("node:fs");
130
+ const path = await import("node:path");
131
+ const url = await import("node:url");
132
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
133
+ const src = fs.readFileSync(
134
+ path.join(here, "..", "auto-timeout-recovery.ts"),
135
+ "utf-8",
136
+ );
137
+ // The only bump surface allowed is via bumpAndResolveSynthetic (advance
138
+ // paths) — a direct bumpTurnGeneration call in this file would bump even
139
+ // when the function later decides to take a steering retry branch.
140
+ assert.equal(
141
+ /\bbumpTurnGeneration\s*\(/.test(src),
142
+ false,
143
+ "auto-timeout-recovery.ts must not call bumpTurnGeneration directly — use bumpAndResolveSynthetic so bump and supersede are atomic and tied to advance-only branches",
144
+ );
145
+ });
146
+
147
+ test("recoverTimedOutUnit: bumpAndResolveSynthetic appears exactly four times (one per advance branch)", async () => {
148
+ const fs = await import("node:fs");
149
+ const path = await import("node:path");
150
+ const url = await import("node:url");
151
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
152
+ const src = fs.readFileSync(
153
+ path.join(here, "..", "auto-timeout-recovery.ts"),
154
+ "utf-8",
155
+ );
156
+ const matches = src.match(/bumpAndResolveSynthetic\s*\(/g) ?? [];
157
+ assert.equal(
158
+ matches.length,
159
+ 4,
160
+ `expected 4 advance-branch supersede sites (durableComplete, execute-task-exhausted, artifact-already-exists, non-execute-exhausted); found ${matches.length}`,
161
+ );
162
+ });
@@ -30,6 +30,7 @@ import { checkOwnership, sliceUnitKey } from "../unit-ownership.js";
30
30
  import { saveFile, clearParseCache } from "../files.js";
31
31
  import { invalidateStateCache } from "../state.js";
32
32
  import { renderRoadmapCheckboxes } from "../markdown-renderer.js";
33
+ import { isStaleWrite } from "../auto/turn-epoch.js";
33
34
  import { renderAllProjections } from "../workflow-projections.js";
34
35
  import { writeManifest } from "../workflow-manifest.js";
35
36
  import { appendEvent } from "../workflow-events.js";
@@ -40,6 +41,14 @@ export interface CompleteSliceResult {
40
41
  milestoneId: string;
41
42
  summaryPath: string;
42
43
  uatPath: string;
44
+ /**
45
+ * True when this call re-completed an already-closed slice from a turn
46
+ * superseded by timeout recovery or cancellation. Response is shaped like
47
+ * success so the orphaned LLM tool call unwinds cleanly without mutating
48
+ * state.
49
+ */
50
+ duplicate?: boolean;
51
+ stale?: boolean;
43
52
  }
44
53
 
45
54
  /**
@@ -283,6 +292,10 @@ export async function handleCompleteSlice(
283
292
 
284
293
  const slice = getSlice(params.milestoneId, params.sliceId);
285
294
  if (slice && isClosedStatus(slice.status)) {
295
+ if (isStaleWrite("complete-slice")) {
296
+ guardError = "__stale_duplicate__";
297
+ return;
298
+ }
286
299
  guardError = `slice ${params.sliceId} is already complete — use gsd_slice_reopen first if you need to redo it`;
287
300
  return;
288
301
  }
@@ -307,6 +320,31 @@ export async function handleCompleteSlice(
307
320
  updateSliceStatus(params.milestoneId, params.sliceId, "complete", completedAt);
308
321
  });
309
322
 
323
+ if (guardError === "__stale_duplicate__") {
324
+ // Stale duplicate from a turn superseded by timeout recovery. Return a
325
+ // non-mutating success so the orphaned LLM tool call unwinds quietly.
326
+ const sliceDir = resolveSlicePath(basePath, params.milestoneId, params.sliceId);
327
+ const staleSummaryPath = sliceDir
328
+ ? join(sliceDir, `${params.sliceId}-SUMMARY.md`)
329
+ : join(
330
+ basePath,
331
+ ".gsd",
332
+ "milestones",
333
+ params.milestoneId,
334
+ "slices",
335
+ params.sliceId,
336
+ `${params.sliceId}-SUMMARY.md`,
337
+ );
338
+ return {
339
+ sliceId: params.sliceId,
340
+ milestoneId: params.milestoneId,
341
+ summaryPath: staleSummaryPath,
342
+ uatPath: staleSummaryPath.replace(/-SUMMARY\.md$/, "-UAT.md"),
343
+ duplicate: true,
344
+ stale: true,
345
+ };
346
+ }
347
+
310
348
  if (guardError) {
311
349
  return { error: guardError };
312
350
  }