@kynetic-ai/spec 0.10.0 → 0.12.0

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 (487) hide show
  1. package/README.md +55 -455
  2. package/dist/agent-runtime/bootstrap.d.ts +31 -0
  3. package/dist/agent-runtime/bootstrap.d.ts.map +1 -0
  4. package/dist/agent-runtime/bootstrap.js +302 -0
  5. package/dist/agent-runtime/bootstrap.js.map +1 -0
  6. package/dist/agent-runtime/dispatch.d.ts +150 -10
  7. package/dist/agent-runtime/dispatch.d.ts.map +1 -1
  8. package/dist/agent-runtime/dispatch.js +1248 -244
  9. package/dist/agent-runtime/dispatch.js.map +1 -1
  10. package/dist/agent-runtime/invocation.d.ts +28 -1
  11. package/dist/agent-runtime/invocation.d.ts.map +1 -1
  12. package/dist/agent-runtime/invocation.js +172 -60
  13. package/dist/agent-runtime/invocation.js.map +1 -1
  14. package/dist/agent-runtime/prompts.d.ts +9 -0
  15. package/dist/agent-runtime/prompts.d.ts.map +1 -1
  16. package/dist/agent-runtime/prompts.js +42 -7
  17. package/dist/agent-runtime/prompts.js.map +1 -1
  18. package/dist/agent-runtime/session-event-accumulator.d.ts +83 -0
  19. package/dist/agent-runtime/session-event-accumulator.d.ts.map +1 -0
  20. package/dist/agent-runtime/session-event-accumulator.js +203 -0
  21. package/dist/agent-runtime/session-event-accumulator.js.map +1 -0
  22. package/dist/agent-runtime/session-event-types.d.ts +67 -0
  23. package/dist/agent-runtime/session-event-types.d.ts.map +1 -0
  24. package/dist/agent-runtime/session-event-types.js +13 -0
  25. package/dist/agent-runtime/session-event-types.js.map +1 -0
  26. package/dist/agent-runtime/workspace.d.ts +244 -0
  27. package/dist/agent-runtime/workspace.d.ts.map +1 -0
  28. package/dist/agent-runtime/workspace.js +2025 -0
  29. package/dist/agent-runtime/workspace.js.map +1 -0
  30. package/dist/agents/adapters.d.ts.map +1 -1
  31. package/dist/agents/adapters.js +58 -13
  32. package/dist/agents/adapters.js.map +1 -1
  33. package/dist/agents/spawner.d.ts +8 -0
  34. package/dist/agents/spawner.d.ts.map +1 -1
  35. package/dist/agents/spawner.js +25 -3
  36. package/dist/agents/spawner.js.map +1 -1
  37. package/dist/cli/batch-exec.js +1 -1
  38. package/dist/cli/batch-exec.js.map +1 -1
  39. package/dist/cli/command-annotations.d.ts +15 -3
  40. package/dist/cli/command-annotations.d.ts.map +1 -1
  41. package/dist/cli/command-annotations.js +23 -3
  42. package/dist/cli/command-annotations.js.map +1 -1
  43. package/dist/cli/commands/agent.d.ts +2 -0
  44. package/dist/cli/commands/agent.d.ts.map +1 -1
  45. package/dist/cli/commands/agent.js +144 -27
  46. package/dist/cli/commands/agent.js.map +1 -1
  47. package/dist/cli/commands/agents.d.ts.map +1 -1
  48. package/dist/cli/commands/agents.js +5 -5
  49. package/dist/cli/commands/agents.js.map +1 -1
  50. package/dist/cli/commands/derive.d.ts.map +1 -1
  51. package/dist/cli/commands/derive.js +118 -3
  52. package/dist/cli/commands/derive.js.map +1 -1
  53. package/dist/cli/commands/guard.d.ts.map +1 -1
  54. package/dist/cli/commands/guard.js +8 -6
  55. package/dist/cli/commands/guard.js.map +1 -1
  56. package/dist/cli/commands/index.d.ts +1 -0
  57. package/dist/cli/commands/index.d.ts.map +1 -1
  58. package/dist/cli/commands/index.js +1 -0
  59. package/dist/cli/commands/index.js.map +1 -1
  60. package/dist/cli/commands/init.d.ts.map +1 -1
  61. package/dist/cli/commands/init.js +20 -0
  62. package/dist/cli/commands/init.js.map +1 -1
  63. package/dist/cli/commands/item.d.ts.map +1 -1
  64. package/dist/cli/commands/item.js +205 -47
  65. package/dist/cli/commands/item.js.map +1 -1
  66. package/dist/cli/commands/log.d.ts.map +1 -1
  67. package/dist/cli/commands/log.js +24 -10
  68. package/dist/cli/commands/log.js.map +1 -1
  69. package/dist/cli/commands/meta.d.ts.map +1 -1
  70. package/dist/cli/commands/meta.js +10 -1
  71. package/dist/cli/commands/meta.js.map +1 -1
  72. package/dist/cli/commands/plan-import.d.ts +3 -3
  73. package/dist/cli/commands/plan-import.d.ts.map +1 -1
  74. package/dist/cli/commands/plan-import.js +213 -528
  75. package/dist/cli/commands/plan-import.js.map +1 -1
  76. package/dist/cli/commands/plan.d.ts.map +1 -1
  77. package/dist/cli/commands/plan.js +533 -83
  78. package/dist/cli/commands/plan.js.map +1 -1
  79. package/dist/cli/commands/review.d.ts +14 -0
  80. package/dist/cli/commands/review.d.ts.map +1 -0
  81. package/dist/cli/commands/review.js +1142 -0
  82. package/dist/cli/commands/review.js.map +1 -0
  83. package/dist/cli/commands/serve.d.ts +1 -0
  84. package/dist/cli/commands/serve.d.ts.map +1 -1
  85. package/dist/cli/commands/serve.js +33 -10
  86. package/dist/cli/commands/serve.js.map +1 -1
  87. package/dist/cli/commands/session/checkpoint.d.ts +2 -4
  88. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -1
  89. package/dist/cli/commands/session/checkpoint.js +6 -107
  90. package/dist/cli/commands/session/checkpoint.js.map +1 -1
  91. package/dist/cli/commands/session/commands.d.ts.map +1 -1
  92. package/dist/cli/commands/session/commands.js +33 -23
  93. package/dist/cli/commands/session/commands.js.map +1 -1
  94. package/dist/cli/commands/session/compact.js +4 -4
  95. package/dist/cli/commands/session/compact.js.map +1 -1
  96. package/dist/cli/commands/session/create.js +2 -2
  97. package/dist/cli/commands/session/create.js.map +1 -1
  98. package/dist/cli/commands/session/format.d.ts.map +1 -1
  99. package/dist/cli/commands/session/format.js +1 -6
  100. package/dist/cli/commands/session/format.js.map +1 -1
  101. package/dist/cli/commands/session/log.d.ts +32 -7
  102. package/dist/cli/commands/session/log.d.ts.map +1 -1
  103. package/dist/cli/commands/session/log.js +166 -60
  104. package/dist/cli/commands/session/log.js.map +1 -1
  105. package/dist/cli/commands/session/migrate.d.ts +9 -0
  106. package/dist/cli/commands/session/migrate.d.ts.map +1 -0
  107. package/dist/cli/commands/session/migrate.js +46 -0
  108. package/dist/cli/commands/session/migrate.js.map +1 -0
  109. package/dist/cli/commands/session/stale-close.d.ts.map +1 -1
  110. package/dist/cli/commands/session/stale-close.js +5 -8
  111. package/dist/cli/commands/session/stale-close.js.map +1 -1
  112. package/dist/cli/commands/session/types.d.ts +1 -1
  113. package/dist/cli/commands/session/types.d.ts.map +1 -1
  114. package/dist/cli/commands/setup.d.ts +2 -2
  115. package/dist/cli/commands/setup.d.ts.map +1 -1
  116. package/dist/cli/commands/setup.js +287 -257
  117. package/dist/cli/commands/setup.js.map +1 -1
  118. package/dist/cli/commands/shadow.d.ts.map +1 -1
  119. package/dist/cli/commands/shadow.js +147 -31
  120. package/dist/cli/commands/shadow.js.map +1 -1
  121. package/dist/cli/commands/skill-crud.d.ts +7 -0
  122. package/dist/cli/commands/skill-crud.d.ts.map +1 -1
  123. package/dist/cli/commands/skill-crud.js +41 -18
  124. package/dist/cli/commands/skill-crud.js.map +1 -1
  125. package/dist/cli/commands/skill-diff.d.ts.map +1 -1
  126. package/dist/cli/commands/skill-diff.js +29 -3
  127. package/dist/cli/commands/skill-diff.js.map +1 -1
  128. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  129. package/dist/cli/commands/skill-install.js +5 -4
  130. package/dist/cli/commands/skill-install.js.map +1 -1
  131. package/dist/cli/commands/task.d.ts.map +1 -1
  132. package/dist/cli/commands/task.js +359 -49
  133. package/dist/cli/commands/task.js.map +1 -1
  134. package/dist/cli/commands/trait.d.ts.map +1 -1
  135. package/dist/cli/commands/trait.js +5 -27
  136. package/dist/cli/commands/trait.js.map +1 -1
  137. package/dist/cli/commands/validate.d.ts.map +1 -1
  138. package/dist/cli/commands/validate.js +113 -52
  139. package/dist/cli/commands/validate.js.map +1 -1
  140. package/dist/cli/index.d.ts.map +1 -1
  141. package/dist/cli/index.js +69 -2
  142. package/dist/cli/index.js.map +1 -1
  143. package/dist/cli/output.d.ts +26 -0
  144. package/dist/cli/output.d.ts.map +1 -1
  145. package/dist/cli/output.js +108 -1
  146. package/dist/cli/output.js.map +1 -1
  147. package/dist/cli/sync-mode.d.ts +44 -0
  148. package/dist/cli/sync-mode.d.ts.map +1 -0
  149. package/dist/cli/sync-mode.js +64 -0
  150. package/dist/cli/sync-mode.js.map +1 -0
  151. package/dist/daemon/middleware/project-context.ts +25 -7
  152. package/dist/daemon/project-context.ts +18 -0
  153. package/dist/daemon/routes/agent-dispatch.ts +107 -23
  154. package/dist/daemon/routes/aggregation.ts +184 -0
  155. package/dist/daemon/routes/inbox.ts +5 -0
  156. package/dist/daemon/routes/items.ts +167 -0
  157. package/dist/daemon/routes/meta.ts +141 -1
  158. package/dist/daemon/routes/plans.ts +147 -0
  159. package/dist/daemon/routes/projects.ts +28 -6
  160. package/dist/daemon/routes/ref-resolution.ts +119 -0
  161. package/dist/daemon/routes/refs.ts +42 -0
  162. package/dist/daemon/routes/session-related.ts +140 -0
  163. package/dist/daemon/routes/sessions.ts +581 -0
  164. package/dist/daemon/routes/tasks.ts +257 -2
  165. package/dist/daemon/routes/triage.ts +40 -1
  166. package/dist/daemon/routes/validation.ts +1 -1
  167. package/dist/daemon/server.ts +165 -50
  168. package/dist/daemon/session-sync.ts +11 -0
  169. package/dist/daemon/shadow-sync.ts +11 -0
  170. package/dist/daemon/watcher.ts +56 -5
  171. package/dist/daemon/websocket/project-resolution.ts +77 -0
  172. package/dist/export/json.d.ts.map +1 -1
  173. package/dist/export/json.js +104 -1
  174. package/dist/export/json.js.map +1 -1
  175. package/dist/export/types.d.ts +52 -1
  176. package/dist/export/types.d.ts.map +1 -1
  177. package/dist/index.d.ts +1 -0
  178. package/dist/index.d.ts.map +1 -1
  179. package/dist/index.js +1 -0
  180. package/dist/index.js.map +1 -1
  181. package/dist/parser/agent-detection.d.ts +1 -1
  182. package/dist/parser/agent-detection.d.ts.map +1 -1
  183. package/dist/parser/agent-detection.js +10 -0
  184. package/dist/parser/agent-detection.js.map +1 -1
  185. package/dist/parser/alignment.d.ts.map +1 -1
  186. package/dist/parser/alignment.js +4 -2
  187. package/dist/parser/alignment.js.map +1 -1
  188. package/dist/parser/config.d.ts +397 -2
  189. package/dist/parser/config.d.ts.map +1 -1
  190. package/dist/parser/config.js +125 -3
  191. package/dist/parser/config.js.map +1 -1
  192. package/dist/parser/dispatch-workspaces.d.ts +18 -0
  193. package/dist/parser/dispatch-workspaces.d.ts.map +1 -0
  194. package/dist/parser/dispatch-workspaces.js +209 -0
  195. package/dist/parser/dispatch-workspaces.js.map +1 -0
  196. package/dist/parser/doctor.d.ts.map +1 -1
  197. package/dist/parser/doctor.js +27 -8
  198. package/dist/parser/doctor.js.map +1 -1
  199. package/dist/parser/file-lock.d.ts.map +1 -1
  200. package/dist/parser/file-lock.js +9 -2
  201. package/dist/parser/file-lock.js.map +1 -1
  202. package/dist/parser/index.d.ts +6 -0
  203. package/dist/parser/index.d.ts.map +1 -1
  204. package/dist/parser/index.js +6 -0
  205. package/dist/parser/index.js.map +1 -1
  206. package/dist/parser/plans.d.ts.map +1 -1
  207. package/dist/parser/plans.js +1 -0
  208. package/dist/parser/plans.js.map +1 -1
  209. package/dist/parser/refs.d.ts +8 -1
  210. package/dist/parser/refs.d.ts.map +1 -1
  211. package/dist/parser/refs.js +27 -1
  212. package/dist/parser/refs.js.map +1 -1
  213. package/dist/parser/review-operations.d.ts +72 -0
  214. package/dist/parser/review-operations.d.ts.map +1 -0
  215. package/dist/parser/review-operations.js +185 -0
  216. package/dist/parser/review-operations.js.map +1 -0
  217. package/dist/parser/review-task-integration.d.ts +78 -0
  218. package/dist/parser/review-task-integration.d.ts.map +1 -0
  219. package/dist/parser/review-task-integration.js +173 -0
  220. package/dist/parser/review-task-integration.js.map +1 -0
  221. package/dist/parser/review-threads.d.ts +101 -0
  222. package/dist/parser/review-threads.d.ts.map +1 -0
  223. package/dist/parser/review-threads.js +222 -0
  224. package/dist/parser/review-threads.js.map +1 -0
  225. package/dist/parser/review-validation.d.ts +69 -0
  226. package/dist/parser/review-validation.d.ts.map +1 -0
  227. package/dist/parser/review-validation.js +207 -0
  228. package/dist/parser/review-validation.js.map +1 -0
  229. package/dist/parser/reviews.d.ts +58 -0
  230. package/dist/parser/reviews.d.ts.map +1 -0
  231. package/dist/parser/reviews.js +230 -0
  232. package/dist/parser/reviews.js.map +1 -0
  233. package/dist/parser/session-branch.d.ts +91 -0
  234. package/dist/parser/session-branch.d.ts.map +1 -0
  235. package/dist/parser/session-branch.js +565 -0
  236. package/dist/parser/session-branch.js.map +1 -0
  237. package/dist/parser/session-sync-scheduler.d.ts +53 -0
  238. package/dist/parser/session-sync-scheduler.d.ts.map +1 -0
  239. package/dist/parser/session-sync-scheduler.js +100 -0
  240. package/dist/parser/session-sync-scheduler.js.map +1 -0
  241. package/dist/parser/setup-status.d.ts +7 -1
  242. package/dist/parser/setup-status.d.ts.map +1 -1
  243. package/dist/parser/setup-status.js +104 -39
  244. package/dist/parser/setup-status.js.map +1 -1
  245. package/dist/parser/shadow-sync-scheduler.d.ts +71 -0
  246. package/dist/parser/shadow-sync-scheduler.d.ts.map +1 -0
  247. package/dist/parser/shadow-sync-scheduler.js +139 -0
  248. package/dist/parser/shadow-sync-scheduler.js.map +1 -0
  249. package/dist/parser/shadow.d.ts +121 -14
  250. package/dist/parser/shadow.d.ts.map +1 -1
  251. package/dist/parser/shadow.js +752 -27
  252. package/dist/parser/shadow.js.map +1 -1
  253. package/dist/parser/skill-render.d.ts +24 -0
  254. package/dist/parser/skill-render.d.ts.map +1 -1
  255. package/dist/parser/skill-render.js +98 -26
  256. package/dist/parser/skill-render.js.map +1 -1
  257. package/dist/parser/validate.d.ts +43 -3
  258. package/dist/parser/validate.d.ts.map +1 -1
  259. package/dist/parser/validate.js +204 -30
  260. package/dist/parser/validate.js.map +1 -1
  261. package/dist/parser/yaml.d.ts +47 -11
  262. package/dist/parser/yaml.d.ts.map +1 -1
  263. package/dist/parser/yaml.js +329 -149
  264. package/dist/parser/yaml.js.map +1 -1
  265. package/dist/review/checks.d.ts +97 -0
  266. package/dist/review/checks.d.ts.map +1 -0
  267. package/dist/review/checks.js +175 -0
  268. package/dist/review/checks.js.map +1 -0
  269. package/dist/review/index.d.ts +3 -0
  270. package/dist/review/index.d.ts.map +1 -0
  271. package/dist/review/index.js +3 -0
  272. package/dist/review/index.js.map +1 -0
  273. package/dist/review/subject-bindings.d.ts +83 -0
  274. package/dist/review/subject-bindings.d.ts.map +1 -0
  275. package/dist/review/subject-bindings.js +175 -0
  276. package/dist/review/subject-bindings.js.map +1 -0
  277. package/dist/schema/common.d.ts +26 -0
  278. package/dist/schema/common.d.ts.map +1 -1
  279. package/dist/schema/common.js +13 -0
  280. package/dist/schema/common.js.map +1 -1
  281. package/dist/schema/dispatch-workspace.d.ts +2643 -0
  282. package/dist/schema/dispatch-workspace.d.ts.map +1 -0
  283. package/dist/schema/dispatch-workspace.js +187 -0
  284. package/dist/schema/dispatch-workspace.js.map +1 -0
  285. package/dist/schema/inbox.d.ts +8 -8
  286. package/dist/schema/index.d.ts +2 -0
  287. package/dist/schema/index.d.ts.map +1 -1
  288. package/dist/schema/index.js +2 -0
  289. package/dist/schema/index.js.map +1 -1
  290. package/dist/schema/meta.d.ts +663 -116
  291. package/dist/schema/meta.d.ts.map +1 -1
  292. package/dist/schema/meta.js +28 -0
  293. package/dist/schema/meta.js.map +1 -1
  294. package/dist/schema/plan.d.ts +30 -19
  295. package/dist/schema/plan.d.ts.map +1 -1
  296. package/dist/schema/plan.js +3 -1
  297. package/dist/schema/plan.js.map +1 -1
  298. package/dist/schema/review-records.d.ts +2676 -0
  299. package/dist/schema/review-records.d.ts.map +1 -0
  300. package/dist/schema/review-records.js +232 -0
  301. package/dist/schema/review-records.js.map +1 -0
  302. package/dist/schema/spec.d.ts +32 -14
  303. package/dist/schema/spec.d.ts.map +1 -1
  304. package/dist/schema/spec.js +5 -0
  305. package/dist/schema/spec.js.map +1 -1
  306. package/dist/schema/task.d.ts +187 -29
  307. package/dist/schema/task.d.ts.map +1 -1
  308. package/dist/schema/task.js +12 -2
  309. package/dist/schema/task.js.map +1 -1
  310. package/dist/schema/triage.d.ts +22 -22
  311. package/dist/sessions/cache.d.ts +119 -0
  312. package/dist/sessions/cache.d.ts.map +1 -0
  313. package/dist/sessions/cache.js +284 -0
  314. package/dist/sessions/cache.js.map +1 -0
  315. package/dist/sessions/index.d.ts +1 -0
  316. package/dist/sessions/index.d.ts.map +1 -1
  317. package/dist/sessions/index.js +2 -0
  318. package/dist/sessions/index.js.map +1 -1
  319. package/dist/sessions/legacy.d.ts +77 -0
  320. package/dist/sessions/legacy.d.ts.map +1 -0
  321. package/dist/sessions/legacy.js +146 -0
  322. package/dist/sessions/legacy.js.map +1 -0
  323. package/dist/sessions/store.d.ts +115 -71
  324. package/dist/sessions/store.d.ts.map +1 -1
  325. package/dist/sessions/store.js +357 -182
  326. package/dist/sessions/store.js.map +1 -1
  327. package/dist/sessions/types.d.ts +44 -16
  328. package/dist/sessions/types.d.ts.map +1 -1
  329. package/dist/sessions/types.js +11 -2
  330. package/dist/sessions/types.js.map +1 -1
  331. package/dist/strings/errors.d.ts +32 -0
  332. package/dist/strings/errors.d.ts.map +1 -1
  333. package/dist/strings/errors.js +17 -0
  334. package/dist/strings/errors.js.map +1 -1
  335. package/dist/strings/labels.d.ts +1 -0
  336. package/dist/strings/labels.d.ts.map +1 -1
  337. package/dist/strings/labels.js +1 -0
  338. package/dist/strings/labels.js.map +1 -1
  339. package/dist/utils/activity.d.ts +101 -0
  340. package/dist/utils/activity.d.ts.map +1 -0
  341. package/dist/utils/activity.js +408 -0
  342. package/dist/utils/activity.js.map +1 -0
  343. package/dist/utils/git.d.ts +31 -0
  344. package/dist/utils/git.d.ts.map +1 -1
  345. package/dist/utils/git.js +87 -0
  346. package/dist/utils/git.js.map +1 -1
  347. package/dist/utils/index.d.ts +2 -0
  348. package/dist/utils/index.d.ts.map +1 -1
  349. package/dist/utils/index.js +1 -0
  350. package/dist/utils/index.js.map +1 -1
  351. package/dist/web-ui/_app/immutable/assets/0.tmlwn-Ih.css +1 -0
  352. package/dist/web-ui/_app/immutable/assets/9.BwwJybWx.css +1 -0
  353. package/dist/web-ui/_app/immutable/chunks/2KqE8gtn.js +1 -0
  354. package/dist/web-ui/_app/immutable/chunks/70-t_QvE.js +1 -0
  355. package/dist/web-ui/_app/immutable/chunks/AiWQj974.js +1 -0
  356. package/dist/web-ui/_app/immutable/chunks/B25nWFyA.js +5 -0
  357. package/dist/web-ui/_app/immutable/chunks/B2bcA_Q_.js +1 -0
  358. package/dist/web-ui/_app/immutable/chunks/B5e5HYyB.js +1 -0
  359. package/dist/web-ui/_app/immutable/chunks/B7-5z6eA.js +1 -0
  360. package/dist/web-ui/_app/immutable/chunks/B7bGmhK0.js +1 -0
  361. package/dist/web-ui/_app/immutable/chunks/B8tYZKAE.js +1 -0
  362. package/dist/web-ui/_app/immutable/chunks/BFGAyJjD.js +1 -0
  363. package/dist/web-ui/_app/immutable/chunks/BG0850zf.js +1 -0
  364. package/dist/web-ui/_app/immutable/chunks/BG8eSzAd.js +1 -0
  365. package/dist/web-ui/_app/immutable/chunks/BIMxXS8I.js +1 -0
  366. package/dist/web-ui/_app/immutable/chunks/BSzL1fpU.js +1 -0
  367. package/dist/web-ui/_app/immutable/chunks/BYtjHfeq.js +1 -0
  368. package/dist/web-ui/_app/immutable/chunks/{D1ArdqNb.js → Bp5pFYXL.js} +1 -1
  369. package/dist/web-ui/_app/immutable/chunks/BsJFsuAT.js +1 -0
  370. package/dist/web-ui/_app/immutable/chunks/BvpNHcD6.js +1 -0
  371. package/dist/web-ui/_app/immutable/chunks/BypqA25-.js +1 -0
  372. package/dist/web-ui/_app/immutable/chunks/C0w6WDm5.js +1 -0
  373. package/dist/web-ui/_app/immutable/chunks/C5_PAZ0y.js +1 -0
  374. package/dist/web-ui/_app/immutable/chunks/CDRO15Iv.js +1 -0
  375. package/dist/web-ui/_app/immutable/chunks/CF1CoqD5.js +1 -0
  376. package/dist/web-ui/_app/immutable/chunks/CS2sa4_m.js +1 -0
  377. package/dist/web-ui/_app/immutable/chunks/CWUQwB9H.js +1 -0
  378. package/dist/web-ui/_app/immutable/chunks/CY5FDdSU.js +1 -0
  379. package/dist/web-ui/_app/immutable/chunks/C_7MTDoj.js +1 -0
  380. package/dist/web-ui/_app/immutable/chunks/CaAJD3dl.js +1 -0
  381. package/dist/web-ui/_app/immutable/chunks/{i-XnOIX0.js → ChB5iyEL.js} +1 -1
  382. package/dist/web-ui/_app/immutable/chunks/ChQD-6N8.js +1 -0
  383. package/dist/web-ui/_app/immutable/chunks/{BCkp8Hs8.js → CqbsoCwA.js} +1 -1
  384. package/dist/web-ui/_app/immutable/chunks/DCeJW50p.js +1 -0
  385. package/dist/web-ui/_app/immutable/chunks/DJtZNgcs.js +1 -0
  386. package/dist/web-ui/_app/immutable/chunks/DKIeaprD.js +1 -0
  387. package/dist/web-ui/_app/immutable/chunks/DLd2uVIA.js +1 -0
  388. package/dist/web-ui/_app/immutable/chunks/DW_subyT.js +2 -0
  389. package/dist/web-ui/_app/immutable/chunks/DbU6lVn0.js +1 -0
  390. package/dist/web-ui/_app/immutable/chunks/Dc7ZCC5m.js +1 -0
  391. package/dist/web-ui/_app/immutable/chunks/Dd5umPsk.js +2 -0
  392. package/dist/web-ui/_app/immutable/chunks/Dg_zDpDS.js +1 -0
  393. package/dist/web-ui/_app/immutable/chunks/Dgqu8Yuc.js +1 -0
  394. package/dist/web-ui/_app/immutable/chunks/DmxsPZTB.js +1 -0
  395. package/dist/web-ui/_app/immutable/chunks/DphTaFUB.js +1 -0
  396. package/dist/web-ui/_app/immutable/chunks/DqK4iHp0.js +1 -0
  397. package/dist/web-ui/_app/immutable/chunks/DqT6OH_u.js +2 -0
  398. package/dist/web-ui/_app/immutable/chunks/Ds9I9wQb.js +1 -0
  399. package/dist/web-ui/_app/immutable/chunks/Du5ng3u4.js +1 -0
  400. package/dist/web-ui/_app/immutable/chunks/DxJw79Wi.js +1 -0
  401. package/dist/web-ui/_app/immutable/chunks/GFTX8GgV.js +1 -0
  402. package/dist/web-ui/_app/immutable/chunks/HNjs76Zz.js +1 -0
  403. package/dist/web-ui/_app/immutable/chunks/HVMjDi4_.js +1 -0
  404. package/dist/web-ui/_app/immutable/chunks/P0A_fJvS.js +1 -0
  405. package/dist/web-ui/_app/immutable/chunks/T3vGWjIL.js +1 -0
  406. package/dist/web-ui/_app/immutable/chunks/VTmrX9Qu.js +1 -0
  407. package/dist/web-ui/_app/immutable/chunks/Xvwhx_F1.js +1 -0
  408. package/dist/web-ui/_app/immutable/chunks/Yyz1XMQA.js +1 -0
  409. package/dist/web-ui/_app/immutable/chunks/dh5HeqUr.js +1 -0
  410. package/dist/web-ui/_app/immutable/chunks/fZMteyca.js +62 -0
  411. package/dist/web-ui/_app/immutable/chunks/{D28BF5MJ.js → gPrj-hqC.js} +1 -1
  412. package/dist/web-ui/_app/immutable/chunks/htcWMiYN.js +1 -0
  413. package/dist/web-ui/_app/immutable/chunks/oTsvd9y4.js +1 -0
  414. package/dist/web-ui/_app/immutable/chunks/qJfLUwU4.js +1 -0
  415. package/dist/web-ui/_app/immutable/chunks/xCtiO_JE.js +1 -0
  416. package/dist/web-ui/_app/immutable/chunks/y4GeEH6k.js +1 -0
  417. package/dist/web-ui/_app/immutable/entry/app.C4h_eOn6.js +2 -0
  418. package/dist/web-ui/_app/immutable/entry/start.CQFTf9ep.js +1 -0
  419. package/dist/web-ui/_app/immutable/nodes/0.Dh1xO970.js +1 -0
  420. package/dist/web-ui/_app/immutable/nodes/1.l75D3Opx.js +1 -0
  421. package/dist/web-ui/_app/immutable/nodes/10.DBidBPc-.js +1 -0
  422. package/dist/web-ui/_app/immutable/nodes/11.Ab0gUKWe.js +1 -0
  423. package/dist/web-ui/_app/immutable/nodes/12.CMsnoxfs.js +1 -0
  424. package/dist/web-ui/_app/immutable/nodes/13.D8YKuknB.js +1 -0
  425. package/dist/web-ui/_app/immutable/nodes/14.DZ0aan7y.js +1 -0
  426. package/dist/web-ui/_app/immutable/nodes/15.CUIKreDL.js +2 -0
  427. package/dist/web-ui/_app/immutable/nodes/16.BWc8--BO.js +1 -0
  428. package/dist/web-ui/_app/immutable/nodes/2.CDUonbuh.js +1 -0
  429. package/dist/web-ui/_app/immutable/nodes/3.Ctg3M00i.js +1 -0
  430. package/dist/web-ui/_app/immutable/nodes/4.Ci-JDwbA.js +2 -0
  431. package/dist/web-ui/_app/immutable/nodes/5.CTyEDAq0.js +1 -0
  432. package/dist/web-ui/_app/immutable/nodes/6.BTZZqsAb.js +1 -0
  433. package/dist/web-ui/_app/immutable/nodes/7.BI52g_Jo.js +137 -0
  434. package/dist/web-ui/_app/immutable/nodes/8.3hZPaB9x.js +1 -0
  435. package/dist/web-ui/_app/immutable/nodes/9.DS49kvwl.js +29 -0
  436. package/dist/web-ui/_app/version.json +1 -1
  437. package/dist/web-ui/favicon-192.png +0 -0
  438. package/dist/web-ui/favicon-32.png +0 -0
  439. package/dist/web-ui/favicon.ico +0 -0
  440. package/dist/web-ui/index.html +14 -11
  441. package/package.json +14 -7
  442. package/plugin/.claude-plugin/marketplace.json +1 -1
  443. package/plugin/.claude-plugin/plugin.json +1 -1
  444. package/plugin/plugins/kspec/skills/merge/SKILL.md +127 -0
  445. package/plugin/plugins/kspec/skills/plan/SKILL.md +55 -26
  446. package/plugin/plugins/kspec/skills/review/SKILL.md +350 -133
  447. package/plugin/plugins/kspec/skills/task-work/SKILL.md +96 -106
  448. package/templates/agents-sections/04-pr-workflow.md +15 -12
  449. package/templates/agents-sections/06-ralph-loop.md +15 -10
  450. package/templates/skills/manifest.yaml +25 -7
  451. package/templates/skills/merge/SKILL.md +120 -0
  452. package/templates/skills/plan/SKILL.md +55 -26
  453. package/templates/skills/review/SKILL.md +346 -130
  454. package/templates/skills/task-work/SKILL.md +93 -103
  455. package/dist/web-ui/_app/immutable/assets/0.BxCxvrZR.css +0 -1
  456. package/dist/web-ui/_app/immutable/chunks/B-CZR0q8.js +0 -1
  457. package/dist/web-ui/_app/immutable/chunks/B1IR5Su5.js +0 -1
  458. package/dist/web-ui/_app/immutable/chunks/B_Cvvtc4.js +0 -1
  459. package/dist/web-ui/_app/immutable/chunks/BtFaGGII.js +0 -1
  460. package/dist/web-ui/_app/immutable/chunks/Bu8JVsCH.js +0 -1
  461. package/dist/web-ui/_app/immutable/chunks/C87u-CNA.js +0 -1
  462. package/dist/web-ui/_app/immutable/chunks/CrFkBTYp.js +0 -1
  463. package/dist/web-ui/_app/immutable/chunks/D6RtLpzL.js +0 -1
  464. package/dist/web-ui/_app/immutable/chunks/D7FHSgx2.js +0 -1
  465. package/dist/web-ui/_app/immutable/chunks/DBXrsxZQ.js +0 -2
  466. package/dist/web-ui/_app/immutable/chunks/Da_hHMuA.js +0 -1
  467. package/dist/web-ui/_app/immutable/chunks/Do6LchSF.js +0 -1
  468. package/dist/web-ui/_app/immutable/chunks/DoNPtcAw.js +0 -1
  469. package/dist/web-ui/_app/immutable/chunks/DtUbXRZz.js +0 -1
  470. package/dist/web-ui/_app/immutable/chunks/DyFPRlLl.js +0 -1
  471. package/dist/web-ui/_app/immutable/chunks/DzAP8lRM.js +0 -1
  472. package/dist/web-ui/_app/immutable/chunks/DzVXElzN.js +0 -2
  473. package/dist/web-ui/_app/immutable/chunks/aoPBFken.js +0 -1
  474. package/dist/web-ui/_app/immutable/chunks/laxtrUO3.js +0 -1
  475. package/dist/web-ui/_app/immutable/chunks/q1nIWgqB.js +0 -1
  476. package/dist/web-ui/_app/immutable/chunks/sTLbk5Nm.js +0 -1
  477. package/dist/web-ui/_app/immutable/chunks/vwKgQu5P.js +0 -5
  478. package/dist/web-ui/_app/immutable/entry/app.BCwMcqnT.js +0 -2
  479. package/dist/web-ui/_app/immutable/entry/start.wKCQH-tt.js +0 -1
  480. package/dist/web-ui/_app/immutable/nodes/0.CjGVMG74.js +0 -1
  481. package/dist/web-ui/_app/immutable/nodes/1.B6_AIPan.js +0 -1
  482. package/dist/web-ui/_app/immutable/nodes/2.q4oCS7Ws.js +0 -1
  483. package/dist/web-ui/_app/immutable/nodes/3.rTKZf9o2.js +0 -1
  484. package/dist/web-ui/_app/immutable/nodes/4.DVIDRu1d.js +0 -1
  485. package/dist/web-ui/_app/immutable/nodes/5.8PtPXIOd.js +0 -1
  486. package/dist/web-ui/_app/immutable/nodes/6.ZZrTemy_.js +0 -1
  487. package/dist/web-ui/_app/immutable/nodes/7.IP-gxCxi.js +0 -1
@@ -0,0 +1,2025 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { initContext } from "../parser/index.js";
5
+ import { acquireFileLock } from "../parser/file-lock.js";
6
+ import { findDispatchWorkspaceByTaskRef, getDispatchWorkspaceRegistryPath, loadDispatchWorkspaceRegistry, saveDispatchWorkspaceRecord, } from "../parser/dispatch-workspaces.js";
7
+ import { loadProjectConfig } from "../parser/config.js";
8
+ import { commitIfShadow } from "../parser/shadow.js";
9
+ const DISPATCH_WORKSPACE_METADATA_FILE = ".kspec-dispatch-workspace.json";
10
+ const DISPATCH_BRANCH_PREFIX = "dispatch/task/";
11
+ const DISPATCH_SHADOW_MUTATION_LOCK_FILE = ".kspec-dispatch-shadow-mutation";
12
+ export function getDispatchShadowMutationLockPath(projectDir) {
13
+ return path.join(projectDir, DISPATCH_SHADOW_MUTATION_LOCK_FILE);
14
+ }
15
+ function emptyBootstrapRoleState() {
16
+ return {
17
+ status: "not_run",
18
+ configHash: null,
19
+ canonicalBranchHead: null,
20
+ lastRunAt: null,
21
+ invalidationReasons: [],
22
+ steps: [],
23
+ failureMessage: null,
24
+ };
25
+ }
26
+ export function normalizeDispatchBootstrapState(bootstrap) {
27
+ const workerState = bootstrap?.roleStates?.worker ?? emptyBootstrapRoleState();
28
+ const reviewerState = bootstrap?.roleStates?.reviewer ?? emptyBootstrapRoleState();
29
+ const lastRole = bootstrap?.lastRole ?? null;
30
+ if (!bootstrap?.roleStates && bootstrap) {
31
+ const migrated = {
32
+ status: bootstrap.status ?? "not_run",
33
+ configHash: bootstrap.configHash ?? null,
34
+ canonicalBranchHead: bootstrap.canonicalBranchHead ?? null,
35
+ lastRunAt: bootstrap.lastRunAt ?? null,
36
+ invalidationReasons: [...(bootstrap.invalidationReasons ?? [])],
37
+ steps: [...(bootstrap.steps ?? [])],
38
+ failureMessage: bootstrap.failureMessage ?? null,
39
+ };
40
+ if (lastRole === "reviewer") {
41
+ return {
42
+ ...migrated,
43
+ lastRole,
44
+ roleStates: {
45
+ worker: emptyBootstrapRoleState(),
46
+ reviewer: migrated,
47
+ },
48
+ };
49
+ }
50
+ return {
51
+ ...migrated,
52
+ lastRole: lastRole ?? "worker",
53
+ roleStates: {
54
+ worker: migrated,
55
+ reviewer: emptyBootstrapRoleState(),
56
+ },
57
+ };
58
+ }
59
+ const activeRole = lastRole === "reviewer" ? "reviewer" : "worker";
60
+ const activeState = activeRole === "reviewer" ? reviewerState : workerState;
61
+ return {
62
+ ...activeState,
63
+ lastRole,
64
+ roleStates: {
65
+ worker: {
66
+ ...workerState,
67
+ invalidationReasons: [...workerState.invalidationReasons],
68
+ steps: [...workerState.steps],
69
+ },
70
+ reviewer: {
71
+ ...reviewerState,
72
+ invalidationReasons: [...reviewerState.invalidationReasons],
73
+ steps: [...reviewerState.steps],
74
+ },
75
+ },
76
+ };
77
+ }
78
+ export class DispatchWorkspaceError extends Error {
79
+ suggestion;
80
+ constructor(message, suggestion) {
81
+ super(message);
82
+ this.name = "DispatchWorkspaceError";
83
+ this.suggestion = suggestion;
84
+ }
85
+ }
86
+ function runGit(cwd, args) {
87
+ const result = spawnSync("git", args, {
88
+ cwd,
89
+ encoding: "utf-8",
90
+ stdio: "pipe",
91
+ });
92
+ return {
93
+ stdout: (result.stdout ?? "").trim(),
94
+ stderr: (result.stderr ?? "").trim(),
95
+ status: result.status,
96
+ };
97
+ }
98
+ function resolveDispatchMutationLockTimeoutMs() {
99
+ const raw = process.env.KSPEC_SHADOW_MUTATION_LOCK_TIMEOUT_MS;
100
+ if (!raw)
101
+ return undefined;
102
+ const parsed = Number(raw);
103
+ return Number.isFinite(parsed) ? parsed : undefined;
104
+ }
105
+ function runGitOrThrow(cwd, args, message, suggestion) {
106
+ const result = runGit(cwd, args);
107
+ if (result.status === 0) {
108
+ return result.stdout;
109
+ }
110
+ const detail = result.stderr || result.stdout || "git command failed";
111
+ throw new DispatchWorkspaceError(`${message}: ${detail}`, suggestion);
112
+ }
113
+ function listGitRemotes(projectDir) {
114
+ const result = runGit(projectDir, ["remote"]);
115
+ if (result.status !== 0 || !result.stdout) {
116
+ return [];
117
+ }
118
+ const remotes = result.stdout
119
+ .split(/\r?\n/)
120
+ .map((line) => line.trim())
121
+ .filter(Boolean)
122
+ .sort();
123
+ const originFirst = remotes.filter((remote) => remote === "origin");
124
+ const rest = remotes.filter((remote) => remote !== "origin");
125
+ return [...originFirst, ...rest];
126
+ }
127
+ function refExists(projectDir, ref) {
128
+ const result = runGit(projectDir, ["show-ref", "--verify", "--quiet", ref]);
129
+ return result.status === 0;
130
+ }
131
+ function resolveBranchStartPoint(projectDir, branch) {
132
+ if (refExists(projectDir, `refs/heads/${branch}`)) {
133
+ return { startPoint: branch, branch };
134
+ }
135
+ for (const remote of listGitRemotes(projectDir)) {
136
+ const remoteRef = `refs/remotes/${remote}/${branch}`;
137
+ if (refExists(projectDir, remoteRef)) {
138
+ return { startPoint: `${remote}/${branch}`, branch };
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+ /**
144
+ * Attempt to restore a missing local branch from a remote ref.
145
+ * Iterates configured remotes (origin first) and creates the local branch
146
+ * from the first matching remote ref. Returns true if the branch was restored.
147
+ * On failure, logs at debug level and returns false (graceful degradation).
148
+ */
149
+ function tryRestoreBranchFromRemote(projectDir, branch) {
150
+ for (const remote of listGitRemotes(projectDir)) {
151
+ const remoteRef = `refs/remotes/${remote}/${branch}`;
152
+ if (!refExists(projectDir, remoteRef))
153
+ continue;
154
+ const result = runGit(projectDir, ["branch", branch, `${remote}/${branch}`]);
155
+ if (result.status === 0) {
156
+ return true;
157
+ }
158
+ console.debug(`[dispatch] Failed to restore branch "${branch}" from ${remote}: ${result.stderr || result.stdout}`);
159
+ }
160
+ return false;
161
+ }
162
+ function resolveRemoteHeadBranch(projectDir) {
163
+ for (const remote of listGitRemotes(projectDir)) {
164
+ const result = runGit(projectDir, ["symbolic-ref", "--quiet", `refs/remotes/${remote}/HEAD`]);
165
+ if (result.status !== 0 || !result.stdout)
166
+ continue;
167
+ const prefix = `refs/remotes/${remote}/`;
168
+ if (result.stdout.startsWith(prefix)) {
169
+ return result.stdout.slice(prefix.length);
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+ function resolveCurrentBranch(projectDir) {
175
+ const result = runGit(projectDir, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
176
+ return result.status === 0 && result.stdout ? result.stdout : null;
177
+ }
178
+ function normalizeTaskSlug(taskRef, task) {
179
+ const preferred = task?.slugs?.[0] ?? task?.title ?? taskRef.replace(/^@/, "task");
180
+ const normalized = preferred
181
+ .toLowerCase()
182
+ .replace(/[^a-z0-9]+/g, "-")
183
+ .replace(/^-+|-+$/g, "")
184
+ .replace(/--+/g, "-");
185
+ return normalized || "task";
186
+ }
187
+ function shortTaskId(taskRef) {
188
+ return taskRef.replace(/^@/, "").slice(0, 8).toLowerCase();
189
+ }
190
+ function workspaceIdFor(taskRef) {
191
+ return `dispatch-workspace-${taskRef.replace(/^@/, "")}`;
192
+ }
193
+ function resolveCommit(cwd, ref) {
194
+ return runGitOrThrow(cwd, ["rev-parse", `${ref}^{commit}`], `Failed to resolve commit for "${ref}"`, "Inspect the dispatch branch/base branch references and retry.");
195
+ }
196
+ function metadataPathFor(worktreeDir) {
197
+ return path.join(worktreeDir, DISPATCH_WORKSPACE_METADATA_FILE);
198
+ }
199
+ function commandAvailable(command) {
200
+ const result = spawnSync(command, ["--version"], {
201
+ encoding: "utf-8",
202
+ stdio: "pipe",
203
+ });
204
+ return result.status === 0;
205
+ }
206
+ function hasGitHubRemote(projectDir) {
207
+ for (const remote of listGitRemotes(projectDir)) {
208
+ const result = runGit(projectDir, ["remote", "get-url", remote]);
209
+ if (result.status !== 0 || !result.stdout) {
210
+ continue;
211
+ }
212
+ if (result.stdout.includes("github.com/") ||
213
+ result.stdout.includes("github.com:")) {
214
+ return true;
215
+ }
216
+ }
217
+ return false;
218
+ }
219
+ function resolvePublicationMode(projectDir, configuredMode) {
220
+ if (configuredMode && configuredMode !== "auto") {
221
+ return configuredMode;
222
+ }
223
+ return commandAvailable("gh") && hasGitHubRemote(projectDir)
224
+ ? "pull_request"
225
+ : "manual_merge";
226
+ }
227
+ function resolveWorkspacePublicationMode(projectDir, existingRecord, configuredMode) {
228
+ if (!existingRecord) {
229
+ return resolvePublicationMode(projectDir, configuredMode);
230
+ }
231
+ switch (existingRecord.integration.status) {
232
+ case "pending":
233
+ case "in_progress":
234
+ return resolvePublicationMode(projectDir, configuredMode);
235
+ default:
236
+ return existingRecord.integration.publication_mode;
237
+ }
238
+ }
239
+ // AC: @adopt-existing-task-branch-lineage ac-2 — rehydrate adopted branch from remote
240
+ function rehydrateAdoptedBranch(projectDir, branchName, remote, remoteUrl) {
241
+ // Try the specified remote first, then fall back to all remotes
242
+ const remotes = remote
243
+ ? [remote, ...listGitRemotes(projectDir).filter((r) => r !== remote)]
244
+ : listGitRemotes(projectDir);
245
+ for (const remoteName of remotes) {
246
+ // Fetch the specific branch from the remote
247
+ const fetchResult = runGit(projectDir, [
248
+ "fetch", remoteName, `${branchName}:${branchName}`,
249
+ ]);
250
+ if (fetchResult.status === 0) {
251
+ return true;
252
+ }
253
+ // Also try refs/heads/<branch> in case the remote ref name differs
254
+ const fetchAlt = runGit(projectDir, [
255
+ "fetch", remoteName, `refs/heads/${branchName}:refs/heads/${branchName}`,
256
+ ]);
257
+ if (fetchAlt.status === 0) {
258
+ return true;
259
+ }
260
+ }
261
+ // Fall back to fetching directly from the remote URL when named remotes
262
+ // don't have the branch (e.g. fork URL not configured as a named remote)
263
+ if (remoteUrl) {
264
+ const fetchUrl = runGit(projectDir, [
265
+ "fetch", remoteUrl, `${branchName}:${branchName}`,
266
+ ]);
267
+ if (fetchUrl.status === 0) {
268
+ return true;
269
+ }
270
+ }
271
+ return false;
272
+ }
273
+ function isDispatchBranch(branch) {
274
+ return Boolean(branch && branch.startsWith(DISPATCH_BRANCH_PREFIX));
275
+ }
276
+ function isPathInside(parent, candidate) {
277
+ const relative = path.relative(path.resolve(parent), path.resolve(candidate));
278
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
279
+ }
280
+ async function readWorkspaceMetadata(worktreeDir) {
281
+ try {
282
+ const raw = await fs.readFile(metadataPathFor(worktreeDir), "utf-8");
283
+ return JSON.parse(raw);
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ }
289
+ async function writeWorkspaceMetadata(worktreeDir, metadata) {
290
+ const metadataPath = metadataPathFor(worktreeDir);
291
+ await fs.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf-8");
292
+ return metadataPath;
293
+ }
294
+ function resolveIntegrationOutcome(publicationMode, integrationState) {
295
+ switch (integrationState) {
296
+ case "merged":
297
+ return "merged";
298
+ case "abandoned":
299
+ return "abandoned";
300
+ case "reset":
301
+ return "reset";
302
+ case "pending":
303
+ default:
304
+ return publicationMode === "pull_request" ? "pull_request" : "manual_merge";
305
+ }
306
+ }
307
+ function parseWorktreeList(projectDir) {
308
+ const result = runGit(projectDir, ["worktree", "list", "--porcelain"]);
309
+ if (result.status !== 0 || !result.stdout) {
310
+ return [];
311
+ }
312
+ const entries = [];
313
+ const blocks = result.stdout.split(/\n\s*\n/).filter(Boolean);
314
+ for (const block of blocks) {
315
+ const lines = block.split(/\r?\n/);
316
+ const worktreePath = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
317
+ if (!worktreePath)
318
+ continue;
319
+ const branchRef = lines.find((line) => line.startsWith("branch "))?.slice("branch ".length) ?? null;
320
+ entries.push({ path: worktreePath, branch: branchRef });
321
+ }
322
+ return entries;
323
+ }
324
+ function normalizeBranchRef(branch) {
325
+ return branch ? branch.replace(/^refs\/heads\//, "") : null;
326
+ }
327
+ function findExistingWorktreeForBranch(projectDir, canonicalBranch) {
328
+ const branchRef = `refs/heads/${canonicalBranch}`;
329
+ return parseWorktreeList(projectDir).find((entry) => entry.branch === branchRef)?.path ?? null;
330
+ }
331
+ function findWorktreeByPath(projectDir, worktreeDir) {
332
+ const normalized = path.resolve(worktreeDir);
333
+ return parseWorktreeList(projectDir).find((entry) => path.resolve(entry.path) === normalized) ?? null;
334
+ }
335
+ function pathExists(targetPath) {
336
+ return spawnSync("bash", ["-lc", `test -e "${targetPath.replace(/(["\\$`])/g, "\\$1")}"`], {
337
+ stdio: "ignore",
338
+ }).status === 0;
339
+ }
340
+ function defaultBranchProvenance() {
341
+ return {
342
+ ownership: "dispatcher-managed",
343
+ source: "provisioned",
344
+ remote_ref: null,
345
+ adopted_from: null,
346
+ adopted_at: null,
347
+ rehydrated: null,
348
+ };
349
+ }
350
+ function adoptedBranchProvenance(adoptedFrom, remoteRef, now, rehydrated) {
351
+ return {
352
+ ownership: "adopted",
353
+ source: "task-submission-linkage",
354
+ remote_ref: remoteRef,
355
+ adopted_from: adoptedFrom,
356
+ adopted_at: now,
357
+ rehydrated,
358
+ };
359
+ }
360
+ function defaultBootstrapState(now) {
361
+ return {
362
+ ...emptyBootstrapRoleState(),
363
+ lastRole: null,
364
+ roleStates: {
365
+ worker: emptyBootstrapRoleState(),
366
+ reviewer: emptyBootstrapRoleState(),
367
+ },
368
+ };
369
+ }
370
+ function createCleanupRecord(state, now) {
371
+ return {
372
+ status: state.cleanupEligible ? "scheduled" : "not_scheduled",
373
+ eligible: state.cleanupEligible,
374
+ reason: state.cleanupReason,
375
+ detail: state.cleanupReason,
376
+ updated_at: now,
377
+ };
378
+ }
379
+ function resolveCleanupRecord(cleanupState, existingRecord, now) {
380
+ if (cleanupState) {
381
+ return createCleanupRecord(resolveDispatchWorkspaceCleanupState(cleanupState), now);
382
+ }
383
+ if (existingRecord) {
384
+ return {
385
+ ...existingRecord.cleanup,
386
+ updated_at: now,
387
+ };
388
+ }
389
+ return createCleanupRecord(resolveDispatchWorkspaceCleanupState({}), now);
390
+ }
391
+ function resolveIntegrationRecord(targetBranch, targetCommit, publicationMode, cleanupState, existingRecord, now) {
392
+ const status = cleanupState?.integrationState ?? existingRecord?.integration.status ?? "pending";
393
+ return {
394
+ status,
395
+ target_branch: targetBranch,
396
+ target_commit: existingRecord?.integration.target_commit ?? targetCommit,
397
+ publication_mode: publicationMode,
398
+ outcome: resolveIntegrationOutcome(publicationMode, status),
399
+ detail: cleanupState?.integrationState ? `integration:${cleanupState.integrationState}` : existingRecord?.integration.detail ?? null,
400
+ updated_at: now,
401
+ };
402
+ }
403
+ function resolveRegistryStateForTaskStatus(taskStatus, existingRecord, now) {
404
+ if (taskStatus === "completed") {
405
+ const cleanupState = {
406
+ integrationState: "merged",
407
+ taskStatus,
408
+ };
409
+ return {
410
+ integration: resolveIntegrationRecord(existingRecord.integration.target_branch, existingRecord.integration.target_commit, existingRecord.integration.publication_mode, cleanupState, existingRecord, now),
411
+ cleanup: existingRecord.cleanup.status === "blocked"
412
+ || existingRecord.cleanup.status === "completed"
413
+ ? {
414
+ ...existingRecord.cleanup,
415
+ updated_at: now,
416
+ }
417
+ : resolveCleanupRecord(cleanupState, existingRecord, now),
418
+ };
419
+ }
420
+ if (taskStatus === "cancelled") {
421
+ const cleanupState = {
422
+ integrationState: "abandoned",
423
+ taskStatus,
424
+ };
425
+ return {
426
+ integration: resolveIntegrationRecord(existingRecord.integration.target_branch, existingRecord.integration.target_commit, existingRecord.integration.publication_mode, cleanupState, existingRecord, now),
427
+ cleanup: existingRecord.cleanup.status === "blocked"
428
+ || existingRecord.cleanup.status === "completed"
429
+ ? {
430
+ ...existingRecord.cleanup,
431
+ updated_at: now,
432
+ }
433
+ : resolveCleanupRecord(cleanupState, existingRecord, now),
434
+ };
435
+ }
436
+ const shouldResetLifecycle = existingRecord.lifecycle_state === "closing"
437
+ || existingRecord.integration.status === "merged"
438
+ || existingRecord.integration.status === "abandoned"
439
+ || existingRecord.cleanup.status !== "not_scheduled"
440
+ || existingRecord.cleanup.eligible;
441
+ if (taskStatus && shouldResetLifecycle) {
442
+ const cleanupState = {
443
+ integrationState: "reset",
444
+ taskStatus,
445
+ };
446
+ return {
447
+ integration: resolveIntegrationRecord(existingRecord.integration.target_branch, existingRecord.integration.target_commit, existingRecord.integration.publication_mode, cleanupState, existingRecord, now),
448
+ cleanup: resolveCleanupRecord(cleanupState, existingRecord, now),
449
+ };
450
+ }
451
+ return {
452
+ integration: {
453
+ ...existingRecord.integration,
454
+ updated_at: now,
455
+ },
456
+ cleanup: {
457
+ ...existingRecord.cleanup,
458
+ updated_at: now,
459
+ },
460
+ };
461
+ }
462
+ function resolveLifecycleState(taskStatus, health, integration, cleanup, activeRole) {
463
+ const resetReopenedTask = integration.status === "reset"
464
+ && taskStatus !== null
465
+ && taskStatus !== "completed"
466
+ && taskStatus !== "cancelled";
467
+ if (cleanup.status === "completed")
468
+ return "closed";
469
+ if (cleanup.status === "blocked")
470
+ return "cleanup_blocked";
471
+ if (health.status !== "healthy")
472
+ return "stale";
473
+ if (!resetReopenedTask && (cleanup.eligible || integration.status === "merged" || integration.status === "abandoned")) {
474
+ return "closing";
475
+ }
476
+ if (activeRole === "reviewer")
477
+ return "integrating";
478
+ if (activeRole === "worker")
479
+ return "active";
480
+ if (integration.status === "in_progress" || taskStatus === "pending_review") {
481
+ return "integrating";
482
+ }
483
+ return "ready";
484
+ }
485
+ function createHealthyState(now) {
486
+ return {
487
+ status: "healthy",
488
+ summary: "Workspace record matches current git branch and worktree state.",
489
+ issues: [],
490
+ updated_at: now,
491
+ };
492
+ }
493
+ function buildIssue(code, message, suggestion) {
494
+ return {
495
+ code,
496
+ message,
497
+ suggestion,
498
+ };
499
+ }
500
+ // AC: @adopted-branch-cleanup-and-recoverability ac-3
501
+ function reconcileWorkspaceHealth(projectDir, record, now) {
502
+ const issues = [];
503
+ const branchRef = `refs/heads/${record.canonical_branch}`;
504
+ let branchExists = refExists(projectDir, branchRef);
505
+ if (!branchExists) {
506
+ branchExists = tryRestoreBranchFromRemote(projectDir, record.canonical_branch);
507
+ }
508
+ if (!branchExists) {
509
+ const isAdopted = record.branch_provenance?.ownership === "adopted";
510
+ const hasRemoteLocator = Boolean(record.branch_provenance?.remote_ref);
511
+ if (isAdopted && hasRemoteLocator) {
512
+ issues.push(buildIssue("missing_adopted_branch_recoverable", `Adopted canonical branch "${record.canonical_branch}" is missing locally but a remote locator is known (${record.branch_provenance.remote_ref}).`, `Rehydrate the adopted branch from the remote locator with: git fetch <remote> ${record.canonical_branch}:${record.canonical_branch}`));
513
+ }
514
+ else if (isAdopted) {
515
+ issues.push(buildIssue("missing_adopted_branch", `Adopted canonical branch "${record.canonical_branch}" is missing locally and no remote locator is recorded.`, "Locate the original branch source and manually restore it, or re-submit the task with updated submission linkage."));
516
+ }
517
+ else {
518
+ issues.push(buildIssue("missing_canonical_branch", `Canonical branch "${record.canonical_branch}" is missing.`, "Re-provision the workspace or restore the branch before dispatch resumes."));
519
+ }
520
+ }
521
+ const workerRegistered = findExistingWorktreeForBranch(projectDir, record.canonical_branch);
522
+ const workerExists = pathExists(record.worktrees.worker.path);
523
+ if (!workerExists || (!workerRegistered && record.lifecycle_state !== "closed")) {
524
+ issues.push(buildIssue("missing_worker_worktree", `Worker worktree "${record.worktrees.worker.path}" is missing or no longer registered.`, "Re-provision the worker worktree from the recorded canonical branch."));
525
+ }
526
+ if (record.worktrees.reviewer) {
527
+ const reviewerRegistered = findWorktreeByPath(projectDir, record.worktrees.reviewer.path);
528
+ const reviewerExists = pathExists(record.worktrees.reviewer.path);
529
+ if (!reviewerExists || !reviewerRegistered) {
530
+ issues.push(buildIssue("missing_reviewer_worktree", `Reviewer worktree "${record.worktrees.reviewer.path}" is missing or no longer registered.`, "Recreate the detached reviewer snapshot before running review again."));
531
+ }
532
+ }
533
+ if (issues.length === 0) {
534
+ return createHealthyState(now);
535
+ }
536
+ const hasUnrecoverableBranch = issues.some((issue) => issue.code === "missing_canonical_branch" || issue.code === "missing_adopted_branch");
537
+ return {
538
+ status: hasUnrecoverableBranch ? "invalid" : "stale",
539
+ summary: hasUnrecoverableBranch
540
+ ? "Workspace registry record is invalid because required git state is missing."
541
+ : "Workspace registry record is stale and needs reconciliation.",
542
+ issues,
543
+ updated_at: now,
544
+ };
545
+ }
546
+ function toMetadata(record) {
547
+ const bootstrapState = normalizeDispatchBootstrapState(record.bootstrap);
548
+ const primaryBootstrapState = bootstrapState.lastRole === "reviewer"
549
+ ? bootstrapState.roleStates.reviewer
550
+ : bootstrapState.roleStates.worker;
551
+ const healthStatus = primaryBootstrapState.status === "failed"
552
+ ? "unhealthy"
553
+ : record.health.status === "healthy" && !record.cleanup.eligible
554
+ ? "healthy"
555
+ : "unhealthy";
556
+ const healthReason = primaryBootstrapState.failureMessage
557
+ ?? (record.cleanup.eligible ? record.cleanup.reason ?? "workspace-marked-for-cleanup" : null)
558
+ ?? (record.health.issues[0]?.message ?? null);
559
+ return {
560
+ workspaceId: record.workspace_id,
561
+ taskRef: record.task_ref,
562
+ taskSlug: record.task_slug,
563
+ baseBranch: record.resolved_base_branch,
564
+ baseBranchPoint: record.base_branch_point,
565
+ mergeTargetBranch: record.integration.target_branch,
566
+ integrationTargetBranch: record.integration.target_branch,
567
+ integrationTargetCommit: record.integration.target_commit,
568
+ canonicalBranch: record.canonical_branch,
569
+ canonicalBranchHead: record.canonical_branch_head,
570
+ branchProvenance: record.branch_provenance ?? defaultBranchProvenance(),
571
+ publicationMode: record.integration.publication_mode,
572
+ integrationState: record.integration.status,
573
+ integrationOutcome: record.integration.outcome,
574
+ integrationUpdatedAt: record.integration.updated_at,
575
+ worktreeRoot: record.worktree_root,
576
+ workerWorktreeDir: record.worktrees.worker.path,
577
+ reviewerWorktreeDir: record.worktrees.reviewer?.path ?? null,
578
+ lifecycleState: record.lifecycle_state,
579
+ activeRole: record.active_role ?? null,
580
+ bootstrapState,
581
+ healthState: record.health,
582
+ cleanupState: record.cleanup,
583
+ healthStatus,
584
+ healthReason,
585
+ bootstrap: bootstrapState,
586
+ cleanupEligible: record.cleanup.eligible,
587
+ cleanupReason: record.cleanup.reason ?? null,
588
+ cleanupScheduledAt: record.cleanup.eligible
589
+ ? (record.timestamps.closed_at ?? record.cleanup.updated_at)
590
+ : null,
591
+ cleanupBlockedReason: record.cleanup.status === "blocked"
592
+ ? (record.cleanup.reason ?? record.cleanup.detail ?? null)
593
+ : null,
594
+ createdAt: record.timestamps.created_at,
595
+ updatedAt: record.timestamps.updated_at,
596
+ lastReconciledAt: record.timestamps.last_reconciled_at ?? null,
597
+ lastActiveAt: record.timestamps.last_active_at ?? null,
598
+ closedAt: record.timestamps.closed_at ?? null,
599
+ };
600
+ }
601
+ async function loadWorkspaceRecord(projectDir, taskRef) {
602
+ const ctx = await initContext(projectDir);
603
+ return findDispatchWorkspaceByTaskRef(ctx, taskRef, { includeClosed: true });
604
+ }
605
+ /**
606
+ * Save a workspace record to the registry file and update worktree metadata.
607
+ * Does NOT acquire the dispatch shadow mutation lock or trigger a shadow commit.
608
+ * Callers MUST hold the dispatch shadow mutation lock (via
609
+ * {@link withDispatchShadowMutationLock}) and follow up with
610
+ * {@link commitWorkspaceRegistryToShadow}.
611
+ */
612
+ async function saveWorkspaceRecordToRegistry(projectDir, record) {
613
+ const ctx = await initContext(projectDir);
614
+ const registryPath = getDispatchWorkspaceRegistryPath(ctx);
615
+ await saveDispatchWorkspaceRecord(ctx, {
616
+ ...record,
617
+ _sourceFile: registryPath,
618
+ });
619
+ const workerDir = record.worktrees.worker.path;
620
+ if (workerDir && pathExists(workerDir)) {
621
+ await writeWorkspaceMetadata(workerDir, toMetadata(record));
622
+ }
623
+ return registryPath;
624
+ }
625
+ /**
626
+ * Run a callback inside the dispatch shadow mutation file lock.
627
+ * Serializes with other shadow writers across processes.
628
+ *
629
+ * AC: @dispatch-workspace-registry ac-8
630
+ */
631
+ async function withDispatchShadowMutationLock(projectDir, taskRef, fn) {
632
+ const lockPath = getDispatchShadowMutationLockPath(projectDir);
633
+ const timeoutMs = resolveDispatchMutationLockTimeoutMs();
634
+ let release;
635
+ try {
636
+ release = await acquireFileLock(lockPath, timeoutMs);
637
+ }
638
+ catch (error) {
639
+ const reason = error instanceof Error ? error.message : String(error);
640
+ throw new DispatchWorkspaceError(`Dispatch shadow mutation lock unavailable while committing workspace registry for ${taskRef}: ${reason}`, `Wait for the overlapping kspec mutation to finish, or remove ${path.basename(lockPath)}.lock if the lock holder is gone.`);
641
+ }
642
+ try {
643
+ return await fn();
644
+ }
645
+ finally {
646
+ await release?.();
647
+ }
648
+ }
649
+ /**
650
+ * Commit pending workspace registry changes on the shadow branch.
651
+ * MUST be called while the dispatch shadow mutation lock is held.
652
+ * Does NOT acquire the lock itself — callers are responsible for
653
+ * wrapping the write-then-commit sequence inside
654
+ * {@link withDispatchShadowMutationLock}.
655
+ *
656
+ * AC: @dispatch-workspace-registry ac-8
657
+ */
658
+ async function commitWorkspaceRegistryToShadow(projectDir, taskRef) {
659
+ const ctx = await initContext(projectDir);
660
+ if (!ctx.shadow?.enabled)
661
+ return;
662
+ const bareRef = taskRef.replace(/^@/, "");
663
+ const committed = await commitIfShadow(ctx.shadow, "dispatch-workspace-registry", bareRef);
664
+ if (!committed) {
665
+ const shadowStatus = runGit(ctx.shadow.worktreeDir, ["status", "--porcelain"]);
666
+ const hasPendingShadowChanges = shadowStatus.status !== 0 || shadowStatus.stdout.trim().length > 0;
667
+ if (hasPendingShadowChanges) {
668
+ throw new DispatchWorkspaceError(`Dispatch workspace registry write succeeded but could not be durably committed on the shadow branch for ${taskRef}.`, "Resolve the shadow branch commit issue, then rerun dispatch reconciliation or workspace provisioning so the registry state becomes durable.");
669
+ }
670
+ }
671
+ }
672
+ /**
673
+ * Persist a single workspace record: acquire the dispatch shadow mutation
674
+ * lock, save to registry, then durably commit — all within the lock scope
675
+ * so the write is never visible without a matching shadow commit.
676
+ *
677
+ * AC: @dispatch-workspace-registry ac-8
678
+ */
679
+ async function persistWorkspaceRecord(projectDir, record) {
680
+ return withDispatchShadowMutationLock(projectDir, record.task_ref, async () => {
681
+ const registryPath = await saveWorkspaceRecordToRegistry(projectDir, record);
682
+ await commitWorkspaceRegistryToShadow(projectDir, record.task_ref);
683
+ return registryPath;
684
+ });
685
+ }
686
+ export async function persistDispatchWorkspaceMetadata(projectDir, metadata) {
687
+ const existingRecord = await loadWorkspaceRecord(projectDir, metadata.taskRef);
688
+ if (!existingRecord) {
689
+ throw new DispatchWorkspaceError(`Cannot persist dispatch metadata for ${metadata.taskRef}: workspace registry record is missing.`, "Re-provision the dispatch workspace before retrying bootstrap persistence.");
690
+ }
691
+ const now = metadata.updatedAt || new Date().toISOString();
692
+ return persistWorkspaceRecord(projectDir, {
693
+ ...existingRecord,
694
+ task_slug: metadata.taskSlug,
695
+ base_branch_point: metadata.baseBranchPoint,
696
+ canonical_branch_head: metadata.canonicalBranchHead,
697
+ bootstrap: normalizeDispatchBootstrapState(metadata.bootstrap),
698
+ timestamps: {
699
+ ...existingRecord.timestamps,
700
+ updated_at: now,
701
+ last_reconciled_at: metadata.lastReconciledAt ?? existingRecord.timestamps.last_reconciled_at,
702
+ last_active_at: metadata.lastActiveAt ?? existingRecord.timestamps.last_active_at,
703
+ closed_at: metadata.closedAt ?? existingRecord.timestamps.closed_at,
704
+ },
705
+ });
706
+ }
707
+ async function findWorkspaceRegistrationByTaskRef(projectDir, taskRef, task) {
708
+ // Try the deterministic dispatch/task/* branch first (most common path).
709
+ const slug = normalizeTaskSlug(taskRef, task);
710
+ const shortId = shortTaskId(taskRef);
711
+ const syntheticBranch = `dispatch/task/${slug}/${shortId}`;
712
+ const workerWorktreeDir = findExistingWorktreeForBranch(projectDir, syntheticBranch);
713
+ if (workerWorktreeDir) {
714
+ const metadata = await readWorkspaceMetadata(workerWorktreeDir);
715
+ if (metadata) {
716
+ return { canonicalBranch: syntheticBranch, workerWorktreeDir, metadata };
717
+ }
718
+ }
719
+ // Fall back to registry lookup — adopted branches use a non-dispatch canonical
720
+ // branch name, so the synthetic prefix won't match.
721
+ const record = await loadWorkspaceRecord(projectDir, taskRef);
722
+ if (!record) {
723
+ return null;
724
+ }
725
+ const registryBranch = record.canonical_branch;
726
+ const registryWorktreeDir = findExistingWorktreeForBranch(projectDir, registryBranch);
727
+ if (!registryWorktreeDir) {
728
+ return null;
729
+ }
730
+ const metadata = await readWorkspaceMetadata(registryWorktreeDir);
731
+ if (!metadata) {
732
+ return null;
733
+ }
734
+ return { canonicalBranch: registryBranch, workerWorktreeDir: registryWorktreeDir, metadata };
735
+ }
736
+ async function recoverWorkspaceRecordFromMetadata(projectDir, resolvedConfig, candidatePath) {
737
+ const metadata = await readWorkspaceMetadata(candidatePath);
738
+ if (!metadata) {
739
+ return null;
740
+ }
741
+ const existingRecord = await loadWorkspaceRecord(projectDir, metadata.taskRef);
742
+ if (existingRecord) {
743
+ return existingRecord;
744
+ }
745
+ const workerWorktreeDir = path.resolve(metadata.workerWorktreeDir || candidatePath);
746
+ if (!isPathInside(resolvedConfig.worktreeRoot, workerWorktreeDir)) {
747
+ return null;
748
+ }
749
+ const workerRegistration = findWorktreeByPath(projectDir, workerWorktreeDir);
750
+ const reviewerWorktreeDir = metadata.reviewerWorktreeDir
751
+ ? path.resolve(metadata.reviewerWorktreeDir)
752
+ : null;
753
+ const reviewerRegistration = reviewerWorktreeDir
754
+ ? findWorktreeByPath(projectDir, reviewerWorktreeDir)
755
+ : null;
756
+ if (!workerRegistration && !reviewerRegistration) {
757
+ return null;
758
+ }
759
+ const taskSlug = normalizeTaskSlug(metadata.taskRef, {
760
+ title: metadata.taskSlug,
761
+ slugs: [metadata.taskSlug],
762
+ });
763
+ const hasAdoptedProvenance = metadata.branchProvenance?.ownership === "adopted";
764
+ // When branch_provenance is missing (legacy workspace) AND the canonical branch
765
+ // is not a dispatch branch, infer adopted status to preserve the branch identity
766
+ // instead of normalizing it back to dispatch/task/* (AC-2).
767
+ const inferredAdopted = !metadata.branchProvenance && !isDispatchBranch(metadata.canonicalBranch);
768
+ const canonicalBranch = (hasAdoptedProvenance || inferredAdopted)
769
+ ? metadata.canonicalBranch
770
+ : isDispatchBranch(metadata.canonicalBranch)
771
+ ? metadata.canonicalBranch
772
+ : `dispatch/task/${taskSlug}/${shortTaskId(metadata.taskRef)}`;
773
+ const currentWorkerBranch = normalizeBranchRef(workerRegistration?.branch);
774
+ if (workerRegistration && currentWorkerBranch !== canonicalBranch && !hasAdoptedProvenance && !inferredAdopted) {
775
+ try {
776
+ runGitOrThrow(workerWorktreeDir, ["checkout", "-B", canonicalBranch], `Failed to normalize legacy dispatch branch for ${metadata.taskRef}`, "Repair or remove the legacy dispatch worktree before retrying reconciliation.");
777
+ }
778
+ catch {
779
+ // Persist a stale record below so the dispatcher can surface a concrete
780
+ // task-linked recovery path instead of silently dropping the workspace.
781
+ }
782
+ }
783
+ const now = new Date().toISOString();
784
+ const baseBranchPoint = metadata.baseBranchPoint
785
+ || metadata.integrationTargetCommit
786
+ || metadata.canonicalBranchHead
787
+ || resolvedConfig.baseBranchStartPoint;
788
+ const publicationMode = metadata.publicationMode ?? resolvePublicationMode(projectDir, resolvedConfig.publicationMode);
789
+ const integration = {
790
+ status: metadata.integrationState ?? "pending",
791
+ target_branch: metadata.integrationTargetBranch || metadata.mergeTargetBranch || metadata.baseBranch || resolvedConfig.baseBranch,
792
+ target_commit: metadata.integrationTargetCommit || baseBranchPoint,
793
+ publication_mode: publicationMode,
794
+ outcome: metadata.integrationOutcome ?? resolveIntegrationOutcome(publicationMode, metadata.integrationState ?? "pending"),
795
+ detail: metadata.cleanupState?.detail ?? null,
796
+ updated_at: metadata.integrationUpdatedAt ?? now,
797
+ };
798
+ const cleanup = metadata.cleanupState
799
+ ? {
800
+ ...metadata.cleanupState,
801
+ updated_at: now,
802
+ }
803
+ : createCleanupRecord({
804
+ cleanupEligible: metadata.cleanupEligible,
805
+ cleanupReason: metadata.cleanupReason,
806
+ }, now);
807
+ const canonicalBranchHead = refExists(projectDir, `refs/heads/${canonicalBranch}`)
808
+ ? resolveCommit(projectDir, canonicalBranch)
809
+ : workerRegistration
810
+ ? resolveCommit(workerWorktreeDir, "HEAD")
811
+ : metadata.canonicalBranchHead;
812
+ const reviewerWorktree = reviewerWorktreeDir && pathExists(reviewerWorktreeDir)
813
+ ? buildWorktreeRecord(reviewerWorktreeDir, "detached", null, reviewerRegistration ? resolveCommit(reviewerWorktreeDir, "HEAD") : null, now)
814
+ : null;
815
+ const branchProvenance = metadata.branchProvenance
816
+ ?? (isDispatchBranch(metadata.canonicalBranch)
817
+ ? defaultBranchProvenance()
818
+ : adoptedBranchProvenance(metadata.canonicalBranch, null, now, false));
819
+ const provisionalRecord = {
820
+ workspace_id: metadata.workspaceId || workspaceIdFor(metadata.taskRef),
821
+ task_ref: metadata.taskRef,
822
+ task_slug: taskSlug,
823
+ worktree_root: resolvedConfig.worktreeRoot,
824
+ resolved_base_branch: metadata.baseBranch || resolvedConfig.baseBranch,
825
+ base_branch_point: baseBranchPoint,
826
+ canonical_branch: canonicalBranch,
827
+ canonical_branch_head: canonicalBranchHead,
828
+ branch_provenance: branchProvenance,
829
+ lifecycle_state: metadata.lifecycleState ?? "ready",
830
+ active_role: metadata.activeRole ?? null,
831
+ worktrees: {
832
+ worker: buildWorktreeRecord(workerWorktreeDir, "branch", canonicalBranch, workerRegistration ? resolveCommit(workerWorktreeDir, "HEAD") : canonicalBranchHead, now),
833
+ reviewer: reviewerWorktree,
834
+ },
835
+ bootstrap: normalizeDispatchBootstrapState(metadata.bootstrap),
836
+ integration,
837
+ health: createHealthyState(now),
838
+ cleanup,
839
+ timestamps: {
840
+ created_at: metadata.createdAt ?? now,
841
+ updated_at: now,
842
+ last_reconciled_at: now,
843
+ last_active_at: metadata.lastActiveAt ?? null,
844
+ closed_at: metadata.closedAt ?? null,
845
+ },
846
+ };
847
+ const health = reconcileWorkspaceHealth(projectDir, provisionalRecord, now);
848
+ const record = {
849
+ ...provisionalRecord,
850
+ lifecycle_state: resolveLifecycleState(null, health, integration, cleanup, metadata.activeRole ?? null),
851
+ health,
852
+ };
853
+ await persistWorkspaceRecord(projectDir, record);
854
+ return record;
855
+ }
856
+ async function ensureUsableWorktreeRoot(projectDir, worktreeRoot) {
857
+ const shadowDir = path.join(projectDir, ".kspec");
858
+ const relativeToShadow = path.relative(shadowDir, worktreeRoot);
859
+ const insideShadow = relativeToShadow === "" || (!relativeToShadow.startsWith("..") && !path.isAbsolute(relativeToShadow));
860
+ if (insideShadow) {
861
+ throw new DispatchWorkspaceError(`Resolved dispatch worktree root "${worktreeRoot}" is inside the shadow worktree.`, "Set dispatch.worktree_root to a directory outside .kspec/.");
862
+ }
863
+ try {
864
+ await fs.mkdir(worktreeRoot, { recursive: true });
865
+ }
866
+ catch (error) {
867
+ const message = error instanceof Error ? error.message : String(error);
868
+ throw new DispatchWorkspaceError(`Cannot create dispatch worktree root "${worktreeRoot}": ${message}`, "Fix the path or permissions for dispatch.worktree_root and try again.");
869
+ }
870
+ const stat = await fs.stat(worktreeRoot).catch(() => null);
871
+ if (!stat?.isDirectory()) {
872
+ throw new DispatchWorkspaceError(`Resolved dispatch worktree root "${worktreeRoot}" is not a directory.`, "Choose a directory path for dispatch.worktree_root.");
873
+ }
874
+ }
875
+ async function assertPathSafeForWorktree(worktreeDir, projectDir) {
876
+ const existing = await fs.stat(worktreeDir).catch(() => null);
877
+ if (!existing)
878
+ return;
879
+ const registered = parseWorktreeList(projectDir).some((entry) => entry.path === worktreeDir);
880
+ if (registered)
881
+ return;
882
+ const entries = await fs.readdir(worktreeDir).catch(() => []);
883
+ if (entries.length > 0) {
884
+ throw new DispatchWorkspaceError(`Dispatch worktree path "${worktreeDir}" already exists and is not a registered git worktree.`, "Remove or rename that directory, or choose a different dispatch.worktree_root.");
885
+ }
886
+ }
887
+ export async function resolveDispatchWorkspaceConfig(projectDir) {
888
+ const { config } = await loadProjectConfig(projectDir, projectDir);
889
+ const configuredBaseBranch = config.dispatch.base_branch?.trim() || null;
890
+ const publicationMode = config.dispatch.publication_mode;
891
+ const rawRoot = config.dispatch.worktree_root?.trim() || ".kspec-worktrees";
892
+ const worktreeRoot = path.isAbsolute(rawRoot)
893
+ ? rawRoot
894
+ : path.resolve(projectDir, rawRoot);
895
+ if (configuredBaseBranch) {
896
+ const resolved = resolveBranchStartPoint(projectDir, configuredBaseBranch);
897
+ if (!resolved) {
898
+ throw new DispatchWorkspaceError(`Configured dispatch.base_branch "${configuredBaseBranch}" does not exist in this repository.`, "Create or fetch that branch, or update kspec.config.yaml to a valid base branch.");
899
+ }
900
+ return {
901
+ baseBranch: configuredBaseBranch,
902
+ baseBranchStartPoint: resolved.startPoint,
903
+ baseBranchSource: "configured",
904
+ worktreeRoot,
905
+ publicationMode,
906
+ };
907
+ }
908
+ const remoteHeadBranch = resolveRemoteHeadBranch(projectDir);
909
+ if (remoteHeadBranch) {
910
+ const resolved = resolveBranchStartPoint(projectDir, remoteHeadBranch);
911
+ if (resolved) {
912
+ return {
913
+ baseBranch: remoteHeadBranch,
914
+ baseBranchStartPoint: resolved.startPoint,
915
+ baseBranchSource: "remote-head",
916
+ worktreeRoot,
917
+ publicationMode,
918
+ };
919
+ }
920
+ }
921
+ const currentBranch = resolveCurrentBranch(projectDir);
922
+ if (currentBranch) {
923
+ const resolved = resolveBranchStartPoint(projectDir, currentBranch) ?? {
924
+ startPoint: currentBranch,
925
+ branch: currentBranch,
926
+ };
927
+ return {
928
+ baseBranch: currentBranch,
929
+ baseBranchStartPoint: resolved.startPoint,
930
+ baseBranchSource: "current-branch",
931
+ worktreeRoot,
932
+ publicationMode,
933
+ };
934
+ }
935
+ const defaultBranch = "main";
936
+ const resolved = resolveBranchStartPoint(projectDir, defaultBranch);
937
+ if (resolved) {
938
+ return {
939
+ baseBranch: defaultBranch,
940
+ baseBranchStartPoint: resolved.startPoint,
941
+ baseBranchSource: "default",
942
+ worktreeRoot,
943
+ publicationMode,
944
+ };
945
+ }
946
+ throw new DispatchWorkspaceError('No base branch could be resolved: no configured dispatch.base_branch, no remote HEAD, ' +
947
+ 'no current branch, and default "main" does not exist.', "Set dispatch.base_branch in kspec.config.yaml, or ensure the repository has a main branch.");
948
+ }
949
+ export function resolveDispatchWorkspaceCleanupState(options) {
950
+ if (options.integrationState === "merged") {
951
+ return { cleanupEligible: true, cleanupReason: "integrated-into-base-branch" };
952
+ }
953
+ if (options.integrationState === "abandoned") {
954
+ return { cleanupEligible: true, cleanupReason: "task-abandoned" };
955
+ }
956
+ if (options.integrationState === "reset") {
957
+ return { cleanupEligible: true, cleanupReason: "task-reset" };
958
+ }
959
+ if (options.taskStatus === "completed" || options.taskStatus === "cancelled") {
960
+ return { cleanupEligible: true, cleanupReason: "task-closed" };
961
+ }
962
+ return { cleanupEligible: false, cleanupReason: null };
963
+ }
964
+ function resolveBaseBranchPoint(projectDir, canonicalBranch, resolvedBaseStartPoint, existingRecord) {
965
+ if (existingRecord?.base_branch_point) {
966
+ return existingRecord.base_branch_point;
967
+ }
968
+ if (refExists(projectDir, `refs/heads/${canonicalBranch}`)) {
969
+ const mergeBase = runGit(projectDir, ["merge-base", canonicalBranch, resolvedBaseStartPoint]);
970
+ if (mergeBase.status === 0 && mergeBase.stdout) {
971
+ return mergeBase.stdout;
972
+ }
973
+ }
974
+ return resolveCommit(projectDir, resolvedBaseStartPoint);
975
+ }
976
+ function buildWorktreeRecord(worktreePath, branchMode, branchRef, head, now) {
977
+ return {
978
+ path: worktreePath,
979
+ branch_mode: branchMode,
980
+ branch_ref: branchRef,
981
+ head,
982
+ last_seen_at: now,
983
+ };
984
+ }
985
+ /**
986
+ * Compare two sub-objects by their serializable fields, excluding `updated_at`
987
+ * timestamps that are consequences of change rather than triggers.
988
+ *
989
+ * AC: @dispatch-workspace-registry ac-10
990
+ */
991
+ function deepEqualExcludingTimestamps(a, b) {
992
+ const keysA = Object.keys(a).filter((k) => k !== "updated_at");
993
+ const keysB = Object.keys(b).filter((k) => k !== "updated_at");
994
+ if (keysA.length !== keysB.length)
995
+ return false;
996
+ for (const key of keysA) {
997
+ if (!keysB.includes(key))
998
+ return false;
999
+ const va = a[key];
1000
+ const vb = b[key];
1001
+ if (va === vb)
1002
+ continue;
1003
+ if (va === null || vb === null || va === undefined || vb === undefined)
1004
+ return false;
1005
+ if (typeof va === "object" && typeof vb === "object") {
1006
+ if (Array.isArray(va) && Array.isArray(vb)) {
1007
+ if (va.length !== vb.length)
1008
+ return false;
1009
+ for (let i = 0; i < va.length; i++) {
1010
+ if (typeof va[i] === "object" && va[i] !== null && typeof vb[i] === "object" && vb[i] !== null) {
1011
+ if (!deepEqualExcludingTimestamps(va[i], vb[i]))
1012
+ return false;
1013
+ }
1014
+ else if (va[i] !== vb[i]) {
1015
+ return false;
1016
+ }
1017
+ }
1018
+ continue;
1019
+ }
1020
+ if (!deepEqualExcludingTimestamps(va, vb))
1021
+ return false;
1022
+ continue;
1023
+ }
1024
+ return false;
1025
+ }
1026
+ return true;
1027
+ }
1028
+ /**
1029
+ * Determine whether the computed reconciliation state differs from the
1030
+ * existing persisted record in any meaningful field. Timestamps are NOT
1031
+ * considered meaningful triggers — they are consequences of real changes.
1032
+ *
1033
+ * Fields compared (meaningful):
1034
+ * - canonical_branch_head
1035
+ * - lifecycle_state
1036
+ * - active_role
1037
+ * - health (deep compare, excluding updated_at)
1038
+ * - cleanup (deep compare, excluding updated_at)
1039
+ * - integration (deep compare, excluding updated_at)
1040
+ *
1041
+ * AC: @dispatch-workspace-registry ac-10
1042
+ */
1043
+ export function isWorkspaceRecordDirty(existing, computed) {
1044
+ if (existing.canonical_branch_head !== computed.canonical_branch_head)
1045
+ return true;
1046
+ if (existing.lifecycle_state !== computed.lifecycle_state)
1047
+ return true;
1048
+ if ((existing.active_role ?? null) !== (computed.active_role ?? null))
1049
+ return true;
1050
+ if (!deepEqualExcludingTimestamps(existing.health, computed.health))
1051
+ return true;
1052
+ if (!deepEqualExcludingTimestamps(existing.cleanup, computed.cleanup))
1053
+ return true;
1054
+ if (!deepEqualExcludingTimestamps(existing.integration, computed.integration))
1055
+ return true;
1056
+ return false;
1057
+ }
1058
+ export async function reconcileDispatchWorkspaceRegistry(projectDir, taskStatusByRef, activeRoleByTaskRef) {
1059
+ const ctx = await initContext(projectDir);
1060
+ const records = await loadDispatchWorkspaceRegistry(ctx);
1061
+ const nonClosedRecords = records.filter((r) => r.lifecycle_state !== "closed");
1062
+ if (nonClosedRecords.length === 0)
1063
+ return;
1064
+ // Use the first non-closed record's task_ref for lock/commit attribution.
1065
+ const lockTaskRef = nonClosedRecords[0].task_ref;
1066
+ // AC: @dispatch-workspace-registry ac-8 — all registry writes + commit
1067
+ // happen inside the shadow mutation lock so no write is visible without
1068
+ // a matching durable commit.
1069
+ await withDispatchShadowMutationLock(projectDir, lockTaskRef, async () => {
1070
+ let lastTaskRef = null;
1071
+ let anyDirty = false;
1072
+ for (const record of nonClosedRecords) {
1073
+ const now = new Date().toISOString();
1074
+ const currentTaskStatus = taskStatusByRef?.get(record.task_ref) ?? null;
1075
+ const health = reconcileWorkspaceHealth(projectDir, record, now);
1076
+ const canonicalBranchHead = refExists(projectDir, `refs/heads/${record.canonical_branch}`)
1077
+ ? resolveCommit(projectDir, record.canonical_branch)
1078
+ : record.canonical_branch_head;
1079
+ const { cleanup, integration } = resolveRegistryStateForTaskStatus(currentTaskStatus, record, now);
1080
+ const activeRole = activeRoleByTaskRef?.get(record.task_ref) ?? null;
1081
+ const lifecycleState = resolveLifecycleState(currentTaskStatus, health, integration, cleanup, activeRole);
1082
+ // AC: @dispatch-workspace-registry ac-10 — skip save when no meaningful
1083
+ // field has changed. Timestamps must not change unless a real field differs.
1084
+ const dirty = isWorkspaceRecordDirty(record, {
1085
+ canonical_branch_head: canonicalBranchHead,
1086
+ lifecycle_state: lifecycleState,
1087
+ active_role: activeRole,
1088
+ health,
1089
+ cleanup,
1090
+ integration,
1091
+ });
1092
+ if (dirty) {
1093
+ const closedAt = lifecycleState === "closed"
1094
+ ? (record.timestamps.closed_at ?? now)
1095
+ : null;
1096
+ await saveWorkspaceRecordToRegistry(projectDir, {
1097
+ ...record,
1098
+ canonical_branch_head: canonicalBranchHead,
1099
+ lifecycle_state: lifecycleState,
1100
+ active_role: activeRole,
1101
+ health,
1102
+ cleanup,
1103
+ integration,
1104
+ timestamps: {
1105
+ ...record.timestamps,
1106
+ updated_at: now,
1107
+ last_reconciled_at: now,
1108
+ closed_at: closedAt,
1109
+ },
1110
+ });
1111
+ anyDirty = true;
1112
+ }
1113
+ lastTaskRef = record.task_ref;
1114
+ }
1115
+ // Commit all registry changes once after the loop rather than per-record.
1116
+ // AC: @dispatch-workspace-registry ac-10 — only commit when at least one
1117
+ // record had a meaningful change.
1118
+ if (anyDirty && lastTaskRef) {
1119
+ await commitWorkspaceRegistryToShadow(projectDir, lastTaskRef);
1120
+ }
1121
+ });
1122
+ }
1123
+ async function safelyRemoveDispatchWorktree(projectDir, worktreeRoot, worktreeDir) {
1124
+ const shadowDir = path.join(projectDir, ".kspec");
1125
+ if (!isPathInside(worktreeRoot, worktreeDir)) {
1126
+ throw new DispatchWorkspaceError(`Refusing to remove worktree outside dispatch root: "${worktreeDir}"`, "Inspect dispatch workspace metadata and worktree paths before retrying cleanup.");
1127
+ }
1128
+ if (path.resolve(worktreeDir) === path.resolve(projectDir) ||
1129
+ path.resolve(worktreeDir) === path.resolve(shadowDir)) {
1130
+ throw new DispatchWorkspaceError(`Refusing to remove protected worktree path "${worktreeDir}"`, "Only dispatcher-managed worktrees under dispatch.worktree_root may be cleaned up.");
1131
+ }
1132
+ const registration = findWorktreeByPath(projectDir, worktreeDir);
1133
+ if (registration) {
1134
+ runGitOrThrow(projectDir, ["worktree", "remove", "--force", worktreeDir], `Failed to remove dispatch worktree "${worktreeDir}"`, "Inspect git worktree state and remove stale registrations before retrying cleanup.");
1135
+ return;
1136
+ }
1137
+ await fs.rm(worktreeDir, { recursive: true, force: true });
1138
+ }
1139
+ function deleteDispatchBranch(projectDir, branch) {
1140
+ if (!isDispatchBranch(branch)) {
1141
+ throw new DispatchWorkspaceError(`Refusing to delete non-dispatch branch "${branch}"`, "Only canonical dispatch/task/* branches are eligible for dispatcher cleanup.");
1142
+ }
1143
+ if (!refExists(projectDir, `refs/heads/${branch}`)) {
1144
+ return;
1145
+ }
1146
+ runGitOrThrow(projectDir, ["branch", "-D", branch], `Failed to delete dispatch branch "${branch}"`, "Inspect branch state and active worktree registrations before retrying cleanup.");
1147
+ }
1148
+ // AC: @adopted-branch-cleanup-and-recoverability ac-4
1149
+ // Removes a local branch ref that was created by dispatch solely for
1150
+ // rehydrating an adopted externally-owned branch. This is distinct from
1151
+ // deleteDispatchBranch, which refuses non-dispatch branches. This function
1152
+ // is the cleanup counterpart: it safely removes the local mirror ref without
1153
+ // affecting the externally-owned branch lineage on the remote.
1154
+ function deleteRehydratedAdoptedBranch(projectDir, branch) {
1155
+ if (!refExists(projectDir, `refs/heads/${branch}`)) {
1156
+ return;
1157
+ }
1158
+ // Safety: never delete protected branch names
1159
+ if (branch === "main" || branch === "master" || branch === "develop") {
1160
+ return;
1161
+ }
1162
+ runGitOrThrow(projectDir, ["branch", "-D", branch], `Failed to delete rehydrated adopted branch "${branch}"`, "Inspect branch state and active worktree registrations before retrying cleanup.");
1163
+ }
1164
+ function listDispatchBranches(projectDir) {
1165
+ const result = runGit(projectDir, [
1166
+ "for-each-ref",
1167
+ "--format=%(refname:short)",
1168
+ `refs/heads/${DISPATCH_BRANCH_PREFIX}`,
1169
+ ]);
1170
+ if (result.status !== 0 || !result.stdout) {
1171
+ return [];
1172
+ }
1173
+ return result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1174
+ }
1175
+ export async function reconcileDispatchWorkspaceLifecycle(options) {
1176
+ const { projectDir, taskRef, cleanupState, task } = options;
1177
+ const existingRecord = await loadWorkspaceRecord(projectDir, taskRef);
1178
+ if (!existingRecord) {
1179
+ return null;
1180
+ }
1181
+ const now = new Date().toISOString();
1182
+ const health = reconcileWorkspaceHealth(projectDir, existingRecord, now);
1183
+ const cleanup = resolveCleanupRecord(cleanupState, existingRecord, now);
1184
+ const integration = resolveIntegrationRecord(existingRecord.integration.target_branch, existingRecord.integration.target_commit, existingRecord.integration.publication_mode, cleanupState, existingRecord, now);
1185
+ const lifecycleState = resolveLifecycleState(cleanupState.taskStatus ?? null, health, integration, cleanup, null);
1186
+ const canonicalBranchHead = refExists(projectDir, `refs/heads/${existingRecord.canonical_branch}`)
1187
+ ? resolveCommit(projectDir, existingRecord.canonical_branch)
1188
+ : existingRecord.canonical_branch_head;
1189
+ const updatedTaskSlug = normalizeTaskSlug(taskRef, task) || existingRecord.task_slug;
1190
+ const record = {
1191
+ ...existingRecord,
1192
+ task_slug: updatedTaskSlug,
1193
+ canonical_branch_head: canonicalBranchHead,
1194
+ lifecycle_state: lifecycleState,
1195
+ active_role: null,
1196
+ health,
1197
+ cleanup,
1198
+ integration,
1199
+ timestamps: {
1200
+ ...existingRecord.timestamps,
1201
+ updated_at: now,
1202
+ last_reconciled_at: now,
1203
+ closed_at: lifecycleState === "closed"
1204
+ ? (existingRecord.timestamps.closed_at ?? now)
1205
+ : null,
1206
+ },
1207
+ };
1208
+ const metadataPath = await persistWorkspaceRecord(projectDir, record);
1209
+ return {
1210
+ cwd: record.worktrees.worker.path,
1211
+ metadataPath,
1212
+ metadata: toMetadata(record),
1213
+ };
1214
+ }
1215
+ export async function cleanupReviewerDispatchWorkspace(projectDir, taskRef, task) {
1216
+ const existing = await findWorkspaceRegistrationByTaskRef(projectDir, taskRef, task);
1217
+ const existingRecord = await loadWorkspaceRecord(projectDir, taskRef);
1218
+ if (!existing || !existing.metadata.reviewerWorktreeDir || !existingRecord) {
1219
+ return { taskRef, action: "none", blockedReason: null };
1220
+ }
1221
+ await safelyRemoveDispatchWorktree(projectDir, existing.metadata.worktreeRoot, existing.metadata.reviewerWorktreeDir);
1222
+ // Re-read the record inside the lock to avoid a TOCTOU race where a
1223
+ // concurrent writer (e.g. handleStateChange → reconcileDispatchWorkspaceLifecycle)
1224
+ // updates the registry between our initial read and the write below.
1225
+ await withDispatchShadowMutationLock(projectDir, taskRef, async () => {
1226
+ const latestRecord = await loadWorkspaceRecord(projectDir, taskRef);
1227
+ if (!latestRecord)
1228
+ return;
1229
+ const now = new Date().toISOString();
1230
+ const lifecycleState = latestRecord.cleanup.eligible ? "closing" : "ready";
1231
+ const updatedRecord = {
1232
+ ...latestRecord,
1233
+ lifecycle_state: lifecycleState,
1234
+ worktrees: {
1235
+ ...latestRecord.worktrees,
1236
+ reviewer: null,
1237
+ },
1238
+ health: reconcileWorkspaceHealth(projectDir, {
1239
+ ...latestRecord,
1240
+ worktrees: {
1241
+ ...latestRecord.worktrees,
1242
+ reviewer: null,
1243
+ },
1244
+ }, now),
1245
+ timestamps: {
1246
+ ...latestRecord.timestamps,
1247
+ updated_at: now,
1248
+ last_reconciled_at: now,
1249
+ },
1250
+ };
1251
+ await saveWorkspaceRecordToRegistry(projectDir, updatedRecord);
1252
+ await commitWorkspaceRegistryToShadow(projectDir, taskRef);
1253
+ });
1254
+ return { taskRef, action: "reviewer_cleaned", blockedReason: null };
1255
+ }
1256
+ export async function reapDispatchWorkspace(projectDir, taskRef, options) {
1257
+ const existing = await findWorkspaceRegistrationByTaskRef(projectDir, taskRef, options?.task);
1258
+ if (!existing) {
1259
+ return { taskRef, action: "none", blockedReason: null };
1260
+ }
1261
+ const activeTaskRefs = new Set(options?.activeTaskRefs ?? []);
1262
+ if (activeTaskRefs.has(taskRef)) {
1263
+ const blockedReason = "Cleanup blocked: canonical branch still has an active dispatch invocation.";
1264
+ const metadata = {
1265
+ ...existing.metadata,
1266
+ lifecycleState: "cleanup_blocked",
1267
+ cleanupBlockedReason: blockedReason,
1268
+ cleanupScheduledAt: existing.metadata.cleanupScheduledAt ?? new Date().toISOString(),
1269
+ updatedAt: new Date().toISOString(),
1270
+ };
1271
+ await writeWorkspaceMetadata(existing.workerWorktreeDir, metadata);
1272
+ return { taskRef, action: "cleanup_blocked", blockedReason };
1273
+ }
1274
+ if (!existing.metadata.cleanupEligible) {
1275
+ const blockedReason = "Cleanup blocked: workspace integration outcome is unresolved, so the canonical branch must be retained.";
1276
+ const metadata = {
1277
+ ...existing.metadata,
1278
+ lifecycleState: "cleanup_blocked",
1279
+ cleanupBlockedReason: blockedReason,
1280
+ cleanupScheduledAt: existing.metadata.cleanupScheduledAt ?? new Date().toISOString(),
1281
+ updatedAt: new Date().toISOString(),
1282
+ };
1283
+ await writeWorkspaceMetadata(existing.workerWorktreeDir, metadata);
1284
+ return { taskRef, action: "cleanup_blocked", blockedReason };
1285
+ }
1286
+ if (existing.metadata.reviewerWorktreeDir) {
1287
+ await safelyRemoveDispatchWorktree(projectDir, existing.metadata.worktreeRoot, existing.metadata.reviewerWorktreeDir);
1288
+ }
1289
+ await safelyRemoveDispatchWorktree(projectDir, existing.metadata.worktreeRoot, existing.workerWorktreeDir);
1290
+ // AC: @adopted-branch-cleanup-and-recoverability ac-1, ac-2, ac-4
1291
+ // Dispatcher-managed branches are always deleted on cleanup.
1292
+ // Adopted branches are preserved unless they were rehydrated (local ref
1293
+ // created by dispatch solely for adoption), in which case only the local
1294
+ // dispatch-side mirror ref is removed — the externally-owned branch lineage
1295
+ // lives on the remote and is not mutated.
1296
+ if (existing.metadata.branchProvenance.ownership !== "adopted") {
1297
+ deleteDispatchBranch(projectDir, existing.metadata.canonicalBranch);
1298
+ }
1299
+ else if (existing.metadata.branchProvenance.rehydrated) {
1300
+ deleteRehydratedAdoptedBranch(projectDir, existing.metadata.canonicalBranch);
1301
+ }
1302
+ return { taskRef, action: "reaped", blockedReason: null };
1303
+ }
1304
+ export async function reconcileDispatchWorkspaceArtifacts(projectDir, options) {
1305
+ const resolvedConfig = await resolveDispatchWorkspaceConfig(projectDir);
1306
+ await ensureUsableWorktreeRoot(projectDir, resolvedConfig.worktreeRoot);
1307
+ const activeTaskRefs = new Set(options?.activeTaskRefs ?? []);
1308
+ const worktreeEntries = parseWorktreeList(projectDir);
1309
+ const entriesUnderRoot = worktreeEntries.filter((entry) => isPathInside(resolvedConfig.worktreeRoot, entry.path));
1310
+ const referencedReviewerDirs = new Set();
1311
+ const trackedBranches = new Set();
1312
+ for (const entry of entriesUnderRoot) {
1313
+ const recoveredRecord = await recoverWorkspaceRecordFromMetadata(projectDir, resolvedConfig, entry.path);
1314
+ const metadata = recoveredRecord
1315
+ ? toMetadata(recoveredRecord)
1316
+ : await readWorkspaceMetadata(entry.path);
1317
+ const branchName = normalizeBranchRef(entry.branch);
1318
+ if (!metadata && !isDispatchBranch(branchName)) {
1319
+ continue;
1320
+ }
1321
+ if (!metadata) {
1322
+ await safelyRemoveDispatchWorktree(projectDir, resolvedConfig.worktreeRoot, entry.path);
1323
+ if (branchName && isDispatchBranch(branchName)) {
1324
+ deleteDispatchBranch(projectDir, branchName);
1325
+ }
1326
+ continue;
1327
+ }
1328
+ trackedBranches.add(metadata.canonicalBranch);
1329
+ if (metadata.reviewerWorktreeDir) {
1330
+ referencedReviewerDirs.add(path.resolve(metadata.reviewerWorktreeDir));
1331
+ const reviewerRegistration = findWorktreeByPath(projectDir, metadata.reviewerWorktreeDir);
1332
+ if (!reviewerRegistration) {
1333
+ const updatedMetadata = {
1334
+ ...metadata,
1335
+ reviewerWorktreeDir: null,
1336
+ updatedAt: new Date().toISOString(),
1337
+ };
1338
+ await writeWorkspaceMetadata(entry.path, updatedMetadata);
1339
+ }
1340
+ }
1341
+ if (metadata.cleanupEligible) {
1342
+ await reapDispatchWorkspace(projectDir, metadata.taskRef, {
1343
+ activeTaskRefs,
1344
+ task: {
1345
+ title: metadata.taskSlug,
1346
+ slugs: [metadata.taskSlug],
1347
+ },
1348
+ });
1349
+ }
1350
+ }
1351
+ for (const entry of entriesUnderRoot) {
1352
+ if (entry.branch === null && entry.path.endsWith("-review")) {
1353
+ if (!referencedReviewerDirs.has(path.resolve(entry.path))) {
1354
+ await safelyRemoveDispatchWorktree(projectDir, resolvedConfig.worktreeRoot, entry.path);
1355
+ }
1356
+ }
1357
+ }
1358
+ const rootEntries = await fs.readdir(resolvedConfig.worktreeRoot, { withFileTypes: true }).catch(() => []);
1359
+ for (const dirent of rootEntries) {
1360
+ const candidate = path.join(resolvedConfig.worktreeRoot, dirent.name);
1361
+ if (findWorktreeByPath(projectDir, candidate)) {
1362
+ continue;
1363
+ }
1364
+ await fs.rm(candidate, { recursive: true, force: true });
1365
+ }
1366
+ for (const branch of listDispatchBranches(projectDir)) {
1367
+ if (trackedBranches.has(branch)) {
1368
+ continue;
1369
+ }
1370
+ deleteDispatchBranch(projectDir, branch);
1371
+ }
1372
+ }
1373
+ async function ensureReviewerWorktree(projectDir, reviewerWorktreeDir, canonicalBranch) {
1374
+ const existingRegistration = findWorktreeByPath(projectDir, reviewerWorktreeDir);
1375
+ if (!existingRegistration) {
1376
+ await assertPathSafeForWorktree(reviewerWorktreeDir, projectDir);
1377
+ runGitOrThrow(projectDir, ["worktree", "add", "--detach", reviewerWorktreeDir, canonicalBranch], `Failed to create detached reviewer worktree for "${canonicalBranch}"`, "Inspect git worktree state and remove stale reviewer worktrees before retrying.");
1378
+ return;
1379
+ }
1380
+ runGitOrThrow(reviewerWorktreeDir, ["checkout", "--detach", canonicalBranch], `Failed to refresh reviewer snapshot for "${canonicalBranch}"`, "Inspect reviewer worktree state and remove or repair it before retrying.");
1381
+ runGitOrThrow(reviewerWorktreeDir, ["reset", "--hard", canonicalBranch], `Failed to align reviewer snapshot with "${canonicalBranch}"`, "Inspect reviewer worktree state and remove or repair it before retrying.");
1382
+ }
1383
+ export async function provisionDispatchWorkspace(options) {
1384
+ const { projectDir, taskRef, task, role = "worker", cleanupState, submissionLinkage, taskStatus } = options;
1385
+ const resolvedConfig = await resolveDispatchWorkspaceConfig(projectDir);
1386
+ await ensureUsableWorktreeRoot(projectDir, resolvedConfig.worktreeRoot);
1387
+ const existingRecord = await loadWorkspaceRecord(projectDir, taskRef);
1388
+ const taskSlug = existingRecord?.task_slug ?? normalizeTaskSlug(taskRef, task);
1389
+ const shortId = shortTaskId(taskRef);
1390
+ // AC: @adopt-existing-task-branch-lineage ac-1, ac-2, ac-3, ac-4
1391
+ // When no workspace record exists but submission linkage provides a branch,
1392
+ // adopt that existing branch lineage instead of creating a fresh dispatch branch.
1393
+ // For pending_review or needs_work tasks without either, fail explicitly.
1394
+ const isReviewOrFixCycle = taskStatus === "pending_review" || taskStatus === "needs_work";
1395
+ let adoptedBranch = null;
1396
+ let adoptionRemoteRef = null;
1397
+ let adoptionRehydrated = false;
1398
+ if (!existingRecord && submissionLinkage?.branch) {
1399
+ const linkageBranch = submissionLinkage.branch;
1400
+ const branchExistsLocally = refExists(projectDir, `refs/heads/${linkageBranch}`);
1401
+ if (branchExistsLocally) {
1402
+ // AC: @adopt-existing-task-branch-lineage ac-1 — adopt the local branch directly
1403
+ adoptedBranch = linkageBranch;
1404
+ }
1405
+ else {
1406
+ // AC: @adopt-existing-task-branch-lineage ac-2 — rehydrate from remote
1407
+ const rehydrated = rehydrateAdoptedBranch(projectDir, linkageBranch, submissionLinkage.remote ?? null, submissionLinkage.remote_url ?? null);
1408
+ if (rehydrated) {
1409
+ adoptedBranch = linkageBranch;
1410
+ adoptionRehydrated = true;
1411
+ adoptionRemoteRef = submissionLinkage.remote
1412
+ ? `${submissionLinkage.remote}/${linkageBranch}`
1413
+ : null;
1414
+ }
1415
+ }
1416
+ }
1417
+ // AC: @adopt-existing-task-branch-lineage ac-4 — explicit failure when no record
1418
+ // and no recoverable submission linkage for review/fix-cycle tasks.
1419
+ if (!existingRecord && !adoptedBranch && isReviewOrFixCycle) {
1420
+ const detail = submissionLinkage
1421
+ ? submissionLinkage.branch
1422
+ ? `Submission linkage references branch "${submissionLinkage.branch}" but it could not be found locally or on any remote.`
1423
+ : `Submission linkage exists but records no branch name (detached HEAD at ${submissionLinkage.commit}).`
1424
+ : `Task ${taskRef} has no submission linkage recorded.`;
1425
+ throw new DispatchWorkspaceError(`Cannot provision workspace for ${taskRef} in ${taskStatus}: no existing workspace record and no recoverable branch lineage. ${detail}`, `Ensure the task has submission linkage with a valid branch (kspec task set ${taskRef} --submission-linkage), or manually create the workspace branch and re-submit.`);
1426
+ }
1427
+ const canonicalBranch = existingRecord?.canonical_branch
1428
+ ?? (adoptedBranch || `dispatch/task/${taskSlug}/${shortId}`);
1429
+ const branchProvenance = existingRecord?.branch_provenance
1430
+ ?? (adoptedBranch
1431
+ ? adoptedBranchProvenance(adoptedBranch, adoptionRemoteRef, new Date().toISOString(), adoptionRehydrated)
1432
+ : defaultBranchProvenance());
1433
+ const workspaceId = existingRecord?.workspace_id ?? workspaceIdFor(taskRef);
1434
+ const workerWorktreeDir = existingRecord?.worktrees.worker.path
1435
+ ?? findExistingWorktreeForBranch(projectDir, canonicalBranch)
1436
+ ?? path.join(resolvedConfig.worktreeRoot, `${taskSlug}-${shortId}`);
1437
+ const reviewerWorktreeDir = existingRecord?.worktrees.reviewer?.path
1438
+ ?? path.join(resolvedConfig.worktreeRoot, `${taskSlug}-${shortId}-review`);
1439
+ const baseBranch = existingRecord?.resolved_base_branch ?? resolvedConfig.baseBranch;
1440
+ const baseBranchPoint = resolveBaseBranchPoint(projectDir, canonicalBranch, resolvedConfig.baseBranchStartPoint, existingRecord);
1441
+ const mergeTargetBranch = existingRecord?.integration.target_branch ?? baseBranch;
1442
+ const integrationTargetCommit = existingRecord?.integration.target_commit ?? baseBranchPoint;
1443
+ const publicationMode = resolveWorkspacePublicationMode(projectDir, existingRecord, resolvedConfig.publicationMode);
1444
+ const now = new Date().toISOString();
1445
+ const provisioningRecord = {
1446
+ workspace_id: workspaceId,
1447
+ task_ref: taskRef,
1448
+ task_slug: taskSlug,
1449
+ worktree_root: resolvedConfig.worktreeRoot,
1450
+ resolved_base_branch: baseBranch,
1451
+ base_branch_point: baseBranchPoint,
1452
+ canonical_branch: canonicalBranch,
1453
+ canonical_branch_head: existingRecord?.canonical_branch_head ?? baseBranchPoint,
1454
+ branch_provenance: branchProvenance,
1455
+ lifecycle_state: "provisioning",
1456
+ active_role: null,
1457
+ worktrees: {
1458
+ worker: buildWorktreeRecord(workerWorktreeDir, "branch", canonicalBranch, existingRecord?.worktrees.worker.head ?? baseBranchPoint, now),
1459
+ reviewer: existingRecord?.worktrees.reviewer ?? null,
1460
+ },
1461
+ bootstrap: existingRecord?.bootstrap ?? defaultBootstrapState(now),
1462
+ integration: resolveIntegrationRecord(mergeTargetBranch, integrationTargetCommit, publicationMode, cleanupState, existingRecord, now),
1463
+ health: createHealthyState(now),
1464
+ cleanup: resolveCleanupRecord(cleanupState, existingRecord, now),
1465
+ timestamps: {
1466
+ created_at: existingRecord?.timestamps.created_at ?? now,
1467
+ updated_at: now,
1468
+ last_reconciled_at: existingRecord?.timestamps.last_reconciled_at ?? now,
1469
+ last_active_at: existingRecord?.timestamps.last_active_at ?? null,
1470
+ closed_at: null,
1471
+ },
1472
+ };
1473
+ // Perform git worktree operations before acquiring the shadow mutation lock
1474
+ // so we don't hold the lock during potentially slow git operations.
1475
+ await assertPathSafeForWorktree(workerWorktreeDir, projectDir);
1476
+ const existingWorkerWorktree = findExistingWorktreeForBranch(projectDir, canonicalBranch);
1477
+ if (!existingWorkerWorktree) {
1478
+ const branchExists = refExists(projectDir, `refs/heads/${canonicalBranch}`);
1479
+ if (branchExists) {
1480
+ runGitOrThrow(projectDir, ["worktree", "add", workerWorktreeDir, canonicalBranch], `Failed to attach existing dispatch branch "${canonicalBranch}"`, "Inspect git worktree state and remove stale registrations before retrying.");
1481
+ }
1482
+ else {
1483
+ runGitOrThrow(projectDir, ["worktree", "add", "-b", canonicalBranch, workerWorktreeDir, resolvedConfig.baseBranchStartPoint], `Failed to create dispatch worktree for ${taskRef} from "${resolvedConfig.baseBranchStartPoint}"`, "Ensure the base branch exists locally or on a tracked remote, then retry dispatch.");
1484
+ }
1485
+ }
1486
+ let reviewerRecord = existingRecord?.worktrees.reviewer ?? null;
1487
+ if (role === "reviewer") {
1488
+ await ensureReviewerWorktree(projectDir, reviewerWorktreeDir, canonicalBranch);
1489
+ reviewerRecord = buildWorktreeRecord(reviewerWorktreeDir, "detached", null, resolveCommit(reviewerWorktreeDir, "HEAD"), now);
1490
+ }
1491
+ const canonicalBranchHead = resolveCommit(projectDir, canonicalBranch);
1492
+ const health = reconcileWorkspaceHealth(projectDir, {
1493
+ ...provisioningRecord,
1494
+ canonical_branch_head: canonicalBranchHead,
1495
+ worktrees: {
1496
+ worker: buildWorktreeRecord(workerWorktreeDir, "branch", canonicalBranch, canonicalBranchHead, now),
1497
+ reviewer: reviewerRecord,
1498
+ },
1499
+ }, now);
1500
+ const integration = resolveIntegrationRecord(mergeTargetBranch, integrationTargetCommit, publicationMode, cleanupState, existingRecord, now);
1501
+ const cleanup = resolveCleanupRecord(cleanupState, existingRecord, now);
1502
+ const record = {
1503
+ ...provisioningRecord,
1504
+ canonical_branch_head: canonicalBranchHead,
1505
+ lifecycle_state: resolveLifecycleState(cleanupState?.taskStatus ?? null, health, integration, cleanup, null),
1506
+ worktrees: {
1507
+ worker: buildWorktreeRecord(workerWorktreeDir, "branch", canonicalBranch, canonicalBranchHead, now),
1508
+ reviewer: reviewerRecord,
1509
+ },
1510
+ integration,
1511
+ health,
1512
+ cleanup,
1513
+ timestamps: {
1514
+ ...provisioningRecord.timestamps,
1515
+ updated_at: now,
1516
+ last_reconciled_at: now,
1517
+ },
1518
+ };
1519
+ // AC: @dispatch-workspace-registry ac-8 — both the provisioning and final
1520
+ // registry writes happen inside the shadow mutation lock, followed by a
1521
+ // single durable commit, so no uncommitted state is left on disk.
1522
+ const metadataPath = await withDispatchShadowMutationLock(projectDir, taskRef, async () => {
1523
+ const regPath = await saveWorkspaceRecordToRegistry(projectDir, provisioningRecord);
1524
+ await saveWorkspaceRecordToRegistry(projectDir, record);
1525
+ await commitWorkspaceRegistryToShadow(projectDir, taskRef);
1526
+ return regPath;
1527
+ });
1528
+ return {
1529
+ cwd: role === "reviewer" && reviewerRecord ? reviewerRecord.path : workerWorktreeDir,
1530
+ metadataPath,
1531
+ metadata: toMetadata(record),
1532
+ };
1533
+ }
1534
+ export async function markDispatchWorkspaceActive(options) {
1535
+ const existingRecord = await loadWorkspaceRecord(options.projectDir, options.taskRef);
1536
+ if (!existingRecord)
1537
+ return null;
1538
+ const now = new Date().toISOString();
1539
+ const health = reconcileWorkspaceHealth(options.projectDir, existingRecord, now);
1540
+ const lifecycleState = resolveLifecycleState(null, health, {
1541
+ ...existingRecord.integration,
1542
+ updated_at: now,
1543
+ }, {
1544
+ ...existingRecord.cleanup,
1545
+ updated_at: now,
1546
+ }, options.role);
1547
+ const canonicalBranchHead = refExists(options.projectDir, `refs/heads/${existingRecord.canonical_branch}`)
1548
+ ? resolveCommit(options.projectDir, existingRecord.canonical_branch)
1549
+ : existingRecord.canonical_branch_head;
1550
+ const record = {
1551
+ ...existingRecord,
1552
+ canonical_branch_head: canonicalBranchHead,
1553
+ lifecycle_state: lifecycleState,
1554
+ active_role: options.role,
1555
+ health,
1556
+ timestamps: {
1557
+ ...existingRecord.timestamps,
1558
+ updated_at: now,
1559
+ last_reconciled_at: now,
1560
+ last_active_at: now,
1561
+ },
1562
+ };
1563
+ const metadataPath = await persistWorkspaceRecord(options.projectDir, record);
1564
+ return {
1565
+ cwd: options.role === "reviewer" && record.worktrees.reviewer
1566
+ ? record.worktrees.reviewer.path
1567
+ : record.worktrees.worker.path,
1568
+ metadataPath,
1569
+ metadata: toMetadata(record),
1570
+ };
1571
+ }
1572
+ export async function markDispatchWorkspaceIdle(options) {
1573
+ const existingRecord = await loadWorkspaceRecord(options.projectDir, options.taskRef);
1574
+ if (!existingRecord)
1575
+ return null;
1576
+ // If a lifecycle reconciliation (e.g. task completed/cancelled) has already
1577
+ // moved the record into a terminal state, don't regress it. The taskStatus
1578
+ // passed here is from the invocation's original dispatch event and may be
1579
+ // stale relative to the current record.
1580
+ const terminalStates = new Set(["closing", "cleanup_blocked", "closed"]);
1581
+ if (terminalStates.has(existingRecord.lifecycle_state)) {
1582
+ const now = new Date().toISOString();
1583
+ const record = {
1584
+ ...existingRecord,
1585
+ active_role: null,
1586
+ timestamps: {
1587
+ ...existingRecord.timestamps,
1588
+ updated_at: now,
1589
+ last_reconciled_at: now,
1590
+ },
1591
+ };
1592
+ const metadataPath = await persistWorkspaceRecord(options.projectDir, record);
1593
+ return {
1594
+ cwd: record.worktrees.worker.path,
1595
+ metadataPath,
1596
+ metadata: toMetadata(record),
1597
+ };
1598
+ }
1599
+ const now = new Date().toISOString();
1600
+ const health = reconcileWorkspaceHealth(options.projectDir, existingRecord, now);
1601
+ const cleanup = {
1602
+ ...existingRecord.cleanup,
1603
+ updated_at: now,
1604
+ };
1605
+ const integration = {
1606
+ ...existingRecord.integration,
1607
+ updated_at: now,
1608
+ };
1609
+ const lifecycleState = resolveLifecycleState(options.taskStatus, health, integration, cleanup, null);
1610
+ const record = {
1611
+ ...existingRecord,
1612
+ lifecycle_state: lifecycleState,
1613
+ active_role: null,
1614
+ health,
1615
+ cleanup,
1616
+ integration,
1617
+ timestamps: {
1618
+ ...existingRecord.timestamps,
1619
+ updated_at: now,
1620
+ last_reconciled_at: now,
1621
+ closed_at: lifecycleState === "closed"
1622
+ ? (existingRecord.timestamps.closed_at ?? now)
1623
+ : null,
1624
+ },
1625
+ };
1626
+ const metadataPath = await persistWorkspaceRecord(options.projectDir, record);
1627
+ return {
1628
+ cwd: record.worktrees.worker.path,
1629
+ metadataPath,
1630
+ metadata: toMetadata(record),
1631
+ };
1632
+ }
1633
+ export async function getDispatchWorkspaceHealth(options) {
1634
+ const { projectDir, taskRef, role = "worker" } = options;
1635
+ const existingRecord = await loadWorkspaceRecord(projectDir, taskRef);
1636
+ if (!existingRecord) {
1637
+ return {
1638
+ exists: false,
1639
+ healthy: true,
1640
+ reason: null,
1641
+ metadata: null,
1642
+ };
1643
+ }
1644
+ const now = new Date().toISOString();
1645
+ const health = reconcileWorkspaceHealth(projectDir, existingRecord, now);
1646
+ const cleanup = {
1647
+ ...existingRecord.cleanup,
1648
+ updated_at: now,
1649
+ };
1650
+ const metadata = toMetadata({
1651
+ ...existingRecord,
1652
+ health,
1653
+ cleanup,
1654
+ timestamps: {
1655
+ ...existingRecord.timestamps,
1656
+ updated_at: now,
1657
+ },
1658
+ });
1659
+ const reviewerWorktree = existingRecord.worktrees.reviewer;
1660
+ const reviewerMissingRecordedWorktree = role === "reviewer"
1661
+ && reviewerWorktree != null
1662
+ && !pathExists(reviewerWorktree.path);
1663
+ const healthy = health.status === "healthy"
1664
+ && !cleanup.eligible
1665
+ && metadata.bootstrap.roleStates[role].status !== "failed"
1666
+ && !reviewerMissingRecordedWorktree;
1667
+ const primaryIssue = health.issues[0];
1668
+ const reason = reviewerMissingRecordedWorktree
1669
+ ? "missing-reviewer-worktree"
1670
+ : metadata.bootstrap.roleStates[role].status === "failed"
1671
+ ? (metadata.bootstrap.roleStates[role].failureMessage ?? "bootstrap-failed")
1672
+ : cleanup.eligible
1673
+ ? (cleanup.reason ?? "workspace-marked-for-cleanup")
1674
+ : primaryIssue
1675
+ ? primaryIssue.code.replace(/_/g, "-")
1676
+ : health.status === "healthy"
1677
+ ? null
1678
+ : health.status;
1679
+ return {
1680
+ exists: true,
1681
+ healthy,
1682
+ reason,
1683
+ metadata,
1684
+ };
1685
+ }
1686
+ /**
1687
+ * Attempt workspace discovery and recovery for a `pending_review` or
1688
+ * `needs_work` dispatch entry that has no healthy local workspace candidate.
1689
+ *
1690
+ * Applies explicit precedence ordering (AC-4):
1691
+ * 1. Existing registry state
1692
+ * 2. Metadata-backed worktrees
1693
+ * 3. Recorded task submission linkage
1694
+ * 4. Remote or review-derived discovery
1695
+ *
1696
+ * If recovery succeeds, the workspace is adopted or re-registered so normal
1697
+ * provisioning can proceed (AC-2).
1698
+ *
1699
+ * If no trustworthy recovery path exists, returns structured diagnostics
1700
+ * with task-linked recovery guidance (AC-3).
1701
+ *
1702
+ * When multiple branch signals exist and cannot be reconciled, blocks with
1703
+ * diagnostics rather than guessing (AC-4).
1704
+ *
1705
+ * AC: @review-and-fix-cycle-workspace-discovery-before-discard ac-1
1706
+ * AC: @review-and-fix-cycle-workspace-discovery-before-discard ac-2
1707
+ * AC: @review-and-fix-cycle-workspace-discovery-before-discard ac-3
1708
+ * AC: @review-and-fix-cycle-workspace-discovery-before-discard ac-4
1709
+ */
1710
+ export async function discoverWorkspaceForReviewOrFixCycle(options) {
1711
+ const { projectDir, taskRef, role = "worker", task } = options;
1712
+ const diagnostics = [];
1713
+ const branchSignals = [];
1714
+ // Phase 1: Registry state — the highest precedence source.
1715
+ // AC: @review-and-fix-cycle-workspace-discovery-before-discard ac-1, ac-4
1716
+ const existingRecord = await loadWorkspaceRecord(projectDir, taskRef);
1717
+ if (existingRecord) {
1718
+ branchSignals.push({
1719
+ source: "registry-state",
1720
+ branch: existingRecord.canonical_branch,
1721
+ });
1722
+ // Registry record exists but workspace may be unhealthy.
1723
+ // Attempt to reconcile: restore branch from remote if missing.
1724
+ // reconcileWorkspaceHealth internally calls tryRestoreBranchFromRemote.
1725
+ const now = new Date().toISOString();
1726
+ const health = reconcileWorkspaceHealth(projectDir, existingRecord, now);
1727
+ // Recovery is successful if the canonical branch is intact (healthy or
1728
+ // stale). Stale means worktrees are missing but the branch exists —
1729
+ // provisioning can recreate them. Only "invalid" (missing canonical
1730
+ // branch) is truly unrecoverable from registry state alone.
1731
+ if (health.status !== "invalid") {
1732
+ return {
1733
+ recovered: true,
1734
+ recoverySource: "registry-state",
1735
+ health: await getDispatchWorkspaceHealth({ projectDir, taskRef, role, task }),
1736
+ diagnostics: [],
1737
+ conflictingSignals: null,
1738
+ };
1739
+ }
1740
+ // Registry exists but canonical branch is missing everywhere —
1741
+ // continue to collect other signals for potential conflict detection
1742
+ // or alternative recovery.
1743
+ }
1744
+ // Phase 2: Metadata-backed worktrees — scan worktree root for metadata
1745
+ // files that reference this task.
1746
+ // AC: @review-and-fix-cycle-workspace-discovery-before-discard ac-1
1747
+ let metadataCandidate = null;
1748
+ try {
1749
+ const resolvedConfig = await resolveDispatchWorkspaceConfig(projectDir);
1750
+ const worktreeEntries = parseWorktreeList(projectDir);
1751
+ const entriesUnderRoot = worktreeEntries.filter((entry) => isPathInside(resolvedConfig.worktreeRoot, entry.path));
1752
+ for (const entry of entriesUnderRoot) {
1753
+ const metadata = await readWorkspaceMetadata(entry.path);
1754
+ if (metadata && metadata.taskRef === taskRef) {
1755
+ metadataCandidate = {
1756
+ branch: metadata.canonicalBranch,
1757
+ worktreeDir: entry.path,
1758
+ };
1759
+ branchSignals.push({
1760
+ source: "metadata-backed-worktree",
1761
+ branch: metadata.canonicalBranch,
1762
+ });
1763
+ break;
1764
+ }
1765
+ }
1766
+ }
1767
+ catch {
1768
+ // Config or worktree listing failure is non-fatal for discovery.
1769
+ }
1770
+ // If we recovered from metadata but had no registry record, attempt
1771
+ // to reconstruct the registry record from the worktree metadata.
1772
+ if (metadataCandidate && !existingRecord) {
1773
+ try {
1774
+ const resolvedConfig = await resolveDispatchWorkspaceConfig(projectDir);
1775
+ const recovered = await recoverWorkspaceRecordFromMetadata(projectDir, resolvedConfig, metadataCandidate.worktreeDir);
1776
+ if (recovered) {
1777
+ await withDispatchShadowMutationLock(projectDir, taskRef, async () => {
1778
+ await saveWorkspaceRecordToRegistry(projectDir, recovered);
1779
+ await commitWorkspaceRegistryToShadow(projectDir, taskRef);
1780
+ });
1781
+ // Re-check health after metadata recovery. The record is restored
1782
+ // but the workspace may still be unhealthy (e.g. missing worktrees).
1783
+ // Only consider this recovered if the canonical branch is intact
1784
+ // so provisioning can recreate missing worktrees.
1785
+ const postRecoveryHealth = reconcileWorkspaceHealth(projectDir, recovered, new Date().toISOString());
1786
+ if (postRecoveryHealth.status !== "invalid") {
1787
+ return {
1788
+ recovered: true,
1789
+ recoverySource: "metadata-backed-worktree",
1790
+ health: await getDispatchWorkspaceHealth({ projectDir, taskRef, role, task }),
1791
+ diagnostics: [],
1792
+ conflictingSignals: null,
1793
+ };
1794
+ }
1795
+ // Record restored but workspace is invalid — continue collecting
1796
+ // signals for potential alternative recovery.
1797
+ }
1798
+ }
1799
+ catch {
1800
+ // Recovery failure is non-fatal; continue to next source.
1801
+ }
1802
+ }
1803
+ // Phase 3: Task submission linkage — use the captured branch/commit
1804
+ // from the task's submission to discover or adopt the branch.
1805
+ // AC: @review-and-fix-cycle-workspace-discovery-before-discard ac-1, ac-4
1806
+ const submissionLinkage = task?.submission_linkage;
1807
+ if (submissionLinkage?.branch) {
1808
+ branchSignals.push({
1809
+ source: "task-submission-linkage",
1810
+ branch: submissionLinkage.branch,
1811
+ });
1812
+ // Check if the branch exists locally or on a remote.
1813
+ const branchRef = `refs/heads/${submissionLinkage.branch}`;
1814
+ let branchAvailable = refExists(projectDir, branchRef);
1815
+ let branchRehydrated = false;
1816
+ if (!branchAvailable) {
1817
+ branchAvailable = tryRestoreBranchFromRemote(projectDir, submissionLinkage.branch);
1818
+ branchRehydrated = branchAvailable;
1819
+ }
1820
+ if (branchAvailable && !existingRecord) {
1821
+ // Adopt the submission branch as the canonical branch for this task.
1822
+ try {
1823
+ const resolvedConfig = await resolveDispatchWorkspaceConfig(projectDir);
1824
+ const now = new Date().toISOString();
1825
+ const taskSlug = normalizeTaskSlug(taskRef, task);
1826
+ const shortId = shortTaskId(taskRef);
1827
+ const workspaceId = workspaceIdFor(taskRef);
1828
+ const workerWorktreeDir = findExistingWorktreeForBranch(projectDir, submissionLinkage.branch)
1829
+ ?? path.join(resolvedConfig.worktreeRoot, `${taskSlug}-${shortId}`);
1830
+ const baseBranch = resolvedConfig.baseBranch;
1831
+ const baseBranchPoint = resolvedConfig.baseBranchStartPoint;
1832
+ const publicationMode = resolvePublicationMode(projectDir, resolvedConfig.publicationMode);
1833
+ const adoptedRecord = {
1834
+ workspace_id: workspaceId,
1835
+ task_ref: taskRef,
1836
+ task_slug: taskSlug,
1837
+ worktree_root: resolvedConfig.worktreeRoot,
1838
+ resolved_base_branch: baseBranch,
1839
+ base_branch_point: baseBranchPoint,
1840
+ canonical_branch: submissionLinkage.branch,
1841
+ canonical_branch_head: submissionLinkage.commit,
1842
+ branch_provenance: adoptedBranchProvenance(submissionLinkage.branch, submissionLinkage.remote ?? null, now, branchRehydrated),
1843
+ lifecycle_state: "ready",
1844
+ active_role: null,
1845
+ worktrees: {
1846
+ worker: buildWorktreeRecord(workerWorktreeDir, "branch", submissionLinkage.branch, submissionLinkage.commit, now),
1847
+ reviewer: null,
1848
+ },
1849
+ bootstrap: defaultBootstrapState(now),
1850
+ integration: {
1851
+ status: "pending",
1852
+ target_branch: baseBranch,
1853
+ target_commit: baseBranchPoint,
1854
+ publication_mode: publicationMode,
1855
+ outcome: resolveIntegrationOutcome(publicationMode, "pending"),
1856
+ detail: null,
1857
+ updated_at: now,
1858
+ },
1859
+ health: createHealthyState(now),
1860
+ cleanup: {
1861
+ status: "not_scheduled",
1862
+ eligible: false,
1863
+ reason: null,
1864
+ detail: null,
1865
+ updated_at: now,
1866
+ },
1867
+ timestamps: {
1868
+ created_at: now,
1869
+ updated_at: now,
1870
+ last_reconciled_at: now,
1871
+ last_active_at: null,
1872
+ closed_at: null,
1873
+ },
1874
+ };
1875
+ await withDispatchShadowMutationLock(projectDir, taskRef, async () => {
1876
+ await saveWorkspaceRecordToRegistry(projectDir, adoptedRecord);
1877
+ await commitWorkspaceRegistryToShadow(projectDir, taskRef);
1878
+ });
1879
+ console.log(`[dispatch] Adopted branch "${submissionLinkage.branch}" from task submission linkage for ${taskRef}`);
1880
+ return {
1881
+ recovered: true,
1882
+ recoverySource: "task-submission-linkage",
1883
+ health: await getDispatchWorkspaceHealth({ projectDir, taskRef, role, task }),
1884
+ diagnostics: [],
1885
+ conflictingSignals: null,
1886
+ };
1887
+ }
1888
+ catch {
1889
+ // Adoption failure is non-fatal; continue to next source.
1890
+ }
1891
+ }
1892
+ }
1893
+ // Phase 4: Remote or review-derived discovery — try the deterministic
1894
+ // dispatch branch name on remotes as a last resort.
1895
+ // AC: @review-and-fix-cycle-workspace-discovery-before-discard ac-1, ac-4
1896
+ if (!existingRecord) {
1897
+ const taskSlug = normalizeTaskSlug(taskRef, task);
1898
+ const shortId = shortTaskId(taskRef);
1899
+ const syntheticBranch = `dispatch/task/${taskSlug}/${shortId}`;
1900
+ const restoredFromRemote = tryRestoreBranchFromRemote(projectDir, syntheticBranch);
1901
+ if (restoredFromRemote) {
1902
+ branchSignals.push({
1903
+ source: "remote-or-review-locator",
1904
+ branch: syntheticBranch,
1905
+ });
1906
+ try {
1907
+ const resolvedConfig = await resolveDispatchWorkspaceConfig(projectDir);
1908
+ const now = new Date().toISOString();
1909
+ const workspaceId = workspaceIdFor(taskRef);
1910
+ const workerWorktreeDir = findExistingWorktreeForBranch(projectDir, syntheticBranch)
1911
+ ?? path.join(resolvedConfig.worktreeRoot, `${taskSlug}-${shortId}`);
1912
+ const baseBranch = resolvedConfig.baseBranch;
1913
+ const baseBranchPoint = resolvedConfig.baseBranchStartPoint;
1914
+ const publicationMode = resolvePublicationMode(projectDir, resolvedConfig.publicationMode);
1915
+ const remoteRecord = {
1916
+ workspace_id: workspaceId,
1917
+ task_ref: taskRef,
1918
+ task_slug: taskSlug,
1919
+ worktree_root: resolvedConfig.worktreeRoot,
1920
+ resolved_base_branch: baseBranch,
1921
+ base_branch_point: baseBranchPoint,
1922
+ canonical_branch: syntheticBranch,
1923
+ canonical_branch_head: resolveCommit(projectDir, syntheticBranch),
1924
+ branch_provenance: defaultBranchProvenance(),
1925
+ lifecycle_state: "ready",
1926
+ active_role: null,
1927
+ worktrees: {
1928
+ worker: buildWorktreeRecord(workerWorktreeDir, "branch", syntheticBranch, resolveCommit(projectDir, syntheticBranch), now),
1929
+ reviewer: null,
1930
+ },
1931
+ bootstrap: defaultBootstrapState(now),
1932
+ integration: {
1933
+ status: "pending",
1934
+ target_branch: baseBranch,
1935
+ target_commit: baseBranchPoint,
1936
+ publication_mode: publicationMode,
1937
+ outcome: resolveIntegrationOutcome(publicationMode, "pending"),
1938
+ detail: null,
1939
+ updated_at: now,
1940
+ },
1941
+ health: createHealthyState(now),
1942
+ cleanup: {
1943
+ status: "not_scheduled",
1944
+ eligible: false,
1945
+ reason: null,
1946
+ detail: null,
1947
+ updated_at: now,
1948
+ },
1949
+ timestamps: {
1950
+ created_at: now,
1951
+ updated_at: now,
1952
+ last_reconciled_at: now,
1953
+ last_active_at: null,
1954
+ closed_at: null,
1955
+ },
1956
+ };
1957
+ await withDispatchShadowMutationLock(projectDir, taskRef, async () => {
1958
+ await saveWorkspaceRecordToRegistry(projectDir, remoteRecord);
1959
+ await commitWorkspaceRegistryToShadow(projectDir, taskRef);
1960
+ });
1961
+ console.log(`[dispatch] Restored dispatch branch "${syntheticBranch}" from remote for ${taskRef}`);
1962
+ return {
1963
+ recovered: true,
1964
+ recoverySource: "remote-or-review-locator",
1965
+ health: await getDispatchWorkspaceHealth({ projectDir, taskRef, role, task }),
1966
+ diagnostics: [],
1967
+ conflictingSignals: null,
1968
+ };
1969
+ }
1970
+ catch {
1971
+ // Remote recovery failure is non-fatal; fall through to diagnostics.
1972
+ }
1973
+ }
1974
+ }
1975
+ // AC: @review-and-fix-cycle-workspace-discovery-before-discard ac-4
1976
+ // Check for conflicting branch signals before emitting final diagnostics.
1977
+ const uniqueBranches = new Set(branchSignals.map((s) => s.branch));
1978
+ if (uniqueBranches.size > 1) {
1979
+ diagnostics.push({
1980
+ taskRef,
1981
+ code: "conflicting-branch-signals",
1982
+ message: `Multiple branch signals exist for ${taskRef} that cannot be reconciled safely: ${branchSignals.map((s) => `${s.source}="${s.branch}"`).join(", ")}.`,
1983
+ suggestion: "Inspect task submission linkage and workspace registry state. Use `kspec task set @ref --submission-linkage` to repair the branch-of-record, or delete stale workspace records.",
1984
+ });
1985
+ return {
1986
+ recovered: false,
1987
+ recoverySource: null,
1988
+ health: await getDispatchWorkspaceHealth({ projectDir, taskRef, role, task }),
1989
+ diagnostics,
1990
+ conflictingSignals: branchSignals,
1991
+ };
1992
+ }
1993
+ // If we had a registry record but it was unhealthy and no other source
1994
+ // helped, the registry recovery is our best signal. Re-check health.
1995
+ if (existingRecord && branchSignals.length === 1) {
1996
+ const updatedHealth = await getDispatchWorkspaceHealth({ projectDir, taskRef, role, task });
1997
+ if (updatedHealth.healthy) {
1998
+ return {
1999
+ recovered: true,
2000
+ recoverySource: "registry-state",
2001
+ health: updatedHealth,
2002
+ diagnostics: [],
2003
+ conflictingSignals: null,
2004
+ };
2005
+ }
2006
+ }
2007
+ // AC: @review-and-fix-cycle-workspace-discovery-before-discard ac-3
2008
+ // No trustworthy recovery path exists — emit explicit diagnostics.
2009
+ diagnostics.push({
2010
+ taskRef,
2011
+ code: "no-recoverable-workspace",
2012
+ message: `No trustworthy recovery path exists for ${taskRef}. Attempted: registry state, metadata-backed worktrees, task submission linkage, and remote/review locators.`,
2013
+ suggestion: existingRecord
2014
+ ? "The workspace registry record exists but the canonical branch is missing. Restore the branch from a backup, push it to a remote, or use `kspec task set @ref --submission-linkage` to update the branch-of-record."
2015
+ : "No workspace record or branch could be found for this task. Ensure the task was submitted with `kspec task submit` (which captures submission linkage), or manually provision a workspace with `kspec agent workspace provision`.",
2016
+ });
2017
+ return {
2018
+ recovered: false,
2019
+ recoverySource: null,
2020
+ health: await getDispatchWorkspaceHealth({ projectDir, taskRef, role, task }),
2021
+ diagnostics,
2022
+ conflictingSignals: null,
2023
+ };
2024
+ }
2025
+ //# sourceMappingURL=workspace.js.map