@kynetic-ai/spec 0.9.1 → 0.11.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 (310) hide show
  1. package/README.md +2 -1
  2. package/dist/acp/client.d.ts +6 -1
  3. package/dist/acp/client.d.ts.map +1 -1
  4. package/dist/acp/client.js +7 -2
  5. package/dist/acp/client.js.map +1 -1
  6. package/dist/acp/framing.d.ts +12 -1
  7. package/dist/acp/framing.d.ts.map +1 -1
  8. package/dist/acp/framing.js +27 -4
  9. package/dist/acp/framing.js.map +1 -1
  10. package/dist/agent-runtime/dispatch.d.ts +292 -0
  11. package/dist/agent-runtime/dispatch.d.ts.map +1 -0
  12. package/dist/agent-runtime/dispatch.js +860 -0
  13. package/dist/agent-runtime/dispatch.js.map +1 -0
  14. package/dist/agent-runtime/index.d.ts +11 -0
  15. package/dist/agent-runtime/index.d.ts.map +1 -0
  16. package/dist/agent-runtime/index.js +11 -0
  17. package/dist/agent-runtime/index.js.map +1 -0
  18. package/dist/agent-runtime/invocation.d.ts +86 -0
  19. package/dist/agent-runtime/invocation.d.ts.map +1 -0
  20. package/dist/agent-runtime/invocation.js +442 -0
  21. package/dist/agent-runtime/invocation.js.map +1 -0
  22. package/dist/agent-runtime/prompts.d.ts +50 -0
  23. package/dist/agent-runtime/prompts.d.ts.map +1 -0
  24. package/dist/agent-runtime/prompts.js +108 -0
  25. package/dist/agent-runtime/prompts.js.map +1 -0
  26. package/dist/agents/spawner.d.ts.map +1 -1
  27. package/dist/agents/spawner.js +60 -4
  28. package/dist/agents/spawner.js.map +1 -1
  29. package/dist/cli/batch-exec.d.ts.map +1 -1
  30. package/dist/cli/batch-exec.js +140 -62
  31. package/dist/cli/batch-exec.js.map +1 -1
  32. package/dist/cli/batch-write-buffer.d.ts +141 -0
  33. package/dist/cli/batch-write-buffer.d.ts.map +1 -0
  34. package/dist/cli/batch-write-buffer.js +400 -0
  35. package/dist/cli/batch-write-buffer.js.map +1 -0
  36. package/dist/cli/commands/agent.d.ts +20 -0
  37. package/dist/cli/commands/agent.d.ts.map +1 -0
  38. package/dist/cli/commands/agent.js +831 -0
  39. package/dist/cli/commands/agent.js.map +1 -0
  40. package/dist/cli/commands/inbox.d.ts.map +1 -1
  41. package/dist/cli/commands/inbox.js +46 -22
  42. package/dist/cli/commands/inbox.js.map +1 -1
  43. package/dist/cli/commands/index.d.ts +1 -0
  44. package/dist/cli/commands/index.d.ts.map +1 -1
  45. package/dist/cli/commands/index.js +1 -0
  46. package/dist/cli/commands/index.js.map +1 -1
  47. package/dist/cli/commands/item.d.ts.map +1 -1
  48. package/dist/cli/commands/item.js +22 -16
  49. package/dist/cli/commands/item.js.map +1 -1
  50. package/dist/cli/commands/log.js +1 -1
  51. package/dist/cli/commands/log.js.map +1 -1
  52. package/dist/cli/commands/meta.d.ts.map +1 -1
  53. package/dist/cli/commands/meta.js +168 -6
  54. package/dist/cli/commands/meta.js.map +1 -1
  55. package/dist/cli/commands/module.d.ts.map +1 -1
  56. package/dist/cli/commands/module.js +2 -1
  57. package/dist/cli/commands/module.js.map +1 -1
  58. package/dist/cli/commands/plan-import.js +19 -3
  59. package/dist/cli/commands/plan-import.js.map +1 -1
  60. package/dist/cli/commands/plan.d.ts.map +1 -1
  61. package/dist/cli/commands/plan.js +87 -43
  62. package/dist/cli/commands/plan.js.map +1 -1
  63. package/dist/cli/commands/ralph.d.ts +5 -56
  64. package/dist/cli/commands/ralph.d.ts.map +1 -1
  65. package/dist/cli/commands/ralph.js +52 -1502
  66. package/dist/cli/commands/ralph.js.map +1 -1
  67. package/dist/cli/commands/search.d.ts.map +1 -1
  68. package/dist/cli/commands/search.js +22 -13
  69. package/dist/cli/commands/search.js.map +1 -1
  70. package/dist/cli/commands/serve.d.ts.map +1 -1
  71. package/dist/cli/commands/serve.js +70 -11
  72. package/dist/cli/commands/serve.js.map +1 -1
  73. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -1
  74. package/dist/cli/commands/session/checkpoint.js +7 -2
  75. package/dist/cli/commands/session/checkpoint.js.map +1 -1
  76. package/dist/cli/commands/session/commands.d.ts.map +1 -1
  77. package/dist/cli/commands/session/commands.js +15 -0
  78. package/dist/cli/commands/session/commands.js.map +1 -1
  79. package/dist/cli/commands/session/context.d.ts.map +1 -1
  80. package/dist/cli/commands/session/context.js +10 -5
  81. package/dist/cli/commands/session/context.js.map +1 -1
  82. package/dist/cli/commands/session/log.d.ts +1 -0
  83. package/dist/cli/commands/session/log.d.ts.map +1 -1
  84. package/dist/cli/commands/session/log.js +124 -8
  85. package/dist/cli/commands/session/log.js.map +1 -1
  86. package/dist/cli/commands/session/stale-close.d.ts +17 -0
  87. package/dist/cli/commands/session/stale-close.d.ts.map +1 -0
  88. package/dist/cli/commands/session/stale-close.js +378 -0
  89. package/dist/cli/commands/session/stale-close.js.map +1 -0
  90. package/dist/cli/commands/setup.d.ts.map +1 -1
  91. package/dist/cli/commands/setup.js +95 -0
  92. package/dist/cli/commands/setup.js.map +1 -1
  93. package/dist/cli/commands/skill-crud.d.ts.map +1 -1
  94. package/dist/cli/commands/skill-crud.js +4 -3
  95. package/dist/cli/commands/skill-crud.js.map +1 -1
  96. package/dist/cli/commands/skill-diff.d.ts.map +1 -1
  97. package/dist/cli/commands/skill-diff.js +15 -0
  98. package/dist/cli/commands/skill-diff.js.map +1 -1
  99. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  100. package/dist/cli/commands/skill-install.js +50 -18
  101. package/dist/cli/commands/skill-install.js.map +1 -1
  102. package/dist/cli/commands/task.d.ts.map +1 -1
  103. package/dist/cli/commands/task.js +536 -310
  104. package/dist/cli/commands/task.js.map +1 -1
  105. package/dist/cli/commands/tasks.js +1 -1
  106. package/dist/cli/commands/tasks.js.map +1 -1
  107. package/dist/cli/commands/triage.d.ts.map +1 -1
  108. package/dist/cli/commands/triage.js +37 -13
  109. package/dist/cli/commands/triage.js.map +1 -1
  110. package/dist/cli/commands/validate.d.ts.map +1 -1
  111. package/dist/cli/commands/validate.js +65 -25
  112. package/dist/cli/commands/validate.js.map +1 -1
  113. package/dist/cli/help/content.d.ts.map +1 -1
  114. package/dist/cli/help/content.js +5 -0
  115. package/dist/cli/help/content.js.map +1 -1
  116. package/dist/cli/index.d.ts.map +1 -1
  117. package/dist/cli/index.js +2 -1
  118. package/dist/cli/index.js.map +1 -1
  119. package/dist/cli/output.d.ts.map +1 -1
  120. package/dist/cli/output.js +5 -1
  121. package/dist/cli/output.js.map +1 -1
  122. package/dist/daemon/project-context.ts +22 -0
  123. package/dist/daemon/routes/agent-dispatch.ts +279 -0
  124. package/dist/daemon/routes/items.ts +22 -0
  125. package/dist/daemon/routes/meta.ts +141 -1
  126. package/dist/daemon/routes/plans.ts +147 -0
  127. package/dist/daemon/routes/sessions.ts +180 -0
  128. package/dist/daemon/routes/tasks.ts +198 -0
  129. package/dist/daemon/routes/validation.ts +1 -1
  130. package/dist/daemon/server.ts +77 -21
  131. package/dist/daemon/websocket/handler.ts +67 -6
  132. package/dist/daemon/websocket/lifecycle.ts +19 -0
  133. package/dist/daemon/websocket/pubsub.ts +74 -3
  134. package/dist/export/html.d.ts.map +1 -1
  135. package/dist/export/html.js +5 -2
  136. package/dist/export/html.js.map +1 -1
  137. package/dist/export/triage.d.ts +1 -1
  138. package/dist/export/triage.d.ts.map +1 -1
  139. package/dist/export/triage.js +5 -3
  140. package/dist/export/triage.js.map +1 -1
  141. package/dist/parser/alignment.d.ts.map +1 -1
  142. package/dist/parser/alignment.js +10 -5
  143. package/dist/parser/alignment.js.map +1 -1
  144. package/dist/parser/assess.js +1 -1
  145. package/dist/parser/assess.js.map +1 -1
  146. package/dist/parser/config.d.ts +6 -6
  147. package/dist/parser/meta.d.ts.map +1 -1
  148. package/dist/parser/meta.js +9 -8
  149. package/dist/parser/meta.js.map +1 -1
  150. package/dist/parser/plan-document.d.ts +12 -12
  151. package/dist/parser/plans.d.ts +7 -0
  152. package/dist/parser/plans.d.ts.map +1 -1
  153. package/dist/parser/plans.js +100 -15
  154. package/dist/parser/plans.js.map +1 -1
  155. package/dist/parser/refs.d.ts +5 -0
  156. package/dist/parser/refs.d.ts.map +1 -1
  157. package/dist/parser/refs.js +17 -12
  158. package/dist/parser/refs.js.map +1 -1
  159. package/dist/parser/shadow.d.ts +1 -1
  160. package/dist/parser/shadow.d.ts.map +1 -1
  161. package/dist/parser/shadow.js +71 -4
  162. package/dist/parser/shadow.js.map +1 -1
  163. package/dist/parser/skill-render.d.ts.map +1 -1
  164. package/dist/parser/skill-render.js +6 -3
  165. package/dist/parser/skill-render.js.map +1 -1
  166. package/dist/parser/validate.d.ts.map +1 -1
  167. package/dist/parser/validate.js +35 -76
  168. package/dist/parser/validate.js.map +1 -1
  169. package/dist/parser/yaml.d.ts +24 -5
  170. package/dist/parser/yaml.d.ts.map +1 -1
  171. package/dist/parser/yaml.js +224 -64
  172. package/dist/parser/yaml.js.map +1 -1
  173. package/dist/schema/meta.d.ts +457 -119
  174. package/dist/schema/meta.d.ts.map +1 -1
  175. package/dist/schema/meta.js +56 -0
  176. package/dist/schema/meta.js.map +1 -1
  177. package/dist/schema/plan.d.ts +22 -22
  178. package/dist/schema/spec.d.ts +39 -39
  179. package/dist/schema/task.d.ts +43 -32
  180. package/dist/schema/task.d.ts.map +1 -1
  181. package/dist/schema/task.js +5 -0
  182. package/dist/schema/task.js.map +1 -1
  183. package/dist/sessions/store.d.ts +126 -0
  184. package/dist/sessions/store.d.ts.map +1 -1
  185. package/dist/sessions/store.js +440 -22
  186. package/dist/sessions/store.js.map +1 -1
  187. package/dist/sessions/types.d.ts +75 -17
  188. package/dist/sessions/types.d.ts.map +1 -1
  189. package/dist/sessions/types.js +51 -1
  190. package/dist/sessions/types.js.map +1 -1
  191. package/dist/triage/actions.d.ts +1 -0
  192. package/dist/triage/actions.d.ts.map +1 -1
  193. package/dist/triage/actions.js +34 -7
  194. package/dist/triage/actions.js.map +1 -1
  195. package/dist/utils/commit.js +1 -1
  196. package/dist/utils/commit.js.map +1 -1
  197. package/dist/web-ui/_app/env.js +1 -0
  198. package/dist/web-ui/_app/immutable/assets/0.BJaYkGW2.css +1 -0
  199. package/dist/web-ui/_app/immutable/assets/9.SzGLxi4x.css +1 -0
  200. package/dist/web-ui/_app/immutable/assets/select-trigger.CV-KWLNP.css +1 -0
  201. package/dist/web-ui/_app/immutable/chunks/-lc0BifF.js +1 -0
  202. package/dist/web-ui/_app/immutable/chunks/62JVKtnb.js +1 -0
  203. package/dist/web-ui/_app/immutable/chunks/8RBjHMN1.js +1 -0
  204. package/dist/web-ui/_app/immutable/chunks/B5LJFxqa.js +1 -0
  205. package/dist/web-ui/_app/immutable/chunks/B5wTVqxm.js +1 -0
  206. package/dist/web-ui/_app/immutable/chunks/B6VSmczZ.js +1 -0
  207. package/dist/web-ui/_app/immutable/chunks/B8a0xDxR.js +1 -0
  208. package/dist/web-ui/_app/immutable/chunks/BEOQc37C.js +1 -0
  209. package/dist/web-ui/_app/immutable/chunks/BHtYorjv.js +1 -0
  210. package/dist/web-ui/_app/immutable/chunks/BJ0JX3ea.js +1 -0
  211. package/dist/web-ui/_app/immutable/chunks/BMuCqDX8.js +1 -0
  212. package/dist/web-ui/_app/immutable/chunks/BP352uRn.js +1 -0
  213. package/dist/web-ui/_app/immutable/chunks/BUZujXJ2.js +1 -0
  214. package/dist/web-ui/_app/immutable/chunks/BVA9Exy-.js +1 -0
  215. package/dist/web-ui/_app/immutable/chunks/BWET-efb.js +1 -0
  216. package/dist/web-ui/_app/immutable/chunks/BXkNecpt.js +1 -0
  217. package/dist/web-ui/_app/immutable/chunks/BYzrIfX8.js +1 -0
  218. package/dist/web-ui/_app/immutable/chunks/BkOJ8DkV.js +1 -0
  219. package/dist/web-ui/_app/immutable/chunks/BpuwufMc.js +1 -0
  220. package/dist/web-ui/_app/immutable/chunks/BwMO4RrG.js +1 -0
  221. package/dist/web-ui/_app/immutable/chunks/BysXJlZb.js +1 -0
  222. package/dist/web-ui/_app/immutable/chunks/C076q4JN.js +1 -0
  223. package/dist/web-ui/_app/immutable/chunks/C33JaVbg.js +1 -0
  224. package/dist/web-ui/_app/immutable/chunks/CGtqifKp.js +1 -0
  225. package/dist/web-ui/_app/immutable/chunks/CHDZZ7OG.js +1 -0
  226. package/dist/web-ui/_app/immutable/chunks/CPPfDSei.js +5 -0
  227. package/dist/web-ui/_app/immutable/chunks/CUir3f4J.js +60 -0
  228. package/dist/web-ui/_app/immutable/chunks/Cncwi6fQ.js +1 -0
  229. package/dist/web-ui/_app/immutable/chunks/CrCIbn0C.js +1 -0
  230. package/dist/web-ui/_app/immutable/chunks/CwELQvbx.js +1 -0
  231. package/dist/web-ui/_app/immutable/chunks/D3vxvonu.js +1 -0
  232. package/dist/web-ui/_app/immutable/chunks/D6TVmR9T.js +1 -0
  233. package/dist/web-ui/_app/immutable/chunks/D7LTux4W.js +1 -0
  234. package/dist/web-ui/_app/immutable/chunks/D82RulSH.js +1 -0
  235. package/dist/web-ui/_app/immutable/chunks/D9QNBZM2.js +2 -0
  236. package/dist/web-ui/_app/immutable/chunks/DAMmvwn4.js +1 -0
  237. package/dist/web-ui/_app/immutable/chunks/DAh4Wfku.js +1 -0
  238. package/dist/web-ui/_app/immutable/chunks/DAx07bEQ.js +1 -0
  239. package/dist/web-ui/_app/immutable/chunks/DBYE9jOd.js +1 -0
  240. package/dist/web-ui/_app/immutable/chunks/DOno4cA2.js +1 -0
  241. package/dist/web-ui/_app/immutable/chunks/DQA8NZIH.js +2 -0
  242. package/dist/web-ui/_app/immutable/chunks/DRfPm2bo.js +1 -0
  243. package/dist/web-ui/_app/immutable/chunks/DhQhksaB.js +1 -0
  244. package/dist/web-ui/_app/immutable/chunks/DjG7s6hm.js +1 -0
  245. package/dist/web-ui/_app/immutable/chunks/DjcCz-PU.js +2 -0
  246. package/dist/web-ui/_app/immutable/chunks/DkltRNvh.js +1 -0
  247. package/dist/web-ui/_app/immutable/chunks/DlaTnPKL.js +1 -0
  248. package/dist/web-ui/_app/immutable/chunks/DvA-KON-.js +1 -0
  249. package/dist/web-ui/_app/immutable/chunks/DxCk-KHc.js +1 -0
  250. package/dist/web-ui/_app/immutable/chunks/DzO4hlg9.js +1 -0
  251. package/dist/web-ui/_app/immutable/chunks/Eo4gF7ih.js +1 -0
  252. package/dist/web-ui/_app/immutable/chunks/ExCq5swK.js +1 -0
  253. package/dist/web-ui/_app/immutable/chunks/T3zZGv51.js +1 -0
  254. package/dist/web-ui/_app/immutable/chunks/XZumBYeP.js +1 -0
  255. package/dist/web-ui/_app/immutable/chunks/_ySfNjkF.js +1 -0
  256. package/dist/web-ui/_app/immutable/chunks/iEtR5cV6.js +1 -0
  257. package/dist/web-ui/_app/immutable/chunks/k_Qegko0.js +1 -0
  258. package/dist/web-ui/_app/immutable/chunks/pE6cYWlS.js +1 -0
  259. package/dist/web-ui/_app/immutable/entry/app.Cgu6uKeS.js +2 -0
  260. package/dist/web-ui/_app/immutable/entry/start.9XifnLoB.js +1 -0
  261. package/dist/web-ui/_app/immutable/nodes/0.DISwcKSK.js +1 -0
  262. package/dist/web-ui/_app/immutable/nodes/1.Cx2Ufqp1.js +1 -0
  263. package/dist/web-ui/_app/immutable/nodes/10.C3z8ijXL.js +1 -0
  264. package/dist/web-ui/_app/immutable/nodes/11.DZdIjZmM.js +1 -0
  265. package/dist/web-ui/_app/immutable/nodes/12.FsIGfAOa.js +1 -0
  266. package/dist/web-ui/_app/immutable/nodes/13.DZoFwagf.js +1 -0
  267. package/dist/web-ui/_app/immutable/nodes/14.DaIzDKbQ.js +1 -0
  268. package/dist/web-ui/_app/immutable/nodes/15.BYyt4XWF.js +2 -0
  269. package/dist/web-ui/_app/immutable/nodes/16.CQkSqpOe.js +1 -0
  270. package/dist/web-ui/_app/immutable/nodes/2.Bkf_j2UJ.js +1 -0
  271. package/dist/web-ui/_app/immutable/nodes/3.kaMCurJG.js +1 -0
  272. package/dist/web-ui/_app/immutable/nodes/4.BSsFPTHG.js +2 -0
  273. package/dist/web-ui/_app/immutable/nodes/5.CpPlcCEZ.js +1 -0
  274. package/dist/web-ui/_app/immutable/nodes/6.BN4FqQmY.js +1 -0
  275. package/dist/web-ui/_app/immutable/nodes/7.9kBYIZik.js +1 -0
  276. package/dist/web-ui/_app/immutable/nodes/8.BuijtZ6B.js +1 -0
  277. package/dist/web-ui/_app/immutable/nodes/9.C-Weba8R.js +1 -0
  278. package/dist/web-ui/_app/version.json +1 -0
  279. package/dist/web-ui/index.html +39 -0
  280. package/dist/web-ui/robots.txt +3 -0
  281. package/package.json +4 -2
  282. package/plugin/.claude-plugin/marketplace.json +1 -1
  283. package/plugin/.claude-plugin/plugin.json +1 -1
  284. package/plugin/plugins/kspec/skills/task-work/SKILL.md +25 -2
  285. package/templates/agents-sections/06-ralph-loop.md +64 -11
  286. package/templates/skills/task-work/SKILL.md +25 -2
  287. package/dist/ralph/cli-renderer.d.ts +0 -27
  288. package/dist/ralph/cli-renderer.d.ts.map +0 -1
  289. package/dist/ralph/cli-renderer.js +0 -250
  290. package/dist/ralph/cli-renderer.js.map +0 -1
  291. package/dist/ralph/events.d.ts +0 -65
  292. package/dist/ralph/events.d.ts.map +0 -1
  293. package/dist/ralph/events.js +0 -600
  294. package/dist/ralph/events.js.map +0 -1
  295. package/dist/ralph/index.d.ts +0 -11
  296. package/dist/ralph/index.d.ts.map +0 -1
  297. package/dist/ralph/index.js +0 -16
  298. package/dist/ralph/index.js.map +0 -1
  299. package/dist/ralph/loop-errors.d.ts +0 -83
  300. package/dist/ralph/loop-errors.d.ts.map +0 -1
  301. package/dist/ralph/loop-errors.js +0 -150
  302. package/dist/ralph/loop-errors.js.map +0 -1
  303. package/dist/ralph/subagent.d.ts +0 -127
  304. package/dist/ralph/subagent.d.ts.map +0 -1
  305. package/dist/ralph/subagent.js +0 -268
  306. package/dist/ralph/subagent.js.map +0 -1
  307. package/dist/ralph/wrap-up.d.ts +0 -127
  308. package/dist/ralph/wrap-up.d.ts.map +0 -1
  309. package/dist/ralph/wrap-up.js +0 -271
  310. package/dist/ralph/wrap-up.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { markMutating } from "../command-annotations.js";
3
- import { checkSlugUniqueness, createNote, createTask, createTodo, deleteTask, getAuthor, initContext, loadAllItems, loadAllTasks, ReferenceIndex, saveTask, scanTestCoverage, syncSpecImplementationStatus, } from "../../parser/index.js";
3
+ import { checkSlugUniqueness, createNote, createTask, createTodo, deleteTask, getAuthor, initContext, loadAllItems, loadAllTasks, mutateTaskAtomically, ReferenceIndex, saveTask, scanTestCoverage, syncSpecImplementationStatus, } from "../../parser/index.js";
4
4
  import { commitIfShadow } from "../../parser/shadow.js";
5
5
  import { normalizeRefInput } from "../../schema/index.js";
6
6
  import { alignmentCheck, errors } from "../../strings/index.js";
@@ -13,6 +13,55 @@ import { parsePriority, validateEnumOption, validateSpecRef, } from "../validato
13
13
  import { addListOptions, listTasksAction } from "./tasks.js";
14
14
  import { findClosestCommand } from "../suggest.js";
15
15
  import { checkBudget, incrementBudget, isEndLoopRequested } from "../../sessions/store.js";
16
+ import { PidFileManager } from "../pid-utils.js";
17
+ /**
18
+ * Post a task state change event to the daemon dispatch engine.
19
+ * Fails silently — dispatch requires a running daemon; if absent, this is a no-op.
20
+ * AC: @daemon-agent-dispatch ac-2, ac-7
21
+ * AC: @agent-dispatch-engine ac-18
22
+ */
23
+ async function postDispatchEvent(opts) {
24
+ // AC: @agent-dispatch-engine ac-18 - Suppress self-triggering when running
25
+ // inside a dispatched agent invocation. The file watcher will independently
26
+ // detect the change, so the CLI event would be redundant and causes stale
27
+ // queue entries to accumulate.
28
+ // NOTE: This relies on the daemon's file watcher being active. If the watcher
29
+ // is temporarily down, dispatched agents' task mutations won't produce dispatch
30
+ // events until the watcher recovers and diffs the changed state.
31
+ if (process.env.KSPEC_SESSION_ID)
32
+ return;
33
+ const pidManager = new PidFileManager();
34
+ if (!pidManager.isDaemonRunning())
35
+ return;
36
+ let port;
37
+ try {
38
+ port = pidManager.readPort();
39
+ }
40
+ catch {
41
+ return; // Daemon running but port unreadable — silent fail
42
+ }
43
+ const headers = { 'Content-Type': 'application/json' };
44
+ if (opts.projectPath) {
45
+ headers['X-Kspec-Dir'] = opts.projectPath;
46
+ }
47
+ try {
48
+ await fetch(`http://localhost:${port}/api/agent/events`, {
49
+ method: 'POST',
50
+ headers,
51
+ body: JSON.stringify({
52
+ task_id: opts.taskId,
53
+ task_ref: opts.taskRef,
54
+ from_status: opts.fromStatus,
55
+ to_status: opts.toStatus,
56
+ timestamp: Date.now(),
57
+ }),
58
+ signal: AbortSignal.timeout(1000), // 1s timeout — fire-and-forget
59
+ });
60
+ }
61
+ catch {
62
+ // Silent fail — daemon unreachable or dispatch engine not running
63
+ }
64
+ }
16
65
  /**
17
66
  * Find a task by reference with detailed error reporting.
18
67
  * Returns the task or exits with appropriate error.
@@ -97,20 +146,11 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
97
146
  };
98
147
  }
99
148
  }
100
- // Build updated task with only provided options
101
- const updatedTask = { ...foundTask };
102
149
  const changes = [];
103
- if (options.title) {
104
- updatedTask.title = options.title;
105
- changes.push("title");
106
- }
150
+ let noChangesMessage;
107
151
  if (options.specRef !== undefined) {
108
152
  // Handle 'null' string to clear spec_ref
109
- if (options.specRef === "null") {
110
- updatedTask.spec_ref = null;
111
- changes.push("spec_ref: cleared");
112
- }
113
- else {
153
+ if (options.specRef !== "null") {
114
154
  // Validate the spec ref exists and is a spec item
115
155
  const specResult = index.resolve(options.specRef);
116
156
  if (!specResult.ok) {
@@ -127,17 +167,11 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
127
167
  error: errors.reference.specRefIsTask(options.specRef),
128
168
  };
129
169
  }
130
- updatedTask.spec_ref = normalizeRefInput(options.specRef);
131
- changes.push("spec_ref");
132
170
  }
133
171
  }
134
172
  if (options.metaRef !== undefined) {
135
173
  // Handle 'null' string to clear meta_ref
136
- if (options.metaRef === "null") {
137
- updatedTask.meta_ref = null;
138
- changes.push("meta_ref: cleared");
139
- }
140
- else {
174
+ if (options.metaRef !== "null") {
141
175
  // Validate the meta ref exists and is a meta item
142
176
  const metaRefResult = index.resolve(options.metaRef);
143
177
  if (!metaRefResult.ok) {
@@ -155,17 +189,11 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
155
189
  error: errors.reference.metaRefPointsToSpec(options.metaRef),
156
190
  };
157
191
  }
158
- updatedTask.meta_ref = normalizeRefInput(options.metaRef);
159
- changes.push("meta_ref");
160
192
  }
161
193
  }
162
194
  if (options.planRef !== undefined) {
163
195
  // Handle 'null' string to clear plan_ref
164
- if (options.planRef === "null") {
165
- updatedTask.plan_ref = null;
166
- changes.push("plan_ref: cleared");
167
- }
168
- else {
196
+ if (options.planRef !== "null") {
169
197
  // First check if it's a task or spec item (wrong type)
170
198
  const cleanRef = options.planRef.startsWith("@")
171
199
  ? options.planRef.slice(1)
@@ -191,32 +219,26 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
191
219
  error: `Plan reference not found: ${options.planRef}`,
192
220
  };
193
221
  }
194
- updatedTask.plan_ref = normalizeRefInput(options.planRef);
195
- changes.push("plan_ref");
196
222
  }
197
223
  }
224
+ // Validate review URL if provided and not clearing
225
+ if (options.reviewUrl !== undefined && options.reviewUrl !== "null") {
226
+ try {
227
+ new URL(options.reviewUrl);
228
+ }
229
+ catch {
230
+ return { success: false, error: `Invalid review URL: ${options.reviewUrl}` };
231
+ }
232
+ }
233
+ let parsedPriority;
198
234
  if (options.priority) {
199
235
  const priorityResult = parsePriority(options.priority);
200
236
  if (!priorityResult.ok) {
201
237
  return { success: false, error: priorityResult.error };
202
238
  }
203
- updatedTask.priority = priorityResult.value;
204
- changes.push("priority");
205
- }
206
- if (options.slug) {
207
- if (!updatedTask.slugs.includes(options.slug)) {
208
- updatedTask.slugs = [...updatedTask.slugs, options.slug];
209
- changes.push("slug");
210
- }
211
- }
212
- if (options.tag) {
213
- const parsedTags = parseTagsArray(options.tag);
214
- const newTags = parsedTags.filter((t) => !updatedTask.tags.includes(t));
215
- if (newTags.length > 0) {
216
- updatedTask.tags = [...updatedTask.tags, ...newTags];
217
- changes.push("tags");
218
- }
239
+ parsedPriority = priorityResult.value;
219
240
  }
241
+ const parsedTags = options.tag ? parseTagsArray(options.tag) : [];
220
242
  if (options.dependsOn) {
221
243
  // Validate all dependency refs
222
244
  for (const depRef of options.dependsOn) {
@@ -236,34 +258,12 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
236
258
  };
237
259
  }
238
260
  }
239
- updatedTask.depends_on = options.dependsOn.map(normalizeRefInput);
240
- changes.push("depends_on");
241
- }
242
- // AC: @spec-task-clear-deps ac-1, ac-2 - Clear all dependencies
243
- if (options.clearDeps) {
244
- if (foundTask.depends_on.length === 0) {
245
- // AC: @spec-task-clear-deps ac-2 - No changes needed
246
- return {
247
- success: true,
248
- message: "No changes: task has no dependencies to clear",
249
- };
250
- }
251
- updatedTask.depends_on = [];
252
- changes.push("depends_on");
253
- // Add note documenting the change
254
- // AC: @task-set ac-author
255
- const note = createNote(`Dependencies cleared (was: ${foundTask.depends_on.join(", ")})`, getAuthor(ctx.config?.identity?.author));
256
- updatedTask.notes = [...updatedTask.notes, note];
257
261
  }
258
262
  // AC: @task-automation-eligibility ac-5, ac-11, ac-12, ac-18
259
263
  // Handle automation status changes
260
264
  // Note: --no-automation sets options.automation to false, so check that first
261
- if (options.automation === false) {
262
- // --no-automation flag clears the automation status (AC: ac-12)
263
- delete updatedTask.automation;
264
- changes.push("automation");
265
- }
266
- else if (options.automation !== undefined) {
265
+ let validatedAutomation;
266
+ if (options.automation !== undefined && options.automation !== false) {
267
267
  const automationResult = validateEnumOption(options.automation, ["eligible", "needs_review", "manual_only"], "automation status");
268
268
  if (!automationResult.ok) {
269
269
  return { success: false, error: automationResult.error };
@@ -275,15 +275,119 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
275
275
  error: "Setting automation to needs_review requires --reason flag explaining why",
276
276
  };
277
277
  }
278
- updatedTask.automation = automationResult.value;
279
- changes.push("automation");
280
- // If reason provided, add a note documenting the change
281
- // AC: @task-set ac-author
282
- if (options.reason) {
283
- const note = createNote(`Automation status set to ${automationResult.value}: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
284
- updatedTask.notes = [...updatedTask.notes, note];
285
- changes.push("note");
278
+ validatedAutomation = automationResult.value;
279
+ }
280
+ let updatedTask = foundTask;
281
+ updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
282
+ const nextTask = { ...latestTask };
283
+ const mutationChanges = [];
284
+ if (options.title) {
285
+ nextTask.title = options.title;
286
+ mutationChanges.push("title");
287
+ }
288
+ if (options.description !== undefined) {
289
+ if (options.description === "null" || options.description.trim() === "") {
290
+ delete nextTask.description;
291
+ mutationChanges.push("description: cleared");
292
+ }
293
+ else {
294
+ nextTask.description = options.description;
295
+ mutationChanges.push("description");
296
+ }
297
+ }
298
+ if (options.specRef !== undefined) {
299
+ if (options.specRef === "null") {
300
+ nextTask.spec_ref = null;
301
+ mutationChanges.push("spec_ref: cleared");
302
+ }
303
+ else {
304
+ nextTask.spec_ref = normalizeRefInput(options.specRef);
305
+ mutationChanges.push("spec_ref");
306
+ }
307
+ }
308
+ if (options.metaRef !== undefined) {
309
+ if (options.metaRef === "null") {
310
+ nextTask.meta_ref = null;
311
+ mutationChanges.push("meta_ref: cleared");
312
+ }
313
+ else {
314
+ nextTask.meta_ref = normalizeRefInput(options.metaRef);
315
+ mutationChanges.push("meta_ref");
316
+ }
317
+ }
318
+ if (options.planRef !== undefined) {
319
+ if (options.planRef === "null") {
320
+ nextTask.plan_ref = null;
321
+ mutationChanges.push("plan_ref: cleared");
322
+ }
323
+ else {
324
+ nextTask.plan_ref = normalizeRefInput(options.planRef);
325
+ mutationChanges.push("plan_ref");
326
+ }
286
327
  }
328
+ if (options.reviewUrl !== undefined) {
329
+ if (options.reviewUrl === "null") {
330
+ delete nextTask.review_url;
331
+ mutationChanges.push("review_url: cleared");
332
+ }
333
+ else {
334
+ nextTask.review_url = options.reviewUrl;
335
+ mutationChanges.push("review_url");
336
+ }
337
+ }
338
+ if (parsedPriority !== undefined) {
339
+ nextTask.priority = parsedPriority;
340
+ mutationChanges.push("priority");
341
+ }
342
+ if (options.slug && !nextTask.slugs.includes(options.slug)) {
343
+ nextTask.slugs = [...nextTask.slugs, options.slug];
344
+ mutationChanges.push("slug");
345
+ }
346
+ if (parsedTags.length > 0) {
347
+ const newTags = parsedTags.filter((tag) => !nextTask.tags.includes(tag));
348
+ if (newTags.length > 0) {
349
+ nextTask.tags = [...nextTask.tags, ...newTags];
350
+ mutationChanges.push("tags");
351
+ }
352
+ }
353
+ if (options.dependsOn) {
354
+ nextTask.depends_on = options.dependsOn.map(normalizeRefInput);
355
+ mutationChanges.push("depends_on");
356
+ }
357
+ if (options.clearDeps) {
358
+ if (latestTask.depends_on.length === 0) {
359
+ // AC: @spec-task-clear-deps ac-2 - No changes needed
360
+ noChangesMessage = "No changes: task has no dependencies to clear";
361
+ return latestTask;
362
+ }
363
+ nextTask.depends_on = [];
364
+ mutationChanges.push("depends_on");
365
+ // AC: @task-set ac-author
366
+ const note = createNote(`Dependencies cleared (was: ${latestTask.depends_on.join(", ")})`, getAuthor(ctx.config?.identity?.author));
367
+ nextTask.notes = [...nextTask.notes, note];
368
+ }
369
+ if (options.automation === false) {
370
+ delete nextTask.automation;
371
+ mutationChanges.push("automation");
372
+ }
373
+ else if (validatedAutomation) {
374
+ nextTask.automation = validatedAutomation;
375
+ mutationChanges.push("automation");
376
+ if (options.reason) {
377
+ const note = createNote(`Automation status set to ${validatedAutomation}: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
378
+ nextTask.notes = [...nextTask.notes, note];
379
+ mutationChanges.push("note");
380
+ }
381
+ }
382
+ changes.splice(0, changes.length, ...mutationChanges);
383
+ return nextTask;
384
+ });
385
+ if (noChangesMessage) {
386
+ return {
387
+ success: true,
388
+ message: noChangesMessage,
389
+ data: { task: updatedTask },
390
+ };
287
391
  }
288
392
  // AC: @spec-task-set-batch ac-4 - Warn on no changes, don't fail
289
393
  if (changes.length === 0) {
@@ -293,7 +397,6 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
293
397
  data: { task: updatedTask },
294
398
  };
295
399
  }
296
- await saveTask(ctx, updatedTask);
297
400
  await commitIfShadow(ctx.shadow, "task-set", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), changes.join(", "));
298
401
  return {
299
402
  success: true,
@@ -592,9 +695,11 @@ Examples:
592
695
  .description("Update task fields")
593
696
  .option("--refs <refs...>", "Update multiple tasks (AC: @spec-task-set-batch ac-1)")
594
697
  .option("--title <title>", "Update task title")
698
+ .option("--description <description>", "Update task description (use 'null' to clear)")
595
699
  .option("--spec-ref <ref>", "Link to spec item (use 'null' to clear)")
596
700
  .option("--meta-ref <ref>", "Link to meta item (use 'null' to clear)")
597
701
  .option("--plan-ref <ref>", "Link to plan (use 'null' to clear)")
702
+ .option("--review-url <url>", "Set review URL (use 'null' to clear)")
598
703
  .option("--priority <n>", "Set priority (1-5 or P1-P5)")
599
704
  .option("--slug <slug>", "Add a slug alias")
600
705
  .option("--tag <tag...>", "Add tags")
@@ -607,6 +712,7 @@ Examples:
607
712
  .addHelpText("after", `
608
713
  Examples:
609
714
  $ kspec task set @task-slug --priority 2
715
+ $ kspec task set @task-slug --description "Updated context"
610
716
  $ kspec task set @task-slug --depends-on @dep1 @dep2
611
717
  $ kspec task set @task-slug --tag cli urgent
612
718
  $ kspec task set --refs @task1 @task2 --priority 3`)
@@ -771,20 +877,22 @@ Examples:
771
877
  process.exit(EXIT_CODES.ERROR);
772
878
  }
773
879
  }
774
- // Build updated task
775
- const updatedTask = { ...foundTask, ...validatedPatch };
776
880
  // Track changes for output
777
881
  const changes = Object.keys(validatedPatch);
778
882
  if (options.dryRun) {
883
+ const dryRunTask = { ...foundTask, ...validatedPatch };
779
884
  info("Dry run - no changes will be written");
780
885
  info(`Would update: ${changes.join(", ")}`);
781
- output({ changes, updated: updatedTask }, () => {
886
+ output({ changes, updated: dryRunTask }, () => {
782
887
  console.log(`\nChanges: ${changes.join(", ")}\n`);
783
- return formatTaskDetails(updatedTask, index);
888
+ return formatTaskDetails(dryRunTask, index);
784
889
  });
785
890
  return;
786
891
  }
787
- await saveTask(ctx, updatedTask);
892
+ const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => ({
893
+ ...latestTask,
894
+ ...validatedPatch,
895
+ }));
788
896
  await commitIfShadow(ctx.shadow, "task-patch", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), changes.join(", "));
789
897
  success(`Patched task: ${index.shortUlid(updatedTask._ulid)} (${changes.join(", ")})`, { task: updatedTask });
790
898
  }
@@ -830,30 +938,47 @@ Examples:
830
938
  process.exit(EXIT_CODES.VALIDATION_FAILED);
831
939
  }
832
940
  }
833
- // AC: @task-budget-enforcement ac-block-start, ac-no-session, ac-no-budget
834
- // Check budget before starting — only when session is set
835
- const budgetCheck = await checkBudget(ctx.specDir, sessionId || undefined);
836
- if (!budgetCheck.allowed) {
837
- error(budgetCheck.reason);
838
- process.exit(EXIT_CODES.VALIDATION_FAILED);
941
+ // AC: @task-budget-enforcement ac-block-start, ac-no-session, ac-no-budget, ac-needs-work-no-increment
942
+ // Check budget before starting — only when session is set.
943
+ // Skip budget check for needs_work→in_progress (fix cycles): the task
944
+ // already consumed a budget slot when originally started; blocking it
945
+ // would prevent completing already-assigned work.
946
+ if (foundTask.status !== "needs_work") {
947
+ const budgetCheck = await checkBudget(ctx.specDir, sessionId || undefined);
948
+ if (!budgetCheck.allowed) {
949
+ error(budgetCheck.reason);
950
+ process.exit(EXIT_CODES.VALIDATION_FAILED);
951
+ }
839
952
  }
840
953
  // Update status
841
954
  // AC: @session-scoped-task-claiming ac-stamp, ac-no-env
842
- const updatedTask = {
843
- ...foundTask,
844
- status: "in_progress",
845
- started_at: new Date().toISOString(),
846
- ...(sessionId ? { session_id: sessionId } : {}),
847
- };
848
- await saveTask(ctx, updatedTask);
955
+ let transitionFromStatus = foundTask.status;
956
+ const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
957
+ transitionFromStatus = latestTask.status;
958
+ return {
959
+ ...latestTask,
960
+ status: "in_progress",
961
+ started_at: new Date().toISOString(),
962
+ ...(sessionId ? { session_id: sessionId } : {}),
963
+ };
964
+ });
849
965
  await commitIfShadow(ctx.shadow, "task-start", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
850
- // AC: @task-budget-enforcement ac-increment, ac-resume-no-increment
851
- // Increment budget counter after successful start.
852
- // Resume case (already in_progress) returns early above, so this only
853
- // runs for genuine pending→in_progress or needs_work→in_progress transitions.
854
- if (sessionId) {
966
+ // AC: @task-budget-enforcement ac-increment, ac-resume-no-increment, ac-needs-work-no-increment
967
+ // Increment budget counter after successful start, but only for
968
+ // genuine pending→in_progress transitions. Resume case returns early
969
+ // above. needs_work→in_progress is a fix cycle (task already consumed
970
+ // a budget slot when originally started) — don't double-count it.
971
+ if (sessionId && transitionFromStatus === "pending") {
855
972
  await incrementBudget(ctx.specDir, sessionId);
856
973
  }
974
+ // AC: @daemon-agent-dispatch ac-2, ac-7 - Notify daemon of state change (fire-and-forget)
975
+ postDispatchEvent({
976
+ taskId: updatedTask._ulid,
977
+ taskRef: `@${updatedTask.slugs[0] || updatedTask._ulid}`,
978
+ fromStatus: transitionFromStatus,
979
+ toStatus: updatedTask.status,
980
+ projectPath: ctx.rootDir,
981
+ });
857
982
  success(`Started task: ${index.shortUlid(updatedTask._ulid)}`, {
858
983
  task: updatedTask,
859
984
  });
@@ -940,89 +1065,82 @@ Examples:
940
1065
  },
941
1066
  executeOperation: async (foundTask, { ctx, tasks, items, index, options }) => {
942
1067
  try {
943
- // AC: @spec-completion-enforcement ac-6
944
- if (foundTask.status === "completed") {
945
- return {
946
- success: false,
947
- error: errors.status.completeAlreadyCompleted,
948
- };
949
- }
950
- // AC: @task-commands ac-1 - Allow --force to bypass all state checks
951
1068
  const forcingCompletion = options.force;
952
- // AC: @spec-completion-enforcement ac-7 - Allow skip-review bypass
953
- if (!options.skipReview && !forcingCompletion) {
954
- // AC: @spec-completion-enforcement ac-2
955
- if (foundTask.status === "in_progress") {
956
- return {
957
- success: false,
958
- error: errors.status.completeRequiresReview,
959
- };
960
- }
961
- // AC: @spec-completion-enforcement ac-3
962
- if (foundTask.status === "pending") {
963
- return {
964
- success: false,
965
- error: errors.status.completeRequiresStart,
966
- };
967
- }
968
- // AC: @spec-completion-enforcement ac-4
969
- if (foundTask.status === "blocked") {
970
- return {
971
- success: false,
972
- error: errors.status.completeBlockedTask,
973
- };
974
- }
975
- // AC: @spec-completion-enforcement ac-5
976
- if (foundTask.status === "cancelled") {
977
- return {
978
- success: false,
979
- error: errors.status.completeCancelledTask,
980
- };
981
- }
982
- // AC: @spec-completion-enforcement ac-1 - Only pending_review allowed
983
- if (foundTask.status !== "pending_review") {
984
- return {
985
- success: false,
986
- error: errors.status.cannotComplete(foundTask.status),
987
- };
988
- }
989
- }
990
1069
  const now = new Date().toISOString();
991
- // AC: @spec-completion-enforcement ac-7 - Document skip-review reason
992
- // AC: @spec-completion-enforcement ac-author
993
- let taskNotes = foundTask.notes;
994
- if (options.skipReview && options.reason) {
995
- const skipNote = createNote(`Completed with --skip-review: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
996
- taskNotes = [...taskNotes, skipNote];
997
- }
998
- // AC: @task-commands ac-1 - Document force completion from non-standard state
999
- const forcedFromNonStandard = forcingCompletion &&
1000
- foundTask.status !== "pending_review";
1070
+ let transitionFromStatus = foundTask.status;
1071
+ let forcedFromNonStandard = false;
1001
1072
  let forceStateDetail;
1002
- if (forcedFromNonStandard) {
1003
- forceStateDetail = `from ${foundTask.status} state`;
1004
- if (foundTask.status === "blocked") {
1005
- const blockedBy = foundTask.blocked_by.join("; ");
1006
- forceStateDetail += `. Was blocked by: ${blockedBy || "(dependency-blocked)"}`;
1073
+ const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
1074
+ transitionFromStatus = latestTask.status;
1075
+ // AC: @spec-completion-enforcement ac-6
1076
+ if (latestTask.status === "completed") {
1077
+ throw new Error(errors.status.completeAlreadyCompleted);
1007
1078
  }
1008
- let forceMessage = `Completed with --force ${forceStateDetail}`;
1009
- if (options.reason) {
1010
- forceMessage += `. Reason: ${options.reason}`;
1079
+ // AC: @spec-completion-enforcement ac-7 - Allow skip-review bypass
1080
+ if (!options.skipReview && !forcingCompletion) {
1081
+ // AC: @spec-completion-enforcement ac-2
1082
+ if (latestTask.status === "in_progress") {
1083
+ throw new Error(errors.status.completeRequiresReview);
1084
+ }
1085
+ // AC: @spec-completion-enforcement ac-3
1086
+ if (latestTask.status === "pending") {
1087
+ throw new Error(errors.status.completeRequiresStart);
1088
+ }
1089
+ // AC: @spec-completion-enforcement ac-4
1090
+ if (latestTask.status === "blocked") {
1091
+ throw new Error(errors.status.completeBlockedTask);
1092
+ }
1093
+ // AC: @spec-completion-enforcement ac-5
1094
+ if (latestTask.status === "cancelled") {
1095
+ throw new Error(errors.status.completeCancelledTask);
1096
+ }
1097
+ // AC: @spec-completion-enforcement ac-1 - Only pending_review allowed
1098
+ if (latestTask.status !== "pending_review") {
1099
+ throw new Error(errors.status.cannotComplete(latestTask.status));
1100
+ }
1011
1101
  }
1012
- const forceNote = createNote(forceMessage, getAuthor(ctx.config?.identity?.author));
1013
- taskNotes = [...taskNotes, forceNote];
1014
- }
1015
- // Update status
1016
- const updatedTask = {
1017
- ...foundTask,
1018
- status: "completed",
1019
- completed_at: now,
1020
- closed_reason: options.reason || null,
1021
- started_at: foundTask.started_at || now,
1022
- notes: taskNotes,
1023
- };
1024
- await saveTask(ctx, updatedTask);
1102
+ // AC: @spec-completion-enforcement ac-7 - Document skip-review reason
1103
+ // AC: @spec-completion-enforcement ac-author
1104
+ let taskNotes = latestTask.notes;
1105
+ if (options.skipReview && options.reason) {
1106
+ const skipNote = createNote(`Completed with --skip-review: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
1107
+ taskNotes = [...taskNotes, skipNote];
1108
+ }
1109
+ // AC: @task-commands ac-1 - Document force completion from non-standard state
1110
+ forcedFromNonStandard =
1111
+ forcingCompletion &&
1112
+ latestTask.status !== "pending_review";
1113
+ if (forcedFromNonStandard) {
1114
+ forceStateDetail = `from ${latestTask.status} state`;
1115
+ if (latestTask.status === "blocked") {
1116
+ const blockedBy = latestTask.blocked_by.join("; ");
1117
+ forceStateDetail += `. Was blocked by: ${blockedBy || "(dependency-blocked)"}`;
1118
+ }
1119
+ let forceMessage = `Completed with --force ${forceStateDetail}`;
1120
+ if (options.reason) {
1121
+ forceMessage += `. Reason: ${options.reason}`;
1122
+ }
1123
+ const forceNote = createNote(forceMessage, getAuthor(ctx.config?.identity?.author));
1124
+ taskNotes = [...taskNotes, forceNote];
1125
+ }
1126
+ return {
1127
+ ...latestTask,
1128
+ status: "completed",
1129
+ completed_at: now,
1130
+ closed_reason: options.reason || null,
1131
+ started_at: latestTask.started_at || now,
1132
+ notes: taskNotes,
1133
+ };
1134
+ });
1025
1135
  await commitIfShadow(ctx.shadow, "task-complete", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), options.reason);
1136
+ // AC: @daemon-agent-dispatch ac-2, ac-7 - Notify daemon of state change (fire-and-forget)
1137
+ postDispatchEvent({
1138
+ taskId: updatedTask._ulid,
1139
+ taskRef: `@${updatedTask.slugs[0] || updatedTask._ulid}`,
1140
+ fromStatus: transitionFromStatus,
1141
+ toStatus: updatedTask.status,
1142
+ projectPath: ctx.rootDir,
1143
+ });
1026
1144
  // Sync spec implementation status (unless --no-sync)
1027
1145
  if (options.sync !== false && foundTask.spec_ref) {
1028
1146
  const updatedTasks = tasks.map((t) => t._ulid === updatedTask._ulid ? { ...t, ...updatedTask } : t);
@@ -1089,26 +1207,54 @@ Examples:
1089
1207
  });
1090
1208
  // kspec task submit <ref>
1091
1209
  // Transitions in_progress → pending_review (code done, awaiting merge)
1210
+ // AC: @task-submit ac-submit-1, ac-submit-2, ac-submit-3
1092
1211
  markMutating(task.command("submit <ref>"))
1093
1212
  .description("Submit task for review (transitions to pending_review)")
1094
- .action(async (ref) => {
1213
+ .option("--review-url <url>", "PR or review URL")
1214
+ .action(async (ref, options) => {
1095
1215
  try {
1216
+ // AC: @task-submit ac-submit-3 - Validate URL before any state change
1217
+ if (options.reviewUrl !== undefined) {
1218
+ try {
1219
+ new URL(options.reviewUrl);
1220
+ }
1221
+ catch {
1222
+ error(`Invalid review URL: ${options.reviewUrl}`);
1223
+ process.exit(EXIT_CODES.VALIDATION_FAILED);
1224
+ }
1225
+ }
1096
1226
  const ctx = await initContext();
1097
1227
  const tasks = await loadAllTasks(ctx);
1098
1228
  const items = await loadAllItems(ctx);
1099
1229
  const index = new ReferenceIndex(tasks, items);
1100
1230
  const foundTask = resolveTaskRef(ref, tasks, index);
1101
- if (foundTask.status !== "in_progress") {
1102
- error(`Cannot submit task with status: ${foundTask.status}. Task must be in_progress.`);
1231
+ let transitionFromStatus = foundTask.status;
1232
+ const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
1233
+ transitionFromStatus = latestTask.status;
1234
+ if (latestTask.status !== "in_progress") {
1235
+ return latestTask;
1236
+ }
1237
+ return {
1238
+ ...latestTask,
1239
+ status: "pending_review",
1240
+ submitted_at: new Date().toISOString(),
1241
+ // AC: @task-submit ac-submit-2
1242
+ ...(options.reviewUrl !== undefined && { review_url: options.reviewUrl }),
1243
+ };
1244
+ });
1245
+ if (transitionFromStatus !== "in_progress") {
1246
+ error(`Cannot submit task with status: ${transitionFromStatus}. Task must be in_progress.`);
1103
1247
  process.exit(EXIT_CODES.VALIDATION_FAILED);
1104
1248
  }
1105
- const updatedTask = {
1106
- ...foundTask,
1107
- status: "pending_review",
1108
- submitted_at: new Date().toISOString(),
1109
- };
1110
- await saveTask(ctx, updatedTask);
1111
1249
  await commitIfShadow(ctx.shadow, "task-submit", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1250
+ // AC: @daemon-agent-dispatch ac-2, ac-7 - Notify daemon of state change (fire-and-forget)
1251
+ postDispatchEvent({
1252
+ taskId: updatedTask._ulid,
1253
+ taskRef: `@${updatedTask.slugs[0] || updatedTask._ulid}`,
1254
+ fromStatus: transitionFromStatus,
1255
+ toStatus: updatedTask.status,
1256
+ projectPath: ctx.rootDir,
1257
+ });
1112
1258
  success(`Submitted task for review: ${index.shortUlid(updatedTask._ulid)}`, { task: updatedTask });
1113
1259
  }
1114
1260
  catch (err) {
@@ -1128,23 +1274,38 @@ Examples:
1128
1274
  const items = await loadAllItems(ctx);
1129
1275
  const index = new ReferenceIndex(tasks, items);
1130
1276
  const foundTask = resolveTaskRef(ref, tasks, index);
1131
- if (foundTask.status !== "pending_review") {
1132
- error(errors.status.cannotNeedsWork(foundTask.status));
1277
+ let transitionFromStatus = foundTask.status;
1278
+ let cycleNumber = 0;
1279
+ const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
1280
+ transitionFromStatus = latestTask.status;
1281
+ if (latestTask.status !== "pending_review") {
1282
+ return latestTask;
1283
+ }
1284
+ // Track fix cycle count from existing kickback notes
1285
+ const existingKickbacks = latestTask.notes.filter((note) => note.content.includes("[FIX_CYCLE:")).length;
1286
+ cycleNumber = existingKickbacks + 1;
1287
+ const note = createNote(`[FIX_CYCLE: ${cycleNumber}] Review findings: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
1288
+ // AC: @session-scoped-task-claiming ac-claim-clear
1289
+ return {
1290
+ ...latestTask,
1291
+ status: "needs_work",
1292
+ session_id: null,
1293
+ notes: [...latestTask.notes, note],
1294
+ };
1295
+ });
1296
+ if (transitionFromStatus !== "pending_review") {
1297
+ error(errors.status.cannotNeedsWork(transitionFromStatus));
1133
1298
  process.exit(EXIT_CODES.VALIDATION_FAILED);
1134
1299
  }
1135
- // Track fix cycle count from existing kickback notes
1136
- const existingKickbacks = foundTask.notes.filter((n) => n.content.includes("[FIX_CYCLE:")).length;
1137
- const cycleNumber = existingKickbacks + 1;
1138
- const note = createNote(`[FIX_CYCLE: ${cycleNumber}] Review findings: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
1139
- // AC: @session-scoped-task-claiming ac-claim-clear
1140
- const updatedTask = {
1141
- ...foundTask,
1142
- status: "needs_work",
1143
- session_id: null,
1144
- notes: [...foundTask.notes, note],
1145
- };
1146
- await saveTask(ctx, updatedTask);
1147
1300
  await commitIfShadow(ctx.shadow, "task-needs-work", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), `cycle ${cycleNumber}`);
1301
+ // AC: @daemon-agent-dispatch ac-2, ac-7 - Notify daemon of state change (fire-and-forget)
1302
+ postDispatchEvent({
1303
+ taskId: updatedTask._ulid,
1304
+ taskRef: `@${updatedTask.slugs[0] || updatedTask._ulid}`,
1305
+ fromStatus: transitionFromStatus,
1306
+ toStatus: updatedTask.status,
1307
+ projectPath: ctx.rootDir,
1308
+ });
1148
1309
  success(`Kicked back task: ${index.shortUlid(updatedTask._ulid)} (fix cycle ${cycleNumber})`, { task: updatedTask });
1149
1310
  }
1150
1311
  catch (err) {
@@ -1163,18 +1324,33 @@ Examples:
1163
1324
  const items = await loadAllItems(ctx);
1164
1325
  const index = new ReferenceIndex(tasks, items);
1165
1326
  const foundTask = resolveTaskRef(ref, tasks, index);
1166
- if (foundTask.status === "completed" ||
1167
- foundTask.status === "cancelled") {
1168
- error(errors.status.cannotBlock(foundTask.status));
1327
+ let transitionFromStatus = foundTask.status;
1328
+ const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
1329
+ transitionFromStatus = latestTask.status;
1330
+ if (latestTask.status === "completed" ||
1331
+ latestTask.status === "cancelled") {
1332
+ return latestTask;
1333
+ }
1334
+ return {
1335
+ ...latestTask,
1336
+ status: "blocked",
1337
+ blocked_by: [...latestTask.blocked_by, options.reason],
1338
+ };
1339
+ });
1340
+ if (transitionFromStatus === "completed" ||
1341
+ transitionFromStatus === "cancelled") {
1342
+ error(errors.status.cannotBlock(transitionFromStatus));
1169
1343
  process.exit(EXIT_CODES.VALIDATION_FAILED);
1170
1344
  }
1171
- const updatedTask = {
1172
- ...foundTask,
1173
- status: "blocked",
1174
- blocked_by: [...foundTask.blocked_by, options.reason],
1175
- };
1176
- await saveTask(ctx, updatedTask);
1177
1345
  await commitIfShadow(ctx.shadow, "task-block", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1346
+ // AC: @daemon-agent-dispatch ac-2, ac-7 - Notify daemon of state change (fire-and-forget)
1347
+ postDispatchEvent({
1348
+ taskId: updatedTask._ulid,
1349
+ taskRef: `@${updatedTask.slugs[0] || updatedTask._ulid}`,
1350
+ fromStatus: transitionFromStatus,
1351
+ toStatus: updatedTask.status,
1352
+ projectPath: ctx.rootDir,
1353
+ });
1178
1354
  success(`Blocked task: ${index.shortUlid(updatedTask._ulid)}`, {
1179
1355
  task: updatedTask,
1180
1356
  });
@@ -1194,19 +1370,33 @@ Examples:
1194
1370
  const items = await loadAllItems(ctx);
1195
1371
  const index = new ReferenceIndex(tasks, items);
1196
1372
  const foundTask = resolveTaskRef(ref, tasks, index);
1197
- if (foundTask.status !== "blocked") {
1373
+ let transitionFromStatus = foundTask.status;
1374
+ const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
1375
+ transitionFromStatus = latestTask.status;
1376
+ if (latestTask.status !== "blocked") {
1377
+ return latestTask;
1378
+ }
1379
+ // AC: @session-scoped-task-claiming ac-claim-clear
1380
+ return {
1381
+ ...latestTask,
1382
+ status: "pending",
1383
+ blocked_by: [],
1384
+ session_id: null,
1385
+ };
1386
+ });
1387
+ if (transitionFromStatus !== "blocked") {
1198
1388
  warn("Task is not blocked");
1199
1389
  return;
1200
1390
  }
1201
- // AC: @session-scoped-task-claiming ac-claim-clear
1202
- const updatedTask = {
1203
- ...foundTask,
1204
- status: "pending",
1205
- blocked_by: [],
1206
- session_id: null,
1207
- };
1208
- await saveTask(ctx, updatedTask);
1209
1391
  await commitIfShadow(ctx.shadow, "task-unblock", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1392
+ // AC: @daemon-agent-dispatch ac-2, ac-7 - Notify daemon of state change (fire-and-forget)
1393
+ postDispatchEvent({
1394
+ taskId: updatedTask._ulid,
1395
+ taskRef: `@${updatedTask.slugs[0] || updatedTask._ulid}`,
1396
+ fromStatus: transitionFromStatus,
1397
+ toStatus: updatedTask.status,
1398
+ projectPath: ctx.rootDir,
1399
+ });
1210
1400
  success(`Unblocked task: ${index.shortUlid(updatedTask._ulid)}`, {
1211
1401
  task: updatedTask,
1212
1402
  });
@@ -1291,54 +1481,60 @@ Examples:
1291
1481
  const items = await loadAllItems(ctx);
1292
1482
  const index = new ReferenceIndex(tasks, items);
1293
1483
  const foundTask = resolveTaskRef(ref, tasks, index);
1294
- // AC: @spec-task-reset ac-2 - Error if already pending
1295
- if (foundTask.status === "pending") {
1484
+ let previousStatus = foundTask.status;
1485
+ const clearedFields = [];
1486
+ const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
1487
+ previousStatus = latestTask.status;
1488
+ // AC: @spec-task-reset ac-2 - Error if already pending
1489
+ if (latestTask.status === "pending") {
1490
+ return latestTask;
1491
+ }
1492
+ // AC: @spec-task-reset ac-1 - Reset to pending, clear completion-related fields
1493
+ const nextTask = {
1494
+ ...latestTask,
1495
+ status: "pending",
1496
+ };
1497
+ clearedFields.splice(0, clearedFields.length);
1498
+ // Clear timestamps and reasons based on previous status
1499
+ if (latestTask.completed_at !== undefined &&
1500
+ latestTask.completed_at !== null) {
1501
+ nextTask.completed_at = null;
1502
+ clearedFields.push("completed_at");
1503
+ }
1504
+ if (latestTask.started_at !== undefined &&
1505
+ latestTask.started_at !== null) {
1506
+ nextTask.started_at = null;
1507
+ clearedFields.push("started_at");
1508
+ }
1509
+ if (latestTask.closed_reason !== undefined &&
1510
+ latestTask.closed_reason !== null) {
1511
+ nextTask.closed_reason = null;
1512
+ clearedFields.push("closed_reason");
1513
+ }
1514
+ if (latestTask.blocked_by.length > 0) {
1515
+ nextTask.blocked_by = [];
1516
+ clearedFields.push("blocked_by");
1517
+ }
1518
+ // AC: @session-scoped-task-claiming ac-claim-clear
1519
+ if (latestTask.session_id) {
1520
+ nextTask.session_id = null;
1521
+ clearedFields.push("session_id");
1522
+ }
1523
+ // AC: @spec-task-reset ac-4 - Add note documenting the reset
1524
+ // AC: @spec-task-reset ac-author
1525
+ const hadCancelReason = latestTask.closed_reason && latestTask.status === "cancelled";
1526
+ const cancelReasonText = hadCancelReason
1527
+ ? ` (was cancelled: ${latestTask.closed_reason})`
1528
+ : "";
1529
+ const noteContent = `Reset from ${latestTask.status} to pending${cancelReasonText}`;
1530
+ const note = createNote(noteContent, getAuthor(ctx.config?.identity?.author));
1531
+ nextTask.notes = [...nextTask.notes, note];
1532
+ return nextTask;
1533
+ });
1534
+ if (previousStatus === "pending") {
1296
1535
  error("Task is already pending");
1297
1536
  process.exit(EXIT_CODES.VALIDATION_FAILED);
1298
1537
  }
1299
- // Track previous status and reason for note (AC-4)
1300
- const previousStatus = foundTask.status;
1301
- const hadCancelReason = foundTask.closed_reason && foundTask.status === "cancelled";
1302
- const cancelReasonText = hadCancelReason
1303
- ? ` (was cancelled: ${foundTask.closed_reason})`
1304
- : "";
1305
- // AC: @spec-task-reset ac-1 - Reset to pending, clear completion-related fields
1306
- const clearedFields = [];
1307
- const updatedTask = {
1308
- ...foundTask,
1309
- status: "pending",
1310
- };
1311
- // Clear timestamps and reasons based on previous status
1312
- if (foundTask.completed_at !== undefined &&
1313
- foundTask.completed_at !== null) {
1314
- updatedTask.completed_at = null;
1315
- clearedFields.push("completed_at");
1316
- }
1317
- if (foundTask.started_at !== undefined &&
1318
- foundTask.started_at !== null) {
1319
- updatedTask.started_at = null;
1320
- clearedFields.push("started_at");
1321
- }
1322
- if (foundTask.closed_reason !== undefined &&
1323
- foundTask.closed_reason !== null) {
1324
- updatedTask.closed_reason = null;
1325
- clearedFields.push("closed_reason");
1326
- }
1327
- if (foundTask.blocked_by.length > 0) {
1328
- updatedTask.blocked_by = [];
1329
- clearedFields.push("blocked_by");
1330
- }
1331
- // AC: @session-scoped-task-claiming ac-claim-clear
1332
- if (foundTask.session_id) {
1333
- updatedTask.session_id = null;
1334
- clearedFields.push("session_id");
1335
- }
1336
- // AC: @spec-task-reset ac-4 - Add note documenting the reset
1337
- // AC: @spec-task-reset ac-author
1338
- const noteContent = `Reset from ${previousStatus} to pending${cancelReasonText}`;
1339
- const note = createNote(noteContent, getAuthor(ctx.config?.identity?.author));
1340
- updatedTask.notes = [...updatedTask.notes, note];
1341
- await saveTask(ctx, updatedTask);
1342
1538
  // AC: @spec-task-reset ac-3 - Shadow commit with message task-reset
1343
1539
  await commitIfShadow(ctx.shadow, "task-reset", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), `from ${previousStatus}`);
1344
1540
  // AC: @spec-task-reset ac-6 - JSON output includes previous_status, new_status, cleared_fields
@@ -1459,11 +1655,10 @@ Examples:
1459
1655
  const index = new ReferenceIndex(tasks, items);
1460
1656
  const foundTask = resolveTaskRef(ref, tasks, index);
1461
1657
  const note = createNote(message, options.author, options.supersedes);
1462
- const updatedTask = {
1463
- ...foundTask,
1464
- notes: [...foundTask.notes, note],
1465
- };
1466
- await saveTask(ctx, updatedTask);
1658
+ const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => ({
1659
+ ...latestTask,
1660
+ notes: [...latestTask.notes, note],
1661
+ }));
1467
1662
  await commitIfShadow(ctx.shadow, "task-note", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1468
1663
  success(`Added note to task: ${index.shortUlid(updatedTask._ulid)}`, {
1469
1664
  note,
@@ -1561,8 +1756,11 @@ Examples:
1561
1756
  possibleRefs.push(`@${specItem.slugs[0]} ${ac.id}`);
1562
1757
  possibleRefs.push(`@${specItem.slugs[0]}`);
1563
1758
  }
1564
- possibleRefs.push(`@${specItem._ulid.slice(0, 8)} ${ac.id}`);
1565
- possibleRefs.push(`@${specItem._ulid.slice(0, 8)}`);
1759
+ for (let prefixLength = 8; prefixLength <= specItem._ulid.length; prefixLength++) {
1760
+ const prefix = specItem._ulid.slice(0, prefixLength);
1761
+ possibleRefs.push(`@${prefix} ${ac.id}`);
1762
+ possibleRefs.push(`@${prefix}`);
1763
+ }
1566
1764
  const isCovered = possibleRefs.some((ref) => coveredACs.has(ref));
1567
1765
  if (isCovered) {
1568
1766
  covered.push(ac.id);
@@ -1701,16 +1899,18 @@ Examples:
1701
1899
  const items = await loadAllItems(ctx);
1702
1900
  const index = new ReferenceIndex(tasks, items);
1703
1901
  const foundTask = resolveTaskRef(ref, tasks, index);
1704
- // Calculate next ID (max existing + 1, or 1 if none)
1705
- const nextId = foundTask.todos.length > 0
1706
- ? Math.max(...foundTask.todos.map((t) => t.id)) + 1
1707
- : 1;
1708
- const todo = createTodo(nextId, text, options.author);
1709
- const updatedTask = {
1710
- ...foundTask,
1711
- todos: [...foundTask.todos, todo],
1712
- };
1713
- await saveTask(ctx, updatedTask);
1902
+ let todo = createTodo(1, text, options.author);
1903
+ const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
1904
+ // Calculate next ID (max existing + 1, or 1 if none)
1905
+ const nextId = latestTask.todos.length > 0
1906
+ ? Math.max(...latestTask.todos.map((entry) => entry.id)) + 1
1907
+ : 1;
1908
+ todo = createTodo(nextId, text, options.author);
1909
+ return {
1910
+ ...latestTask,
1911
+ todos: [...latestTask.todos, todo],
1912
+ };
1913
+ });
1714
1914
  await commitIfShadow(ctx.shadow, "task-note", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1715
1915
  success(`Added todo #${todo.id} to task: ${index.shortUlid(updatedTask._ulid)}`, { todo });
1716
1916
  }
@@ -1734,29 +1934,42 @@ Examples:
1734
1934
  error(errors.todo.invalidId(idStr));
1735
1935
  process.exit(EXIT_CODES.USAGE_ERROR);
1736
1936
  }
1737
- const todoIndex = foundTask.todos.findIndex((t) => t.id === id);
1738
- if (todoIndex === -1) {
1937
+ let todoState;
1938
+ let updatedTodo;
1939
+ await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
1940
+ const todoIndex = latestTask.todos.findIndex((todo) => todo.id === id);
1941
+ if (todoIndex === -1) {
1942
+ todoState = "not_found";
1943
+ return latestTask;
1944
+ }
1945
+ if (latestTask.todos[todoIndex].done) {
1946
+ todoState = "already_done";
1947
+ updatedTodo = latestTask.todos[todoIndex];
1948
+ return latestTask;
1949
+ }
1950
+ const updatedTodos = [...latestTask.todos];
1951
+ updatedTodos[todoIndex] = {
1952
+ ...updatedTodos[todoIndex],
1953
+ done: true,
1954
+ done_at: new Date().toISOString(),
1955
+ };
1956
+ updatedTodo = updatedTodos[todoIndex];
1957
+ return {
1958
+ ...latestTask,
1959
+ todos: updatedTodos,
1960
+ };
1961
+ });
1962
+ if (todoState === "not_found") {
1739
1963
  error(errors.todo.notFound(id));
1740
1964
  process.exit(EXIT_CODES.NOT_FOUND);
1741
1965
  }
1742
- if (foundTask.todos[todoIndex].done) {
1966
+ if (todoState === "already_done") {
1743
1967
  warn(`Todo #${id} is already done`);
1744
1968
  return;
1745
1969
  }
1746
- const updatedTodos = [...foundTask.todos];
1747
- updatedTodos[todoIndex] = {
1748
- ...updatedTodos[todoIndex],
1749
- done: true,
1750
- done_at: new Date().toISOString(),
1751
- };
1752
- const updatedTask = {
1753
- ...foundTask,
1754
- todos: updatedTodos,
1755
- };
1756
- await saveTask(ctx, updatedTask);
1757
1970
  await commitIfShadow(ctx.shadow, "task-note", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1758
1971
  success(`Marked todo #${id} as done`, {
1759
- todo: updatedTodos[todoIndex],
1972
+ todo: updatedTodo,
1760
1973
  });
1761
1974
  }
1762
1975
  catch (err) {
@@ -1779,29 +1992,42 @@ Examples:
1779
1992
  error(errors.todo.invalidId(idStr));
1780
1993
  process.exit(EXIT_CODES.USAGE_ERROR);
1781
1994
  }
1782
- const todoIndex = foundTask.todos.findIndex((t) => t.id === id);
1783
- if (todoIndex === -1) {
1995
+ let todoState;
1996
+ let updatedTodo;
1997
+ await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
1998
+ const todoIndex = latestTask.todos.findIndex((todo) => todo.id === id);
1999
+ if (todoIndex === -1) {
2000
+ todoState = "not_found";
2001
+ return latestTask;
2002
+ }
2003
+ if (!latestTask.todos[todoIndex].done) {
2004
+ todoState = "already_not_done";
2005
+ updatedTodo = latestTask.todos[todoIndex];
2006
+ return latestTask;
2007
+ }
2008
+ const updatedTodos = [...latestTask.todos];
2009
+ updatedTodos[todoIndex] = {
2010
+ ...updatedTodos[todoIndex],
2011
+ done: false,
2012
+ done_at: undefined,
2013
+ };
2014
+ updatedTodo = updatedTodos[todoIndex];
2015
+ return {
2016
+ ...latestTask,
2017
+ todos: updatedTodos,
2018
+ };
2019
+ });
2020
+ if (todoState === "not_found") {
1784
2021
  error(errors.todo.notFound(id));
1785
2022
  process.exit(EXIT_CODES.NOT_FOUND);
1786
2023
  }
1787
- if (!foundTask.todos[todoIndex].done) {
2024
+ if (todoState === "already_not_done") {
1788
2025
  warn(`Todo #${id} is not done`);
1789
2026
  return;
1790
2027
  }
1791
- const updatedTodos = [...foundTask.todos];
1792
- updatedTodos[todoIndex] = {
1793
- ...updatedTodos[todoIndex],
1794
- done: false,
1795
- done_at: undefined,
1796
- };
1797
- const updatedTask = {
1798
- ...foundTask,
1799
- todos: updatedTodos,
1800
- };
1801
- await saveTask(ctx, updatedTask);
1802
2028
  await commitIfShadow(ctx.shadow, "task-note", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1803
2029
  success(`Marked todo #${id} as not done`, {
1804
- todo: updatedTodos[todoIndex],
2030
+ todo: updatedTodo,
1805
2031
  });
1806
2032
  }
1807
2033
  catch (err) {