gsd-pi 2.49.0-dev.de3d9f6 → 2.50.0-dev.9476db8

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 (249) hide show
  1. package/dist/headless-ui.js +12 -2
  2. package/dist/headless.js +29 -13
  3. package/dist/resources/extensions/gsd/auto/infra-errors.js +1 -0
  4. package/dist/resources/extensions/gsd/auto/phases.js +11 -11
  5. package/dist/resources/extensions/gsd/auto/resolve.js +2 -2
  6. package/dist/resources/extensions/gsd/auto/run-unit.js +2 -2
  7. package/dist/resources/extensions/gsd/auto/session.js +4 -0
  8. package/dist/resources/extensions/gsd/auto-artifact-paths.js +8 -10
  9. package/dist/resources/extensions/gsd/auto-dashboard.js +6 -3
  10. package/dist/resources/extensions/gsd/auto-dispatch.js +33 -21
  11. package/dist/resources/extensions/gsd/auto-post-unit.js +17 -24
  12. package/dist/resources/extensions/gsd/auto-prompts.js +102 -21
  13. package/dist/resources/extensions/gsd/auto-recovery.js +62 -184
  14. package/dist/resources/extensions/gsd/auto-start.js +4 -31
  15. package/dist/resources/extensions/gsd/auto-timers.js +2 -2
  16. package/dist/resources/extensions/gsd/auto-verification.js +4 -7
  17. package/dist/resources/extensions/gsd/auto-worktree.js +257 -113
  18. package/dist/resources/extensions/gsd/auto.js +7 -5
  19. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +89 -0
  20. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +8 -1
  21. package/dist/resources/extensions/gsd/branch-patterns.js +13 -0
  22. package/dist/resources/extensions/gsd/doctor-checks.js +5 -1234
  23. package/dist/resources/extensions/gsd/doctor-engine-checks.js +168 -0
  24. package/dist/resources/extensions/gsd/doctor-environment.js +28 -7
  25. package/dist/resources/extensions/gsd/doctor-git-checks.js +405 -0
  26. package/dist/resources/extensions/gsd/doctor-global-checks.js +74 -0
  27. package/dist/resources/extensions/gsd/doctor-runtime-checks.js +600 -0
  28. package/dist/resources/extensions/gsd/doctor.js +9 -1
  29. package/dist/resources/extensions/gsd/extension-manifest.json +1 -1
  30. package/dist/resources/extensions/gsd/git-service.js +9 -10
  31. package/dist/resources/extensions/gsd/gsd-db.js +124 -1
  32. package/dist/resources/extensions/gsd/guided-flow-queue.js +10 -11
  33. package/dist/resources/extensions/gsd/markdown-renderer.js +33 -5
  34. package/dist/resources/extensions/gsd/preferences-types.js +2 -1
  35. package/dist/resources/extensions/gsd/preferences-validation.js +39 -0
  36. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +27 -8
  37. package/dist/resources/extensions/gsd/prompts/complete-slice.md +9 -8
  38. package/dist/resources/extensions/gsd/prompts/execute-task.md +16 -13
  39. package/dist/resources/extensions/gsd/prompts/forensics.md +12 -5
  40. package/dist/resources/extensions/gsd/prompts/gate-evaluate.md +32 -0
  41. package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -1
  42. package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +1 -1
  43. package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +1 -1
  44. package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -1
  45. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  46. package/dist/resources/extensions/gsd/prompts/plan-slice.md +8 -3
  47. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +3 -0
  48. package/dist/resources/extensions/gsd/prompts/replan-slice.md +1 -1
  49. package/dist/resources/extensions/gsd/repo-identity.js +29 -0
  50. package/dist/resources/extensions/gsd/roadmap-slices.js +2 -2
  51. package/dist/resources/extensions/gsd/session-forensics.js +6 -11
  52. package/dist/resources/extensions/gsd/session-lock.js +67 -56
  53. package/dist/resources/extensions/gsd/state.js +34 -7
  54. package/dist/resources/extensions/gsd/templates/milestone-summary.md +8 -0
  55. package/dist/resources/extensions/gsd/templates/plan.md +16 -0
  56. package/dist/resources/extensions/gsd/templates/roadmap.md +13 -0
  57. package/dist/resources/extensions/gsd/templates/slice-summary.md +9 -0
  58. package/dist/resources/extensions/gsd/templates/task-plan.md +24 -0
  59. package/dist/resources/extensions/gsd/tools/plan-slice.js +14 -1
  60. package/dist/resources/extensions/gsd/tools/validate-milestone.js +3 -3
  61. package/dist/resources/extensions/gsd/verdict-parser.js +84 -0
  62. package/dist/resources/extensions/gsd/worktree-resolver.js +24 -0
  63. package/dist/resources/extensions/gsd/worktree.js +3 -2
  64. package/dist/resources/extensions/remote-questions/config.js +3 -5
  65. package/dist/resources/extensions/search-the-web/native-search.js +8 -3
  66. package/dist/resources/extensions/search-the-web/tool-search.js +19 -2
  67. package/dist/resources/skills/github-workflows/references/gh/SKILL.md +22 -1
  68. package/dist/web/standalone/.next/BUILD_ID +1 -1
  69. package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
  70. package/dist/web/standalone/.next/build-manifest.json +3 -3
  71. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  72. package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
  73. package/dist/web/standalone/.next/required-server-files.json +1 -1
  74. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  75. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  76. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  77. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  78. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  79. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  80. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  81. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  82. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  83. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  84. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  85. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  86. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  87. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  88. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  89. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  90. package/dist/web/standalone/.next/server/app/index.html +1 -1
  91. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  92. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  93. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  94. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  95. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  96. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  97. package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
  98. package/dist/web/standalone/.next/server/chunks/229.js +2 -2
  99. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  100. package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
  101. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  102. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  103. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  104. package/dist/web/standalone/.next/static/chunks/4024.7c75ac378de0f2b5.js +9 -0
  105. package/dist/web/standalone/.next/static/chunks/{webpack-0a4cd455ec4197d2.js → webpack-2473ce2c3879fff4.js} +1 -1
  106. package/dist/web/standalone/server.js +1 -1
  107. package/package.json +1 -1
  108. package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
  109. package/packages/pi-agent-core/dist/agent-loop.js +4 -1
  110. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  111. package/packages/pi-agent-core/src/agent-loop.ts +4 -1
  112. package/packages/pi-ai/dist/providers/openai-codex-responses.js +39 -10
  113. package/packages/pi-ai/dist/providers/openai-codex-responses.js.map +1 -1
  114. package/packages/pi-ai/src/providers/openai-codex-responses.ts +39 -8
  115. package/packages/pi-coding-agent/dist/core/blob-store.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/core/blob-store.js +8 -3
  117. package/packages/pi-coding-agent/dist/core/blob-store.js.map +1 -1
  118. package/packages/pi-coding-agent/dist/core/discovery-cache.d.ts.map +1 -1
  119. package/packages/pi-coding-agent/dist/core/discovery-cache.js +9 -2
  120. package/packages/pi-coding-agent/dist/core/discovery-cache.js.map +1 -1
  121. package/packages/pi-coding-agent/dist/core/retry-handler.js +1 -1
  122. package/packages/pi-coding-agent/dist/core/retry-handler.js.map +1 -1
  123. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  124. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +7 -32
  125. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  126. package/packages/pi-coding-agent/dist/modes/rpc/jsonl.d.ts.map +1 -1
  127. package/packages/pi-coding-agent/dist/modes/rpc/jsonl.js +5 -0
  128. package/packages/pi-coding-agent/dist/modes/rpc/jsonl.js.map +1 -1
  129. package/packages/pi-coding-agent/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  130. package/packages/pi-coding-agent/dist/modes/rpc/rpc-client.js +0 -1
  131. package/packages/pi-coding-agent/dist/modes/rpc/rpc-client.js.map +1 -1
  132. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +1 -1
  133. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  134. package/packages/pi-coding-agent/package.json +1 -1
  135. package/packages/pi-coding-agent/src/core/blob-store.ts +6 -3
  136. package/packages/pi-coding-agent/src/core/discovery-cache.ts +9 -2
  137. package/packages/pi-coding-agent/src/core/retry-handler.ts +1 -1
  138. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +7 -32
  139. package/packages/pi-coding-agent/src/modes/rpc/jsonl.ts +6 -0
  140. package/packages/pi-coding-agent/src/modes/rpc/rpc-client.ts +0 -2
  141. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +2 -2
  142. package/pkg/package.json +1 -1
  143. package/src/resources/extensions/gsd/auto/infra-errors.ts +1 -0
  144. package/src/resources/extensions/gsd/auto/phases.ts +10 -11
  145. package/src/resources/extensions/gsd/auto/resolve.ts +3 -3
  146. package/src/resources/extensions/gsd/auto/run-unit.ts +2 -2
  147. package/src/resources/extensions/gsd/auto/session.ts +5 -0
  148. package/src/resources/extensions/gsd/auto/types.ts +13 -0
  149. package/src/resources/extensions/gsd/auto-artifact-paths.ts +19 -21
  150. package/src/resources/extensions/gsd/auto-dashboard.ts +5 -2
  151. package/src/resources/extensions/gsd/auto-dispatch.ts +39 -21
  152. package/src/resources/extensions/gsd/auto-loop.ts +1 -1
  153. package/src/resources/extensions/gsd/auto-post-unit.ts +18 -28
  154. package/src/resources/extensions/gsd/auto-prompts.ts +113 -19
  155. package/src/resources/extensions/gsd/auto-recovery.ts +65 -199
  156. package/src/resources/extensions/gsd/auto-start.ts +7 -27
  157. package/src/resources/extensions/gsd/auto-timers.ts +2 -2
  158. package/src/resources/extensions/gsd/auto-verification.ts +4 -7
  159. package/src/resources/extensions/gsd/auto-worktree.ts +305 -108
  160. package/src/resources/extensions/gsd/auto.ts +11 -10
  161. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +93 -0
  162. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +8 -0
  163. package/src/resources/extensions/gsd/branch-patterns.ts +16 -0
  164. package/src/resources/extensions/gsd/doctor-checks.ts +5 -1291
  165. package/src/resources/extensions/gsd/doctor-engine-checks.ts +182 -0
  166. package/src/resources/extensions/gsd/doctor-environment.ts +30 -7
  167. package/src/resources/extensions/gsd/doctor-git-checks.ts +415 -0
  168. package/src/resources/extensions/gsd/doctor-global-checks.ts +84 -0
  169. package/src/resources/extensions/gsd/doctor-runtime-checks.ts +626 -0
  170. package/src/resources/extensions/gsd/doctor.ts +9 -1
  171. package/src/resources/extensions/gsd/extension-manifest.json +1 -1
  172. package/src/resources/extensions/gsd/git-service.ts +7 -15
  173. package/src/resources/extensions/gsd/gsd-db.ts +150 -2
  174. package/src/resources/extensions/gsd/guided-flow-queue.ts +11 -12
  175. package/src/resources/extensions/gsd/markdown-renderer.ts +37 -4
  176. package/src/resources/extensions/gsd/preferences-types.ts +5 -1
  177. package/src/resources/extensions/gsd/preferences-validation.ts +37 -0
  178. package/src/resources/extensions/gsd/prompts/complete-milestone.md +27 -8
  179. package/src/resources/extensions/gsd/prompts/complete-slice.md +9 -8
  180. package/src/resources/extensions/gsd/prompts/execute-task.md +16 -13
  181. package/src/resources/extensions/gsd/prompts/forensics.md +12 -5
  182. package/src/resources/extensions/gsd/prompts/gate-evaluate.md +32 -0
  183. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -1
  184. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -1
  185. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +1 -1
  186. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -1
  187. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  188. package/src/resources/extensions/gsd/prompts/plan-slice.md +8 -3
  189. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +3 -0
  190. package/src/resources/extensions/gsd/prompts/replan-slice.md +1 -1
  191. package/src/resources/extensions/gsd/repo-identity.ts +28 -0
  192. package/src/resources/extensions/gsd/roadmap-slices.ts +2 -2
  193. package/src/resources/extensions/gsd/session-forensics.ts +6 -11
  194. package/src/resources/extensions/gsd/session-lock.ts +92 -64
  195. package/src/resources/extensions/gsd/state.ts +38 -5
  196. package/src/resources/extensions/gsd/templates/milestone-summary.md +8 -0
  197. package/src/resources/extensions/gsd/templates/plan.md +16 -0
  198. package/src/resources/extensions/gsd/templates/roadmap.md +13 -0
  199. package/src/resources/extensions/gsd/templates/slice-summary.md +9 -0
  200. package/src/resources/extensions/gsd/templates/task-plan.md +24 -0
  201. package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +2 -2
  202. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +35 -0
  203. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +1 -81
  204. package/src/resources/extensions/gsd/tests/complete-slice.test.ts +2 -2
  205. package/src/resources/extensions/gsd/tests/complete-task.test.ts +2 -2
  206. package/src/resources/extensions/gsd/tests/completed-units-metrics-sync.test.ts +9 -12
  207. package/src/resources/extensions/gsd/tests/doctor-environment.test.ts +115 -1
  208. package/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +65 -1
  209. package/src/resources/extensions/gsd/tests/doctor-git.test.ts +50 -0
  210. package/src/resources/extensions/gsd/tests/gate-dispatch.test.ts +189 -0
  211. package/src/resources/extensions/gsd/tests/gate-storage.test.ts +156 -0
  212. package/src/resources/extensions/gsd/tests/git-service.test.ts +49 -0
  213. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +1 -1
  214. package/src/resources/extensions/gsd/tests/infra-error.test.ts +12 -2
  215. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +39 -0
  216. package/src/resources/extensions/gsd/tests/md-importer.test.ts +1 -1
  217. package/src/resources/extensions/gsd/tests/memory-store.test.ts +2 -2
  218. package/src/resources/extensions/gsd/tests/quality-gates.test.ts +347 -0
  219. package/src/resources/extensions/gsd/tests/queue-completed-milestone-perf.test.ts +155 -0
  220. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +2 -1
  221. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +32 -0
  222. package/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +26 -0
  223. package/src/resources/extensions/gsd/tests/run-uat.test.ts +20 -16
  224. package/src/resources/extensions/gsd/tests/session-lock-transient-read.test.ts +223 -0
  225. package/src/resources/extensions/gsd/tests/skill-activation.test.ts +44 -4
  226. package/src/resources/extensions/gsd/tests/tool-naming.test.ts +1 -1
  227. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +2 -1
  228. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +0 -16
  229. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +67 -0
  230. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +1 -1
  231. package/src/resources/extensions/gsd/tests/worktree-sync-overwrite-loop.test.ts +204 -0
  232. package/src/resources/extensions/gsd/tools/plan-slice.ts +16 -0
  233. package/src/resources/extensions/gsd/tools/validate-milestone.ts +3 -3
  234. package/src/resources/extensions/gsd/types.ts +30 -0
  235. package/src/resources/extensions/gsd/verdict-parser.ts +95 -0
  236. package/src/resources/extensions/gsd/verification-gate.ts +0 -2
  237. package/src/resources/extensions/gsd/worktree-resolver.ts +31 -0
  238. package/src/resources/extensions/gsd/worktree.ts +3 -2
  239. package/src/resources/extensions/remote-questions/config.ts +3 -5
  240. package/src/resources/extensions/search-the-web/native-search.ts +8 -3
  241. package/src/resources/extensions/search-the-web/tool-search.ts +22 -2
  242. package/src/resources/skills/github-workflows/references/gh/SKILL.md +22 -1
  243. package/dist/resources/extensions/gsd/auto-worktree-sync.js +0 -191
  244. package/dist/resources/extensions/gsd/resource-version.js +0 -97
  245. package/dist/web/standalone/.next/static/chunks/4024.11ca5c01938e5948.js +0 -9
  246. package/src/resources/extensions/gsd/auto-worktree-sync.ts +0 -234
  247. package/src/resources/extensions/gsd/resource-version.ts +0 -101
  248. /package/dist/web/standalone/.next/static/{ceckLbAMjhzHaQ3RPtJnT → MkE9kzqUGny3-cSE0GNnm}/_buildManifest.js +0 -0
  249. /package/dist/web/standalone/.next/static/{ceckLbAMjhzHaQ3RPtJnT → MkE9kzqUGny3-cSE0GNnm}/_ssgManifest.js +0 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * session-lock-transient-read.test.ts — Tests for transient lock file unreadability (#2324).
3
+ *
4
+ * Regression coverage for:
5
+ * #2324 onCompromised declares lock lost when the lock file is temporarily
6
+ * unreadable (NFS/CIFS latency, macOS APFS snapshot, concurrent process
7
+ * briefly holding the file).
8
+ *
9
+ * Tests:
10
+ * - readExistingLockDataWithRetry retries on transient read failure
11
+ * - readExistingLockDataWithRetry returns data when file becomes readable after retries
12
+ * - readExistingLockDataWithRetry returns null only when ALL retries exhausted
13
+ * - onCompromised does not declare compromise when lock file is transiently unreadable
14
+ */
15
+
16
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, renameSync, unlinkSync, chmodSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { tmpdir } from 'node:os';
19
+ import { execSync, spawn } from 'node:child_process';
20
+
21
+ import {
22
+ acquireSessionLock,
23
+ getSessionLockStatus,
24
+ releaseSessionLock,
25
+ readExistingLockDataWithRetry,
26
+ type SessionLockData,
27
+ } from '../session-lock.ts';
28
+ import { gsdRoot } from '../paths.ts';
29
+ import { createTestContext } from './test-helpers.ts';
30
+
31
+ const { assertEq, assertTrue, report } = createTestContext();
32
+
33
+ async function main(): Promise<void> {
34
+
35
+ // ─── 1. readExistingLockDataWithRetry succeeds on first read when file is fine ─
36
+ console.log('\n=== 1. readExistingLockDataWithRetry reads file normally ===');
37
+ {
38
+ const base = mkdtempSync(join(tmpdir(), 'gsd-transient-'));
39
+ mkdirSync(join(base, '.gsd'), { recursive: true });
40
+
41
+ try {
42
+ const lockFile = join(gsdRoot(base), 'auto.lock');
43
+ const lockData: SessionLockData = {
44
+ pid: process.pid,
45
+ startedAt: new Date().toISOString(),
46
+ unitType: 'execute-task',
47
+ unitId: 'M001/S01/T01',
48
+ unitStartedAt: new Date().toISOString(),
49
+ sessionFile: 'test-session.json',
50
+ };
51
+ writeFileSync(lockFile, JSON.stringify(lockData, null, 2));
52
+
53
+ const result = readExistingLockDataWithRetry(lockFile);
54
+ assertTrue(result !== null, 'data returned for readable file');
55
+ assertEq(result!.pid, process.pid, 'correct PID read');
56
+ assertEq(result!.sessionFile, 'test-session.json', 'correct sessionFile read');
57
+ } finally {
58
+ rmSync(base, { recursive: true, force: true });
59
+ }
60
+ }
61
+
62
+ // ─── 2. readExistingLockDataWithRetry returns null for truly missing file ──
63
+ console.log('\n=== 2. readExistingLockDataWithRetry returns null for missing file ===');
64
+ {
65
+ const base = mkdtempSync(join(tmpdir(), 'gsd-transient-'));
66
+ mkdirSync(join(base, '.gsd'), { recursive: true });
67
+
68
+ try {
69
+ const lockFile = join(gsdRoot(base), 'auto.lock');
70
+ // File doesn't exist
71
+ const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 2, delayMs: 10 });
72
+ assertEq(result, null, 'null for truly missing file after retries');
73
+ } finally {
74
+ rmSync(base, { recursive: true, force: true });
75
+ }
76
+ }
77
+
78
+ // ─── 3. readExistingLockDataWithRetry recovers after transient rename ──────
79
+ console.log('\n=== 3. readExistingLockDataWithRetry recovers after transient unavailability ===');
80
+ {
81
+ const base = mkdtempSync(join(tmpdir(), 'gsd-transient-'));
82
+ mkdirSync(join(base, '.gsd'), { recursive: true });
83
+
84
+ try {
85
+ const lockFile = join(gsdRoot(base), 'auto.lock');
86
+ const tmpFile = lockFile + '.hidden';
87
+ const lockData: SessionLockData = {
88
+ pid: process.pid,
89
+ startedAt: new Date().toISOString(),
90
+ unitType: 'execute-task',
91
+ unitId: 'M001/S01/T01',
92
+ unitStartedAt: new Date().toISOString(),
93
+ sessionFile: 'recovery-session.json',
94
+ };
95
+ writeFileSync(lockFile, JSON.stringify(lockData, null, 2));
96
+
97
+ // Simulate transient unavailability: move file away, spawn a child process
98
+ // to restore it after 100ms. The child runs outside our event loop so it
99
+ // fires even during busy-wait retries.
100
+ renameSync(lockFile, tmpFile);
101
+ spawn('bash', ['-c', `sleep 0.1 && mv "${tmpFile}" "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
102
+
103
+ // With retries (3 attempts, 200ms delay), it should recover on 2nd or 3rd attempt
104
+ const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 3, delayMs: 200 });
105
+ assertTrue(result !== null, 'data recovered after transient unavailability');
106
+ if (result) {
107
+ assertEq(result.pid, process.pid, 'correct PID after recovery');
108
+ assertEq(result.sessionFile, 'recovery-session.json', 'correct sessionFile after recovery');
109
+ }
110
+ } finally {
111
+ rmSync(base, { recursive: true, force: true });
112
+ }
113
+ }
114
+
115
+ // ─── 4. readExistingLockDataWithRetry recovers from transient permission error ─
116
+ console.log('\n=== 4. readExistingLockDataWithRetry recovers from transient permission error ===');
117
+ {
118
+ const base = mkdtempSync(join(tmpdir(), 'gsd-transient-'));
119
+ mkdirSync(join(base, '.gsd'), { recursive: true });
120
+
121
+ try {
122
+ const lockFile = join(gsdRoot(base), 'auto.lock');
123
+ const lockData: SessionLockData = {
124
+ pid: process.pid,
125
+ startedAt: new Date().toISOString(),
126
+ unitType: 'execute-task',
127
+ unitId: 'M001/S01/T01',
128
+ unitStartedAt: new Date().toISOString(),
129
+ sessionFile: 'perm-session.json',
130
+ };
131
+ writeFileSync(lockFile, JSON.stringify(lockData, null, 2));
132
+
133
+ // Remove read permission to simulate NFS/CIFS latency, then spawn a child
134
+ // to restore permissions after 100ms (runs outside our event loop).
135
+ chmodSync(lockFile, 0o000);
136
+ spawn('bash', ['-c', `sleep 0.1 && chmod 644 "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
137
+
138
+ const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 3, delayMs: 200 });
139
+ assertTrue(result !== null, 'data recovered after transient permission error');
140
+ if (result) {
141
+ assertEq(result.pid, process.pid, 'correct PID after permission recovery');
142
+ }
143
+
144
+ // Ensure permissions restored for cleanup
145
+ try { chmodSync(lockFile, 0o644); } catch { /* best-effort */ }
146
+ } finally {
147
+ rmSync(base, { recursive: true, force: true });
148
+ }
149
+ }
150
+
151
+ // ─── 5. getSessionLockStatus does not false-positive on transient read failure ─
152
+ console.log('\n=== 5. getSessionLockStatus tolerates transient lock file unavailability ===');
153
+ {
154
+ const base = mkdtempSync(join(tmpdir(), 'gsd-transient-'));
155
+ mkdirSync(join(base, '.gsd'), { recursive: true });
156
+
157
+ try {
158
+ const result = acquireSessionLock(base);
159
+ assertTrue(result.acquired, 'lock acquired');
160
+
161
+ // Validate works initially
162
+ const status1 = getSessionLockStatus(base);
163
+ assertTrue(status1.valid, 'lock valid before transient failure');
164
+
165
+ // Temporarily hide the lock file
166
+ const lockFile = join(gsdRoot(base), 'auto.lock');
167
+ const tmpFile = lockFile + '.hidden';
168
+ renameSync(lockFile, tmpFile);
169
+
170
+ // Schedule restoration
171
+ setTimeout(() => {
172
+ try { renameSync(tmpFile, lockFile); } catch { /* best-effort */ }
173
+ }, 30);
174
+
175
+ // Small delay to ensure restoration runs, then check — with the OS lock
176
+ // still held, getSessionLockStatus should return valid=true even if the
177
+ // lock file was briefly missing (it checks _releaseFunction first).
178
+ await new Promise(r => setTimeout(r, 60));
179
+ const status2 = getSessionLockStatus(base);
180
+ assertTrue(status2.valid, 'lock still valid after transient file disappearance (OS lock held)');
181
+
182
+ // Restore if not yet restored
183
+ try { renameSync(tmpFile, lockFile); } catch { /* already restored */ }
184
+
185
+ releaseSessionLock(base);
186
+ } finally {
187
+ rmSync(base, { recursive: true, force: true });
188
+ }
189
+ }
190
+
191
+ // ─── 6. Retry defaults: 3 attempts with 200ms delay ────────────────────────
192
+ console.log('\n=== 6. Default retry params: function works with defaults ===');
193
+ {
194
+ const base = mkdtempSync(join(tmpdir(), 'gsd-transient-'));
195
+ mkdirSync(join(base, '.gsd'), { recursive: true });
196
+
197
+ try {
198
+ const lockFile = join(gsdRoot(base), 'auto.lock');
199
+ const lockData: SessionLockData = {
200
+ pid: process.pid,
201
+ startedAt: new Date().toISOString(),
202
+ unitType: 'execute-task',
203
+ unitId: 'M001/S01/T01',
204
+ unitStartedAt: new Date().toISOString(),
205
+ sessionFile: 'status-session.json',
206
+ };
207
+ writeFileSync(lockFile, JSON.stringify(lockData, null, 2));
208
+
209
+ // Call with no options — uses defaults (3 attempts, 200ms)
210
+ const result = readExistingLockDataWithRetry(lockFile);
211
+ assertTrue(result !== null, 'default params work for readable file');
212
+ } finally {
213
+ rmSync(base, { recursive: true, force: true });
214
+ }
215
+ }
216
+
217
+ report();
218
+ }
219
+
220
+ main().catch((error) => {
221
+ console.error(error);
222
+ process.exit(1);
223
+ });
@@ -75,7 +75,7 @@ test("buildSkillActivationBlock activates skills via prefer_skills when context
75
75
  prefer_skills: ["react"],
76
76
  });
77
77
 
78
- assert.match(result, /Call Skill\('react'\)/);
78
+ assert.match(result, /Call Skill\(\{ skill: 'react' \}\)/);
79
79
  assert.doesNotMatch(result, /swiftui/);
80
80
  } finally {
81
81
  cleanup(base);
@@ -92,7 +92,7 @@ test("buildSkillActivationBlock includes always_use_skills from preferences usin
92
92
  always_use_skills: ["swift-testing"],
93
93
  });
94
94
 
95
- assert.equal(result, "<skill_activation>Call Skill('swift-testing').</skill_activation>");
95
+ assert.equal(result, "<skill_activation>Call Skill({ skill: 'swift-testing' }).</skill_activation>");
96
96
  } finally {
97
97
  cleanup(base);
98
98
  }
@@ -120,8 +120,8 @@ test("buildSkillActivationBlock includes skill_rules matches and task-plan skill
120
120
  skill_rules: [{ when: "prisma database schema", use: ["prisma"] }],
121
121
  });
122
122
 
123
- assert.match(result, /Call Skill\('accessibility'\)/);
124
- assert.match(result, /Call Skill\('prisma'\)/);
123
+ assert.match(result, /Call Skill\(\{ skill: 'accessibility' \}\)/);
124
+ assert.match(result, /Call Skill\(\{ skill: 'prisma' \}\)/);
125
125
  } finally {
126
126
  cleanup(base);
127
127
  }
@@ -191,3 +191,43 @@ test("buildSkillActivationBlock does not activate skills from extraContext or ta
191
191
  cleanup(base);
192
192
  }
193
193
  });
194
+
195
+ test("buildSkillActivationBlock rejects skill names with special characters", () => {
196
+ const base = makeTempBase();
197
+ try {
198
+ // Skill names with quotes, braces, or other non-alphanumeric characters are
199
+ // rejected by the SAFE_SKILL_NAME guard to prevent prompt injection.
200
+ writeSkill(base, "my-skill's", "Skill with apostrophe in name.");
201
+ loadOnlyTestSkills(base);
202
+
203
+ const result = buildBlock(base, {}, {
204
+ always_use_skills: ["my-skill's"],
205
+ });
206
+
207
+ // Unsafe skill name is filtered out — empty result
208
+ assert.equal(result, "");
209
+ } finally {
210
+ cleanup(base);
211
+ }
212
+ });
213
+
214
+ test("buildSkillActivationBlock allows valid skill names and rejects invalid ones", () => {
215
+ const base = makeTempBase();
216
+ try {
217
+ writeSkill(base, "react", "React skill.");
218
+ writeSkill(base, "bad'name", "Injection attempt.");
219
+ writeSkill(base, "good-skill-2", "Another valid skill.");
220
+ loadOnlyTestSkills(base);
221
+
222
+ const result = buildBlock(base, {}, {
223
+ always_use_skills: ["react", "bad'name", "good-skill-2"],
224
+ });
225
+
226
+ assert.match(result, /skill_activation/);
227
+ assert.match(result, /Call Skill\(\{ skill: 'react' \}\)/);
228
+ assert.match(result, /Call Skill\(\{ skill: 'good-skill-2' \}\)/);
229
+ assert.doesNotMatch(result, /bad'name/);
230
+ } finally {
231
+ cleanup(base);
232
+ }
233
+ });
@@ -44,7 +44,7 @@ console.log('\n── Tool naming: registration count ──');
44
44
  const pi = makeMockPi();
45
45
  registerDbTools(pi);
46
46
 
47
- assert.deepStrictEqual(pi.tools.length, 26, 'Should register exactly 26 tools (13 canonical + 13 aliases)');
47
+ assert.deepStrictEqual(pi.tools.length, 27, 'Should register exactly 27 tools (13 canonical + 13 aliases + 1 gate tool)');
48
48
 
49
49
  // ─── Both names exist for each pair ──────────────────────────────────────────
50
50
 
@@ -6,7 +6,8 @@ import { tmpdir } from "node:os";
6
6
  import { randomUUID } from "node:crypto";
7
7
 
8
8
  import { deriveState, isValidationTerminal } from "../state.ts";
9
- import { resolveExpectedArtifactPath, verifyExpectedArtifact, diagnoseExpectedArtifact, buildLoopRemediationSteps } from "../auto-recovery.ts";
9
+ import { resolveExpectedArtifactPath, diagnoseExpectedArtifact } from "../auto-artifact-paths.ts";
10
+ import { verifyExpectedArtifact, buildLoopRemediationSteps } from "../auto-recovery.ts";
10
11
  import { resolveDispatch, type DispatchContext } from "../auto-dispatch.ts";
11
12
  import type { GSDState } from "../types.ts";
12
13
  import { clearPathCache } from "../paths.ts";
@@ -226,8 +226,6 @@ describe("verification-gate: execution", () => {
226
226
 
227
227
  test("all commands pass → gate passes", () => {
228
228
  const result = runVerificationGate({
229
- basePath: tmp,
230
- unitId: "T01",
231
229
  cwd: tmp,
232
230
  preferenceCommands: ["echo hello", "echo world"],
233
231
  });
@@ -243,8 +241,6 @@ describe("verification-gate: execution", () => {
243
241
 
244
242
  test("one command fails → gate fails with exit code + stderr", () => {
245
243
  const result = runVerificationGate({
246
- basePath: tmp,
247
- unitId: "T01",
248
244
  cwd: tmp,
249
245
  preferenceCommands: ["echo ok", "sh -c 'echo err >&2; exit 1'"],
250
246
  });
@@ -257,8 +253,6 @@ describe("verification-gate: execution", () => {
257
253
 
258
254
  test("no commands discovered → gate passes with 0 checks", () => {
259
255
  const result = runVerificationGate({
260
- basePath: tmp,
261
- unitId: "T01",
262
256
  cwd: tmp,
263
257
  });
264
258
  assert.equal(result.passed, true);
@@ -268,8 +262,6 @@ describe("verification-gate: execution", () => {
268
262
 
269
263
  test("command not found → exit code 127", () => {
270
264
  const result = runVerificationGate({
271
- basePath: tmp,
272
- unitId: "T01",
273
265
  cwd: tmp,
274
266
  preferenceCommands: ["__nonexistent_command_xyz_42__"],
275
267
  });
@@ -289,8 +281,6 @@ describe("verification-gate: execution", () => {
289
281
  const script = [
290
282
  `import { runVerificationGate } from ${JSON.stringify(pathToFileURL(gatePath).href)};`,
291
283
  `runVerificationGate({`,
292
- ` basePath: ${JSON.stringify(tmp)},`,
293
- ` unitId: "T-DEP",`,
294
284
  ` cwd: ${JSON.stringify(tmp)},`,
295
285
  ` preferenceCommands: ["echo dep0190-check"],`,
296
286
  `});`,
@@ -317,8 +307,6 @@ describe("verification-gate: execution", () => {
317
307
 
318
308
  test("each check has durationMs", () => {
319
309
  const result = runVerificationGate({
320
- basePath: tmp,
321
- unitId: "T01",
322
310
  cwd: tmp,
323
311
  preferenceCommands: ["echo fast"],
324
312
  });
@@ -330,8 +318,6 @@ describe("verification-gate: execution", () => {
330
318
  test("one command fails — remaining commands still run (non-short-circuit)", () => {
331
319
  // First fails, second and third should still execute
332
320
  const result = runVerificationGate({
333
- basePath: tmp,
334
- unitId: "T02",
335
321
  cwd: tmp,
336
322
  preferenceCommands: [
337
323
  "sh -c 'exit 1'",
@@ -351,8 +337,6 @@ describe("verification-gate: execution", () => {
351
337
  test("gate execution uses cwd for spawnSync", () => {
352
338
  // pwd should report the temp dir
353
339
  const result = runVerificationGate({
354
- basePath: tmp,
355
- unitId: "T02",
356
340
  cwd: tmp,
357
341
  preferenceCommands: ["pwd"],
358
342
  });
@@ -846,3 +846,70 @@ test("GitService is rebuilt with originalBasePath after exitMilestone", () => {
846
846
 
847
847
  assert.equal(gitServiceBasePath, "/project"); // project root, not worktree
848
848
  });
849
+
850
+ // ─── Isolation Degradation Tests (#2483) ──────────────────────────────────
851
+
852
+ test("enterMilestone sets isolationDegraded when worktree creation throws (#2483)", () => {
853
+ const s = makeSession();
854
+ const deps = makeDeps({
855
+ getAutoWorktreePath: () => null,
856
+ createAutoWorktree: () => {
857
+ throw new Error("empty repo");
858
+ },
859
+ });
860
+ const ctx = makeNotifyCtx();
861
+ const resolver = new WorktreeResolver(s, deps);
862
+
863
+ resolver.enterMilestone("M001", ctx);
864
+
865
+ assert.equal(s.isolationDegraded, true);
866
+ assert.equal(s.basePath, "/project"); // unchanged — error recovery
867
+ });
868
+
869
+ test("enterMilestone is no-op when isolationDegraded is true (#2483)", () => {
870
+ const s = makeSession();
871
+ s.isolationDegraded = true;
872
+ const deps = makeDeps();
873
+ const ctx = makeNotifyCtx();
874
+ const resolver = new WorktreeResolver(s, deps);
875
+
876
+ resolver.enterMilestone("M001", ctx);
877
+
878
+ assert.equal(s.basePath, "/project"); // unchanged
879
+ assert.equal(findCalls(deps.calls, "createAutoWorktree").length, 0);
880
+ assert.equal(findCalls(deps.calls, "enterAutoWorktree").length, 0);
881
+ assert.equal(findCalls(deps.calls, "shouldUseWorktreeIsolation").length, 0);
882
+ });
883
+
884
+ test("mergeAndExit is no-op when isolationDegraded is true (#2483)", () => {
885
+ const s = makeSession({
886
+ basePath: "/project",
887
+ originalBasePath: "/project",
888
+ });
889
+ s.isolationDegraded = true;
890
+ const deps = makeDeps({
891
+ getIsolationMode: () => "worktree",
892
+ });
893
+ const ctx = makeNotifyCtx();
894
+ const resolver = new WorktreeResolver(s, deps);
895
+
896
+ resolver.mergeAndExit("M001", ctx);
897
+
898
+ assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0);
899
+ assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 0);
900
+ assert.equal(findCalls(deps.calls, "getIsolationMode").length, 0);
901
+ assert.ok(
902
+ ctx.messages.some(
903
+ (m) => m.level === "info" && m.msg.includes("isolation was degraded"),
904
+ ),
905
+ );
906
+ });
907
+
908
+ test("isolationDegraded is reset by session.reset() (#2483)", () => {
909
+ const s = new AutoSession();
910
+ s.isolationDegraded = true;
911
+
912
+ s.reset();
913
+
914
+ assert.equal(s.isolationDegraded, false);
915
+ });
@@ -27,7 +27,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync
27
27
  import { join } from 'node:path';
28
28
  import { tmpdir } from 'node:os';
29
29
 
30
- import { syncProjectRootToWorktree } from '../auto-worktree-sync.ts';
30
+ import { syncProjectRootToWorktree } from '../auto-worktree.ts';
31
31
  import { syncGsdStateToWorktree, syncWorktreeStateBack } from '../auto-worktree.ts';
32
32
  import { describe, test } from 'node:test';
33
33
  import assert from 'node:assert/strict';
@@ -0,0 +1,204 @@
1
+ /**
2
+ * worktree-sync-overwrite-loop.test.ts — Regression tests for #1886.
3
+ *
4
+ * Reproduces the infinite validate-milestone loop caused by two bugs
5
+ * in syncProjectRootToWorktree:
6
+ *
7
+ * 1. safeCopyRecursive overwrites worktree-authoritative files (e.g.
8
+ * VALIDATION.md written by validate-milestone gets clobbered by the
9
+ * stale project root copy that lacks the file).
10
+ *
11
+ * 2. completed-units.json is not forward-synced from project root to
12
+ * worktree, so the worktree never learns about already-completed units.
13
+ *
14
+ * Covers:
15
+ * - syncProjectRootToWorktree does NOT overwrite existing worktree files
16
+ * - syncProjectRootToWorktree copies files missing from the worktree
17
+ * - completed-units.json is forward-synced from project root to worktree
18
+ * - completed-units.json sync uses force:true (project root is authoritative)
19
+ */
20
+
21
+ import {
22
+ mkdtempSync,
23
+ mkdirSync,
24
+ writeFileSync,
25
+ rmSync,
26
+ existsSync,
27
+ readFileSync,
28
+ } from "node:fs";
29
+ import { join } from "node:path";
30
+ import { tmpdir } from "node:os";
31
+
32
+ import { syncProjectRootToWorktree } from "../auto-worktree.ts";
33
+ import { createTestContext } from "./test-helpers.ts";
34
+
35
+ const { assertTrue, assertEq, report } = createTestContext();
36
+
37
+ function createBase(name: string): string {
38
+ const base = mkdtempSync(join(tmpdir(), `gsd-wt-1886-${name}-`));
39
+ mkdirSync(join(base, ".gsd", "milestones"), { recursive: true });
40
+ return base;
41
+ }
42
+
43
+ function cleanup(base: string): void {
44
+ rmSync(base, { recursive: true, force: true });
45
+ }
46
+
47
+ async function main(): Promise<void> {
48
+ // ─── 1. Worktree VALIDATION.md must NOT be overwritten by project root ──
49
+ console.log(
50
+ "\n=== 1. #1886: worktree VALIDATION.md preserved (not overwritten) ===",
51
+ );
52
+ {
53
+ const mainBase = createBase("main");
54
+ const wtBase = createBase("wt");
55
+
56
+ try {
57
+ // Project root has an older CONTEXT but no VALIDATION
58
+ const prM004 = join(mainBase, ".gsd", "milestones", "M004");
59
+ mkdirSync(prM004, { recursive: true });
60
+ writeFileSync(join(prM004, "M004-CONTEXT.md"), "# old context");
61
+
62
+ // Worktree has CONTEXT + VALIDATION (written by validate-milestone)
63
+ const wtM004 = join(wtBase, ".gsd", "milestones", "M004");
64
+ mkdirSync(wtM004, { recursive: true });
65
+ writeFileSync(join(wtM004, "M004-CONTEXT.md"), "# worktree context");
66
+ writeFileSync(
67
+ join(wtM004, "M004-VALIDATION.md"),
68
+ "verdict: pass\nremediation_round: 1",
69
+ );
70
+
71
+ syncProjectRootToWorktree(mainBase, wtBase, "M004");
72
+
73
+ // VALIDATION.md must still exist in worktree
74
+ assertTrue(
75
+ existsSync(join(wtM004, "M004-VALIDATION.md")),
76
+ "#1886: VALIDATION.md still exists after sync",
77
+ );
78
+ assertEq(
79
+ readFileSync(join(wtM004, "M004-VALIDATION.md"), "utf-8"),
80
+ "verdict: pass\nremediation_round: 1",
81
+ "#1886: VALIDATION.md content preserved",
82
+ );
83
+
84
+ // CONTEXT.md should NOT be overwritten — worktree version is authoritative
85
+ assertEq(
86
+ readFileSync(join(wtM004, "M004-CONTEXT.md"), "utf-8"),
87
+ "# worktree context",
88
+ "#1886: existing worktree CONTEXT.md not overwritten",
89
+ );
90
+ } finally {
91
+ cleanup(mainBase);
92
+ cleanup(wtBase);
93
+ }
94
+ }
95
+
96
+ // ─── 2. Missing files ARE still copied from project root ────────────────
97
+ console.log("\n=== 2. #1886: missing worktree files still copied ===");
98
+ {
99
+ const mainBase = createBase("main");
100
+ const wtBase = createBase("wt");
101
+
102
+ try {
103
+ const prM004 = join(mainBase, ".gsd", "milestones", "M004");
104
+ mkdirSync(prM004, { recursive: true });
105
+ writeFileSync(join(prM004, "M004-CONTEXT.md"), "# from project root");
106
+ writeFileSync(join(prM004, "M004-ROADMAP.md"), "# roadmap");
107
+
108
+ // Worktree has no M004 directory at all
109
+ syncProjectRootToWorktree(mainBase, wtBase, "M004");
110
+
111
+ assertTrue(
112
+ existsSync(join(wtBase, ".gsd", "milestones", "M004", "M004-CONTEXT.md")),
113
+ "#1886: missing CONTEXT.md copied from project root",
114
+ );
115
+ assertTrue(
116
+ existsSync(join(wtBase, ".gsd", "milestones", "M004", "M004-ROADMAP.md")),
117
+ "#1886: missing ROADMAP.md copied from project root",
118
+ );
119
+ } finally {
120
+ cleanup(mainBase);
121
+ cleanup(wtBase);
122
+ }
123
+ }
124
+
125
+ // ─── 3. completed-units.json forward-synced from project root ───────────
126
+ console.log(
127
+ "\n=== 3. #1886: completed-units.json forward-synced to worktree ===",
128
+ );
129
+ {
130
+ const mainBase = createBase("main");
131
+ const wtBase = createBase("wt");
132
+
133
+ try {
134
+ // Project root has completed units (authoritative after crash recovery)
135
+ writeFileSync(
136
+ join(mainBase, ".gsd", "completed-units.json"),
137
+ JSON.stringify(["validate-milestone/M004"]),
138
+ );
139
+
140
+ // Worktree has empty completed-units
141
+ writeFileSync(
142
+ join(wtBase, ".gsd", "completed-units.json"),
143
+ JSON.stringify([]),
144
+ );
145
+
146
+ syncProjectRootToWorktree(mainBase, wtBase, "M004");
147
+
148
+ const wtCompleted = JSON.parse(
149
+ readFileSync(join(wtBase, ".gsd", "completed-units.json"), "utf-8"),
150
+ );
151
+ assertEq(
152
+ wtCompleted,
153
+ ["validate-milestone/M004"],
154
+ "#1886: completed-units.json synced from project root (force:true)",
155
+ );
156
+ } finally {
157
+ cleanup(mainBase);
158
+ cleanup(wtBase);
159
+ }
160
+ }
161
+
162
+ // ─── 4. completed-units.json: no-op when project root has no file ───────
163
+ console.log(
164
+ "\n=== 4. #1886: completed-units.json no-op when missing in project root ===",
165
+ );
166
+ {
167
+ const mainBase = createBase("main");
168
+ const wtBase = createBase("wt");
169
+
170
+ try {
171
+ // Project root milestone dir must exist for sync to run
172
+ const prM004 = join(mainBase, ".gsd", "milestones", "M004");
173
+ mkdirSync(prM004, { recursive: true });
174
+
175
+ // No completed-units.json in project root
176
+ // Worktree has its own
177
+ writeFileSync(
178
+ join(wtBase, ".gsd", "completed-units.json"),
179
+ JSON.stringify(["some-unit/M001"]),
180
+ );
181
+
182
+ syncProjectRootToWorktree(mainBase, wtBase, "M004");
183
+
184
+ const wtCompleted = JSON.parse(
185
+ readFileSync(join(wtBase, ".gsd", "completed-units.json"), "utf-8"),
186
+ );
187
+ assertEq(
188
+ wtCompleted,
189
+ ["some-unit/M001"],
190
+ "#1886: worktree completed-units.json untouched when project root has none",
191
+ );
192
+ } finally {
193
+ cleanup(mainBase);
194
+ cleanup(wtBase);
195
+ }
196
+ }
197
+
198
+ report();
199
+ }
200
+
201
+ main().catch((error) => {
202
+ console.error(error);
203
+ process.exit(1);
204
+ });