@kynetic-ai/spec 0.9.0 → 0.10.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 (291) hide show
  1. package/README.md +2 -1
  2. package/dist/acp/client.d.ts +13 -1
  3. package/dist/acp/client.d.ts.map +1 -1
  4. package/dist/acp/client.js +17 -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 +261 -0
  11. package/dist/agent-runtime/dispatch.d.ts.map +1 -0
  12. package/dist/agent-runtime/dispatch.js +791 -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 +183 -81
  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/agents.d.ts +1 -1
  41. package/dist/cli/commands/agents.d.ts.map +1 -1
  42. package/dist/cli/commands/agents.js +2 -1
  43. package/dist/cli/commands/agents.js.map +1 -1
  44. package/dist/cli/commands/batch.js +1 -1
  45. package/dist/cli/commands/batch.js.map +1 -1
  46. package/dist/cli/commands/inbox.d.ts.map +1 -1
  47. package/dist/cli/commands/inbox.js +46 -22
  48. package/dist/cli/commands/inbox.js.map +1 -1
  49. package/dist/cli/commands/index.d.ts +1 -0
  50. package/dist/cli/commands/index.d.ts.map +1 -1
  51. package/dist/cli/commands/index.js +1 -0
  52. package/dist/cli/commands/index.js.map +1 -1
  53. package/dist/cli/commands/init.d.ts.map +1 -1
  54. package/dist/cli/commands/init.js +4 -6
  55. package/dist/cli/commands/init.js.map +1 -1
  56. package/dist/cli/commands/item.d.ts.map +1 -1
  57. package/dist/cli/commands/item.js +34 -17
  58. package/dist/cli/commands/item.js.map +1 -1
  59. package/dist/cli/commands/log.js +1 -1
  60. package/dist/cli/commands/log.js.map +1 -1
  61. package/dist/cli/commands/merge-driver.d.ts.map +1 -1
  62. package/dist/cli/commands/merge-driver.js +8 -3
  63. package/dist/cli/commands/merge-driver.js.map +1 -1
  64. package/dist/cli/commands/meta.d.ts.map +1 -1
  65. package/dist/cli/commands/meta.js +159 -6
  66. package/dist/cli/commands/meta.js.map +1 -1
  67. package/dist/cli/commands/module.d.ts.map +1 -1
  68. package/dist/cli/commands/module.js +2 -1
  69. package/dist/cli/commands/module.js.map +1 -1
  70. package/dist/cli/commands/plan-import.js +19 -3
  71. package/dist/cli/commands/plan-import.js.map +1 -1
  72. package/dist/cli/commands/plan.d.ts.map +1 -1
  73. package/dist/cli/commands/plan.js +87 -43
  74. package/dist/cli/commands/plan.js.map +1 -1
  75. package/dist/cli/commands/ralph.d.ts +5 -51
  76. package/dist/cli/commands/ralph.d.ts.map +1 -1
  77. package/dist/cli/commands/ralph.js +52 -1462
  78. package/dist/cli/commands/ralph.js.map +1 -1
  79. package/dist/cli/commands/search.d.ts.map +1 -1
  80. package/dist/cli/commands/search.js +22 -13
  81. package/dist/cli/commands/search.js.map +1 -1
  82. package/dist/cli/commands/serve.d.ts.map +1 -1
  83. package/dist/cli/commands/serve.js +70 -11
  84. package/dist/cli/commands/serve.js.map +1 -1
  85. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -1
  86. package/dist/cli/commands/session/checkpoint.js +7 -2
  87. package/dist/cli/commands/session/checkpoint.js.map +1 -1
  88. package/dist/cli/commands/session/commands.d.ts.map +1 -1
  89. package/dist/cli/commands/session/commands.js +15 -0
  90. package/dist/cli/commands/session/commands.js.map +1 -1
  91. package/dist/cli/commands/session/context.d.ts.map +1 -1
  92. package/dist/cli/commands/session/context.js +10 -5
  93. package/dist/cli/commands/session/context.js.map +1 -1
  94. package/dist/cli/commands/session/log.d.ts +1 -0
  95. package/dist/cli/commands/session/log.d.ts.map +1 -1
  96. package/dist/cli/commands/session/log.js +124 -8
  97. package/dist/cli/commands/session/log.js.map +1 -1
  98. package/dist/cli/commands/session/stale-close.d.ts +17 -0
  99. package/dist/cli/commands/session/stale-close.d.ts.map +1 -0
  100. package/dist/cli/commands/session/stale-close.js +378 -0
  101. package/dist/cli/commands/session/stale-close.js.map +1 -0
  102. package/dist/cli/commands/setup.d.ts +4 -0
  103. package/dist/cli/commands/setup.d.ts.map +1 -1
  104. package/dist/cli/commands/setup.js +150 -6
  105. package/dist/cli/commands/setup.js.map +1 -1
  106. package/dist/cli/commands/skill-crud.d.ts.map +1 -1
  107. package/dist/cli/commands/skill-crud.js +4 -3
  108. package/dist/cli/commands/skill-crud.js.map +1 -1
  109. package/dist/cli/commands/skill-diff.d.ts.map +1 -1
  110. package/dist/cli/commands/skill-diff.js +15 -0
  111. package/dist/cli/commands/skill-diff.js.map +1 -1
  112. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  113. package/dist/cli/commands/skill-install.js +50 -18
  114. package/dist/cli/commands/skill-install.js.map +1 -1
  115. package/dist/cli/commands/task.d.ts.map +1 -1
  116. package/dist/cli/commands/task.js +552 -323
  117. package/dist/cli/commands/task.js.map +1 -1
  118. package/dist/cli/commands/tasks.js +1 -1
  119. package/dist/cli/commands/tasks.js.map +1 -1
  120. package/dist/cli/commands/triage.d.ts.map +1 -1
  121. package/dist/cli/commands/triage.js +37 -13
  122. package/dist/cli/commands/triage.js.map +1 -1
  123. package/dist/cli/commands/validate.d.ts.map +1 -1
  124. package/dist/cli/commands/validate.js +99 -50
  125. package/dist/cli/commands/validate.js.map +1 -1
  126. package/dist/cli/help/content.d.ts.map +1 -1
  127. package/dist/cli/help/content.js +5 -0
  128. package/dist/cli/help/content.js.map +1 -1
  129. package/dist/cli/index.d.ts.map +1 -1
  130. package/dist/cli/index.js +2 -1
  131. package/dist/cli/index.js.map +1 -1
  132. package/dist/cli/output.d.ts.map +1 -1
  133. package/dist/cli/output.js +5 -1
  134. package/dist/cli/output.js.map +1 -1
  135. package/dist/cli/validators.d.ts +4 -0
  136. package/dist/cli/validators.d.ts.map +1 -1
  137. package/dist/cli/validators.js +12 -0
  138. package/dist/cli/validators.js.map +1 -1
  139. package/dist/daemon/project-context.ts +22 -0
  140. package/dist/daemon/routes/agent-dispatch.ts +272 -0
  141. package/dist/daemon/server.ts +55 -20
  142. package/dist/daemon/websocket/handler.ts +67 -6
  143. package/dist/daemon/websocket/lifecycle.ts +19 -0
  144. package/dist/daemon/websocket/pubsub.ts +74 -3
  145. package/dist/export/html.d.ts.map +1 -1
  146. package/dist/export/html.js +5 -2
  147. package/dist/export/html.js.map +1 -1
  148. package/dist/export/triage.d.ts +1 -1
  149. package/dist/export/triage.d.ts.map +1 -1
  150. package/dist/export/triage.js +5 -3
  151. package/dist/export/triage.js.map +1 -1
  152. package/dist/parser/alignment.d.ts.map +1 -1
  153. package/dist/parser/alignment.js +6 -3
  154. package/dist/parser/alignment.js.map +1 -1
  155. package/dist/parser/assess.js +1 -1
  156. package/dist/parser/assess.js.map +1 -1
  157. package/dist/parser/config.d.ts +6 -6
  158. package/dist/parser/meta.d.ts.map +1 -1
  159. package/dist/parser/meta.js +9 -8
  160. package/dist/parser/meta.js.map +1 -1
  161. package/dist/parser/plan-document.d.ts +12 -12
  162. package/dist/parser/plans.d.ts +7 -0
  163. package/dist/parser/plans.d.ts.map +1 -1
  164. package/dist/parser/plans.js +100 -15
  165. package/dist/parser/plans.js.map +1 -1
  166. package/dist/parser/refs.d.ts +5 -0
  167. package/dist/parser/refs.d.ts.map +1 -1
  168. package/dist/parser/refs.js +17 -12
  169. package/dist/parser/refs.js.map +1 -1
  170. package/dist/parser/shadow.d.ts +1 -1
  171. package/dist/parser/shadow.d.ts.map +1 -1
  172. package/dist/parser/shadow.js +241 -76
  173. package/dist/parser/shadow.js.map +1 -1
  174. package/dist/parser/skill-render.d.ts.map +1 -1
  175. package/dist/parser/skill-render.js +6 -3
  176. package/dist/parser/skill-render.js.map +1 -1
  177. package/dist/parser/validate.d.ts.map +1 -1
  178. package/dist/parser/validate.js +70 -108
  179. package/dist/parser/validate.js.map +1 -1
  180. package/dist/parser/yaml.d.ts +24 -5
  181. package/dist/parser/yaml.d.ts.map +1 -1
  182. package/dist/parser/yaml.js +228 -66
  183. package/dist/parser/yaml.js.map +1 -1
  184. package/dist/schema/meta.d.ts +442 -119
  185. package/dist/schema/meta.d.ts.map +1 -1
  186. package/dist/schema/meta.js +55 -0
  187. package/dist/schema/meta.js.map +1 -1
  188. package/dist/schema/plan.d.ts +22 -22
  189. package/dist/schema/spec.d.ts +39 -39
  190. package/dist/schema/task.d.ts +43 -32
  191. package/dist/schema/task.d.ts.map +1 -1
  192. package/dist/schema/task.js +5 -0
  193. package/dist/schema/task.js.map +1 -1
  194. package/dist/sessions/store.d.ts +112 -0
  195. package/dist/sessions/store.d.ts.map +1 -1
  196. package/dist/sessions/store.js +414 -22
  197. package/dist/sessions/store.js.map +1 -1
  198. package/dist/sessions/types.d.ts +75 -17
  199. package/dist/sessions/types.d.ts.map +1 -1
  200. package/dist/sessions/types.js +51 -1
  201. package/dist/sessions/types.js.map +1 -1
  202. package/dist/triage/actions.d.ts +1 -0
  203. package/dist/triage/actions.d.ts.map +1 -1
  204. package/dist/triage/actions.js +34 -7
  205. package/dist/triage/actions.js.map +1 -1
  206. package/dist/utils/commit.js +1 -1
  207. package/dist/utils/commit.js.map +1 -1
  208. package/dist/web-ui/_app/env.js +1 -0
  209. package/dist/web-ui/_app/immutable/assets/0.BxCxvrZR.css +1 -0
  210. package/dist/web-ui/_app/immutable/assets/select-trigger.CV-KWLNP.css +1 -0
  211. package/dist/web-ui/_app/immutable/chunks/B-CZR0q8.js +1 -0
  212. package/dist/web-ui/_app/immutable/chunks/B1IR5Su5.js +1 -0
  213. package/dist/web-ui/_app/immutable/chunks/BCkp8Hs8.js +1 -0
  214. package/dist/web-ui/_app/immutable/chunks/B_Cvvtc4.js +1 -0
  215. package/dist/web-ui/_app/immutable/chunks/BtFaGGII.js +1 -0
  216. package/dist/web-ui/_app/immutable/chunks/Bu8JVsCH.js +1 -0
  217. package/dist/web-ui/_app/immutable/chunks/C87u-CNA.js +1 -0
  218. package/dist/web-ui/_app/immutable/chunks/CrFkBTYp.js +1 -0
  219. package/dist/web-ui/_app/immutable/chunks/D1ArdqNb.js +1 -0
  220. package/dist/web-ui/_app/immutable/chunks/D28BF5MJ.js +1 -0
  221. package/dist/web-ui/_app/immutable/chunks/D6RtLpzL.js +1 -0
  222. package/dist/web-ui/_app/immutable/chunks/D7FHSgx2.js +1 -0
  223. package/dist/web-ui/_app/immutable/chunks/DBXrsxZQ.js +2 -0
  224. package/dist/web-ui/_app/immutable/chunks/Da_hHMuA.js +1 -0
  225. package/dist/web-ui/_app/immutable/chunks/Do6LchSF.js +1 -0
  226. package/dist/web-ui/_app/immutable/chunks/DoNPtcAw.js +1 -0
  227. package/dist/web-ui/_app/immutable/chunks/DtUbXRZz.js +1 -0
  228. package/dist/web-ui/_app/immutable/chunks/DyFPRlLl.js +1 -0
  229. package/dist/web-ui/_app/immutable/chunks/DzAP8lRM.js +1 -0
  230. package/dist/web-ui/_app/immutable/chunks/DzVXElzN.js +2 -0
  231. package/dist/web-ui/_app/immutable/chunks/aoPBFken.js +1 -0
  232. package/dist/web-ui/_app/immutable/chunks/i-XnOIX0.js +1 -0
  233. package/dist/web-ui/_app/immutable/chunks/laxtrUO3.js +1 -0
  234. package/dist/web-ui/_app/immutable/chunks/q1nIWgqB.js +1 -0
  235. package/dist/web-ui/_app/immutable/chunks/sTLbk5Nm.js +1 -0
  236. package/dist/web-ui/_app/immutable/chunks/vwKgQu5P.js +5 -0
  237. package/dist/web-ui/_app/immutable/entry/app.BCwMcqnT.js +2 -0
  238. package/dist/web-ui/_app/immutable/entry/start.wKCQH-tt.js +1 -0
  239. package/dist/web-ui/_app/immutable/nodes/0.CjGVMG74.js +1 -0
  240. package/dist/web-ui/_app/immutable/nodes/1.B6_AIPan.js +1 -0
  241. package/dist/web-ui/_app/immutable/nodes/2.q4oCS7Ws.js +1 -0
  242. package/dist/web-ui/_app/immutable/nodes/3.rTKZf9o2.js +1 -0
  243. package/dist/web-ui/_app/immutable/nodes/4.DVIDRu1d.js +1 -0
  244. package/dist/web-ui/_app/immutable/nodes/5.8PtPXIOd.js +1 -0
  245. package/dist/web-ui/_app/immutable/nodes/6.ZZrTemy_.js +1 -0
  246. package/dist/web-ui/_app/immutable/nodes/7.IP-gxCxi.js +1 -0
  247. package/dist/web-ui/_app/version.json +1 -0
  248. package/dist/web-ui/index.html +36 -0
  249. package/dist/web-ui/robots.txt +3 -0
  250. package/package.json +3 -2
  251. package/plugin/.claude-plugin/marketplace.json +1 -1
  252. package/plugin/.claude-plugin/plugin.json +1 -1
  253. package/plugin/plugins/kspec/skills/create-workflow/SKILL.md +1 -1
  254. package/plugin/plugins/kspec/skills/{observations → observe}/SKILL.md +1 -1
  255. package/plugin/plugins/kspec/skills/plan/SKILL.md +1 -1
  256. package/plugin/plugins/kspec/skills/task-work/SKILL.md +26 -3
  257. package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +1 -1
  258. package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +1 -1
  259. package/templates/agents-sections/01-quick-start.md +1 -0
  260. package/templates/agents-sections/06-ralph-loop.md +64 -11
  261. package/templates/skills/create-workflow/SKILL.md +1 -1
  262. package/templates/skills/manifest.yaml +1 -1
  263. package/templates/skills/plan/SKILL.md +1 -1
  264. package/templates/skills/task-work/SKILL.md +26 -3
  265. package/templates/skills/triage-inbox/SKILL.md +1 -1
  266. package/templates/skills/writing-specs/SKILL.md +1 -1
  267. package/dist/ralph/cli-renderer.d.ts +0 -27
  268. package/dist/ralph/cli-renderer.d.ts.map +0 -1
  269. package/dist/ralph/cli-renderer.js +0 -250
  270. package/dist/ralph/cli-renderer.js.map +0 -1
  271. package/dist/ralph/events.d.ts +0 -65
  272. package/dist/ralph/events.d.ts.map +0 -1
  273. package/dist/ralph/events.js +0 -600
  274. package/dist/ralph/events.js.map +0 -1
  275. package/dist/ralph/index.d.ts +0 -11
  276. package/dist/ralph/index.d.ts.map +0 -1
  277. package/dist/ralph/index.js +0 -16
  278. package/dist/ralph/index.js.map +0 -1
  279. package/dist/ralph/loop-errors.d.ts +0 -83
  280. package/dist/ralph/loop-errors.d.ts.map +0 -1
  281. package/dist/ralph/loop-errors.js +0 -150
  282. package/dist/ralph/loop-errors.js.map +0 -1
  283. package/dist/ralph/subagent.d.ts +0 -96
  284. package/dist/ralph/subagent.d.ts.map +0 -1
  285. package/dist/ralph/subagent.js +0 -195
  286. package/dist/ralph/subagent.js.map +0 -1
  287. package/dist/ralph/wrap-up.d.ts +0 -127
  288. package/dist/ralph/wrap-up.d.ts.map +0 -1
  289. package/dist/ralph/wrap-up.js +0 -271
  290. package/dist/ralph/wrap-up.js.map +0 -1
  291. /package/templates/skills/{observations → observe}/SKILL.md +0 -0
@@ -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";
@@ -9,10 +9,59 @@ import { executeBatchOperation, formatBatchOutput } from "../batch.js";
9
9
  import { EXIT_CODES } from "../exit-codes.js";
10
10
  import { parseTagsArray } from "../parse-utils.js";
11
11
  import { annotateNotesWithSuperseded, error, formatTaskDetails, info, isJsonMode, output, success, warn, } from "../output.js";
12
- import { parseIntOption, validateEnumOption, validateSpecRef, } from "../validators.js";
12
+ import { parsePriority, validateEnumOption, validateSpecRef, } from "../validators.js";
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,36 +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
  }
198
- if (options.priority) {
199
- const priorityResult = parseIntOption(options.priority, {
200
- min: 1,
201
- max: 5,
202
- name: "Priority",
203
- });
204
- if (!priorityResult.ok) {
205
- return { success: false, error: priorityResult.error };
224
+ // Validate review URL if provided and not clearing
225
+ if (options.reviewUrl !== undefined && options.reviewUrl !== "null") {
226
+ try {
227
+ new URL(options.reviewUrl);
206
228
  }
207
- updatedTask.priority = priorityResult.value;
208
- changes.push("priority");
209
- }
210
- if (options.slug) {
211
- if (!updatedTask.slugs.includes(options.slug)) {
212
- updatedTask.slugs = [...updatedTask.slugs, options.slug];
213
- changes.push("slug");
229
+ catch {
230
+ return { success: false, error: `Invalid review URL: ${options.reviewUrl}` };
214
231
  }
215
232
  }
216
- if (options.tag) {
217
- const parsedTags = parseTagsArray(options.tag);
218
- const newTags = parsedTags.filter((t) => !updatedTask.tags.includes(t));
219
- if (newTags.length > 0) {
220
- updatedTask.tags = [...updatedTask.tags, ...newTags];
221
- changes.push("tags");
233
+ let parsedPriority;
234
+ if (options.priority) {
235
+ const priorityResult = parsePriority(options.priority);
236
+ if (!priorityResult.ok) {
237
+ return { success: false, error: priorityResult.error };
222
238
  }
239
+ parsedPriority = priorityResult.value;
223
240
  }
241
+ const parsedTags = options.tag ? parseTagsArray(options.tag) : [];
224
242
  if (options.dependsOn) {
225
243
  // Validate all dependency refs
226
244
  for (const depRef of options.dependsOn) {
@@ -240,34 +258,12 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
240
258
  };
241
259
  }
242
260
  }
243
- updatedTask.depends_on = options.dependsOn.map(normalizeRefInput);
244
- changes.push("depends_on");
245
- }
246
- // AC: @spec-task-clear-deps ac-1, ac-2 - Clear all dependencies
247
- if (options.clearDeps) {
248
- if (foundTask.depends_on.length === 0) {
249
- // AC: @spec-task-clear-deps ac-2 - No changes needed
250
- return {
251
- success: true,
252
- message: "No changes: task has no dependencies to clear",
253
- };
254
- }
255
- updatedTask.depends_on = [];
256
- changes.push("depends_on");
257
- // Add note documenting the change
258
- // AC: @task-set ac-author
259
- const note = createNote(`Dependencies cleared (was: ${foundTask.depends_on.join(", ")})`, getAuthor(ctx.config?.identity?.author));
260
- updatedTask.notes = [...updatedTask.notes, note];
261
261
  }
262
262
  // AC: @task-automation-eligibility ac-5, ac-11, ac-12, ac-18
263
263
  // Handle automation status changes
264
264
  // Note: --no-automation sets options.automation to false, so check that first
265
- if (options.automation === false) {
266
- // --no-automation flag clears the automation status (AC: ac-12)
267
- delete updatedTask.automation;
268
- changes.push("automation");
269
- }
270
- else if (options.automation !== undefined) {
265
+ let validatedAutomation;
266
+ if (options.automation !== undefined && options.automation !== false) {
271
267
  const automationResult = validateEnumOption(options.automation, ["eligible", "needs_review", "manual_only"], "automation status");
272
268
  if (!automationResult.ok) {
273
269
  return { success: false, error: automationResult.error };
@@ -279,15 +275,119 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
279
275
  error: "Setting automation to needs_review requires --reason flag explaining why",
280
276
  };
281
277
  }
282
- updatedTask.automation = automationResult.value;
283
- changes.push("automation");
284
- // If reason provided, add a note documenting the change
285
- // AC: @task-set ac-author
286
- if (options.reason) {
287
- const note = createNote(`Automation status set to ${automationResult.value}: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
288
- updatedTask.notes = [...updatedTask.notes, note];
289
- 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
+ }
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
+ }
290
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
+ };
291
391
  }
292
392
  // AC: @spec-task-set-batch ac-4 - Warn on no changes, don't fail
293
393
  if (changes.length === 0) {
@@ -297,7 +397,6 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
297
397
  data: { task: updatedTask },
298
398
  };
299
399
  }
300
- await saveTask(ctx, updatedTask);
301
400
  await commitIfShadow(ctx.shadow, "task-set", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), changes.join(", "));
302
401
  return {
303
402
  success: true,
@@ -447,7 +546,7 @@ export function registerTaskCommands(program) {
447
546
  .option("--spec-ref <ref>", "Reference to spec item")
448
547
  .option("--meta-ref <ref>", "Reference to meta item (workflow, agent, or convention)")
449
548
  .option("--plan-ref <ref>", "Reference to plan this task is derived from")
450
- .option("--priority <n>", "Priority (1-5)", "3")
549
+ .option("--priority <n>", "Priority (1-5 or P1-P5)", "3")
451
550
  .option("--slug <slug>", "Human-friendly slug")
452
551
  .option("--tag <tag...>", "Tags")
453
552
  .option("--depends-on <refs...>", "Set task dependencies")
@@ -555,11 +654,7 @@ Examples:
555
654
  }
556
655
  }
557
656
  // Validate priority
558
- const priorityResult = parseIntOption(options.priority, {
559
- min: 1,
560
- max: 5,
561
- name: "Priority",
562
- });
657
+ const priorityResult = parsePriority(options.priority);
563
658
  if (!priorityResult.ok) {
564
659
  error(priorityResult.error);
565
660
  process.exit(EXIT_CODES.VALIDATION_FAILED);
@@ -600,10 +695,12 @@ Examples:
600
695
  .description("Update task fields")
601
696
  .option("--refs <refs...>", "Update multiple tasks (AC: @spec-task-set-batch ac-1)")
602
697
  .option("--title <title>", "Update task title")
698
+ .option("--description <description>", "Update task description (use 'null' to clear)")
603
699
  .option("--spec-ref <ref>", "Link to spec item (use 'null' to clear)")
604
700
  .option("--meta-ref <ref>", "Link to meta item (use 'null' to clear)")
605
701
  .option("--plan-ref <ref>", "Link to plan (use 'null' to clear)")
606
- .option("--priority <n>", "Set priority (1-5)")
702
+ .option("--review-url <url>", "Set review URL (use 'null' to clear)")
703
+ .option("--priority <n>", "Set priority (1-5 or P1-P5)")
607
704
  .option("--slug <slug>", "Add a slug alias")
608
705
  .option("--tag <tag...>", "Add tags")
609
706
  .option("--depends-on <refs...>", "Set dependencies (replaces existing)")
@@ -615,6 +712,7 @@ Examples:
615
712
  .addHelpText("after", `
616
713
  Examples:
617
714
  $ kspec task set @task-slug --priority 2
715
+ $ kspec task set @task-slug --description "Updated context"
618
716
  $ kspec task set @task-slug --depends-on @dep1 @dep2
619
717
  $ kspec task set @task-slug --tag cli urgent
620
718
  $ kspec task set --refs @task1 @task2 --priority 3`)
@@ -728,12 +826,23 @@ Examples:
728
826
  jsonData = options.data;
729
827
  }
730
828
  else {
829
+ const isTTY = process.env.KSPEC_TEST_TTY === "1" ||
830
+ process.env.KSPEC_TEST_TTY === "true" ||
831
+ process.stdin.isTTY;
832
+ if (isTTY) {
833
+ error(errors.validation.noPatchData);
834
+ process.exit(EXIT_CODES.VALIDATION_FAILED);
835
+ }
731
836
  // Read from stdin
732
837
  const chunks = [];
733
838
  for await (const chunk of process.stdin) {
734
839
  chunks.push(chunk);
735
840
  }
736
841
  jsonData = Buffer.concat(chunks).toString("utf-8");
842
+ if (!jsonData.trim()) {
843
+ error(errors.validation.noPatchData);
844
+ process.exit(EXIT_CODES.VALIDATION_FAILED);
845
+ }
737
846
  }
738
847
  // Parse JSON
739
848
  let patchData;
@@ -768,20 +877,22 @@ Examples:
768
877
  process.exit(EXIT_CODES.ERROR);
769
878
  }
770
879
  }
771
- // Build updated task
772
- const updatedTask = { ...foundTask, ...validatedPatch };
773
880
  // Track changes for output
774
881
  const changes = Object.keys(validatedPatch);
775
882
  if (options.dryRun) {
883
+ const dryRunTask = { ...foundTask, ...validatedPatch };
776
884
  info("Dry run - no changes will be written");
777
885
  info(`Would update: ${changes.join(", ")}`);
778
- output({ changes, updated: updatedTask }, () => {
886
+ output({ changes, updated: dryRunTask }, () => {
779
887
  console.log(`\nChanges: ${changes.join(", ")}\n`);
780
- return formatTaskDetails(updatedTask, index);
888
+ return formatTaskDetails(dryRunTask, index);
781
889
  });
782
890
  return;
783
891
  }
784
- await saveTask(ctx, updatedTask);
892
+ const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => ({
893
+ ...latestTask,
894
+ ...validatedPatch,
895
+ }));
785
896
  await commitIfShadow(ctx.shadow, "task-patch", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), changes.join(", "));
786
897
  success(`Patched task: ${index.shortUlid(updatedTask._ulid)} (${changes.join(", ")})`, { task: updatedTask });
787
898
  }
@@ -827,30 +938,47 @@ Examples:
827
938
  process.exit(EXIT_CODES.VALIDATION_FAILED);
828
939
  }
829
940
  }
830
- // AC: @task-budget-enforcement ac-block-start, ac-no-session, ac-no-budget
831
- // Check budget before starting — only when session is set
832
- const budgetCheck = await checkBudget(ctx.specDir, sessionId || undefined);
833
- if (!budgetCheck.allowed) {
834
- error(budgetCheck.reason);
835
- 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
+ }
836
952
  }
837
953
  // Update status
838
954
  // AC: @session-scoped-task-claiming ac-stamp, ac-no-env
839
- const updatedTask = {
840
- ...foundTask,
841
- status: "in_progress",
842
- started_at: new Date().toISOString(),
843
- ...(sessionId ? { session_id: sessionId } : {}),
844
- };
845
- 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
+ });
846
965
  await commitIfShadow(ctx.shadow, "task-start", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
847
- // AC: @task-budget-enforcement ac-increment, ac-resume-no-increment
848
- // Increment budget counter after successful start.
849
- // Resume case (already in_progress) returns early above, so this only
850
- // runs for genuine pending→in_progress or needs_work→in_progress transitions.
851
- 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") {
852
972
  await incrementBudget(ctx.specDir, sessionId);
853
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
+ });
854
982
  success(`Started task: ${index.shortUlid(updatedTask._ulid)}`, {
855
983
  task: updatedTask,
856
984
  });
@@ -937,89 +1065,82 @@ Examples:
937
1065
  },
938
1066
  executeOperation: async (foundTask, { ctx, tasks, items, index, options }) => {
939
1067
  try {
940
- // AC: @spec-completion-enforcement ac-6
941
- if (foundTask.status === "completed") {
942
- return {
943
- success: false,
944
- error: errors.status.completeAlreadyCompleted,
945
- };
946
- }
947
- // AC: @task-commands ac-1 - Allow --force to bypass all state checks
948
1068
  const forcingCompletion = options.force;
949
- // AC: @spec-completion-enforcement ac-7 - Allow skip-review bypass
950
- if (!options.skipReview && !forcingCompletion) {
951
- // AC: @spec-completion-enforcement ac-2
952
- if (foundTask.status === "in_progress") {
953
- return {
954
- success: false,
955
- error: errors.status.completeRequiresReview,
956
- };
957
- }
958
- // AC: @spec-completion-enforcement ac-3
959
- if (foundTask.status === "pending") {
960
- return {
961
- success: false,
962
- error: errors.status.completeRequiresStart,
963
- };
964
- }
965
- // AC: @spec-completion-enforcement ac-4
966
- if (foundTask.status === "blocked") {
967
- return {
968
- success: false,
969
- error: errors.status.completeBlockedTask,
970
- };
971
- }
972
- // AC: @spec-completion-enforcement ac-5
973
- if (foundTask.status === "cancelled") {
974
- return {
975
- success: false,
976
- error: errors.status.completeCancelledTask,
977
- };
978
- }
979
- // AC: @spec-completion-enforcement ac-1 - Only pending_review allowed
980
- if (foundTask.status !== "pending_review") {
981
- return {
982
- success: false,
983
- error: errors.status.cannotComplete(foundTask.status),
984
- };
985
- }
986
- }
987
1069
  const now = new Date().toISOString();
988
- // AC: @spec-completion-enforcement ac-7 - Document skip-review reason
989
- // AC: @spec-completion-enforcement ac-author
990
- let taskNotes = foundTask.notes;
991
- if (options.skipReview && options.reason) {
992
- const skipNote = createNote(`Completed with --skip-review: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
993
- taskNotes = [...taskNotes, skipNote];
994
- }
995
- // AC: @task-commands ac-1 - Document force completion from non-standard state
996
- const forcedFromNonStandard = forcingCompletion &&
997
- foundTask.status !== "pending_review";
1070
+ let transitionFromStatus = foundTask.status;
1071
+ let forcedFromNonStandard = false;
998
1072
  let forceStateDetail;
999
- if (forcedFromNonStandard) {
1000
- forceStateDetail = `from ${foundTask.status} state`;
1001
- if (foundTask.status === "blocked") {
1002
- const blockedBy = foundTask.blocked_by.join("; ");
1003
- 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);
1004
1078
  }
1005
- let forceMessage = `Completed with --force ${forceStateDetail}`;
1006
- if (options.reason) {
1007
- 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
+ }
1008
1101
  }
1009
- const forceNote = createNote(forceMessage, getAuthor(ctx.config?.identity?.author));
1010
- taskNotes = [...taskNotes, forceNote];
1011
- }
1012
- // Update status
1013
- const updatedTask = {
1014
- ...foundTask,
1015
- status: "completed",
1016
- completed_at: now,
1017
- closed_reason: options.reason || null,
1018
- started_at: foundTask.started_at || now,
1019
- notes: taskNotes,
1020
- };
1021
- 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
+ });
1022
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
+ });
1023
1144
  // Sync spec implementation status (unless --no-sync)
1024
1145
  if (options.sync !== false && foundTask.spec_ref) {
1025
1146
  const updatedTasks = tasks.map((t) => t._ulid === updatedTask._ulid ? { ...t, ...updatedTask } : t);
@@ -1086,26 +1207,54 @@ Examples:
1086
1207
  });
1087
1208
  // kspec task submit <ref>
1088
1209
  // Transitions in_progress → pending_review (code done, awaiting merge)
1210
+ // AC: @task-submit ac-submit-1, ac-submit-2, ac-submit-3
1089
1211
  markMutating(task.command("submit <ref>"))
1090
1212
  .description("Submit task for review (transitions to pending_review)")
1091
- .action(async (ref) => {
1213
+ .option("--review-url <url>", "PR or review URL")
1214
+ .action(async (ref, options) => {
1092
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
+ }
1093
1226
  const ctx = await initContext();
1094
1227
  const tasks = await loadAllTasks(ctx);
1095
1228
  const items = await loadAllItems(ctx);
1096
1229
  const index = new ReferenceIndex(tasks, items);
1097
1230
  const foundTask = resolveTaskRef(ref, tasks, index);
1098
- if (foundTask.status !== "in_progress") {
1099
- 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.`);
1100
1247
  process.exit(EXIT_CODES.VALIDATION_FAILED);
1101
1248
  }
1102
- const updatedTask = {
1103
- ...foundTask,
1104
- status: "pending_review",
1105
- submitted_at: new Date().toISOString(),
1106
- };
1107
- await saveTask(ctx, updatedTask);
1108
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
+ });
1109
1258
  success(`Submitted task for review: ${index.shortUlid(updatedTask._ulid)}`, { task: updatedTask });
1110
1259
  }
1111
1260
  catch (err) {
@@ -1125,23 +1274,38 @@ Examples:
1125
1274
  const items = await loadAllItems(ctx);
1126
1275
  const index = new ReferenceIndex(tasks, items);
1127
1276
  const foundTask = resolveTaskRef(ref, tasks, index);
1128
- if (foundTask.status !== "pending_review") {
1129
- 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));
1130
1298
  process.exit(EXIT_CODES.VALIDATION_FAILED);
1131
1299
  }
1132
- // Track fix cycle count from existing kickback notes
1133
- const existingKickbacks = foundTask.notes.filter((n) => n.content.includes("[FIX_CYCLE:")).length;
1134
- const cycleNumber = existingKickbacks + 1;
1135
- const note = createNote(`[FIX_CYCLE: ${cycleNumber}] Review findings: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
1136
- // AC: @session-scoped-task-claiming ac-claim-clear
1137
- const updatedTask = {
1138
- ...foundTask,
1139
- status: "needs_work",
1140
- session_id: null,
1141
- notes: [...foundTask.notes, note],
1142
- };
1143
- await saveTask(ctx, updatedTask);
1144
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
+ });
1145
1309
  success(`Kicked back task: ${index.shortUlid(updatedTask._ulid)} (fix cycle ${cycleNumber})`, { task: updatedTask });
1146
1310
  }
1147
1311
  catch (err) {
@@ -1160,18 +1324,33 @@ Examples:
1160
1324
  const items = await loadAllItems(ctx);
1161
1325
  const index = new ReferenceIndex(tasks, items);
1162
1326
  const foundTask = resolveTaskRef(ref, tasks, index);
1163
- if (foundTask.status === "completed" ||
1164
- foundTask.status === "cancelled") {
1165
- 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));
1166
1343
  process.exit(EXIT_CODES.VALIDATION_FAILED);
1167
1344
  }
1168
- const updatedTask = {
1169
- ...foundTask,
1170
- status: "blocked",
1171
- blocked_by: [...foundTask.blocked_by, options.reason],
1172
- };
1173
- await saveTask(ctx, updatedTask);
1174
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
+ });
1175
1354
  success(`Blocked task: ${index.shortUlid(updatedTask._ulid)}`, {
1176
1355
  task: updatedTask,
1177
1356
  });
@@ -1191,19 +1370,33 @@ Examples:
1191
1370
  const items = await loadAllItems(ctx);
1192
1371
  const index = new ReferenceIndex(tasks, items);
1193
1372
  const foundTask = resolveTaskRef(ref, tasks, index);
1194
- 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") {
1195
1388
  warn("Task is not blocked");
1196
1389
  return;
1197
1390
  }
1198
- // AC: @session-scoped-task-claiming ac-claim-clear
1199
- const updatedTask = {
1200
- ...foundTask,
1201
- status: "pending",
1202
- blocked_by: [],
1203
- session_id: null,
1204
- };
1205
- await saveTask(ctx, updatedTask);
1206
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
+ });
1207
1400
  success(`Unblocked task: ${index.shortUlid(updatedTask._ulid)}`, {
1208
1401
  task: updatedTask,
1209
1402
  });
@@ -1288,54 +1481,60 @@ Examples:
1288
1481
  const items = await loadAllItems(ctx);
1289
1482
  const index = new ReferenceIndex(tasks, items);
1290
1483
  const foundTask = resolveTaskRef(ref, tasks, index);
1291
- // AC: @spec-task-reset ac-2 - Error if already pending
1292
- 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") {
1293
1535
  error("Task is already pending");
1294
1536
  process.exit(EXIT_CODES.VALIDATION_FAILED);
1295
1537
  }
1296
- // Track previous status and reason for note (AC-4)
1297
- const previousStatus = foundTask.status;
1298
- const hadCancelReason = foundTask.closed_reason && foundTask.status === "cancelled";
1299
- const cancelReasonText = hadCancelReason
1300
- ? ` (was cancelled: ${foundTask.closed_reason})`
1301
- : "";
1302
- // AC: @spec-task-reset ac-1 - Reset to pending, clear completion-related fields
1303
- const clearedFields = [];
1304
- const updatedTask = {
1305
- ...foundTask,
1306
- status: "pending",
1307
- };
1308
- // Clear timestamps and reasons based on previous status
1309
- if (foundTask.completed_at !== undefined &&
1310
- foundTask.completed_at !== null) {
1311
- updatedTask.completed_at = null;
1312
- clearedFields.push("completed_at");
1313
- }
1314
- if (foundTask.started_at !== undefined &&
1315
- foundTask.started_at !== null) {
1316
- updatedTask.started_at = null;
1317
- clearedFields.push("started_at");
1318
- }
1319
- if (foundTask.closed_reason !== undefined &&
1320
- foundTask.closed_reason !== null) {
1321
- updatedTask.closed_reason = null;
1322
- clearedFields.push("closed_reason");
1323
- }
1324
- if (foundTask.blocked_by.length > 0) {
1325
- updatedTask.blocked_by = [];
1326
- clearedFields.push("blocked_by");
1327
- }
1328
- // AC: @session-scoped-task-claiming ac-claim-clear
1329
- if (foundTask.session_id) {
1330
- updatedTask.session_id = null;
1331
- clearedFields.push("session_id");
1332
- }
1333
- // AC: @spec-task-reset ac-4 - Add note documenting the reset
1334
- // AC: @spec-task-reset ac-author
1335
- const noteContent = `Reset from ${previousStatus} to pending${cancelReasonText}`;
1336
- const note = createNote(noteContent, getAuthor(ctx.config?.identity?.author));
1337
- updatedTask.notes = [...updatedTask.notes, note];
1338
- await saveTask(ctx, updatedTask);
1339
1538
  // AC: @spec-task-reset ac-3 - Shadow commit with message task-reset
1340
1539
  await commitIfShadow(ctx.shadow, "task-reset", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), `from ${previousStatus}`);
1341
1540
  // AC: @spec-task-reset ac-6 - JSON output includes previous_status, new_status, cleared_fields
@@ -1456,11 +1655,10 @@ Examples:
1456
1655
  const index = new ReferenceIndex(tasks, items);
1457
1656
  const foundTask = resolveTaskRef(ref, tasks, index);
1458
1657
  const note = createNote(message, options.author, options.supersedes);
1459
- const updatedTask = {
1460
- ...foundTask,
1461
- notes: [...foundTask.notes, note],
1462
- };
1463
- await saveTask(ctx, updatedTask);
1658
+ const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => ({
1659
+ ...latestTask,
1660
+ notes: [...latestTask.notes, note],
1661
+ }));
1464
1662
  await commitIfShadow(ctx.shadow, "task-note", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1465
1663
  success(`Added note to task: ${index.shortUlid(updatedTask._ulid)}`, {
1466
1664
  note,
@@ -1558,8 +1756,11 @@ Examples:
1558
1756
  possibleRefs.push(`@${specItem.slugs[0]} ${ac.id}`);
1559
1757
  possibleRefs.push(`@${specItem.slugs[0]}`);
1560
1758
  }
1561
- possibleRefs.push(`@${specItem._ulid.slice(0, 8)} ${ac.id}`);
1562
- 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
+ }
1563
1764
  const isCovered = possibleRefs.some((ref) => coveredACs.has(ref));
1564
1765
  if (isCovered) {
1565
1766
  covered.push(ac.id);
@@ -1698,16 +1899,18 @@ Examples:
1698
1899
  const items = await loadAllItems(ctx);
1699
1900
  const index = new ReferenceIndex(tasks, items);
1700
1901
  const foundTask = resolveTaskRef(ref, tasks, index);
1701
- // Calculate next ID (max existing + 1, or 1 if none)
1702
- const nextId = foundTask.todos.length > 0
1703
- ? Math.max(...foundTask.todos.map((t) => t.id)) + 1
1704
- : 1;
1705
- const todo = createTodo(nextId, text, options.author);
1706
- const updatedTask = {
1707
- ...foundTask,
1708
- todos: [...foundTask.todos, todo],
1709
- };
1710
- 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
+ });
1711
1914
  await commitIfShadow(ctx.shadow, "task-note", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1712
1915
  success(`Added todo #${todo.id} to task: ${index.shortUlid(updatedTask._ulid)}`, { todo });
1713
1916
  }
@@ -1731,29 +1934,42 @@ Examples:
1731
1934
  error(errors.todo.invalidId(idStr));
1732
1935
  process.exit(EXIT_CODES.USAGE_ERROR);
1733
1936
  }
1734
- const todoIndex = foundTask.todos.findIndex((t) => t.id === id);
1735
- 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") {
1736
1963
  error(errors.todo.notFound(id));
1737
1964
  process.exit(EXIT_CODES.NOT_FOUND);
1738
1965
  }
1739
- if (foundTask.todos[todoIndex].done) {
1966
+ if (todoState === "already_done") {
1740
1967
  warn(`Todo #${id} is already done`);
1741
1968
  return;
1742
1969
  }
1743
- const updatedTodos = [...foundTask.todos];
1744
- updatedTodos[todoIndex] = {
1745
- ...updatedTodos[todoIndex],
1746
- done: true,
1747
- done_at: new Date().toISOString(),
1748
- };
1749
- const updatedTask = {
1750
- ...foundTask,
1751
- todos: updatedTodos,
1752
- };
1753
- await saveTask(ctx, updatedTask);
1754
1970
  await commitIfShadow(ctx.shadow, "task-note", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1755
1971
  success(`Marked todo #${id} as done`, {
1756
- todo: updatedTodos[todoIndex],
1972
+ todo: updatedTodo,
1757
1973
  });
1758
1974
  }
1759
1975
  catch (err) {
@@ -1776,29 +1992,42 @@ Examples:
1776
1992
  error(errors.todo.invalidId(idStr));
1777
1993
  process.exit(EXIT_CODES.USAGE_ERROR);
1778
1994
  }
1779
- const todoIndex = foundTask.todos.findIndex((t) => t.id === id);
1780
- 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") {
1781
2021
  error(errors.todo.notFound(id));
1782
2022
  process.exit(EXIT_CODES.NOT_FOUND);
1783
2023
  }
1784
- if (!foundTask.todos[todoIndex].done) {
2024
+ if (todoState === "already_not_done") {
1785
2025
  warn(`Todo #${id} is not done`);
1786
2026
  return;
1787
2027
  }
1788
- const updatedTodos = [...foundTask.todos];
1789
- updatedTodos[todoIndex] = {
1790
- ...updatedTodos[todoIndex],
1791
- done: false,
1792
- done_at: undefined,
1793
- };
1794
- const updatedTask = {
1795
- ...foundTask,
1796
- todos: updatedTodos,
1797
- };
1798
- await saveTask(ctx, updatedTask);
1799
2028
  await commitIfShadow(ctx.shadow, "task-note", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1800
2029
  success(`Marked todo #${id} as not done`, {
1801
- todo: updatedTodos[todoIndex],
2030
+ todo: updatedTodo,
1802
2031
  });
1803
2032
  }
1804
2033
  catch (err) {