@opengsd/gsd-pi 1.2.0-dev.4c756166 → 1.2.0-dev.955e4da0

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 (246) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/bg-shell/utilities.js +2 -2
  3. package/dist/resources/extensions/claude-code-cli/models.js +9 -0
  4. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +8 -2
  5. package/dist/resources/extensions/gsd/auto/orchestrator.js +33 -4
  6. package/dist/resources/extensions/gsd/auto/phases.js +6 -1
  7. package/dist/resources/extensions/gsd/auto-post-unit.js +8 -6
  8. package/dist/resources/extensions/gsd/auto-start.js +8 -13
  9. package/dist/resources/extensions/gsd/auto-worktree-repair.js +10 -2
  10. package/dist/resources/extensions/gsd/auto-worktree.js +13 -270
  11. package/dist/resources/extensions/gsd/auto.js +4 -7
  12. package/dist/resources/extensions/gsd/bootstrap/dynamic-tools.js +9 -6
  13. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +32 -3
  14. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +26 -4
  15. package/dist/resources/extensions/gsd/captures.js +5 -13
  16. package/dist/resources/extensions/gsd/closeout-recovery.js +3 -2
  17. package/dist/resources/extensions/gsd/commands/catalog.js +6 -62
  18. package/dist/resources/extensions/gsd/db/engine.js +755 -0
  19. package/dist/resources/extensions/gsd/db/queries.js +372 -0
  20. package/dist/resources/extensions/gsd/db/sql-constants.js +11 -0
  21. package/dist/resources/extensions/gsd/db/writers/cascades.js +194 -0
  22. package/dist/resources/extensions/gsd/db/writers/import-restore.js +182 -0
  23. package/dist/resources/extensions/gsd/db/writers/memory.js +149 -0
  24. package/dist/resources/extensions/gsd/db/writers/reconcile.js +458 -0
  25. package/dist/resources/extensions/gsd/db/writers/status.js +70 -0
  26. package/dist/resources/extensions/gsd/doctor-environment.js +8 -10
  27. package/dist/resources/extensions/gsd/doctor-git-checks.js +4 -3
  28. package/dist/resources/extensions/gsd/doctor-runtime-checks.js +9 -2
  29. package/dist/resources/extensions/gsd/git-service.js +1 -0
  30. package/dist/resources/extensions/gsd/gitignore.js +3 -0
  31. package/dist/resources/extensions/gsd/gsd-db.js +171 -2048
  32. package/dist/resources/extensions/gsd/guided-flow.js +34 -3
  33. package/dist/resources/extensions/gsd/migrate/safety.js +17 -9
  34. package/dist/resources/extensions/gsd/migration-auto-check.js +24 -3
  35. package/dist/resources/extensions/gsd/model-cost-table.js +1 -0
  36. package/dist/resources/extensions/gsd/model-router.js +3 -0
  37. package/dist/resources/extensions/gsd/parallel-merge.js +14 -11
  38. package/dist/resources/extensions/gsd/parallel-monitor-overlay.js +7 -5
  39. package/dist/resources/extensions/gsd/paths.js +10 -24
  40. package/dist/resources/extensions/gsd/preferences.js +14 -0
  41. package/dist/resources/extensions/gsd/recovery-classification.js +12 -1
  42. package/dist/resources/extensions/gsd/safety/evidence-collector.js +37 -4
  43. package/dist/resources/extensions/gsd/safety/evidence-cross-ref.js +7 -2
  44. package/dist/resources/extensions/gsd/safety/file-change-validator.js +10 -0
  45. package/dist/resources/extensions/gsd/state-transition-matrix.js +38 -0
  46. package/dist/resources/extensions/gsd/status-guards.js +56 -8
  47. package/dist/resources/extensions/gsd/tools/complete-slice.js +24 -43
  48. package/dist/resources/extensions/gsd/tools/exec-tool.js +5 -5
  49. package/dist/resources/extensions/gsd/tools/reopen-milestone.js +11 -29
  50. package/dist/resources/extensions/gsd/tools/reopen-slice.js +14 -33
  51. package/dist/resources/extensions/gsd/tools/skip-slice.js +18 -36
  52. package/dist/resources/extensions/gsd/undo.js +8 -7
  53. package/dist/resources/extensions/gsd/worktree-git-recovery.js +287 -0
  54. package/dist/resources/extensions/gsd/worktree-lifecycle.js +9 -1
  55. package/dist/resources/extensions/gsd/worktree-manager.js +45 -28
  56. package/dist/resources/extensions/gsd/worktree-placement.js +59 -0
  57. package/dist/resources/extensions/gsd/worktree-reentry.js +12 -8
  58. package/dist/resources/extensions/gsd/worktree-root.js +17 -6
  59. package/dist/resources/extensions/gsd/worktree-safety.js +8 -5
  60. package/dist/resources/extensions/gsd/worktree-session-state.js +12 -10
  61. package/dist/resources/skills/gsd-browser/SKILL.md +1 -1
  62. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  63. package/dist/web/standalone/.next/BUILD_ID +1 -1
  64. package/dist/web/standalone/.next/app-path-routes-manifest.json +13 -13
  65. package/dist/web/standalone/.next/build-manifest.json +2 -2
  66. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  67. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  68. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  71. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  73. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  74. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  75. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  76. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  77. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  78. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  79. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  80. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  81. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  82. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  83. package/dist/web/standalone/.next/server/app/api/boot/route.js.nft.json +1 -1
  84. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js.nft.json +1 -1
  85. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js.nft.json +1 -1
  86. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js.nft.json +1 -1
  87. package/dist/web/standalone/.next/server/app/api/captures/route.js.nft.json +1 -1
  88. package/dist/web/standalone/.next/server/app/api/cleanup/route.js.nft.json +1 -1
  89. package/dist/web/standalone/.next/server/app/api/doctor/route.js.nft.json +1 -1
  90. package/dist/web/standalone/.next/server/app/api/export-data/route.js.nft.json +1 -1
  91. package/dist/web/standalone/.next/server/app/api/files/route.js.nft.json +1 -1
  92. package/dist/web/standalone/.next/server/app/api/forensics/route.js.nft.json +1 -1
  93. package/dist/web/standalone/.next/server/app/api/git/route.js.nft.json +1 -1
  94. package/dist/web/standalone/.next/server/app/api/history/route.js.nft.json +1 -1
  95. package/dist/web/standalone/.next/server/app/api/hooks/route.js.nft.json +1 -1
  96. package/dist/web/standalone/.next/server/app/api/inspect/route.js.nft.json +1 -1
  97. package/dist/web/standalone/.next/server/app/api/knowledge/route.js.nft.json +1 -1
  98. package/dist/web/standalone/.next/server/app/api/live-state/route.js.nft.json +1 -1
  99. package/dist/web/standalone/.next/server/app/api/mcp-connections/route.js.nft.json +1 -1
  100. package/dist/web/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
  101. package/dist/web/standalone/.next/server/app/api/onboarding/route.js.nft.json +1 -1
  102. package/dist/web/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
  103. package/dist/web/standalone/.next/server/app/api/recovery/route.js.nft.json +1 -1
  104. package/dist/web/standalone/.next/server/app/api/session/browser/route.js.nft.json +1 -1
  105. package/dist/web/standalone/.next/server/app/api/session/command/route.js.nft.json +1 -1
  106. package/dist/web/standalone/.next/server/app/api/session/events/route.js.nft.json +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.nft.json +1 -1
  109. package/dist/web/standalone/.next/server/app/api/shutdown/route.js.nft.json +1 -1
  110. package/dist/web/standalone/.next/server/app/api/skill-health/route.js.nft.json +1 -1
  111. package/dist/web/standalone/.next/server/app/api/steer/route.js.nft.json +1 -1
  112. package/dist/web/standalone/.next/server/app/api/switch-root/route.js.nft.json +1 -1
  113. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js.nft.json +1 -1
  114. package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js.nft.json +1 -1
  115. package/dist/web/standalone/.next/server/app/api/undo/route.js.nft.json +1 -1
  116. package/dist/web/standalone/.next/server/app/api/visualizer/route.js.nft.json +1 -1
  117. package/dist/web/standalone/.next/server/app/index.html +1 -1
  118. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  119. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  120. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  121. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  122. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  123. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  124. package/dist/web/standalone/.next/server/app-paths-manifest.json +13 -13
  125. package/dist/web/standalone/.next/server/chunks/{5047.js → 5942.js} +2 -2
  126. package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
  127. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  128. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  129. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  130. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  131. package/dist/worktree-status-banner.js +7 -3
  132. package/package.json +1 -1
  133. package/packages/cloud-mcp-gateway/package.json +2 -2
  134. package/packages/contracts/package.json +1 -1
  135. package/packages/daemon/package.json +4 -4
  136. package/packages/gsd-agent-core/package.json +5 -5
  137. package/packages/gsd-agent-modes/package.json +7 -7
  138. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  139. package/packages/mcp-server/dist/workflow-tools.js +30 -21
  140. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  141. package/packages/mcp-server/package.json +3 -3
  142. package/packages/native/package.json +1 -1
  143. package/packages/pi-agent-core/package.json +1 -1
  144. package/packages/pi-ai/dist/models.generated.d.ts +266 -35
  145. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  146. package/packages/pi-ai/dist/models.generated.js +235 -46
  147. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  148. package/packages/pi-ai/package.json +1 -1
  149. package/packages/pi-coding-agent/dist/core/capability-patches.d.ts.map +1 -1
  150. package/packages/pi-coding-agent/dist/core/capability-patches.js +3 -1
  151. package/packages/pi-coding-agent/dist/core/capability-patches.js.map +1 -1
  152. package/packages/pi-coding-agent/package.json +7 -7
  153. package/packages/pi-tui/package.json +2 -2
  154. package/packages/rpc-client/package.json +2 -2
  155. package/pkg/package.json +1 -1
  156. package/src/resources/extensions/bg-shell/utilities.ts +2 -2
  157. package/src/resources/extensions/claude-code-cli/models.ts +9 -0
  158. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +6 -0
  159. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +28 -0
  160. package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -1
  161. package/src/resources/extensions/gsd/auto/orchestrator.ts +39 -5
  162. package/src/resources/extensions/gsd/auto/phases.ts +10 -1
  163. package/src/resources/extensions/gsd/auto-post-unit.ts +12 -5
  164. package/src/resources/extensions/gsd/auto-start.ts +8 -14
  165. package/src/resources/extensions/gsd/auto-worktree-repair.ts +13 -2
  166. package/src/resources/extensions/gsd/auto-worktree.ts +20 -280
  167. package/src/resources/extensions/gsd/auto.ts +12 -9
  168. package/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts +10 -6
  169. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +32 -3
  170. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +25 -3
  171. package/src/resources/extensions/gsd/captures.ts +5 -14
  172. package/src/resources/extensions/gsd/closeout-recovery.ts +2 -1
  173. package/src/resources/extensions/gsd/commands/catalog.ts +6 -68
  174. package/src/resources/extensions/gsd/db/engine.ts +809 -0
  175. package/src/resources/extensions/gsd/db/queries.ts +453 -0
  176. package/src/resources/extensions/gsd/db/sql-constants.ts +12 -0
  177. package/src/resources/extensions/gsd/db/writers/cascades.ts +237 -0
  178. package/src/resources/extensions/gsd/db/writers/import-restore.ts +310 -0
  179. package/src/resources/extensions/gsd/db/writers/memory.ts +220 -0
  180. package/src/resources/extensions/gsd/db/writers/reconcile.ts +500 -0
  181. package/src/resources/extensions/gsd/db/writers/status.ts +88 -0
  182. package/src/resources/extensions/gsd/doctor-environment.ts +8 -11
  183. package/src/resources/extensions/gsd/doctor-git-checks.ts +3 -3
  184. package/src/resources/extensions/gsd/doctor-runtime-checks.ts +10 -3
  185. package/src/resources/extensions/gsd/git-service.ts +1 -0
  186. package/src/resources/extensions/gsd/gitignore.ts +3 -0
  187. package/src/resources/extensions/gsd/gsd-db.ts +173 -2373
  188. package/src/resources/extensions/gsd/guided-flow.ts +34 -3
  189. package/src/resources/extensions/gsd/migrate/safety.ts +15 -7
  190. package/src/resources/extensions/gsd/migration-auto-check.ts +28 -3
  191. package/src/resources/extensions/gsd/model-cost-table.ts +1 -0
  192. package/src/resources/extensions/gsd/model-router.ts +3 -0
  193. package/src/resources/extensions/gsd/parallel-merge.ts +12 -9
  194. package/src/resources/extensions/gsd/parallel-monitor-overlay.ts +6 -5
  195. package/src/resources/extensions/gsd/paths.ts +9 -22
  196. package/src/resources/extensions/gsd/preferences.ts +18 -0
  197. package/src/resources/extensions/gsd/recovery-classification.ts +14 -1
  198. package/src/resources/extensions/gsd/safety/evidence-collector.ts +36 -4
  199. package/src/resources/extensions/gsd/safety/evidence-cross-ref.ts +7 -2
  200. package/src/resources/extensions/gsd/safety/file-change-validator.ts +14 -0
  201. package/src/resources/extensions/gsd/state-transition-matrix.ts +42 -0
  202. package/src/resources/extensions/gsd/status-guards.ts +59 -8
  203. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +123 -0
  204. package/src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts +3 -1
  205. package/src/resources/extensions/gsd/tests/auto-post-unit-evidence-crossref-4909.test.ts +46 -0
  206. package/src/resources/extensions/gsd/tests/auto-worktree-registry.test.ts +2 -2
  207. package/src/resources/extensions/gsd/tests/auto-worktree-repair.test.ts +4 -2
  208. package/src/resources/extensions/gsd/tests/clear-stale-autostart.test.ts +22 -0
  209. package/src/resources/extensions/gsd/tests/evidence-xref-gsd-exec.test.ts +157 -0
  210. package/src/resources/extensions/gsd/tests/file-change-validator.test.ts +33 -1
  211. package/src/resources/extensions/gsd/tests/integration/auto-worktree-milestone-merge.test.ts +5 -4
  212. package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +1 -1
  213. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +3 -2
  214. package/src/resources/extensions/gsd/tests/migration-auto-check.test.ts +85 -1
  215. package/src/resources/extensions/gsd/tests/recovery-classification-illegal-transition.test.ts +30 -0
  216. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +91 -1
  217. package/src/resources/extensions/gsd/tests/safety-harness-false-positives.test.ts +38 -0
  218. package/src/resources/extensions/gsd/tests/session-switch-clears-pending-autostart.test.ts +108 -0
  219. package/src/resources/extensions/gsd/tests/single-writer-invariant.test.ts +43 -6
  220. package/src/resources/extensions/gsd/tests/state-transition-matrix.test.ts +36 -0
  221. package/src/resources/extensions/gsd/tests/status-guards.test.ts +38 -0
  222. package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +41 -4
  223. package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +22 -1
  224. package/src/resources/extensions/gsd/tests/worktree-placement.test.ts +113 -0
  225. package/src/resources/extensions/gsd/tests/worktree-reentry.test.ts +1 -1
  226. package/src/resources/extensions/gsd/tests/worktree-safety.test.ts +3 -1
  227. package/src/resources/extensions/gsd/tests/worktree-symlink-removal.test.ts +12 -6
  228. package/src/resources/extensions/gsd/tests/worktree-teardown-safety.test.ts +2 -2
  229. package/src/resources/extensions/gsd/tests/write-gate.test.ts +42 -0
  230. package/src/resources/extensions/gsd/tools/complete-slice.ts +23 -58
  231. package/src/resources/extensions/gsd/tools/exec-tool.ts +5 -5
  232. package/src/resources/extensions/gsd/tools/reopen-milestone.ts +11 -38
  233. package/src/resources/extensions/gsd/tools/reopen-slice.ts +14 -42
  234. package/src/resources/extensions/gsd/tools/skip-slice.ts +18 -44
  235. package/src/resources/extensions/gsd/undo.ts +9 -8
  236. package/src/resources/extensions/gsd/worktree-git-recovery.ts +308 -0
  237. package/src/resources/extensions/gsd/worktree-lifecycle.ts +10 -1
  238. package/src/resources/extensions/gsd/worktree-manager.ts +47 -28
  239. package/src/resources/extensions/gsd/worktree-placement.ts +63 -0
  240. package/src/resources/extensions/gsd/worktree-reentry.ts +10 -7
  241. package/src/resources/extensions/gsd/worktree-root.ts +17 -6
  242. package/src/resources/extensions/gsd/worktree-safety.ts +8 -5
  243. package/src/resources/extensions/gsd/worktree-session-state.ts +12 -10
  244. package/src/resources/skills/gsd-browser/SKILL.md +1 -1
  245. /package/dist/web/standalone/.next/static/{DUFWcMFRH3iXh7d2fbrOF → C24pqUd-aru-l0Dp0gLZP}/_buildManifest.js +0 -0
  246. /package/dist/web/standalone/.next/static/{DUFWcMFRH3iXh7d2fbrOF → C24pqUd-aru-l0Dp0gLZP}/_ssgManifest.js +0 -0
@@ -1582,6 +1582,25 @@ function selfHealRuntimeRecords(basePath: string, ctx: ExtensionContext): { clea
1582
1582
  }
1583
1583
  }
1584
1584
 
1585
+ /**
1586
+ * True when an agent turn is currently streaming or a dispatched message is
1587
+ * still queued waiting to trigger one. Used by the pending-auto-start stale
1588
+ * check: a live discuss turn can run for minutes before writing its first
1589
+ * artifact, and deleting its entry as "stale" re-dispatches the workflow —
1590
+ * resetting the interview and producing a duplicate completion turn.
1591
+ */
1592
+ function isAgentTurnInFlight(ctx: ExtensionCommandContext): boolean {
1593
+ try {
1594
+ if (typeof ctx.isIdle === "function" && !ctx.isIdle()) return true;
1595
+ if (typeof ctx.hasPendingMessages === "function" && ctx.hasPendingMessages()) return true;
1596
+ } catch {
1597
+ // assertActive() throws on a stale runner context; fall through to
1598
+ // artifact/age staleness signals.
1599
+ logWarning("guided", "isAgentTurnInFlight: ctx method threw (stale runner); assuming no turn in flight");
1600
+ }
1601
+ return false;
1602
+ }
1603
+
1585
1604
  // ─── Milestone Actions Submenu ──────────────────────────────────────────────
1586
1605
 
1587
1606
  /**
@@ -1911,12 +1930,18 @@ export async function showSmartEntry(
1911
1930
  // and fires another dispatchWorkflow, resetting the conversation mid-interview.
1912
1931
  if (hasPendingAutoStart(basePath)) {
1913
1932
  // #3274: If /clear interrupted the discussion, the pending entry is stale.
1914
- // Detect staleness: no manifest, no milestone CONTEXT artifact, AND entry is older than
1915
- // 30s (avoids race between .set() and LLM writing first artifact).
1933
+ // Detect staleness: no manifest, no milestone CONTEXT/CONTEXT-DRAFT artifact,
1934
+ // the entry is older than 30s (avoids race between .set() and LLM writing the
1935
+ // first artifact), AND no agent turn is in flight. A dispatched discuss turn
1936
+ // can think for well over 30s before its first question round writes any
1937
+ // artifact; deleting the entry while that turn is live re-dispatches the
1938
+ // workflow, which both resets the interview and queues a duplicate turn that
1939
+ // replays the final "context written" message after the real one.
1916
1940
  const entry = _getPendingAutoStart(basePath)!;
1917
1941
  const ageMs = Date.now() - (entry.createdAt || 0);
1918
1942
  const manifestExists = existsSync(join(gsdRoot(basePath), "DISCUSSION-MANIFEST.json"));
1919
1943
  const milestoneHasContext = !!resolveMilestoneFile(basePath, entry.milestoneId, "CONTEXT");
1944
+ const milestoneHasDraft = !!resolveMilestoneFile(basePath, entry.milestoneId, "CONTEXT-DRAFT");
1920
1945
  const milestoneHasRoadmap = !!resolveMilestoneFile(basePath, entry.milestoneId, "ROADMAP");
1921
1946
  const milestoneRow = isDbAvailable() ? getMilestone(entry.milestoneId) : null;
1922
1947
  const discussPlanComplete = milestoneHasRoadmap && !!milestoneRow && milestoneRow.status !== "queued";
@@ -1924,7 +1949,13 @@ export async function showSmartEntry(
1924
1949
  // The discuss flow already completed, but pending auto-start cleanup handshake did not run.
1925
1950
  // Clear stale in-memory guard and continue through normal active-milestone routing.
1926
1951
  deletePendingAutoStart(basePath);
1927
- } else if (!manifestExists && !milestoneHasContext && ageMs > 30_000) {
1952
+ } else if (
1953
+ !manifestExists &&
1954
+ !milestoneHasContext &&
1955
+ !milestoneHasDraft &&
1956
+ ageMs > 30_000 &&
1957
+ !isAgentTurnInFlight(ctx)
1958
+ ) {
1928
1959
  // Stale entry from an interrupted discussion — clear and continue
1929
1960
  deletePendingAutoStart(basePath);
1930
1961
  } else {
@@ -106,14 +106,22 @@ export function assertMigrationHasSlices(preview: MigrationPreview): void {
106
106
  }
107
107
 
108
108
  function hasWorktreeState(targetRoot: string): boolean {
109
- const worktreesDir = join(gsdRoot(targetRoot), "worktrees");
110
- if (!existsSync(worktreesDir)) return false;
111
- try {
112
- return readdirSync(worktreesDir, { withFileTypes: true })
113
- .some((entry) => entry.isDirectory() || entry.isFile());
114
- } catch {
115
- return true;
109
+ const containers = [
110
+ join(targetRoot, ".gsd-worktrees"),
111
+ join(gsdRoot(targetRoot), "worktrees"),
112
+ ];
113
+ for (const worktreesDir of containers) {
114
+ if (!existsSync(worktreesDir)) continue;
115
+ try {
116
+ if (readdirSync(worktreesDir, { withFileTypes: true })
117
+ .some((entry) => entry.isDirectory() || entry.isFile())) {
118
+ return true;
119
+ }
120
+ } catch {
121
+ return true;
122
+ }
116
123
  }
124
+ return false;
117
125
  }
118
126
 
119
127
  export async function assertMigrationTargetAvailable(targetRoot: string): Promise<void> {
@@ -39,6 +39,9 @@ interface HierarchyScan {
39
39
  milestones: Set<string>;
40
40
  slices: Set<string>;
41
41
  tasks: Set<string>;
42
+ // Markdown milestones whose dir has no ROADMAP (CONTEXT/CONTEXT-DRAFT only
43
+ // or empty). Always empty for DB scans.
44
+ milestonesWithoutRoadmap: Set<string>;
42
45
  }
43
46
 
44
47
  function zeroCounts(): HierarchyCounts {
@@ -46,7 +49,13 @@ function zeroCounts(): HierarchyCounts {
46
49
  }
47
50
 
48
51
  function emptyScan(): HierarchyScan {
49
- return { counts: zeroCounts(), milestones: new Set(), slices: new Set(), tasks: new Set() };
52
+ return {
53
+ counts: zeroCounts(),
54
+ milestones: new Set(),
55
+ slices: new Set(),
56
+ tasks: new Set(),
57
+ milestonesWithoutRoadmap: new Set(),
58
+ };
50
59
  }
51
60
 
52
61
  function sameCounts(a: HierarchyCounts, b: HierarchyCounts): boolean {
@@ -109,7 +118,10 @@ export function scanMarkdownHierarchy(basePath: string): HierarchyScan {
109
118
  scan.milestones.add(milestoneId);
110
119
 
111
120
  const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
112
- if (!roadmapPath || !existsSync(roadmapPath)) continue;
121
+ if (!roadmapPath || !existsSync(roadmapPath)) {
122
+ scan.milestonesWithoutRoadmap.add(milestoneId);
123
+ continue;
124
+ }
113
125
 
114
126
  const roadmap = parseRoadmap(readFileSync(roadmapPath, "utf-8"));
115
127
  scan.counts.slices += roadmap.slices.length;
@@ -164,7 +176,6 @@ export async function checkMarkdownHierarchyAgainstDb(
164
176
  basePath: string,
165
177
  ): Promise<MigrationAutoCheckResult> {
166
178
  const markdownScan = scanMarkdownHierarchy(basePath);
167
- const markdown = markdownScan.counts;
168
179
 
169
180
  // Always open the DB before deciding. An empty markdown tree does NOT imply
170
181
  // an empty project — the DB may hold authoritative rows whose markdown was
@@ -184,6 +195,20 @@ export async function checkMarkdownHierarchyAgainstDb(
184
195
  const dbScan = scanDbHierarchy();
185
196
  const beforeDb = dbScan.counts;
186
197
 
198
+ // Discussion-phase scratch: a milestone dir with no ROADMAP and no DB row is
199
+ // a pre-registration discussion artifact (CONTEXT/CONTEXT-DRAFT only — the
200
+ // queued DB row is inserted only at discussion handoff). Treating it as
201
+ // drift would warn on every live discussion and recommend
202
+ // `/gsd recover --confirm`, an import that materializes abandoned-discussion
203
+ // dirs as ghost active milestones. Exclude such dirs from this comparison
204
+ // only; recover preflights use the raw scans and still see them.
205
+ for (const id of markdownScan.milestonesWithoutRoadmap) {
206
+ if (dbScan.milestones.has(id)) continue;
207
+ markdownScan.milestones.delete(id);
208
+ markdownScan.counts.milestones--;
209
+ }
210
+ const markdown = markdownScan.counts;
211
+
187
212
  const markdownEmpty = sameCounts(markdown, zeroCounts());
188
213
  const dbEmpty = sameCounts(beforeDb, zeroCounts());
189
214
 
@@ -25,6 +25,7 @@ export const BUNDLED_COST_TABLE: ModelCostEntry[] = [
25
25
  { id: "claude-opus-4-6", inputPer1k: 0.005, outputPer1k: 0.025, updatedAt: "2026-04-16" },
26
26
  { id: "claude-opus-4-7", inputPer1k: 0.005, outputPer1k: 0.025, updatedAt: "2026-04-16" },
27
27
  { id: "claude-opus-4-8", inputPer1k: 0.005, outputPer1k: 0.025, updatedAt: "2026-05-28" },
28
+ { id: "claude-fable-5", inputPer1k: 0.010, outputPer1k: 0.050, updatedAt: "2026-06-09" },
28
29
  { id: "claude-sonnet-4-6", inputPer1k: 0.003, outputPer1k: 0.015, updatedAt: "2025-03-15" },
29
30
  { id: "claude-haiku-4-5", inputPer1k: 0.0008, outputPer1k: 0.004, updatedAt: "2025-03-15" },
30
31
  { id: "claude-sonnet-4-5-20250514", inputPer1k: 0.003, outputPer1k: 0.015, updatedAt: "2025-03-15" },
@@ -101,6 +101,7 @@ export const MODEL_CAPABILITY_TIER: Record<string, ComplexityTier> = {
101
101
  "claude-opus-4-6": "heavy",
102
102
  "claude-opus-4-7": "heavy",
103
103
  "claude-opus-4-8": "heavy",
104
+ "claude-fable-5": "heavy",
104
105
  "claude-3-opus-latest": "heavy",
105
106
  "gpt-4-turbo": "heavy",
106
107
  "gpt-5": "heavy",
@@ -129,6 +130,7 @@ const MODEL_COST_PER_1K_INPUT: Record<string, number> = {
129
130
  "claude-opus-4-6": 0.005,
130
131
  "claude-opus-4-7": 0.005,
131
132
  "claude-opus-4-8": 0.005,
133
+ "claude-fable-5": 0.010,
132
134
  "gpt-4o-mini": 0.00015,
133
135
  "gpt-4o": 0.0025,
134
136
  "gpt-4.1": 0.002,
@@ -164,6 +166,7 @@ export const MODEL_CAPABILITY_PROFILES: Record<string, ModelCapabilities> = {
164
166
  "claude-opus-4-6": { coding: 95, debugging: 90, research: 85, reasoning: 95, speed: 30, longContext: 80, instruction: 90 },
165
167
  "claude-opus-4-7": { coding: 95, debugging: 90, research: 85, reasoning: 95, speed: 30, longContext: 80, instruction: 90 },
166
168
  "claude-opus-4-8": { coding: 97, debugging: 92, research: 87, reasoning: 97, speed: 30, longContext: 85, instruction: 92 },
169
+ "claude-fable-5": { coding: 97, debugging: 92, research: 87, reasoning: 97, speed: 30, longContext: 85, instruction: 92 },
167
170
  "claude-sonnet-4-6": { coding: 85, debugging: 80, research: 75, reasoning: 80, speed: 60, longContext: 75, instruction: 85 },
168
171
  "claude-sonnet-4-5-20250514": { coding: 85, debugging: 80, research: 75, reasoning: 80, speed: 60, longContext: 75, instruction: 85 },
169
172
  "claude-3-5-sonnet-latest": { coding: 82, debugging: 78, research: 72, reasoning: 78, speed: 62, longContext: 70, instruction: 82 },
@@ -9,6 +9,7 @@ import { existsSync, readdirSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { spawnSync } from "node:child_process";
11
11
  import { resolveGsdPathContract } from "./paths.js";
12
+ import { worktreePathFor, worktreesDirs } from "./worktree-placement.js";
12
13
  import { getAutoWorktreePath } from "./auto-worktree.js";
13
14
  import { buildWorktreeLifecycleDeps } from "./auto.js";
14
15
  import {
@@ -42,7 +43,7 @@ export type MergeOrder = "sequential" | "by-completion";
42
43
  * Returns true when milestones.status = 'complete' in project gsd.db.
43
44
  */
44
45
  export function isMilestoneCompleteInProjectDb(basePath: string, mid: string): boolean {
45
- const workRoot = join(basePath, ".gsd", "worktrees", mid);
46
+ const workRoot = worktreePathFor(basePath, mid);
46
47
  const dbPath = resolveGsdPathContract(workRoot, basePath).projectDb;
47
48
  if (!existsSync(dbPath)) return false;
48
49
 
@@ -65,15 +66,17 @@ export function isMilestoneCompleteInProjectDb(basePath: string, mid: string): b
65
66
  */
66
67
  function discoverDbCompletedMilestones(basePath: string): Set<string> {
67
68
  const completed = new Set<string>();
68
- const worktreeDir = join(basePath, ".gsd", "worktrees");
69
- try {
70
- for (const entry of readdirSync(worktreeDir)) {
71
- if (entry.startsWith("M") && isMilestoneCompleteInProjectDb(basePath, entry)) {
72
- completed.add(entry);
69
+ for (const worktreeDir of worktreesDirs(basePath)) {
70
+ if (!existsSync(worktreeDir)) continue;
71
+ try {
72
+ for (const entry of readdirSync(worktreeDir)) {
73
+ if (entry.startsWith("M") && isMilestoneCompleteInProjectDb(basePath, entry)) {
74
+ completed.add(entry);
75
+ }
73
76
  }
77
+ } catch (e) {
78
+ logWarning("parallel", `readdirSync for completed set failed: ${(e as Error).message}`);
74
79
  }
75
- } catch (e) {
76
- logWarning("parallel", `readdirSync for completed set failed: ${(e as Error).message}`);
77
80
  }
78
81
  return completed;
79
82
  }
@@ -120,7 +123,7 @@ export function determineMergeOrder(
120
123
  title: mid,
121
124
  pid: 0,
122
125
  process: null,
123
- worktreePath: basePath ? join(basePath, ".gsd", "worktrees", mid) : "",
126
+ worktreePath: basePath ? worktreePathFor(basePath, mid) : "",
124
127
  startedAt: 0,
125
128
  state: "stopped",
126
129
  cost: 0,
@@ -11,6 +11,7 @@ import { matchesKey, Key } from "@gsd/pi-tui";
11
11
  import { formatDuration } from "../shared/mod.js";
12
12
  import { formattedShortcutPair } from "./shortcut-defs.js";
13
13
  import { resolveGsdPathContract } from "./paths.js";
14
+ import { worktreePathFor, worktreesDirs } from "./worktree-placement.js";
14
15
  import {
15
16
  renderBar,
16
17
  renderDialogFrame,
@@ -101,7 +102,6 @@ function tailRead(filePath: string, maxBytes: number): string {
101
102
 
102
103
  function discoverWorkers(basePath: string): string[] {
103
104
  const parallelDir = join(basePath, ".gsd", "parallel");
104
- const worktreeDir = join(basePath, ".gsd", "worktrees");
105
105
  const mids = new Set<string>();
106
106
 
107
107
  if (existsSync(parallelDir)) {
@@ -114,7 +114,8 @@ function discoverWorkers(basePath: string): string[] {
114
114
  } catch { /* skip */ }
115
115
  }
116
116
 
117
- if (existsSync(worktreeDir)) {
117
+ for (const worktreeDir of worktreesDirs(basePath)) {
118
+ if (!existsSync(worktreeDir)) continue;
118
119
  try {
119
120
  for (const d of readdirSync(worktreeDir)) {
120
121
  if (d.startsWith("M") && existsSync(join(worktreeDir, d, ".gsd", "auto.lock"))) {
@@ -128,7 +129,7 @@ function discoverWorkers(basePath: string): string[] {
128
129
  }
129
130
 
130
131
  function querySliceProgress(basePath: string, mid: string): SliceProgress[] {
131
- const workRoot = join(basePath, ".gsd", "worktrees", mid);
132
+ const workRoot = worktreePathFor(basePath, mid);
132
133
  const dbPath = resolveGsdPathContract(workRoot, basePath).projectDb;
133
134
  if (!existsSync(dbPath)) return [];
134
135
 
@@ -169,7 +170,7 @@ function extractCostFromNdjson(basePath: string, mid: string): number {
169
170
  }
170
171
 
171
172
  function queryRecentCompletions(basePath: string, mid: string): string[] {
172
- const workRoot = join(basePath, ".gsd", "worktrees", mid);
173
+ const workRoot = worktreePathFor(basePath, mid);
173
174
  const dbPath = resolveGsdPathContract(workRoot, basePath).projectDb;
174
175
  if (!existsSync(dbPath)) return [];
175
176
  try {
@@ -193,7 +194,7 @@ function collectWorkerData(basePath: string): WorkerView[] {
193
194
 
194
195
  for (const mid of mids) {
195
196
  const status = readJsonSafe<StatusJson>(join(parallelDir, `${mid}.status.json`));
196
- const lock = readJsonSafe<AutoLock>(join(basePath, ".gsd", "worktrees", mid, ".gsd", "auto.lock"));
197
+ const lock = readJsonSafe<AutoLock>(join(worktreePathFor(basePath, mid), ".gsd", "auto.lock"));
197
198
  const slices = querySliceProgress(basePath, mid);
198
199
 
199
200
  const pid = lock?.pid || status?.pid || 0;
@@ -17,7 +17,7 @@ import { spawnSync } from "node:child_process";
17
17
  import { nativeScanGsdTree, type GsdTreeEntry } from "./native-parser-bridge.js";
18
18
  import { DIR_CACHE_MAX } from "./constants.js";
19
19
  import { gsdHome } from "./gsd-home.js";
20
- import { isGsdWorktreePath, resolveExternalStateProjectGsdFromWorktreePath, resolveWorktreeProjectRoot } from "./worktree-root.js";
20
+ import { findWorktreeSegment, isGsdWorktreePath, resolveExternalStateProjectGsdFromWorktreePath, resolveWorktreeProjectRoot } from "./worktree-root.js";
21
21
 
22
22
  // ─── Directory Listing Cache ──────────────────────────────────────────────────
23
23
 
@@ -458,29 +458,16 @@ function assertNotGlobalGsdHome(basePath: string, result: string): void {
458
458
  * When gsdRoot() is called with such a path, we must NOT walk up to the
459
459
  * project root's .gsd — each worktree manages its own .gsd state (#2594).
460
460
  *
461
- * Matches both forward-slash and platform-native separators to handle
462
- * Windows paths (path.sep = '\\') and normalized Unix paths.
461
+ * Layout matching is owned by worktree-root's findWorktreeSegment; this
462
+ * only adds the requirement that a non-empty worktree name follows the
463
+ * marker (the worktrees container dir itself is not a worktree).
463
464
  */
464
465
  function isInsideGsdWorktree(p: string): boolean {
465
- // Match /.gsd/worktrees/<name> where <name> is the final segment or
466
- // followed by a separator. The <name> segment must be non-empty.
467
- const sepFwd = "/";
468
- const sepNative = "\\";
469
- const markers = [
470
- `${sepFwd}.gsd${sepFwd}worktrees${sepFwd}`,
471
- `${sepNative}.gsd${sepNative}worktrees${sepNative}`,
472
- ];
473
- for (const marker of markers) {
474
- const idx = p.indexOf(marker);
475
- if (idx === -1) continue;
476
- // Verify there's a non-empty worktree name after the marker
477
- const afterMarker = p.slice(idx + marker.length);
478
- // The name is everything up to the next separator (or end of string)
479
- const nameEnd = afterMarker.search(/[/\\]/);
480
- const name = nameEnd === -1 ? afterMarker : afterMarker.slice(0, nameEnd);
481
- if (name.length > 0) return true;
482
- }
483
- return false;
466
+ const normalized = p.replaceAll("\\", "/");
467
+ const segment = findWorktreeSegment(normalized);
468
+ if (!segment) return false;
469
+ const name = normalized.slice(segment.afterWorktrees).split("/")[0];
470
+ return name.length > 0;
484
471
  }
485
472
 
486
473
  function probeGsdRoot(rawBasePath: string): string {
@@ -867,6 +867,24 @@ export function getIsolationMode(basePath?: string): "none" | "worktree" | "bran
867
867
  return "none"; // default — no isolation, work on current branch
868
868
  }
869
869
 
870
+ /**
871
+ * Resolve the isolation mode a unit actually runs under. A session whose
872
+ * worktree isolation has degraded (worktree creation failed) falls back to
873
+ * the milestone branch in the project root, so configured "worktree" becomes
874
+ * effective "branch". A stranded-work recovery session likewise runs under
875
+ * the adopted mode (`strandedRecoveryIsolationMode`) rather than the
876
+ * configured one until the recovered milestone merges — adopting the
877
+ * milestone branch in the project root is intentional, not degraded.
878
+ */
879
+ export function resolveEffectiveUnitIsolationMode(
880
+ configuredMode: ReturnType<typeof getIsolationMode>,
881
+ isolationDegraded: boolean,
882
+ strandedRecoveryIsolationMode: "worktree" | "branch" | null = null,
883
+ ): ReturnType<typeof getIsolationMode> {
884
+ if (configuredMode === "worktree" && isolationDegraded) return "branch";
885
+ return strandedRecoveryIsolationMode ?? configuredMode;
886
+ }
887
+
870
888
  export function resolveParallelConfig(prefs: GSDPreferences | undefined): import("./types.js").ParallelConfig {
871
889
  return {
872
890
  enabled: prefs?.parallel?.enabled ?? false,
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { classifyError, isTransient, type ErrorClass } from "./error-classifier.js";
5
5
  import { ReconciliationFailedError } from "./state-reconciliation.js";
6
+ import { IllegalPhaseTransitionError } from "./state-transition-matrix.js";
6
7
 
7
8
  export type RecoveryFailureKind =
8
9
  | "tool-schema"
@@ -13,6 +14,7 @@ export type RecoveryFailureKind =
13
14
  | "worktree-invalid"
14
15
  | "verification-drift"
15
16
  | "reconciliation-drift"
17
+ | "illegal-transition"
16
18
  | "provider"
17
19
  | "runtime-unknown";
18
20
 
@@ -43,7 +45,9 @@ export function classifyFailure(input: RecoveryClassificationInput): RecoveryCla
43
45
  const failureKind =
44
46
  input.error instanceof ReconciliationFailedError
45
47
  ? "reconciliation-drift"
46
- : input.failureKind ?? inferFailureKind(message);
48
+ : input.error instanceof IllegalPhaseTransitionError
49
+ ? "illegal-transition"
50
+ : input.failureKind ?? inferFailureKind(message);
47
51
 
48
52
  switch (failureKind) {
49
53
  case "tool-schema":
@@ -111,6 +115,15 @@ export function classifyFailure(input: RecoveryClassificationInput): RecoveryCla
111
115
  remediation:
112
116
  "Inspect the persistent or repair-failed drift kinds reported by the State Reconciliation Module before resuming.",
113
117
  };
118
+ case "illegal-transition":
119
+ return {
120
+ failureKind,
121
+ action: "escalate",
122
+ reason: `Illegal phase transition${unitSuffix(input)}: ${message}`,
123
+ exitReason: "illegal-transition",
124
+ remediation:
125
+ "A derived Phase edge rejected by the Phase Transition Invariant survived reconciliation; inspect deriveState and the State Reconciliation Module before resuming.",
126
+ };
114
127
  case "provider": {
115
128
  const providerClass = classifyError(message, input.retryAfterMs);
116
129
  return {
@@ -57,9 +57,10 @@ const EXECUTION_TOOL_NAMES = new Set([
57
57
  "functions.exec_command",
58
58
  "gsd_exec",
59
59
  "gsd_exec_search",
60
+ "gsd_uat_exec",
60
61
  "powershell",
61
62
  ]);
62
- const MCP_EXECUTION_TOOL_RE = /^mcp__.+__gsd_exec(?:_search)?$/;
63
+ const MCP_EXECUTION_TOOL_RE = /^mcp__.+__gsd_(?:uat_)?exec(?:_search)?$/;
63
64
 
64
65
  // ─── Module State ───────────────────────────────────────────────────────────
65
66
 
@@ -206,11 +207,17 @@ export function clearEvidenceFromDisk(
206
207
  * Exit codes and output are filled in by recordToolResult after execution.
207
208
  */
208
209
  export function recordToolCall(toolCallId: string, toolName: string, input: Record<string, unknown>): void {
210
+ // Idempotent by toolCallId: native tools reach this via both
211
+ // tool_execution_start and tool_call; external (pre-executed) tools only
212
+ // via tool_execution_start. First recording wins.
213
+ if (unitEvidence.some(e => e.toolCallId === toolCallId)) return;
209
214
  if (isExecutionToolName(toolName)) {
210
215
  unitEvidence.push({
211
216
  kind: "bash",
212
217
  toolCallId,
213
- command: String(input.command ?? input.cmd ?? input.query ?? ""),
218
+ // gsd_exec / gsd_uat_exec carry the script body in `script` (or `code`);
219
+ // bash-style tools use `command`/`cmd`; gsd_exec_search uses `query`.
220
+ command: String(input.command ?? input.script ?? input.cmd ?? input.code ?? input.query ?? ""),
214
221
  exitCode: -1,
215
222
  outputSnippet: "",
216
223
  timestamp: Date.now(),
@@ -249,11 +256,36 @@ export function recordToolResult(
249
256
  if (entry.kind === "bash") {
250
257
  const text = extractResultText(result);
251
258
  entry.outputSnippet = text.slice(0, 500);
252
- const exitMatch = text.match(/Command exited with code (\d+)/);
253
- entry.exitCode = exitMatch ? Number(exitMatch[1]) : (isError ? 1 : 0);
259
+ entry.exitCode = resolveExitCode(text, isError);
254
260
  }
255
261
  }
256
262
 
263
+ /**
264
+ * Resolve the exit code from a tool result's text. Handles the bash tool's
265
+ * prose marker, the gsd_exec / gsd_uat_exec JSON envelope (`"exit_code": N`),
266
+ * and a last-resort read of the run's persisted `.gsd/exec/<id>.meta.json`
267
+ * (covers truncated result text).
268
+ */
269
+ function resolveExitCode(text: string, isError: boolean): number {
270
+ const proseMatch = text.match(/Command exited with code (\d+)/);
271
+ if (proseMatch) return Number(proseMatch[1]);
272
+
273
+ const jsonMatch = text.match(/"exit_code"\s*:\s*(-?\d+)/);
274
+ if (jsonMatch) return Number(jsonMatch[1]);
275
+
276
+ const metaMatch = text.match(/"meta_path"\s*:\s*"([^"]+)"/);
277
+ if (metaMatch) {
278
+ try {
279
+ const meta = JSON.parse(readFileSync(metaMatch[1], "utf-8")) as Record<string, unknown>;
280
+ if (typeof meta.exit_code === "number") return meta.exit_code;
281
+ } catch {
282
+ // Fall through to the isError heuristic
283
+ }
284
+ }
285
+
286
+ return isError ? 1 : 0;
287
+ }
288
+
257
289
  // ─── Internals ──────────────────────────────────────────────────────────────
258
290
 
259
291
  function extractResultText(result: unknown): string {
@@ -121,9 +121,14 @@ function findMatches(
121
121
  const exact = bashCalls.filter(b => b.command.trim() === normalized);
122
122
  if (exact.length > 0) return exact;
123
123
 
124
- // Substring match: claimed is contained in actual or actual in claimed
124
+ // Substring match: claimed is contained in actual or actual in claimed.
125
+ // A claimed verification command typically appears verbatim inside a
126
+ // larger gsd_exec script body (cd prefix, multi-line scripts), so
127
+ // script-containing-claim is the common direction. Blank-command entries
128
+ // must be excluded — `"x".includes("")` is true, so they'd match anything.
125
129
  const substring = bashCalls.filter(
126
- b => b.command.includes(normalized) || normalized.includes(b.command),
130
+ b => b.command.trim().length > 0 &&
131
+ (b.command.includes(normalized) || normalized.includes(b.command)),
127
132
  );
128
133
  if (substring.length > 0) return substring;
129
134
 
@@ -39,6 +39,20 @@ export interface FileChangeAudit {
39
39
 
40
40
  // ─── Public API ─────────────────────────────────────────────────────────────
41
41
 
42
+ /**
43
+ * Build the effective allowlist for a unit's file-change audit.
44
+ *
45
+ * When GSD manages .gitignore (manage_gitignore unset or true), ensureGitignore()
46
+ * appends baseline patterns at auto-start and the edit rides into the task's
47
+ * auto-commit — so .gitignore changes must not be attributed to the task.
48
+ */
49
+ export function effectiveFileChangeAllowlist(
50
+ baseAllowlist: string[],
51
+ manageGitignore: boolean | undefined,
52
+ ): string[] {
53
+ return manageGitignore === false ? baseAllowlist : [...baseAllowlist, ".gitignore"];
54
+ }
55
+
42
56
  /**
43
57
  * Validate file changes after auto-commit for an execute-task unit.
44
58
  * Returns null if task data is unavailable or DB is not loaded.
@@ -131,6 +131,48 @@ export function findTransition(
131
131
  );
132
132
  }
133
133
 
134
+ /**
135
+ * Edge-keyed legality check for the Phase Transition Invariant (ADR-030).
136
+ * `advance()` derives the next Phase and asserts the (from → to) edge here.
137
+ *
138
+ * The matrix is an assertion, not a decision-maker — `deriveState` already
139
+ * chose the Phase. A self-edge is trivially legal (no transition to assert). An
140
+ * edge is legal when some matrix entry permits it, honoring the `*` wildcard
141
+ * rows (e.g. any → blocked via manual-block, any → executing via
142
+ * retryable-failure).
143
+ *
144
+ * Note: the matrix is currently a sparse hardening spec, not a complete
145
+ * legal-edge graph, so `advance()` consumes this in advisory mode (telemetry
146
+ * only). It must be expanded to cover every edge `deriveState` emits before
147
+ * enforcement flips on.
148
+ */
149
+ export function isLegalEdge(from: Phase, to: Phase): boolean {
150
+ if (from === to) return true;
151
+ return STATE_TRANSITION_MATRIX.some(
152
+ (entry) => (entry.from === from || entry.from === "*") && entry.to === to,
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Thrown when an illegal derived Phase edge survives reconciliation. Carries
158
+ * both endpoints so Recovery Classification can report them. Recognized by
159
+ * class in `classifyFailure` and mapped to the `illegal-transition` kind.
160
+ */
161
+ export class IllegalPhaseTransitionError extends Error {
162
+ // Explicit fields, not constructor parameter properties — strip-types
163
+ // consumers (workspace-index subprocess, integration tests) reject the
164
+ // parameter-property syntax with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.
165
+ readonly from: Phase;
166
+ readonly to: Phase;
167
+
168
+ constructor(from: Phase, to: Phase) {
169
+ super(`Illegal phase transition ${from} -> ${to} survived reconciliation`);
170
+ this.name = "IllegalPhaseTransitionError";
171
+ this.from = from;
172
+ this.to = to;
173
+ }
174
+ }
175
+
134
176
  export function validateTransitionMatrix(requiredEvents: readonly string[]): MatrixValidationResult {
135
177
  const seen = new Set<string>();
136
178
  const duplicateKeys: string[] = [];
@@ -1,17 +1,68 @@
1
1
  /**
2
- * Status predicates for GSD state-machine guards.
2
+ * Status predicates and the canonical status vocabulary for GSD state-machine
3
+ * guards (ADR-030).
3
4
  *
4
- * The DB stores status as free-form strings. Three values indicate
5
- * "closed": "complete" (canonical), "done" (legacy / alias),
6
- * "closed" (legacy/imported), and
7
- * "skipped" (user-directed skip via rethink or backtrack).
8
- * Every inline `status === "complete" || status === "done"` should
9
- * use isClosedStatus() instead.
5
+ * The DB column is free-form `string` so legacy/imported rows still load. Three
6
+ * raw values besides canonical "complete"/"skipped" indicate "closed": "done"
7
+ * (legacy alias), "closed" (legacy/imported), and "skipped" (user-directed skip
8
+ * via rethink or backtrack). `RAW_CLOSED_STATUSES` is the single source for both
9
+ * `isClosedStatus()` and the SQL terminal-status fragment
10
+ * (`db/sql-constants.ts` derives `TERMINAL_STATUS_SQL` from it), replacing the
11
+ * prior independent definitions.
12
+ *
13
+ * `toStatus()` is the single seam where a free-form string becomes the canonical
14
+ * `Status` vocabulary; the Status Transition Core writes canonical, so the store
15
+ * converges over time without a forced migration.
16
+ */
17
+
18
+ /**
19
+ * Canonical, normalized entity-status vocabulary across milestones, slices, and
20
+ * tasks — the single source for both the `Status` type and the runtime
21
+ * membership set. The in-memory domain speaks `Status`; the DB column stays
22
+ * free-form.
23
+ */
24
+ export const CANONICAL_STATUSES = [
25
+ "pending", "queued", "active", "parked", "in_progress", "blocked", "complete", "skipped", "deferred",
26
+ ] as const;
27
+ export type Status = (typeof CANONICAL_STATUSES)[number];
28
+ const CANONICAL_STATUS_SET: ReadonlySet<string> = new Set(CANONICAL_STATUSES);
29
+
30
+ /**
31
+ * Raw status values that mean a unit is closed — the single source of truth.
32
+ * Includes legacy/imported aliases ("done", "closed") alongside canonical
33
+ * "complete"/"skipped" because the DB column is free-form and older rows /
34
+ * imports still carry them. Order matters: `TERMINAL_STATUS_SQL` is derived
35
+ * from this array verbatim.
36
+ */
37
+ export const RAW_CLOSED_STATUSES = ["complete", "done", "skipped", "closed"] as const;
38
+ const RAW_CLOSED_SET: ReadonlySet<string> = new Set(RAW_CLOSED_STATUSES);
39
+
40
+ /** Free-form aliases mapped to their canonical Status on read. */
41
+ const ALIAS_TO_CANONICAL: Readonly<Record<string, Status>> = {
42
+ done: "complete",
43
+ closed: "complete",
44
+ planned: "pending",
45
+ "in-progress": "in_progress",
46
+ };
47
+
48
+ /**
49
+ * Normalize a free-form DB status string into the canonical `Status`
50
+ * vocabulary. Maps known aliases (done/closed → complete, planned → pending,
51
+ * in-progress → in_progress). An unrecognized/legacy value is **quarantined** —
52
+ * preserved verbatim rather than silently remapped to a wrong canonical state —
53
+ * so reads never fail and reconciliation/telemetry can surface it.
10
54
  */
55
+ export function toStatus(raw: string): Status {
56
+ const value = raw.trim();
57
+ if (CANONICAL_STATUS_SET.has(value)) return value as Status;
58
+ const alias = ALIAS_TO_CANONICAL[value];
59
+ if (alias) return alias;
60
+ return value as Status;
61
+ }
11
62
 
12
63
  /** Returns true when a milestone, slice, or task status indicates closure. */
13
64
  export function isClosedStatus(status: string): boolean {
14
- return status === "complete" || status === "done" || status === "skipped" || status === "closed";
65
+ return RAW_CLOSED_SET.has(status);
15
66
  }
16
67
 
17
68
  /** Returns true when a slice status indicates it was deferred by a decision. */