@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
@@ -1188,6 +1188,27 @@ function selfHealRuntimeRecords(basePath, ctx) {
1188
1188
  return { cleared: 0 };
1189
1189
  }
1190
1190
  }
1191
+ /**
1192
+ * True when an agent turn is currently streaming or a dispatched message is
1193
+ * still queued waiting to trigger one. Used by the pending-auto-start stale
1194
+ * check: a live discuss turn can run for minutes before writing its first
1195
+ * artifact, and deleting its entry as "stale" re-dispatches the workflow —
1196
+ * resetting the interview and producing a duplicate completion turn.
1197
+ */
1198
+ function isAgentTurnInFlight(ctx) {
1199
+ try {
1200
+ if (typeof ctx.isIdle === "function" && !ctx.isIdle())
1201
+ return true;
1202
+ if (typeof ctx.hasPendingMessages === "function" && ctx.hasPendingMessages())
1203
+ return true;
1204
+ }
1205
+ catch {
1206
+ // assertActive() throws on a stale runner context; fall through to
1207
+ // artifact/age staleness signals.
1208
+ logWarning("guided", "isAgentTurnInFlight: ctx method threw (stale runner); assuming no turn in flight");
1209
+ }
1210
+ return false;
1211
+ }
1191
1212
  // ─── Milestone Actions Submenu ──────────────────────────────────────────────
1192
1213
  /**
1193
1214
  * Shows a submenu with Park / Discard / Skip / Back options for the active milestone.
@@ -1479,12 +1500,18 @@ export async function showSmartEntry(ctx, pi, basePath, options) {
1479
1500
  // and fires another dispatchWorkflow, resetting the conversation mid-interview.
1480
1501
  if (hasPendingAutoStart(basePath)) {
1481
1502
  // #3274: If /clear interrupted the discussion, the pending entry is stale.
1482
- // Detect staleness: no manifest, no milestone CONTEXT artifact, AND entry is older than
1483
- // 30s (avoids race between .set() and LLM writing first artifact).
1503
+ // Detect staleness: no manifest, no milestone CONTEXT/CONTEXT-DRAFT artifact,
1504
+ // the entry is older than 30s (avoids race between .set() and LLM writing the
1505
+ // first artifact), AND no agent turn is in flight. A dispatched discuss turn
1506
+ // can think for well over 30s before its first question round writes any
1507
+ // artifact; deleting the entry while that turn is live re-dispatches the
1508
+ // workflow, which both resets the interview and queues a duplicate turn that
1509
+ // replays the final "context written" message after the real one.
1484
1510
  const entry = _getPendingAutoStart(basePath);
1485
1511
  const ageMs = Date.now() - (entry.createdAt || 0);
1486
1512
  const manifestExists = existsSync(join(gsdRoot(basePath), "DISCUSSION-MANIFEST.json"));
1487
1513
  const milestoneHasContext = !!resolveMilestoneFile(basePath, entry.milestoneId, "CONTEXT");
1514
+ const milestoneHasDraft = !!resolveMilestoneFile(basePath, entry.milestoneId, "CONTEXT-DRAFT");
1488
1515
  const milestoneHasRoadmap = !!resolveMilestoneFile(basePath, entry.milestoneId, "ROADMAP");
1489
1516
  const milestoneRow = isDbAvailable() ? getMilestone(entry.milestoneId) : null;
1490
1517
  const discussPlanComplete = milestoneHasRoadmap && !!milestoneRow && milestoneRow.status !== "queued";
@@ -1493,7 +1520,11 @@ export async function showSmartEntry(ctx, pi, basePath, options) {
1493
1520
  // Clear stale in-memory guard and continue through normal active-milestone routing.
1494
1521
  deletePendingAutoStart(basePath);
1495
1522
  }
1496
- else if (!manifestExists && !milestoneHasContext && ageMs > 30_000) {
1523
+ else if (!manifestExists &&
1524
+ !milestoneHasContext &&
1525
+ !milestoneHasDraft &&
1526
+ ageMs > 30_000 &&
1527
+ !isAgentTurnInFlight(ctx)) {
1497
1528
  // Stale entry from an interrupted discussion — clear and continue
1498
1529
  deletePendingAutoStart(basePath);
1499
1530
  }
@@ -78,16 +78,24 @@ export function assertMigrationHasSlices(preview) {
78
78
  throw new MigrationBlockedError("Migration blocked - the legacy project would produce zero slices. Add a ROADMAP.md or phases/ content before migrating.");
79
79
  }
80
80
  function hasWorktreeState(targetRoot) {
81
- const worktreesDir = join(gsdRoot(targetRoot), "worktrees");
82
- if (!existsSync(worktreesDir))
83
- return false;
84
- try {
85
- return readdirSync(worktreesDir, { withFileTypes: true })
86
- .some((entry) => entry.isDirectory() || entry.isFile());
87
- }
88
- catch {
89
- return true;
81
+ const containers = [
82
+ join(targetRoot, ".gsd-worktrees"),
83
+ join(gsdRoot(targetRoot), "worktrees"),
84
+ ];
85
+ for (const worktreesDir of containers) {
86
+ if (!existsSync(worktreesDir))
87
+ continue;
88
+ try {
89
+ if (readdirSync(worktreesDir, { withFileTypes: true })
90
+ .some((entry) => entry.isDirectory() || entry.isFile())) {
91
+ return true;
92
+ }
93
+ }
94
+ catch {
95
+ return true;
96
+ }
90
97
  }
98
+ return false;
91
99
  }
92
100
  export async function assertMigrationTargetAvailable(targetRoot) {
93
101
  const targetGsdPath = gsdRoot(targetRoot);
@@ -8,7 +8,13 @@ function zeroCounts() {
8
8
  return { milestones: 0, slices: 0, tasks: 0 };
9
9
  }
10
10
  function emptyScan() {
11
- return { counts: zeroCounts(), milestones: new Set(), slices: new Set(), tasks: new Set() };
11
+ return {
12
+ counts: zeroCounts(),
13
+ milestones: new Set(),
14
+ slices: new Set(),
15
+ tasks: new Set(),
16
+ milestonesWithoutRoadmap: new Set(),
17
+ };
12
18
  }
13
19
  function sameCounts(a, b) {
14
20
  return a.milestones === b.milestones && a.slices === b.slices && a.tasks === b.tasks;
@@ -65,8 +71,10 @@ export function scanMarkdownHierarchy(basePath) {
65
71
  scan.counts.milestones++;
66
72
  scan.milestones.add(milestoneId);
67
73
  const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
68
- if (!roadmapPath || !existsSync(roadmapPath))
74
+ if (!roadmapPath || !existsSync(roadmapPath)) {
75
+ scan.milestonesWithoutRoadmap.add(milestoneId);
69
76
  continue;
77
+ }
70
78
  const roadmap = parseRoadmap(readFileSync(roadmapPath, "utf-8"));
71
79
  scan.counts.slices += roadmap.slices.length;
72
80
  for (const slice of roadmap.slices) {
@@ -112,7 +120,6 @@ export function countDbHierarchy() {
112
120
  }
113
121
  export async function checkMarkdownHierarchyAgainstDb(basePath) {
114
122
  const markdownScan = scanMarkdownHierarchy(basePath);
115
- const markdown = markdownScan.counts;
116
123
  // Always open the DB before deciding. An empty markdown tree does NOT imply
117
124
  // an empty project — the DB may hold authoritative rows whose markdown was
118
125
  // lost, which is itself recoverable drift. The previous early return here
@@ -128,6 +135,20 @@ export async function checkMarkdownHierarchyAgainstDb(basePath) {
128
135
  refreshWorkflowDatabaseFromDisk();
129
136
  const dbScan = scanDbHierarchy();
130
137
  const beforeDb = dbScan.counts;
138
+ // Discussion-phase scratch: a milestone dir with no ROADMAP and no DB row is
139
+ // a pre-registration discussion artifact (CONTEXT/CONTEXT-DRAFT only — the
140
+ // queued DB row is inserted only at discussion handoff). Treating it as
141
+ // drift would warn on every live discussion and recommend
142
+ // `/gsd recover --confirm`, an import that materializes abandoned-discussion
143
+ // dirs as ghost active milestones. Exclude such dirs from this comparison
144
+ // only; recover preflights use the raw scans and still see them.
145
+ for (const id of markdownScan.milestonesWithoutRoadmap) {
146
+ if (dbScan.milestones.has(id))
147
+ continue;
148
+ markdownScan.milestones.delete(id);
149
+ markdownScan.counts.milestones--;
150
+ }
151
+ const markdown = markdownScan.counts;
131
152
  const markdownEmpty = sameCounts(markdown, zeroCounts());
132
153
  const dbEmpty = sameCounts(beforeDb, zeroCounts());
133
154
  // Genuinely empty project: nothing on disk, nothing in the DB.
@@ -13,6 +13,7 @@ export const BUNDLED_COST_TABLE = [
13
13
  { id: "claude-opus-4-6", inputPer1k: 0.005, outputPer1k: 0.025, updatedAt: "2026-04-16" },
14
14
  { id: "claude-opus-4-7", inputPer1k: 0.005, outputPer1k: 0.025, updatedAt: "2026-04-16" },
15
15
  { id: "claude-opus-4-8", inputPer1k: 0.005, outputPer1k: 0.025, updatedAt: "2026-05-28" },
16
+ { id: "claude-fable-5", inputPer1k: 0.010, outputPer1k: 0.050, updatedAt: "2026-06-09" },
16
17
  { id: "claude-sonnet-4-6", inputPer1k: 0.003, outputPer1k: 0.015, updatedAt: "2025-03-15" },
17
18
  { id: "claude-haiku-4-5", inputPer1k: 0.0008, outputPer1k: 0.004, updatedAt: "2025-03-15" },
18
19
  { id: "claude-sonnet-4-5-20250514", inputPer1k: 0.003, outputPer1k: 0.015, updatedAt: "2025-03-15" },
@@ -35,6 +35,7 @@ export const MODEL_CAPABILITY_TIER = {
35
35
  "claude-opus-4-6": "heavy",
36
36
  "claude-opus-4-7": "heavy",
37
37
  "claude-opus-4-8": "heavy",
38
+ "claude-fable-5": "heavy",
38
39
  "claude-3-opus-latest": "heavy",
39
40
  "gpt-4-turbo": "heavy",
40
41
  "gpt-5": "heavy",
@@ -61,6 +62,7 @@ const MODEL_COST_PER_1K_INPUT = {
61
62
  "claude-opus-4-6": 0.005,
62
63
  "claude-opus-4-7": 0.005,
63
64
  "claude-opus-4-8": 0.005,
65
+ "claude-fable-5": 0.010,
64
66
  "gpt-4o-mini": 0.00015,
65
67
  "gpt-4o": 0.0025,
66
68
  "gpt-4.1": 0.002,
@@ -94,6 +96,7 @@ export const MODEL_CAPABILITY_PROFILES = {
94
96
  "claude-opus-4-6": { coding: 95, debugging: 90, research: 85, reasoning: 95, speed: 30, longContext: 80, instruction: 90 },
95
97
  "claude-opus-4-7": { coding: 95, debugging: 90, research: 85, reasoning: 95, speed: 30, longContext: 80, instruction: 90 },
96
98
  "claude-opus-4-8": { coding: 97, debugging: 92, research: 87, reasoning: 97, speed: 30, longContext: 85, instruction: 92 },
99
+ "claude-fable-5": { coding: 97, debugging: 92, research: 87, reasoning: 97, speed: 30, longContext: 85, instruction: 92 },
97
100
  "claude-sonnet-4-6": { coding: 85, debugging: 80, research: 75, reasoning: 80, speed: 60, longContext: 75, instruction: 85 },
98
101
  "claude-sonnet-4-5-20250514": { coding: 85, debugging: 80, research: 75, reasoning: 80, speed: 60, longContext: 75, instruction: 85 },
99
102
  "claude-3-5-sonnet-latest": { coding: 82, debugging: 78, research: 72, reasoning: 78, speed: 62, longContext: 70, instruction: 82 },
@@ -5,9 +5,9 @@
5
5
  * with safety checks for parallel execution context.
6
6
  */
7
7
  import { existsSync, readdirSync } from "node:fs";
8
- import { join } from "node:path";
9
8
  import { spawnSync } from "node:child_process";
10
9
  import { resolveGsdPathContract } from "./paths.js";
10
+ import { worktreePathFor, worktreesDirs } from "./worktree-placement.js";
11
11
  import { getAutoWorktreePath } from "./auto-worktree.js";
12
12
  import { buildWorktreeLifecycleDeps } from "./auto.js";
13
13
  import { mergeMilestoneStandalone, } from "./worktree-lifecycle.js";
@@ -22,7 +22,7 @@ import { logWarning } from "./workflow-logger.js";
22
22
  * Returns true when milestones.status = 'complete' in project gsd.db.
23
23
  */
24
24
  export function isMilestoneCompleteInProjectDb(basePath, mid) {
25
- const workRoot = join(basePath, ".gsd", "worktrees", mid);
25
+ const workRoot = worktreePathFor(basePath, mid);
26
26
  const dbPath = resolveGsdPathContract(workRoot, basePath).projectDb;
27
27
  if (!existsSync(dbPath))
28
28
  return false;
@@ -41,16 +41,19 @@ export function isMilestoneCompleteInProjectDb(basePath, mid) {
41
41
  */
42
42
  function discoverDbCompletedMilestones(basePath) {
43
43
  const completed = new Set();
44
- const worktreeDir = join(basePath, ".gsd", "worktrees");
45
- try {
46
- for (const entry of readdirSync(worktreeDir)) {
47
- if (entry.startsWith("M") && isMilestoneCompleteInProjectDb(basePath, entry)) {
48
- completed.add(entry);
44
+ for (const worktreeDir of worktreesDirs(basePath)) {
45
+ if (!existsSync(worktreeDir))
46
+ continue;
47
+ try {
48
+ for (const entry of readdirSync(worktreeDir)) {
49
+ if (entry.startsWith("M") && isMilestoneCompleteInProjectDb(basePath, entry)) {
50
+ completed.add(entry);
51
+ }
49
52
  }
50
53
  }
51
- }
52
- catch (e) {
53
- logWarning("parallel", `readdirSync for completed set failed: ${e.message}`);
54
+ catch (e) {
55
+ logWarning("parallel", `readdirSync for completed set failed: ${e.message}`);
56
+ }
54
57
  }
55
58
  return completed;
56
59
  }
@@ -88,7 +91,7 @@ export function determineMergeOrder(workers, order = "sequential", basePath) {
88
91
  title: mid,
89
92
  pid: 0,
90
93
  process: null,
91
- worktreePath: basePath ? join(basePath, ".gsd", "worktrees", mid) : "",
94
+ worktreePath: basePath ? worktreePathFor(basePath, mid) : "",
92
95
  startedAt: 0,
93
96
  state: "stopped",
94
97
  cost: 0,
@@ -7,6 +7,7 @@ import { matchesKey, Key } from "@gsd/pi-tui";
7
7
  import { formatDuration } from "../shared/mod.js";
8
8
  import { formattedShortcutPair } from "./shortcut-defs.js";
9
9
  import { resolveGsdPathContract } from "./paths.js";
10
+ import { worktreePathFor, worktreesDirs } from "./worktree-placement.js";
10
11
  import { renderBar, renderDialogFrame, renderKeyHints, renderProgressBar, safeLine, statusGlyph, } from "./tui/render-kit.js";
11
12
  // ─── Data Helpers ─────────────────────────────────────────────────────────
12
13
  function readJsonSafe(filePath) {
@@ -42,7 +43,6 @@ function tailRead(filePath, maxBytes) {
42
43
  }
43
44
  function discoverWorkers(basePath) {
44
45
  const parallelDir = join(basePath, ".gsd", "parallel");
45
- const worktreeDir = join(basePath, ".gsd", "worktrees");
46
46
  const mids = new Set();
47
47
  if (existsSync(parallelDir)) {
48
48
  try {
@@ -56,7 +56,9 @@ function discoverWorkers(basePath) {
56
56
  }
57
57
  catch { /* skip */ }
58
58
  }
59
- if (existsSync(worktreeDir)) {
59
+ for (const worktreeDir of worktreesDirs(basePath)) {
60
+ if (!existsSync(worktreeDir))
61
+ continue;
60
62
  try {
61
63
  for (const d of readdirSync(worktreeDir)) {
62
64
  if (d.startsWith("M") && existsSync(join(worktreeDir, d, ".gsd", "auto.lock"))) {
@@ -69,7 +71,7 @@ function discoverWorkers(basePath) {
69
71
  return [...mids].sort();
70
72
  }
71
73
  function querySliceProgress(basePath, mid) {
72
- const workRoot = join(basePath, ".gsd", "worktrees", mid);
74
+ const workRoot = worktreePathFor(basePath, mid);
73
75
  const dbPath = resolveGsdPathContract(workRoot, basePath).projectDb;
74
76
  if (!existsSync(dbPath))
75
77
  return [];
@@ -115,7 +117,7 @@ function extractCostFromNdjson(basePath, mid) {
115
117
  }
116
118
  }
117
119
  function queryRecentCompletions(basePath, mid) {
118
- const workRoot = join(basePath, ".gsd", "worktrees", mid);
120
+ const workRoot = worktreePathFor(basePath, mid);
119
121
  const dbPath = resolveGsdPathContract(workRoot, basePath).projectDb;
120
122
  if (!existsSync(dbPath))
121
123
  return [];
@@ -140,7 +142,7 @@ function collectWorkerData(basePath) {
140
142
  const workers = [];
141
143
  for (const mid of mids) {
142
144
  const status = readJsonSafe(join(parallelDir, `${mid}.status.json`));
143
- const lock = readJsonSafe(join(basePath, ".gsd", "worktrees", mid, ".gsd", "auto.lock"));
145
+ const lock = readJsonSafe(join(worktreePathFor(basePath, mid), ".gsd", "auto.lock"));
144
146
  const slices = querySliceProgress(basePath, mid);
145
147
  const pid = lock?.pid || status?.pid || 0;
146
148
  const alive = pid ? isPidAlive(pid) : false;
@@ -16,7 +16,7 @@ import { spawnSync } from "node:child_process";
16
16
  import { nativeScanGsdTree } from "./native-parser-bridge.js";
17
17
  import { DIR_CACHE_MAX } from "./constants.js";
18
18
  import { gsdHome } from "./gsd-home.js";
19
- import { isGsdWorktreePath, resolveExternalStateProjectGsdFromWorktreePath, resolveWorktreeProjectRoot } from "./worktree-root.js";
19
+ import { findWorktreeSegment, isGsdWorktreePath, resolveExternalStateProjectGsdFromWorktreePath, resolveWorktreeProjectRoot } from "./worktree-root.js";
20
20
  // ─── Directory Listing Cache ──────────────────────────────────────────────────
21
21
  const dirEntryCache = new Map();
22
22
  const dirListCache = new Map();
@@ -422,31 +422,17 @@ function assertNotGlobalGsdHome(basePath, result) {
422
422
  * When gsdRoot() is called with such a path, we must NOT walk up to the
423
423
  * project root's .gsd — each worktree manages its own .gsd state (#2594).
424
424
  *
425
- * Matches both forward-slash and platform-native separators to handle
426
- * Windows paths (path.sep = '\\') and normalized Unix paths.
425
+ * Layout matching is owned by worktree-root's findWorktreeSegment; this
426
+ * only adds the requirement that a non-empty worktree name follows the
427
+ * marker (the worktrees container dir itself is not a worktree).
427
428
  */
428
429
  function isInsideGsdWorktree(p) {
429
- // Match /.gsd/worktrees/<name> where <name> is the final segment or
430
- // followed by a separator. The <name> segment must be non-empty.
431
- const sepFwd = "/";
432
- const sepNative = "\\";
433
- const markers = [
434
- `${sepFwd}.gsd${sepFwd}worktrees${sepFwd}`,
435
- `${sepNative}.gsd${sepNative}worktrees${sepNative}`,
436
- ];
437
- for (const marker of markers) {
438
- const idx = p.indexOf(marker);
439
- if (idx === -1)
440
- continue;
441
- // Verify there's a non-empty worktree name after the marker
442
- const afterMarker = p.slice(idx + marker.length);
443
- // The name is everything up to the next separator (or end of string)
444
- const nameEnd = afterMarker.search(/[/\\]/);
445
- const name = nameEnd === -1 ? afterMarker : afterMarker.slice(0, nameEnd);
446
- if (name.length > 0)
447
- return true;
448
- }
449
- return false;
430
+ const normalized = p.replaceAll("\\", "/");
431
+ const segment = findWorktreeSegment(normalized);
432
+ if (!segment)
433
+ return false;
434
+ const name = normalized.slice(segment.afterWorktrees).split("/")[0];
435
+ return name.length > 0;
450
436
  }
451
437
  function probeGsdRoot(rawBasePath) {
452
438
  const contract = resolveGsdPathContract(rawBasePath);
@@ -699,6 +699,20 @@ export function getIsolationMode(basePath) {
699
699
  return "branch";
700
700
  return "none"; // default — no isolation, work on current branch
701
701
  }
702
+ /**
703
+ * Resolve the isolation mode a unit actually runs under. A session whose
704
+ * worktree isolation has degraded (worktree creation failed) falls back to
705
+ * the milestone branch in the project root, so configured "worktree" becomes
706
+ * effective "branch". A stranded-work recovery session likewise runs under
707
+ * the adopted mode (`strandedRecoveryIsolationMode`) rather than the
708
+ * configured one until the recovered milestone merges — adopting the
709
+ * milestone branch in the project root is intentional, not degraded.
710
+ */
711
+ export function resolveEffectiveUnitIsolationMode(configuredMode, isolationDegraded, strandedRecoveryIsolationMode = null) {
712
+ if (configuredMode === "worktree" && isolationDegraded)
713
+ return "branch";
714
+ return strandedRecoveryIsolationMode ?? configuredMode;
715
+ }
702
716
  export function resolveParallelConfig(prefs) {
703
717
  return {
704
718
  enabled: prefs?.parallel?.enabled ?? false,
@@ -2,6 +2,7 @@
2
2
  // File Purpose: ADR-015 Recovery Classification module for runtime failure taxonomy.
3
3
  import { classifyError, isTransient } from "./error-classifier.js";
4
4
  import { ReconciliationFailedError } from "./state-reconciliation.js";
5
+ import { IllegalPhaseTransitionError } from "./state-transition-matrix.js";
5
6
  export function classifyFailure(input) {
6
7
  const message = errorMessage(input.error);
7
8
  // ADR-017: ReconciliationFailedError is a typed throw from the State
@@ -9,7 +10,9 @@ export function classifyFailure(input) {
9
10
  // failureKind so the taxonomy stays consistent.
10
11
  const failureKind = input.error instanceof ReconciliationFailedError
11
12
  ? "reconciliation-drift"
12
- : input.failureKind ?? inferFailureKind(message);
13
+ : input.error instanceof IllegalPhaseTransitionError
14
+ ? "illegal-transition"
15
+ : input.failureKind ?? inferFailureKind(message);
13
16
  switch (failureKind) {
14
17
  case "tool-schema":
15
18
  return {
@@ -75,6 +78,14 @@ export function classifyFailure(input) {
75
78
  exitReason: "reconciliation-drift",
76
79
  remediation: "Inspect the persistent or repair-failed drift kinds reported by the State Reconciliation Module before resuming.",
77
80
  };
81
+ case "illegal-transition":
82
+ return {
83
+ failureKind,
84
+ action: "escalate",
85
+ reason: `Illegal phase transition${unitSuffix(input)}: ${message}`,
86
+ exitReason: "illegal-transition",
87
+ remediation: "A derived Phase edge rejected by the Phase Transition Invariant survived reconciliation; inspect deriveState and the State Reconciliation Module before resuming.",
88
+ };
78
89
  case "provider": {
79
90
  const providerClass = classifyError(message, input.retryAfterMs);
80
91
  return {
@@ -21,9 +21,10 @@ const EXECUTION_TOOL_NAMES = new Set([
21
21
  "functions.exec_command",
22
22
  "gsd_exec",
23
23
  "gsd_exec_search",
24
+ "gsd_uat_exec",
24
25
  "powershell",
25
26
  ]);
26
- const MCP_EXECUTION_TOOL_RE = /^mcp__.+__gsd_exec(?:_search)?$/;
27
+ const MCP_EXECUTION_TOOL_RE = /^mcp__.+__gsd_(?:uat_)?exec(?:_search)?$/;
27
28
  // ─── Module State ───────────────────────────────────────────────────────────
28
29
  let unitEvidence = [];
29
30
  // ─── Public API ─────────────────────────────────────────────────────────────
@@ -146,11 +147,18 @@ export function clearEvidenceFromDisk(basePath, milestoneId, sliceId, taskId) {
146
147
  * Exit codes and output are filled in by recordToolResult after execution.
147
148
  */
148
149
  export function recordToolCall(toolCallId, toolName, input) {
150
+ // Idempotent by toolCallId: native tools reach this via both
151
+ // tool_execution_start and tool_call; external (pre-executed) tools only
152
+ // via tool_execution_start. First recording wins.
153
+ if (unitEvidence.some(e => e.toolCallId === toolCallId))
154
+ return;
149
155
  if (isExecutionToolName(toolName)) {
150
156
  unitEvidence.push({
151
157
  kind: "bash",
152
158
  toolCallId,
153
- command: String(input.command ?? input.cmd ?? input.query ?? ""),
159
+ // gsd_exec / gsd_uat_exec carry the script body in `script` (or `code`);
160
+ // bash-style tools use `command`/`cmd`; gsd_exec_search uses `query`.
161
+ command: String(input.command ?? input.script ?? input.cmd ?? input.code ?? input.query ?? ""),
154
162
  exitCode: -1,
155
163
  outputSnippet: "",
156
164
  timestamp: Date.now(),
@@ -185,9 +193,34 @@ export function recordToolResult(toolCallId, toolName, result, isError) {
185
193
  if (entry.kind === "bash") {
186
194
  const text = extractResultText(result);
187
195
  entry.outputSnippet = text.slice(0, 500);
188
- const exitMatch = text.match(/Command exited with code (\d+)/);
189
- entry.exitCode = exitMatch ? Number(exitMatch[1]) : (isError ? 1 : 0);
196
+ entry.exitCode = resolveExitCode(text, isError);
197
+ }
198
+ }
199
+ /**
200
+ * Resolve the exit code from a tool result's text. Handles the bash tool's
201
+ * prose marker, the gsd_exec / gsd_uat_exec JSON envelope (`"exit_code": N`),
202
+ * and a last-resort read of the run's persisted `.gsd/exec/<id>.meta.json`
203
+ * (covers truncated result text).
204
+ */
205
+ function resolveExitCode(text, isError) {
206
+ const proseMatch = text.match(/Command exited with code (\d+)/);
207
+ if (proseMatch)
208
+ return Number(proseMatch[1]);
209
+ const jsonMatch = text.match(/"exit_code"\s*:\s*(-?\d+)/);
210
+ if (jsonMatch)
211
+ return Number(jsonMatch[1]);
212
+ const metaMatch = text.match(/"meta_path"\s*:\s*"([^"]+)"/);
213
+ if (metaMatch) {
214
+ try {
215
+ const meta = JSON.parse(readFileSync(metaMatch[1], "utf-8"));
216
+ if (typeof meta.exit_code === "number")
217
+ return meta.exit_code;
218
+ }
219
+ catch {
220
+ // Fall through to the isError heuristic
221
+ }
190
222
  }
223
+ return isError ? 1 : 0;
191
224
  }
192
225
  // ─── Internals ──────────────────────────────────────────────────────────────
193
226
  function extractResultText(result) {
@@ -80,8 +80,13 @@ function findMatches(claimedCommand, bashCalls) {
80
80
  const exact = bashCalls.filter(b => b.command.trim() === normalized);
81
81
  if (exact.length > 0)
82
82
  return exact;
83
- // Substring match: claimed is contained in actual or actual in claimed
84
- const substring = bashCalls.filter(b => b.command.includes(normalized) || normalized.includes(b.command));
83
+ // Substring match: claimed is contained in actual or actual in claimed.
84
+ // A claimed verification command typically appears verbatim inside a
85
+ // larger gsd_exec script body (cd prefix, multi-line scripts), so
86
+ // script-containing-claim is the common direction. Blank-command entries
87
+ // must be excluded — `"x".includes("")` is true, so they'd match anything.
88
+ const substring = bashCalls.filter(b => b.command.trim().length > 0 &&
89
+ (b.command.includes(normalized) || normalized.includes(b.command)));
85
90
  if (substring.length > 0)
86
91
  return substring;
87
92
  // Token match: split on whitespace and check significant overlap
@@ -17,6 +17,16 @@ import { logWarning } from "../workflow-logger.js";
17
17
  const _require = createRequire(import.meta.url);
18
18
  const picomatch = _require("picomatch");
19
19
  // ─── Public API ─────────────────────────────────────────────────────────────
20
+ /**
21
+ * Build the effective allowlist for a unit's file-change audit.
22
+ *
23
+ * When GSD manages .gitignore (manage_gitignore unset or true), ensureGitignore()
24
+ * appends baseline patterns at auto-start and the edit rides into the task's
25
+ * auto-commit — so .gitignore changes must not be attributed to the task.
26
+ */
27
+ export function effectiveFileChangeAllowlist(baseAllowlist, manageGitignore) {
28
+ return manageGitignore === false ? baseAllowlist : [...baseAllowlist, ".gitignore"];
29
+ }
20
30
  /**
21
31
  * Validate file changes after auto-commit for an execute-task unit.
22
32
  * Returns null if task data is unavailable or DB is not loaded.
@@ -99,6 +99,44 @@ export const STATE_TRANSITION_MATRIX = [
99
99
  export function findTransition(from, event) {
100
100
  return STATE_TRANSITION_MATRIX.find((entry) => (entry.from === from || entry.from === "*") && entry.event === event);
101
101
  }
102
+ /**
103
+ * Edge-keyed legality check for the Phase Transition Invariant (ADR-030).
104
+ * `advance()` derives the next Phase and asserts the (from → to) edge here.
105
+ *
106
+ * The matrix is an assertion, not a decision-maker — `deriveState` already
107
+ * chose the Phase. A self-edge is trivially legal (no transition to assert). An
108
+ * edge is legal when some matrix entry permits it, honoring the `*` wildcard
109
+ * rows (e.g. any → blocked via manual-block, any → executing via
110
+ * retryable-failure).
111
+ *
112
+ * Note: the matrix is currently a sparse hardening spec, not a complete
113
+ * legal-edge graph, so `advance()` consumes this in advisory mode (telemetry
114
+ * only). It must be expanded to cover every edge `deriveState` emits before
115
+ * enforcement flips on.
116
+ */
117
+ export function isLegalEdge(from, to) {
118
+ if (from === to)
119
+ return true;
120
+ return STATE_TRANSITION_MATRIX.some((entry) => (entry.from === from || entry.from === "*") && entry.to === to);
121
+ }
122
+ /**
123
+ * Thrown when an illegal derived Phase edge survives reconciliation. Carries
124
+ * both endpoints so Recovery Classification can report them. Recognized by
125
+ * class in `classifyFailure` and mapped to the `illegal-transition` kind.
126
+ */
127
+ export class IllegalPhaseTransitionError extends Error {
128
+ // Explicit fields, not constructor parameter properties — strip-types
129
+ // consumers (workspace-index subprocess, integration tests) reject the
130
+ // parameter-property syntax with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.
131
+ from;
132
+ to;
133
+ constructor(from, to) {
134
+ super(`Illegal phase transition ${from} -> ${to} survived reconciliation`);
135
+ this.name = "IllegalPhaseTransitionError";
136
+ this.from = from;
137
+ this.to = to;
138
+ }
139
+ }
102
140
  export function validateTransitionMatrix(requiredEvents) {
103
141
  const seen = new Set();
104
142
  const duplicateKeys = [];
@@ -1,16 +1,64 @@
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
+ * Canonical, normalized entity-status vocabulary across milestones, slices, and
19
+ * tasks — the single source for both the `Status` type and the runtime
20
+ * membership set. The in-memory domain speaks `Status`; the DB column stays
21
+ * free-form.
22
+ */
23
+ export const CANONICAL_STATUSES = [
24
+ "pending", "queued", "active", "parked", "in_progress", "blocked", "complete", "skipped", "deferred",
25
+ ];
26
+ const CANONICAL_STATUS_SET = new Set(CANONICAL_STATUSES);
27
+ /**
28
+ * Raw status values that mean a unit is closed — the single source of truth.
29
+ * Includes legacy/imported aliases ("done", "closed") alongside canonical
30
+ * "complete"/"skipped" because the DB column is free-form and older rows /
31
+ * imports still carry them. Order matters: `TERMINAL_STATUS_SQL` is derived
32
+ * from this array verbatim.
33
+ */
34
+ export const RAW_CLOSED_STATUSES = ["complete", "done", "skipped", "closed"];
35
+ const RAW_CLOSED_SET = new Set(RAW_CLOSED_STATUSES);
36
+ /** Free-form aliases mapped to their canonical Status on read. */
37
+ const ALIAS_TO_CANONICAL = {
38
+ done: "complete",
39
+ closed: "complete",
40
+ planned: "pending",
41
+ "in-progress": "in_progress",
42
+ };
43
+ /**
44
+ * Normalize a free-form DB status string into the canonical `Status`
45
+ * vocabulary. Maps known aliases (done/closed → complete, planned → pending,
46
+ * in-progress → in_progress). An unrecognized/legacy value is **quarantined** —
47
+ * preserved verbatim rather than silently remapped to a wrong canonical state —
48
+ * so reads never fail and reconciliation/telemetry can surface it.
10
49
  */
50
+ export function toStatus(raw) {
51
+ const value = raw.trim();
52
+ if (CANONICAL_STATUS_SET.has(value))
53
+ return value;
54
+ const alias = ALIAS_TO_CANONICAL[value];
55
+ if (alias)
56
+ return alias;
57
+ return value;
58
+ }
11
59
  /** Returns true when a milestone, slice, or task status indicates closure. */
12
60
  export function isClosedStatus(status) {
13
- return status === "complete" || status === "done" || status === "skipped" || status === "closed";
61
+ return RAW_CLOSED_SET.has(status);
14
62
  }
15
63
  /** Returns true when a slice status indicates it was deferred by a decision. */
16
64
  export function isDeferredStatus(status) {