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
@@ -26,6 +26,9 @@ import {
26
26
  isDbAvailable,
27
27
  getMilestone,
28
28
  getMilestoneSlices,
29
+ closeDatabase,
30
+ openDatabase,
31
+ getDbPath,
29
32
  } from "./gsd-db.js";
30
33
  import { atomicWriteSync } from "./atomic-write.js";
31
34
  import { execFileSync } from "node:child_process";
@@ -1668,22 +1671,51 @@ export function mergeMilestoneToMain(
1668
1671
  const milestonesDir = join(gsdRoot(originalBasePath_), "milestones");
1669
1672
  const shelterDir = join(gsdRoot(originalBasePath_), ".milestone-shelter");
1670
1673
  const shelteredDirs: string[] = [];
1674
+ let shelterRestored = false;
1671
1675
 
1672
1676
  // Helper: restore sheltered milestone directories (#2505).
1673
1677
  // Called on both success and error paths to ensure queued CONTEXT files
1674
- // are never permanently lost.
1678
+ // are never permanently lost. Idempotent — the error path may fire after
1679
+ // the success path has already restored and removed the shelter dir; a
1680
+ // second call is a no-op instead of logging a misleading "shelter restore
1681
+ // failed: ENOENT" error for shelter sources that were cleaned up legitimately.
1675
1682
  const restoreShelter = (): void => {
1683
+ if (shelterRestored) return;
1684
+ shelterRestored = true;
1676
1685
  if (shelteredDirs.length === 0) return;
1686
+ let restoreFailed = false;
1677
1687
  for (const dirName of shelteredDirs) {
1688
+ const src = join(shelterDir, dirName);
1689
+ // If the shelter source is missing the restore cannot proceed for this
1690
+ // entry. Distinguish "legitimately missing" (shelter dir removed by a
1691
+ // prior successful restore or never copied) from a surprising ENOENT
1692
+ // inside an otherwise-populated shelter.
1693
+ if (!existsSync(src)) {
1694
+ logWarning(
1695
+ "worktree",
1696
+ `shelter source missing for ${dirName}; skipping restore (shelter already cleaned or entry never staged)`,
1697
+ );
1698
+ continue;
1699
+ }
1678
1700
  try {
1679
1701
  mkdirSync(milestonesDir, { recursive: true });
1680
- cpSync(join(shelterDir, dirName), join(milestonesDir, dirName), { recursive: true, force: true });
1702
+ cpSync(src, join(milestonesDir, dirName), { recursive: true, force: true });
1681
1703
  } catch (err) { /* best-effort */
1682
- logError("worktree", `shelter restore failed: ${err instanceof Error ? err.message : String(err)}`);
1704
+ restoreFailed = true;
1705
+ logError("worktree", `shelter restore failed (${dirName}): ${err instanceof Error ? err.message : String(err)}`);
1683
1706
  }
1684
1707
  }
1685
- try { rmSync(shelterDir, { recursive: true, force: true }); } catch (err) { /* best-effort */
1686
- logWarning("worktree", `shelter cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
1708
+ // Preserve the shelter if any per-entry restore failed it is the only
1709
+ // surviving copy of the queued milestone dirs (sources were deleted during
1710
+ // shelter). Deleting it here would permanently lose those files (#2505).
1711
+ if (restoreFailed) {
1712
+ logWarning("worktree", `shelter retained at ${shelterDir} — manual recovery required for unrestored entries`);
1713
+ return;
1714
+ }
1715
+ if (existsSync(shelterDir)) {
1716
+ try { rmSync(shelterDir, { recursive: true, force: true }); } catch (err) { /* best-effort */
1717
+ logWarning("worktree", `shelter cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
1718
+ }
1687
1719
  }
1688
1720
  };
1689
1721
 
@@ -1720,6 +1752,21 @@ export function mergeMilestoneToMain(
1720
1752
  // entries (e.g. a gitignored `.gsd` symlink under ADR-002) (#4573).
1721
1753
  // Queued CONTEXT files under `.gsd/milestones/*` are already sheltered
1722
1754
  // in step 7 above, so they won't be swept into the stash.
1755
+ // On Windows, SQLite holds mandatory file locks on the gsd.db WAL/SHM
1756
+ // sidecars while the connection is open. `git stash --include-untracked`
1757
+ // walks those files and fails with EBUSY (#4704). Close the DB before
1758
+ // stashing so Windows releases the handles; reopen after. No-op on
1759
+ // POSIX, where advisory locks don't block git.
1760
+ const needsDbCycle = process.platform === "win32" && isDbAvailable();
1761
+ const dbPathToReopen = needsDbCycle ? getDbPath() : null;
1762
+ if (needsDbCycle) {
1763
+ try {
1764
+ closeDatabase();
1765
+ } catch (err) {
1766
+ logWarning("worktree", `pre-stash db close failed: ${err instanceof Error ? err.message : String(err)}`);
1767
+ }
1768
+ }
1769
+
1723
1770
  let stashed = false;
1724
1771
  try {
1725
1772
  const status = execFileSync("git", ["status", "--porcelain"], {
@@ -1741,6 +1788,14 @@ export function mergeMilestoneToMain(
1741
1788
  logWarning("worktree", `git stash failed: ${err instanceof Error ? err.message : String(err)}`);
1742
1789
  }
1743
1790
 
1791
+ if (needsDbCycle && dbPathToReopen) {
1792
+ try {
1793
+ openDatabase(dbPathToReopen);
1794
+ } catch (err) {
1795
+ logWarning("worktree", `post-stash db reopen failed: ${err instanceof Error ? err.message : String(err)}`);
1796
+ }
1797
+ }
1798
+
1744
1799
  // 7b. Clean up stale merge state before attempting squash merge (#2912).
1745
1800
  // A leftover MERGE_HEAD (from a previous failed merge, libgit2 native path,
1746
1801
  // or interrupted operation) causes `git merge --squash` to refuse with
@@ -485,6 +485,11 @@ export function isAutoActive(): boolean {
485
485
  return s.active;
486
486
  }
487
487
 
488
+ /** Test-only seam for validating auto-mode guards (#4704). Do not use in production code. */
489
+ export function _setAutoActiveForTest(active: boolean): void {
490
+ s.active = active;
491
+ }
492
+
488
493
  export function isAutoPaused(): boolean {
489
494
  return s.paused;
490
495
  }
@@ -12,6 +12,8 @@ type AutoResumeSnapshot = Pick<AutoDashboardData, "active" | "paused" | "stepMod
12
12
 
13
13
  export interface ProviderErrorResumeDeps {
14
14
  getSnapshot(): AutoResumeSnapshot;
15
+ resetTransientRetryState(): void;
16
+ resetSessionTimeoutState(): void;
15
17
  startAuto(
16
18
  ctx: ExtensionCommandContext,
17
19
  pi: ExtensionAPI,
@@ -23,6 +25,8 @@ export interface ProviderErrorResumeDeps {
23
25
 
24
26
  const defaultDeps: ProviderErrorResumeDeps = {
25
27
  getSnapshot: () => getAutoDashboardData(),
28
+ resetTransientRetryState,
29
+ resetSessionTimeoutState,
26
30
  startAuto,
27
31
  };
28
32
 
@@ -47,8 +51,8 @@ export async function resumeAutoAfterProviderDelay(
47
51
  // Reset retry counters before restarting — without this, counters
48
52
  // accumulate across pause/resume cycles and permanently lock out
49
53
  // auto-resume after their respective MAX thresholds.
50
- resetTransientRetryState();
51
- resetSessionTimeoutState();
54
+ deps.resetTransientRetryState();
55
+ deps.resetSessionTimeoutState();
52
56
 
53
57
  await deps.startAuto(
54
58
  ctx as ExtensionCommandContext,
@@ -43,6 +43,25 @@ import {
43
43
  validateFetchedContent,
44
44
  } from "../../workflow-install.js";
45
45
 
46
+ /**
47
+ * Refuses interactive commands that mutate durable .gsd/ planning state while
48
+ * auto-mode holds the worktree. Returns true if the command was blocked and
49
+ * the caller should return immediately; false if it is safe to proceed.
50
+ *
51
+ * Auto-mode's squash merge performs a pre-merge dirty-tree check; concurrent
52
+ * writes by interactive commands between that check and the merge itself
53
+ * cause __dirty_working_tree__ failures (#4704).
54
+ */
55
+ function requireNotAutoActive(commandName: string, ctx: ExtensionCommandContext): boolean {
56
+ if (!isAutoActive()) return false;
57
+ ctx.ui.notify(
58
+ `${commandName} cannot run while auto-mode is active.\n` +
59
+ `Stop auto-mode first with /gsd stop, then run ${commandName}.`,
60
+ "error",
61
+ );
62
+ return true;
63
+ }
64
+
46
65
  // ─── Custom Workflow Subcommands ─────────────────────────────────────────
47
66
 
48
67
  const RESERVED_SUBCOMMANDS = new Set([
@@ -473,12 +492,14 @@ async function handleCustomWorkflow(
473
492
  export async function handleWorkflowCommand(trimmed: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<boolean> {
474
493
  // ── /gsd do — natural language routing (must be early to route to other commands) ──
475
494
  if (trimmed === "do" || trimmed.startsWith("do ")) {
495
+ if (requireNotAutoActive("/gsd do", ctx)) return true;
476
496
  const { handleDo } = await import("../../commands-do.js");
477
497
  await handleDo(trimmed.replace(/^do\s*/, "").trim(), ctx, pi);
478
498
  return true;
479
499
  }
480
500
  // ── Backlog management ──
481
501
  if (trimmed === "backlog" || trimmed.startsWith("backlog ")) {
502
+ if (requireNotAutoActive("/gsd backlog", ctx)) return true;
482
503
  const { handleBacklog } = await import("../../commands-backlog.js");
483
504
  await handleBacklog(trimmed.replace(/^backlog\s*/, "").trim(), ctx, pi);
484
505
  return true;
@@ -490,26 +511,22 @@ export async function handleWorkflowCommand(trimmed: string, ctx: ExtensionComma
490
511
  }
491
512
 
492
513
  if (trimmed === "queue") {
514
+ if (requireNotAutoActive("/gsd queue", ctx)) return true;
493
515
  await showQueue(ctx, pi, projectRoot());
494
516
  return true;
495
517
  }
496
518
  if (trimmed === "discuss") {
519
+ if (requireNotAutoActive("/gsd discuss", ctx)) return true;
497
520
  await showDiscuss(ctx, pi, projectRoot());
498
521
  return true;
499
522
  }
500
523
  if (trimmed === "quick" || trimmed.startsWith("quick ")) {
501
- if (isAutoActive()) {
502
- ctx.ui.notify(
503
- "/gsd quick cannot run while auto-mode is active.\n" +
504
- "Stop auto-mode first with /gsd stop, then run /gsd quick.",
505
- "error",
506
- );
507
- return true;
508
- }
524
+ if (requireNotAutoActive("/gsd quick", ctx)) return true;
509
525
  await handleQuick(trimmed.replace(/^quick\s*/, "").trim(), ctx, pi);
510
526
  return true;
511
527
  }
512
528
  if (trimmed === "new-milestone") {
529
+ if (requireNotAutoActive("/gsd new-milestone", ctx)) return true;
513
530
  const basePath = projectRoot();
514
531
  const headlessContextPath = join(gsdRoot(basePath), "runtime", "headless-context.md");
515
532
  if (existsSync(headlessContextPath)) {
@@ -531,6 +548,7 @@ export async function handleWorkflowCommand(trimmed: string, ctx: ExtensionComma
531
548
  return true;
532
549
  }
533
550
  if (trimmed === "park" || trimmed.startsWith("park ")) {
551
+ if (requireNotAutoActive("/gsd park", ctx)) return true;
534
552
  const basePath = projectRoot();
535
553
  const arg = trimmed.replace(/^park\s*/, "").trim();
536
554
  let targetId = arg;
@@ -556,6 +574,7 @@ export async function handleWorkflowCommand(trimmed: string, ctx: ExtensionComma
556
574
  return true;
557
575
  }
558
576
  if (trimmed === "unpark" || trimmed.startsWith("unpark ")) {
577
+ if (requireNotAutoActive("/gsd unpark", ctx)) return true;
559
578
  const basePath = projectRoot();
560
579
  const arg = trimmed.replace(/^unpark\s*/, "").trim();
561
580
  let targetId = arg;
@@ -1,12 +1,21 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { join } from "node:path";
2
4
 
3
- function _require(name: string) {
5
+ // The file-lock module is loaded in both CJS builds and ESM sources. Under ESM
6
+ // the bare `require` identifier is not defined, so we always go through
7
+ // createRequire. We try the current module's resolution context first and fall
8
+ // back to the installed gsd-pi package if we are running from a consumer
9
+ // project that does not hoist proper-lockfile.
10
+ const localRequire = createRequire(import.meta.url);
11
+
12
+ function _require(name: string): any {
4
13
  try {
5
- return require(name);
14
+ return localRequire(name);
6
15
  } catch {
7
16
  try {
8
- const gsdPiRequire = require("module").createRequire(
9
- require("path").join(process.cwd(), "node_modules", "gsd-pi", "index.js")
17
+ const gsdPiRequire = createRequire(
18
+ join(process.cwd(), "node_modules", "gsd-pi", "index.js"),
10
19
  );
11
20
  return gsdPiRequire(name);
12
21
  } catch {
@@ -15,43 +24,107 @@ function _require(name: string) {
15
24
  }
16
25
  }
17
26
 
18
- export function withFileLockSync<T>(filePath: string, fn: () => T): T {
27
+ export type OnLocked = "fail" | "skip";
28
+
29
+ export interface FileLockOptions {
30
+ /**
31
+ * Behavior when the lock cannot be acquired after retries (ELOCKED).
32
+ * - "fail" (default): rethrow the ELOCKED error so the caller can react.
33
+ * - "skip": run fn() unlocked. Only choose this for best-effort writes
34
+ * that genuinely tolerate contention (e.g. high-frequency audit appends
35
+ * where dropping one entry is acceptable). Silent unlocked execution was
36
+ * the legacy behavior and is a correctness hazard for shared state.
37
+ */
38
+ onLocked?: OnLocked;
39
+ /** proper-lockfile retries (default 5). */
40
+ retries?: number;
41
+ /** proper-lockfile stale threshold in ms (default 10000). */
42
+ stale?: number;
43
+ }
44
+
45
+ const DEFAULT_RETRIES = 5;
46
+ const DEFAULT_STALE_MS = 10000;
47
+ const SYNC_RETRY_DELAY_MS = 50;
48
+
49
+ // Block the thread for `ms` milliseconds without spinning the CPU.
50
+ // Used by the sync lock retry loop, since proper-lockfile's lockSync does not
51
+ // accept a `retries` option (only the async `lock` does).
52
+ function sleepSync(ms: number): void {
53
+ if (ms <= 0) return;
54
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
55
+ }
56
+
57
+ function acquireLockSyncWithRetry(
58
+ lockfile: any,
59
+ filePath: string,
60
+ retries: number,
61
+ stale: number,
62
+ ): () => void {
63
+ let lastErr: any;
64
+ for (let attempt = 0; attempt <= retries; attempt++) {
65
+ try {
66
+ return lockfile.lockSync(filePath, { stale });
67
+ } catch (err: any) {
68
+ lastErr = err;
69
+ if (err?.code !== "ELOCKED") throw err;
70
+ if (attempt < retries) sleepSync(SYNC_RETRY_DELAY_MS);
71
+ }
72
+ }
73
+ throw lastErr;
74
+ }
75
+
76
+ export function withFileLockSync<T>(
77
+ filePath: string,
78
+ fn: () => T,
79
+ opts: FileLockOptions = {},
80
+ ): T {
19
81
  const lockfile = _require("proper-lockfile");
20
82
  if (!lockfile) return fn();
21
83
 
22
84
  if (!existsSync(filePath)) return fn();
23
85
 
86
+ const retries = opts.retries ?? DEFAULT_RETRIES;
87
+ const stale = opts.stale ?? DEFAULT_STALE_MS;
88
+ const onLocked: OnLocked = opts.onLocked ?? "fail";
89
+
24
90
  try {
25
- const release = lockfile.lockSync(filePath, { retries: 5, stale: 10000 });
91
+ const release = acquireLockSyncWithRetry(lockfile, filePath, retries, stale);
26
92
  try {
27
93
  return fn();
28
94
  } finally {
29
95
  release();
30
96
  }
31
97
  } catch (err: any) {
32
- if (err.code === "ELOCKED") {
33
- // Could not get lock after retries, let's fallback to un-locked instead of crashing the whole state machine
98
+ if (err?.code === "ELOCKED" && onLocked === "skip") {
34
99
  return fn();
35
100
  }
36
101
  throw err;
37
102
  }
38
103
  }
39
104
 
40
- export async function withFileLock<T>(filePath: string, fn: () => Promise<T> | T): Promise<T> {
105
+ export async function withFileLock<T>(
106
+ filePath: string,
107
+ fn: () => Promise<T> | T,
108
+ opts: FileLockOptions = {},
109
+ ): Promise<T> {
41
110
  const lockfile = _require("proper-lockfile");
42
111
  if (!lockfile) return await fn();
43
112
 
44
113
  if (!existsSync(filePath)) return await fn();
45
114
 
115
+ const retries = opts.retries ?? DEFAULT_RETRIES;
116
+ const stale = opts.stale ?? DEFAULT_STALE_MS;
117
+ const onLocked: OnLocked = opts.onLocked ?? "fail";
118
+
46
119
  try {
47
- const release = await lockfile.lock(filePath, { retries: 5, stale: 10000 });
120
+ const release = await lockfile.lock(filePath, { retries, stale });
48
121
  try {
49
122
  return await fn();
50
123
  } finally {
51
124
  await release();
52
125
  }
53
126
  } catch (err: any) {
54
- if (err.code === "ELOCKED") {
127
+ if (err?.code === "ELOCKED" && onLocked === "skip") {
55
128
  return await fn();
56
129
  }
57
130
  throw err;
@@ -210,6 +210,7 @@ export interface PreMergeCheckResult {
210
210
  */
211
211
  export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
212
212
  ".gsd/activity/",
213
+ ".gsd/audit/",
213
214
  ".gsd/forensics/",
214
215
  ".gsd/runtime/",
215
216
  ".gsd/worktrees/",
@@ -26,6 +26,7 @@ import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
26
26
  */
27
27
  const GSD_RUNTIME_PATTERNS = [
28
28
  ".gsd/activity/",
29
+ ".gsd/audit/",
29
30
  ".gsd/forensics/",
30
31
  ".gsd/runtime/",
31
32
  ".gsd/worktrees/",
@@ -18,6 +18,7 @@ import {
18
18
  resolveGsdRootFile, relGsdRootFile, relSliceFile,
19
19
  } from "./paths.js";
20
20
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
21
+ import { atomicWriteSync } from "./atomic-write.js";
21
22
  import { nativeAddPaths, nativeCommit } from "./native-git-bridge.js";
22
23
  import { loadEffectiveGSDPreferences } from "./preferences.js";
23
24
  import { loadQueueOrder, sortByQueueOrder, saveQueueOrder } from "./queue-order.js";
@@ -435,5 +436,7 @@ function syncProjectMdSequence(
435
436
  const separatorLine = lines[tableStart + 1];
436
437
  const newTable = [headerLine, separatorLine, ...newRows];
437
438
  lines.splice(tableStart, tableEnd - tableStart, ...newTable);
438
- writeFileSync(projectPath, lines.join("\n"), "utf-8");
439
+ // Atomic write: tmp+rename avoids a torn PROJECT.md appearing dirty in
440
+ // another worktree's working tree during a concurrent /gsd auto merge.
441
+ atomicWriteSync(projectPath, lines.join("\n"), "utf-8");
439
442
  }
@@ -12,8 +12,18 @@
12
12
  * - Silent failure: journal writes never throw — absence of events is the failure signal
13
13
  */
14
14
 
15
- import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
15
+ import {
16
+ appendFileSync,
17
+ closeSync,
18
+ existsSync,
19
+ mkdirSync,
20
+ openSync,
21
+ readdirSync,
22
+ readFileSync,
23
+ } from "node:fs";
16
24
  import { join } from "node:path";
25
+ import { isStaleWrite } from "./auto/turn-epoch.js";
26
+ import { withFileLockSync } from "./file-lock.js";
17
27
  import { gsdRoot } from "./paths.js";
18
28
  import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
19
29
  import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js";
@@ -84,12 +94,27 @@ export interface JournalQueryFilters {
84
94
  * Never throws — all errors are silently caught.
85
95
  */
86
96
  export function emitJournalEvent(basePath: string, entry: JournalEntry): void {
97
+ // Drop writes from a turn superseded by timeout recovery / cancellation.
98
+ // See auto/turn-epoch.ts for the full rationale.
99
+ if (isStaleWrite("journal")) return;
87
100
  try {
88
101
  const journalDir = join(gsdRoot(basePath), "journal");
89
102
  mkdirSync(journalDir, { recursive: true });
90
103
  const dateStr = entry.ts.slice(0, 10);
91
104
  const filePath = join(journalDir, `${dateStr}.jsonl`);
92
- appendFileSync(filePath, JSON.stringify(entry) + "\n");
105
+ // Ensure file exists so proper-lockfile can acquire a lock against it.
106
+ if (!existsSync(filePath)) closeSync(openSync(filePath, "a"));
107
+ // onLocked: "skip" — journal writes are best-effort. POSIX O_APPEND
108
+ // atomicity still protects small entries; the lock mainly serializes
109
+ // larger writes and gives cross-process exclusivity on platforms where
110
+ // O_APPEND semantics are weaker (Windows).
111
+ withFileLockSync(
112
+ filePath,
113
+ () => {
114
+ appendFileSync(filePath, JSON.stringify(entry) + "\n");
115
+ },
116
+ { onLocked: "skip" },
117
+ );
93
118
  } catch {
94
119
  // Silent failure — journal must never break auto-mode
95
120
  }
@@ -23,6 +23,21 @@ import { loadQueueOrder, saveQueueOrder } from "./queue-order.js";
23
23
  import { deleteMilestone, getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
24
24
  import { removeWorktree } from "./worktree-manager.js";
25
25
  import { logWarning } from "./workflow-logger.js";
26
+ import { isAutoActive } from "./auto.js";
27
+
28
+ /**
29
+ * Writer-side assert for mutations that race with auto-mode's squash merge (#4704).
30
+ * Auto-mode is confirmed not to call parkMilestone/discardMilestone/unparkMilestone
31
+ * internally — these throws only surface invariant violations from new or forgotten
32
+ * call sites, which is the correct failure mode to catch loudly.
33
+ */
34
+ function assertNotAutoActive(action: string): void {
35
+ if (isAutoActive()) {
36
+ throw new Error(
37
+ `${action} cannot run while auto-mode is active. Stop auto-mode first with /gsd stop.`,
38
+ );
39
+ }
40
+ }
26
41
 
27
42
  // ─── Park ──────────────────────────────────────────────────────────────────
28
43
 
@@ -32,6 +47,7 @@ import { logWarning } from "./workflow-logger.js";
32
47
  * Returns true if successfully parked, false if milestone not found or already parked.
33
48
  */
34
49
  export function parkMilestone(basePath: string, milestoneId: string, reason: string): boolean {
50
+ assertNotAutoActive("park milestone");
35
51
  const mDir = resolveMilestonePath(basePath, milestoneId);
36
52
  if (!mDir || !existsSync(mDir)) return false;
37
53
 
@@ -74,6 +90,7 @@ export function parkMilestone(basePath: string, milestoneId: string, reason: str
74
90
  * Returns true if successfully unparked, false if milestone not found or not parked.
75
91
  */
76
92
  export function unparkMilestone(basePath: string, milestoneId: string): boolean {
93
+ assertNotAutoActive("unpark milestone");
77
94
  const mDir = resolveMilestonePath(basePath, milestoneId);
78
95
  if (!mDir || !existsSync(mDir)) return false;
79
96
 
@@ -108,6 +125,7 @@ export function unparkMilestone(basePath: string, milestoneId: string): boolean
108
125
  * Returns true if successfully discarded, false if milestone not found.
109
126
  */
110
127
  export function discardMilestone(basePath: string, milestoneId: string): boolean {
128
+ assertNotAutoActive("discard milestone");
111
129
  const mDir = resolveMilestonePath(basePath, milestoneId);
112
130
  if (!mDir || !existsSync(mDir)) return false;
113
131
 
@@ -14,8 +14,9 @@
14
14
  * Manual: /gsd export --html
15
15
  */
16
16
 
17
- import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
17
+ import { readFileSync, mkdirSync, existsSync } from 'node:fs';
18
18
  import { join, basename } from 'node:path';
19
+ import { atomicWriteSync } from './atomic-write.js';
19
20
  import { gsdRoot } from './paths.js';
20
21
  import { formatCost, formatTokenCount } from './metrics.js';
21
22
  import { formatDateShort, formatDuration } from '../shared/format-utils.js';
@@ -83,7 +84,7 @@ export function loadReportsIndex(basePath: string): ReportsIndex | null {
83
84
  function saveReportsIndex(basePath: string, index: ReportsIndex): void {
84
85
  const dir = reportsDir(basePath);
85
86
  mkdirSync(dir, { recursive: true });
86
- writeFileSync(reportsIndexPath(basePath), JSON.stringify(index, null, 2) + '\n', 'utf-8');
87
+ atomicWriteSync(reportsIndexPath(basePath), JSON.stringify(index, null, 2) + '\n', 'utf-8');
87
88
  }
88
89
 
89
90
  // ─── Write a report snapshot ──────────────────────────────────────────────────
@@ -121,7 +122,7 @@ export function writeReportSnapshot(args: WriteReportSnapshotArgs): string {
121
122
  const filename = `${prefix}-${timestamp}.html`;
122
123
  const filePath = join(dir, filename);
123
124
 
124
- writeFileSync(filePath, args.html, 'utf-8');
125
+ atomicWriteSync(filePath, args.html, 'utf-8');
125
126
 
126
127
  // Load or init registry
127
128
  const existing = loadReportsIndex(args.basePath);
@@ -170,7 +171,7 @@ export function writeReportSnapshot(args: WriteReportSnapshotArgs): string {
170
171
 
171
172
  export function regenerateHtmlIndex(basePath: string, index: ReportsIndex): void {
172
173
  const html = buildIndexHtml(index);
173
- writeFileSync(reportsHtmlIndexPath(basePath), html, 'utf-8');
174
+ atomicWriteSync(reportsHtmlIndexPath(basePath), html, 'utf-8');
174
175
  }
175
176
 
176
177
  function buildIndexHtml(index: ReportsIndex): string {
@@ -10,6 +10,7 @@ import {
10
10
  autoLoop,
11
11
  detectStuck,
12
12
  _resetPendingResolve,
13
+ _hasPendingResolveForTest,
13
14
  _setActiveSession,
14
15
  isSessionSwitchInFlight,
15
16
  type UnitResult,
@@ -379,6 +380,7 @@ test("runUnit cancels before dispatch when provider is not request-ready (#4555)
379
380
  /Provider anthropic is not request-ready/,
380
381
  );
381
382
  assert.equal(pi.calls.length, 0, "sendMessage must not be called when provider is not ready");
383
+ assert.equal(_hasPendingResolveForTest(), false, "provider cancellation must clear the pending resolver");
382
384
  });
383
385
 
384
386
  test("runUnit cancels before dispatch using currentUnitModel provider when set (#4555)", async () => {
@@ -1609,9 +1611,16 @@ test("auto-timeout-recovery.ts calls resolveAgentEnd instead of dispatchNextUnit
1609
1611
  !src.includes("await dispatchNextUnit"),
1610
1612
  "auto-timeout-recovery.ts must not call dispatchNextUnit",
1611
1613
  );
1614
+ // After PR #4716, advance branches go through bumpAndResolveSynthetic()
1615
+ // (which bumps the turn epoch and calls resolveAgentEnd atomically).
1616
+ // Either direct resolveAgentEnd() or the helper satisfies the invariant:
1617
+ // the loop must be re-iterated on timeout recovery.
1618
+ const reIteratesLoop =
1619
+ src.includes("resolveAgentEnd(") ||
1620
+ src.includes("bumpAndResolveSynthetic(");
1612
1621
  assert.ok(
1613
- src.includes("resolveAgentEnd("),
1614
- "auto-timeout-recovery.ts must call resolveAgentEnd to re-iterate the loop on timeout recovery",
1622
+ reIteratesLoop,
1623
+ "auto-timeout-recovery.ts must call resolveAgentEnd (directly or via bumpAndResolveSynthetic) to re-iterate the loop on timeout recovery",
1615
1624
  );
1616
1625
  });
1617
1626
 
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Regression tests for auto-mode guards (#4704 Tier 2 / #4712).
3
+ *
4
+ * Validates the defense-in-depth writer asserts in milestone-actions.ts —
5
+ * parkMilestone, unparkMilestone, and discardMilestone must refuse to run
6
+ * while auto-mode is active, regardless of the calling dispatch path.
7
+ */
8
+ import { describe, test, afterEach } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { tmpdir } from 'node:os';
13
+
14
+ import { parkMilestone, unparkMilestone, discardMilestone } from '../milestone-actions.ts';
15
+ import { _setAutoActiveForTest } from '../auto.ts';
16
+
17
+ function createFixture(): string {
18
+ const base = mkdtempSync(join(tmpdir(), 'gsd-guard-test-'));
19
+ const mDir = join(base, '.gsd', 'milestones', 'M001');
20
+ mkdirSync(mDir, { recursive: true });
21
+ writeFileSync(join(mDir, 'M001-ROADMAP.md'), '# M001\n', 'utf-8');
22
+ return base;
23
+ }
24
+
25
+ describe('auto-mode guards (milestone-actions)', () => {
26
+ afterEach(() => {
27
+ _setAutoActiveForTest(false);
28
+ });
29
+
30
+ test('parkMilestone throws when auto-mode is active', () => {
31
+ const base = createFixture();
32
+ try {
33
+ _setAutoActiveForTest(true);
34
+ assert.throws(
35
+ () => parkMilestone(base, 'M001', 'test'),
36
+ /auto-mode is active/,
37
+ );
38
+ } finally {
39
+ rmSync(base, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ test('unparkMilestone throws when auto-mode is active', () => {
44
+ const base = createFixture();
45
+ try {
46
+ _setAutoActiveForTest(true);
47
+ assert.throws(
48
+ () => unparkMilestone(base, 'M001'),
49
+ /auto-mode is active/,
50
+ );
51
+ } finally {
52
+ rmSync(base, { recursive: true, force: true });
53
+ }
54
+ });
55
+
56
+ test('discardMilestone throws when auto-mode is active', () => {
57
+ const base = createFixture();
58
+ try {
59
+ _setAutoActiveForTest(true);
60
+ assert.throws(
61
+ () => discardMilestone(base, 'M001'),
62
+ /auto-mode is active/,
63
+ );
64
+ } finally {
65
+ rmSync(base, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ test('parkMilestone proceeds normally when auto-mode is inactive', () => {
70
+ const base = createFixture();
71
+ try {
72
+ _setAutoActiveForTest(false);
73
+ const result = parkMilestone(base, 'M001', 'baseline');
74
+ assert.ok(result, 'park succeeds when auto is inactive');
75
+ } finally {
76
+ rmSync(base, { recursive: true, force: true });
77
+ }
78
+ });
79
+ });