@kynetic-ai/spec 0.1.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 (278) hide show
  1. package/README.md +263 -0
  2. package/dist/acp/client.d.ts +159 -0
  3. package/dist/acp/client.d.ts.map +1 -0
  4. package/dist/acp/client.js +255 -0
  5. package/dist/acp/client.js.map +1 -0
  6. package/dist/acp/framing.d.ts +119 -0
  7. package/dist/acp/framing.d.ts.map +1 -0
  8. package/dist/acp/framing.js +302 -0
  9. package/dist/acp/framing.js.map +1 -0
  10. package/dist/acp/index.d.ts +14 -0
  11. package/dist/acp/index.d.ts.map +1 -0
  12. package/dist/acp/index.js +13 -0
  13. package/dist/acp/index.js.map +1 -0
  14. package/dist/acp/types.d.ts +89 -0
  15. package/dist/acp/types.d.ts.map +1 -0
  16. package/dist/acp/types.js +99 -0
  17. package/dist/acp/types.js.map +1 -0
  18. package/dist/agents/adapters.d.ts +55 -0
  19. package/dist/agents/adapters.d.ts.map +1 -0
  20. package/dist/agents/adapters.js +84 -0
  21. package/dist/agents/adapters.js.map +1 -0
  22. package/dist/agents/index.d.ts +8 -0
  23. package/dist/agents/index.d.ts.map +1 -0
  24. package/dist/agents/index.js +10 -0
  25. package/dist/agents/index.js.map +1 -0
  26. package/dist/agents/spawner.d.ts +53 -0
  27. package/dist/agents/spawner.d.ts.map +1 -0
  28. package/dist/agents/spawner.js +83 -0
  29. package/dist/agents/spawner.js.map +1 -0
  30. package/dist/cli/batch.d.ts +82 -0
  31. package/dist/cli/batch.d.ts.map +1 -0
  32. package/dist/cli/batch.js +162 -0
  33. package/dist/cli/batch.js.map +1 -0
  34. package/dist/cli/commands/clone-for-testing.d.ts +6 -0
  35. package/dist/cli/commands/clone-for-testing.d.ts.map +1 -0
  36. package/dist/cli/commands/clone-for-testing.js +176 -0
  37. package/dist/cli/commands/clone-for-testing.js.map +1 -0
  38. package/dist/cli/commands/derive.d.ts +6 -0
  39. package/dist/cli/commands/derive.d.ts.map +1 -0
  40. package/dist/cli/commands/derive.js +450 -0
  41. package/dist/cli/commands/derive.js.map +1 -0
  42. package/dist/cli/commands/help.d.ts +6 -0
  43. package/dist/cli/commands/help.d.ts.map +1 -0
  44. package/dist/cli/commands/help.js +196 -0
  45. package/dist/cli/commands/help.js.map +1 -0
  46. package/dist/cli/commands/inbox.d.ts +6 -0
  47. package/dist/cli/commands/inbox.d.ts.map +1 -0
  48. package/dist/cli/commands/inbox.js +235 -0
  49. package/dist/cli/commands/inbox.js.map +1 -0
  50. package/dist/cli/commands/index.d.ts +20 -0
  51. package/dist/cli/commands/index.d.ts.map +1 -0
  52. package/dist/cli/commands/index.js +21 -0
  53. package/dist/cli/commands/index.js.map +1 -0
  54. package/dist/cli/commands/init.d.ts +6 -0
  55. package/dist/cli/commands/init.d.ts.map +1 -0
  56. package/dist/cli/commands/init.js +245 -0
  57. package/dist/cli/commands/init.js.map +1 -0
  58. package/dist/cli/commands/item.d.ts +6 -0
  59. package/dist/cli/commands/item.d.ts.map +1 -0
  60. package/dist/cli/commands/item.js +1311 -0
  61. package/dist/cli/commands/item.js.map +1 -0
  62. package/dist/cli/commands/link.d.ts +6 -0
  63. package/dist/cli/commands/link.d.ts.map +1 -0
  64. package/dist/cli/commands/link.js +288 -0
  65. package/dist/cli/commands/link.js.map +1 -0
  66. package/dist/cli/commands/log.d.ts +16 -0
  67. package/dist/cli/commands/log.d.ts.map +1 -0
  68. package/dist/cli/commands/log.js +291 -0
  69. package/dist/cli/commands/log.js.map +1 -0
  70. package/dist/cli/commands/meta.d.ts +15 -0
  71. package/dist/cli/commands/meta.d.ts.map +1 -0
  72. package/dist/cli/commands/meta.js +1378 -0
  73. package/dist/cli/commands/meta.js.map +1 -0
  74. package/dist/cli/commands/module.d.ts +6 -0
  75. package/dist/cli/commands/module.d.ts.map +1 -0
  76. package/dist/cli/commands/module.js +102 -0
  77. package/dist/cli/commands/module.js.map +1 -0
  78. package/dist/cli/commands/ralph.d.ts +9 -0
  79. package/dist/cli/commands/ralph.d.ts.map +1 -0
  80. package/dist/cli/commands/ralph.js +465 -0
  81. package/dist/cli/commands/ralph.js.map +1 -0
  82. package/dist/cli/commands/search.d.ts +6 -0
  83. package/dist/cli/commands/search.d.ts.map +1 -0
  84. package/dist/cli/commands/search.js +134 -0
  85. package/dist/cli/commands/search.js.map +1 -0
  86. package/dist/cli/commands/session.d.ts +164 -0
  87. package/dist/cli/commands/session.d.ts.map +1 -0
  88. package/dist/cli/commands/session.js +745 -0
  89. package/dist/cli/commands/session.js.map +1 -0
  90. package/dist/cli/commands/setup.d.ts +26 -0
  91. package/dist/cli/commands/setup.d.ts.map +1 -0
  92. package/dist/cli/commands/setup.js +586 -0
  93. package/dist/cli/commands/setup.js.map +1 -0
  94. package/dist/cli/commands/shadow.d.ts +6 -0
  95. package/dist/cli/commands/shadow.d.ts.map +1 -0
  96. package/dist/cli/commands/shadow.js +299 -0
  97. package/dist/cli/commands/shadow.js.map +1 -0
  98. package/dist/cli/commands/task.d.ts +6 -0
  99. package/dist/cli/commands/task.d.ts.map +1 -0
  100. package/dist/cli/commands/task.js +1514 -0
  101. package/dist/cli/commands/task.js.map +1 -0
  102. package/dist/cli/commands/tasks.d.ts +6 -0
  103. package/dist/cli/commands/tasks.d.ts.map +1 -0
  104. package/dist/cli/commands/tasks.js +347 -0
  105. package/dist/cli/commands/tasks.js.map +1 -0
  106. package/dist/cli/commands/trait.d.ts +10 -0
  107. package/dist/cli/commands/trait.d.ts.map +1 -0
  108. package/dist/cli/commands/trait.js +295 -0
  109. package/dist/cli/commands/trait.js.map +1 -0
  110. package/dist/cli/commands/validate.d.ts +6 -0
  111. package/dist/cli/commands/validate.d.ts.map +1 -0
  112. package/dist/cli/commands/validate.js +626 -0
  113. package/dist/cli/commands/validate.js.map +1 -0
  114. package/dist/cli/exit-codes.d.ts +62 -0
  115. package/dist/cli/exit-codes.d.ts.map +1 -0
  116. package/dist/cli/exit-codes.js +65 -0
  117. package/dist/cli/exit-codes.js.map +1 -0
  118. package/dist/cli/help/content.d.ts +35 -0
  119. package/dist/cli/help/content.d.ts.map +1 -0
  120. package/dist/cli/help/content.js +312 -0
  121. package/dist/cli/help/content.js.map +1 -0
  122. package/dist/cli/index.d.ts +5 -0
  123. package/dist/cli/index.d.ts.map +1 -0
  124. package/dist/cli/index.js +85 -0
  125. package/dist/cli/index.js.map +1 -0
  126. package/dist/cli/introspection.d.ts +87 -0
  127. package/dist/cli/introspection.d.ts.map +1 -0
  128. package/dist/cli/introspection.js +127 -0
  129. package/dist/cli/introspection.js.map +1 -0
  130. package/dist/cli/output.d.ts +56 -0
  131. package/dist/cli/output.d.ts.map +1 -0
  132. package/dist/cli/output.js +467 -0
  133. package/dist/cli/output.js.map +1 -0
  134. package/dist/cli/suggest.d.ts +16 -0
  135. package/dist/cli/suggest.d.ts.map +1 -0
  136. package/dist/cli/suggest.js +72 -0
  137. package/dist/cli/suggest.js.map +1 -0
  138. package/dist/index.d.ts +3 -0
  139. package/dist/index.d.ts.map +1 -0
  140. package/dist/index.js +5 -0
  141. package/dist/index.js.map +1 -0
  142. package/dist/parser/alignment.d.ts +113 -0
  143. package/dist/parser/alignment.d.ts.map +1 -0
  144. package/dist/parser/alignment.js +261 -0
  145. package/dist/parser/alignment.js.map +1 -0
  146. package/dist/parser/assess.d.ts +81 -0
  147. package/dist/parser/assess.d.ts.map +1 -0
  148. package/dist/parser/assess.js +197 -0
  149. package/dist/parser/assess.js.map +1 -0
  150. package/dist/parser/convention-validation.d.ts +48 -0
  151. package/dist/parser/convention-validation.d.ts.map +1 -0
  152. package/dist/parser/convention-validation.js +167 -0
  153. package/dist/parser/convention-validation.js.map +1 -0
  154. package/dist/parser/fix.d.ts +38 -0
  155. package/dist/parser/fix.d.ts.map +1 -0
  156. package/dist/parser/fix.js +185 -0
  157. package/dist/parser/fix.js.map +1 -0
  158. package/dist/parser/index.d.ts +12 -0
  159. package/dist/parser/index.d.ts.map +1 -0
  160. package/dist/parser/index.js +13 -0
  161. package/dist/parser/index.js.map +1 -0
  162. package/dist/parser/items.d.ts +138 -0
  163. package/dist/parser/items.d.ts.map +1 -0
  164. package/dist/parser/items.js +321 -0
  165. package/dist/parser/items.js.map +1 -0
  166. package/dist/parser/meta.d.ts +120 -0
  167. package/dist/parser/meta.d.ts.map +1 -0
  168. package/dist/parser/meta.js +441 -0
  169. package/dist/parser/meta.js.map +1 -0
  170. package/dist/parser/refs.d.ts +185 -0
  171. package/dist/parser/refs.d.ts.map +1 -0
  172. package/dist/parser/refs.js +404 -0
  173. package/dist/parser/refs.js.map +1 -0
  174. package/dist/parser/shadow.d.ts +253 -0
  175. package/dist/parser/shadow.d.ts.map +1 -0
  176. package/dist/parser/shadow.js +1053 -0
  177. package/dist/parser/shadow.js.map +1 -0
  178. package/dist/parser/traits.d.ts +72 -0
  179. package/dist/parser/traits.d.ts.map +1 -0
  180. package/dist/parser/traits.js +120 -0
  181. package/dist/parser/traits.js.map +1 -0
  182. package/dist/parser/validate.d.ts +89 -0
  183. package/dist/parser/validate.d.ts.map +1 -0
  184. package/dist/parser/validate.js +817 -0
  185. package/dist/parser/validate.js.map +1 -0
  186. package/dist/parser/yaml.d.ts +326 -0
  187. package/dist/parser/yaml.d.ts.map +1 -0
  188. package/dist/parser/yaml.js +1383 -0
  189. package/dist/parser/yaml.js.map +1 -0
  190. package/dist/ralph/cli-renderer.d.ts +20 -0
  191. package/dist/ralph/cli-renderer.d.ts.map +1 -0
  192. package/dist/ralph/cli-renderer.js +179 -0
  193. package/dist/ralph/cli-renderer.js.map +1 -0
  194. package/dist/ralph/events.d.ts +65 -0
  195. package/dist/ralph/events.d.ts.map +1 -0
  196. package/dist/ralph/events.js +397 -0
  197. package/dist/ralph/events.js.map +1 -0
  198. package/dist/ralph/index.d.ts +8 -0
  199. package/dist/ralph/index.d.ts.map +1 -0
  200. package/dist/ralph/index.js +10 -0
  201. package/dist/ralph/index.js.map +1 -0
  202. package/dist/schema/common.d.ts +46 -0
  203. package/dist/schema/common.d.ts.map +1 -0
  204. package/dist/schema/common.js +71 -0
  205. package/dist/schema/common.js.map +1 -0
  206. package/dist/schema/inbox.d.ts +90 -0
  207. package/dist/schema/inbox.d.ts.map +1 -0
  208. package/dist/schema/inbox.js +30 -0
  209. package/dist/schema/inbox.js.map +1 -0
  210. package/dist/schema/index.d.ts +6 -0
  211. package/dist/schema/index.d.ts.map +1 -0
  212. package/dist/schema/index.js +7 -0
  213. package/dist/schema/index.js.map +1 -0
  214. package/dist/schema/meta.d.ts +762 -0
  215. package/dist/schema/meta.d.ts.map +1 -0
  216. package/dist/schema/meta.js +144 -0
  217. package/dist/schema/meta.js.map +1 -0
  218. package/dist/schema/spec.d.ts +912 -0
  219. package/dist/schema/spec.d.ts.map +1 -0
  220. package/dist/schema/spec.js +104 -0
  221. package/dist/schema/spec.js.map +1 -0
  222. package/dist/schema/task.d.ts +664 -0
  223. package/dist/schema/task.d.ts.map +1 -0
  224. package/dist/schema/task.js +130 -0
  225. package/dist/schema/task.js.map +1 -0
  226. package/dist/sessions/index.d.ts +11 -0
  227. package/dist/sessions/index.d.ts.map +1 -0
  228. package/dist/sessions/index.js +13 -0
  229. package/dist/sessions/index.js.map +1 -0
  230. package/dist/sessions/store.d.ts +144 -0
  231. package/dist/sessions/store.d.ts.map +1 -0
  232. package/dist/sessions/store.js +325 -0
  233. package/dist/sessions/store.js.map +1 -0
  234. package/dist/sessions/types.d.ts +157 -0
  235. package/dist/sessions/types.d.ts.map +1 -0
  236. package/dist/sessions/types.js +90 -0
  237. package/dist/sessions/types.js.map +1 -0
  238. package/dist/strings/errors.d.ts +420 -0
  239. package/dist/strings/errors.d.ts.map +1 -0
  240. package/dist/strings/errors.js +282 -0
  241. package/dist/strings/errors.js.map +1 -0
  242. package/dist/strings/guidance.d.ts +65 -0
  243. package/dist/strings/guidance.d.ts.map +1 -0
  244. package/dist/strings/guidance.js +66 -0
  245. package/dist/strings/guidance.js.map +1 -0
  246. package/dist/strings/index.d.ts +12 -0
  247. package/dist/strings/index.d.ts.map +1 -0
  248. package/dist/strings/index.js +12 -0
  249. package/dist/strings/index.js.map +1 -0
  250. package/dist/strings/labels.d.ts +74 -0
  251. package/dist/strings/labels.d.ts.map +1 -0
  252. package/dist/strings/labels.js +75 -0
  253. package/dist/strings/labels.js.map +1 -0
  254. package/dist/strings/validation.d.ts +126 -0
  255. package/dist/strings/validation.d.ts.map +1 -0
  256. package/dist/strings/validation.js +135 -0
  257. package/dist/strings/validation.js.map +1 -0
  258. package/dist/utils/commit.d.ts +23 -0
  259. package/dist/utils/commit.d.ts.map +1 -0
  260. package/dist/utils/commit.js +67 -0
  261. package/dist/utils/commit.js.map +1 -0
  262. package/dist/utils/git.d.ts +57 -0
  263. package/dist/utils/git.d.ts.map +1 -0
  264. package/dist/utils/git.js +192 -0
  265. package/dist/utils/git.js.map +1 -0
  266. package/dist/utils/grep.d.ts +28 -0
  267. package/dist/utils/grep.d.ts.map +1 -0
  268. package/dist/utils/grep.js +86 -0
  269. package/dist/utils/grep.js.map +1 -0
  270. package/dist/utils/index.d.ts +8 -0
  271. package/dist/utils/index.d.ts.map +1 -0
  272. package/dist/utils/index.js +6 -0
  273. package/dist/utils/index.js.map +1 -0
  274. package/dist/utils/time.d.ts +18 -0
  275. package/dist/utils/time.d.ts.map +1 -0
  276. package/dist/utils/time.js +61 -0
  277. package/dist/utils/time.js.map +1 -0
  278. package/package.json +62 -0
@@ -0,0 +1,1514 @@
1
+ import chalk from 'chalk';
2
+ import * as path from 'node:path';
3
+ import { initContext, loadAllTasks, loadAllItems, saveTask, deleteTask, createTask, createNote, createTodo, syncSpecImplementationStatus, ReferenceIndex, checkSlugUniqueness, } from '../../parser/index.js';
4
+ import { commitIfShadow } from '../../parser/shadow.js';
5
+ import { output, formatTaskDetails, success, error, warn, info, isJsonMode, } from '../output.js';
6
+ import { formatCommitGuidance, printCommitGuidance } from '../../utils/commit.js';
7
+ import { alignmentCheck, errors } from '../../strings/index.js';
8
+ import { executeBatchOperation, formatBatchOutput } from '../batch.js';
9
+ import { EXIT_CODES } from '../exit-codes.js';
10
+ /**
11
+ * Find a task by reference with detailed error reporting.
12
+ * Returns the task or exits with appropriate error.
13
+ */
14
+ function resolveTaskRef(ref, tasks, index) {
15
+ const result = index.resolve(ref);
16
+ if (!result.ok) {
17
+ switch (result.error) {
18
+ case 'not_found':
19
+ error(errors.reference.taskNotFound(ref));
20
+ break;
21
+ case 'ambiguous':
22
+ error(errors.reference.ambiguous(ref));
23
+ for (const candidate of result.candidates) {
24
+ const task = tasks.find(t => t._ulid === candidate);
25
+ const slug = task?.slugs[0] || '';
26
+ console.error(` - ${index.shortUlid(candidate)} ${slug ? `(${slug})` : ''}`);
27
+ }
28
+ break;
29
+ case 'duplicate_slug':
30
+ error(errors.reference.slugMapsToMultiple(ref));
31
+ for (const candidate of result.candidates) {
32
+ console.error(` - ${index.shortUlid(candidate)}`);
33
+ }
34
+ break;
35
+ }
36
+ // AC: @cli-exit-codes consistent-usage - NOT_FOUND for missing resources
37
+ process.exit(EXIT_CODES.NOT_FOUND);
38
+ }
39
+ // Check if it's actually a task
40
+ const task = tasks.find(t => t._ulid === result.ulid);
41
+ if (!task) {
42
+ error(errors.reference.notTask(ref));
43
+ // AC: @cli-exit-codes consistent-usage - NOT_FOUND for missing resources
44
+ process.exit(EXIT_CODES.NOT_FOUND);
45
+ }
46
+ return task;
47
+ }
48
+ /**
49
+ * Batch-compatible resolver that returns null instead of calling process.exit().
50
+ * Used by executeBatchOperation to handle errors without terminating the process.
51
+ * AC: @multi-ref-batch ac-4, ac-8 - Partial failure handling and ref resolution
52
+ */
53
+ function resolveTaskRefForBatch(ref, tasks, index) {
54
+ const result = index.resolve(ref);
55
+ if (!result.ok) {
56
+ let errorMsg;
57
+ switch (result.error) {
58
+ case 'not_found':
59
+ errorMsg = `Reference "${ref}" not found`;
60
+ break;
61
+ case 'ambiguous':
62
+ errorMsg = `Reference "${ref}" is ambiguous (matches ${result.candidates.length} items)`;
63
+ break;
64
+ case 'duplicate_slug':
65
+ errorMsg = `Slug "${ref}" maps to multiple items`;
66
+ break;
67
+ }
68
+ return { task: null, error: errorMsg };
69
+ }
70
+ // Check if it's actually a task
71
+ const task = tasks.find(t => t._ulid === result.ulid);
72
+ if (!task) {
73
+ return { task: null, error: `Reference "${ref}" is not a task` };
74
+ }
75
+ return { task };
76
+ }
77
+ /**
78
+ * Helper function to update task fields.
79
+ * Used by both single-ref and batch modes of task set.
80
+ * AC: @spec-task-set-batch ac-1, ac-2, ac-4, ac-5
81
+ */
82
+ async function setTaskFields(foundTask, ctx, tasks, items, allMetaItems, index, options) {
83
+ try {
84
+ // Check slug uniqueness if adding a new slug
85
+ if (options.slug) {
86
+ const slugCheck = checkSlugUniqueness(index, [options.slug], foundTask._ulid);
87
+ if (!slugCheck.ok) {
88
+ return {
89
+ success: false,
90
+ error: `Slug "${slugCheck.slug}" already exists on ${slugCheck.existingUlid}`,
91
+ };
92
+ }
93
+ }
94
+ // Build updated task with only provided options
95
+ const updatedTask = { ...foundTask };
96
+ const changes = [];
97
+ if (options.title) {
98
+ updatedTask.title = options.title;
99
+ changes.push('title');
100
+ }
101
+ if (options.specRef) {
102
+ // Validate the spec ref exists and is a spec item
103
+ const specResult = index.resolve(options.specRef);
104
+ if (!specResult.ok) {
105
+ return {
106
+ success: false,
107
+ error: errors.reference.specRefNotFound(options.specRef),
108
+ };
109
+ }
110
+ // Check it's not a task
111
+ const isTask = tasks.some(t => t._ulid === specResult.ulid);
112
+ if (isTask) {
113
+ return {
114
+ success: false,
115
+ error: errors.reference.specRefIsTask(options.specRef),
116
+ };
117
+ }
118
+ updatedTask.spec_ref = options.specRef;
119
+ changes.push('spec_ref');
120
+ }
121
+ if (options.metaRef) {
122
+ // Validate the meta ref exists and is a meta item
123
+ const metaRefResult = index.resolve(options.metaRef);
124
+ if (!metaRefResult.ok) {
125
+ return {
126
+ success: false,
127
+ error: errors.reference.metaRefNotFound(options.metaRef),
128
+ };
129
+ }
130
+ // Check if the resolved item is a meta item (not a spec item or task)
131
+ const isTask = tasks.some(t => t._ulid === metaRefResult.ulid);
132
+ const isSpecItem = items.some(i => i._ulid === metaRefResult.ulid);
133
+ if (isTask || isSpecItem) {
134
+ return {
135
+ success: false,
136
+ error: errors.reference.metaRefPointsToSpec(options.metaRef),
137
+ };
138
+ }
139
+ updatedTask.meta_ref = options.metaRef;
140
+ changes.push('meta_ref');
141
+ }
142
+ if (options.priority) {
143
+ const priority = parseInt(options.priority, 10);
144
+ if (isNaN(priority) || priority < 1 || priority > 5) {
145
+ return {
146
+ success: false,
147
+ error: 'Priority must be between 1 and 5',
148
+ };
149
+ }
150
+ updatedTask.priority = priority;
151
+ changes.push('priority');
152
+ }
153
+ if (options.slug) {
154
+ if (!updatedTask.slugs.includes(options.slug)) {
155
+ updatedTask.slugs = [...updatedTask.slugs, options.slug];
156
+ changes.push('slug');
157
+ }
158
+ }
159
+ if (options.tag) {
160
+ const newTags = options.tag.filter((t) => !updatedTask.tags.includes(t));
161
+ if (newTags.length > 0) {
162
+ updatedTask.tags = [...updatedTask.tags, ...newTags];
163
+ changes.push('tags');
164
+ }
165
+ }
166
+ if (options.dependsOn) {
167
+ // Validate all dependency refs
168
+ for (const depRef of options.dependsOn) {
169
+ const depResult = index.resolve(depRef);
170
+ if (!depResult.ok) {
171
+ return {
172
+ success: false,
173
+ error: errors.reference.depNotFound(depRef),
174
+ };
175
+ }
176
+ }
177
+ updatedTask.depends_on = options.dependsOn;
178
+ changes.push('depends_on');
179
+ }
180
+ // AC: @spec-task-clear-deps ac-1, ac-2 - Clear all dependencies
181
+ if (options.clearDeps) {
182
+ if (foundTask.depends_on.length === 0) {
183
+ // AC: @spec-task-clear-deps ac-2 - No changes needed
184
+ return {
185
+ success: true,
186
+ message: 'No changes: task has no dependencies to clear',
187
+ };
188
+ }
189
+ updatedTask.depends_on = [];
190
+ changes.push('depends_on');
191
+ // Add note documenting the change
192
+ const note = createNote(`Dependencies cleared (was: ${foundTask.depends_on.join(', ')})`, '@human');
193
+ updatedTask.notes = [...updatedTask.notes, note];
194
+ }
195
+ // AC: @task-automation-eligibility ac-5, ac-11, ac-12, ac-18
196
+ // Handle automation status changes
197
+ // Note: --no-automation sets options.automation to false, so check that first
198
+ if (options.automation === false) {
199
+ // --no-automation flag clears the automation status (AC: ac-12)
200
+ delete updatedTask.automation;
201
+ changes.push('automation');
202
+ }
203
+ else if (options.automation !== undefined) {
204
+ const validStatuses = ['eligible', 'needs_review', 'manual_only'];
205
+ if (!validStatuses.includes(options.automation)) {
206
+ return {
207
+ success: false,
208
+ error: `Invalid automation status: ${options.automation}. Must be one of: ${validStatuses.join(', ')}`,
209
+ };
210
+ }
211
+ // AC: @task-automation-eligibility ac-18 - require reason for needs_review
212
+ if (options.automation === 'needs_review' && !options.reason) {
213
+ return {
214
+ success: false,
215
+ error: 'Setting automation to needs_review requires --reason flag explaining why',
216
+ };
217
+ }
218
+ updatedTask.automation = options.automation;
219
+ changes.push('automation');
220
+ // If reason provided, add a note documenting the change
221
+ if (options.reason) {
222
+ const note = createNote(`Automation status set to ${options.automation}: ${options.reason}`, '@human');
223
+ updatedTask.notes = [...updatedTask.notes, note];
224
+ changes.push('note');
225
+ }
226
+ }
227
+ // AC: @spec-task-set-batch ac-4 - Warn on no changes, don't fail
228
+ if (changes.length === 0) {
229
+ return {
230
+ success: true,
231
+ message: 'No changes specified',
232
+ data: { task: updatedTask },
233
+ };
234
+ }
235
+ await saveTask(ctx, updatedTask);
236
+ await commitIfShadow(ctx.shadow, 'task-set', foundTask.slugs[0] || index.shortUlid(foundTask._ulid), changes.join(', '));
237
+ return {
238
+ success: true,
239
+ message: `Updated task: ${index.shortUlid(updatedTask._ulid)} (${changes.join(', ')})`,
240
+ data: { task: updatedTask },
241
+ };
242
+ }
243
+ catch (err) {
244
+ return {
245
+ success: false,
246
+ error: err instanceof Error ? err.message : String(err),
247
+ };
248
+ }
249
+ }
250
+ /**
251
+ * Register the 'task' command group (singular - operations on individual tasks)
252
+ */
253
+ export function registerTaskCommands(program) {
254
+ const task = program
255
+ .command('task')
256
+ .description('Operations on individual tasks');
257
+ // kspec task get <ref>
258
+ task
259
+ .command('get <ref>')
260
+ .description('Get task details')
261
+ .action(async (ref) => {
262
+ try {
263
+ const ctx = await initContext();
264
+ const tasks = await loadAllTasks(ctx);
265
+ const items = await loadAllItems(ctx);
266
+ // Build all indexes including TraitIndex
267
+ const { refIndex: index, traitIndex } = await (async () => {
268
+ const { buildIndexes } = await import('../../parser/index.js');
269
+ return buildIndexes(ctx);
270
+ })();
271
+ const foundTask = resolveTaskRef(ref, tasks, index);
272
+ // AC: @trait-display ac-3 - task get shows inherited AC sections
273
+ // Get inherited traits if task has spec_ref
274
+ let inheritedTraits = [];
275
+ if (foundTask.spec_ref) {
276
+ const specResult = index.resolve(foundTask.spec_ref);
277
+ if (specResult.ok) {
278
+ const specUlid = specResult.ulid;
279
+ const inheritedAC = traitIndex.getInheritedAC(specUlid);
280
+ const traitsByTrait = new Map();
281
+ for (const { trait, ac } of inheritedAC) {
282
+ if (!traitsByTrait.has(trait.ulid)) {
283
+ traitsByTrait.set(trait.ulid, { trait, acs: [] });
284
+ }
285
+ traitsByTrait.get(trait.ulid).acs.push(ac);
286
+ }
287
+ inheritedTraits = Array.from(traitsByTrait.values());
288
+ }
289
+ }
290
+ // Build JSON output with inherited traits (AC: @trait-display ac-2)
291
+ const jsonOutput = {
292
+ ...foundTask,
293
+ ...(inheritedTraits.length > 0 && {
294
+ inherited_traits: inheritedTraits.map(({ trait, acs }) => ({
295
+ ref: `@${trait.slug}`,
296
+ title: trait.title,
297
+ acceptance_criteria: acs,
298
+ })),
299
+ }),
300
+ };
301
+ output(jsonOutput, () => {
302
+ formatTaskDetails(foundTask, index);
303
+ // AC: @trait-display ac-3, ac-4, ac-5 - Show inherited AC per trait in labeled sections
304
+ if (inheritedTraits.length > 0) {
305
+ for (const { trait, acs } of inheritedTraits) {
306
+ console.log(chalk.gray(`\n─── Inherited from @${trait.slug} ───`));
307
+ for (const ac of acs) {
308
+ console.log(chalk.cyan(` [${ac.id}]`) + chalk.gray(` (from @${trait.slug})`));
309
+ if (ac.given)
310
+ console.log(` Given: ${ac.given}`);
311
+ if (ac.when)
312
+ console.log(` When: ${ac.when}`);
313
+ if (ac.then)
314
+ console.log(` Then: ${ac.then}`);
315
+ }
316
+ }
317
+ }
318
+ });
319
+ }
320
+ catch (err) {
321
+ error(errors.failures.getTask, err);
322
+ process.exit(EXIT_CODES.ERROR);
323
+ }
324
+ });
325
+ // kspec task add
326
+ task
327
+ .command('add')
328
+ .description('Create a new task')
329
+ .requiredOption('--title <title>', 'Task title')
330
+ .option('--description <description>', 'Task description')
331
+ .option('--type <type>', 'Task type (task, epic, bug, spike, infra)', 'task')
332
+ .option('--spec-ref <ref>', 'Reference to spec item')
333
+ .option('--meta-ref <ref>', 'Reference to meta item (workflow, agent, or convention)')
334
+ .option('--priority <n>', 'Priority (1-5)', '3')
335
+ .option('--slug <slug>', 'Human-friendly slug')
336
+ .option('--tag <tag...>', 'Tags')
337
+ .option('--automation <status>', 'Automation eligibility (eligible, needs_review, manual_only)')
338
+ .action(async (options) => {
339
+ try {
340
+ const ctx = await initContext();
341
+ const tasks = await loadAllTasks(ctx);
342
+ const items = await loadAllItems(ctx);
343
+ // Load meta items for validation
344
+ const { loadMetaContext } = await import('../../parser/meta.js');
345
+ const metaContext = await loadMetaContext(ctx);
346
+ const allMetaItems = [
347
+ ...metaContext.agents,
348
+ ...metaContext.workflows,
349
+ ...metaContext.conventions,
350
+ ...metaContext.observations,
351
+ ];
352
+ // Build index for reference validation
353
+ const refIndex = new ReferenceIndex(tasks, items, allMetaItems);
354
+ // Check slug uniqueness if provided
355
+ if (options.slug) {
356
+ const slugCheck = checkSlugUniqueness(refIndex, [options.slug]);
357
+ if (!slugCheck.ok) {
358
+ error(errors.slug.alreadyExists(slugCheck.slug, slugCheck.existingUlid));
359
+ process.exit(EXIT_CODES.CONFLICT);
360
+ }
361
+ }
362
+ // Validate meta_ref if provided (AC-meta-ref-3, AC-meta-ref-4)
363
+ if (options.metaRef) {
364
+ const metaRefResult = refIndex.resolve(options.metaRef);
365
+ if (!metaRefResult.ok) {
366
+ error(errors.reference.metaRefNotFound(options.metaRef));
367
+ process.exit(EXIT_CODES.NOT_FOUND);
368
+ }
369
+ // Check if the resolved item is a meta item (not a spec item or task)
370
+ const isTask = tasks.some(t => t._ulid === metaRefResult.ulid);
371
+ const isSpecItem = items.some(i => i._ulid === metaRefResult.ulid);
372
+ if (isTask || isSpecItem) {
373
+ error(errors.reference.metaRefPointsToSpec(options.metaRef));
374
+ process.exit(EXIT_CODES.NOT_FOUND);
375
+ }
376
+ }
377
+ // AC: @task-automation-eligibility ac-13 - validate automation if provided
378
+ let automationValue;
379
+ if (options.automation) {
380
+ const validStatuses = ['eligible', 'needs_review', 'manual_only'];
381
+ if (!validStatuses.includes(options.automation)) {
382
+ error(`Invalid automation status: ${options.automation}. Must be one of: ${validStatuses.join(', ')}`);
383
+ process.exit(EXIT_CODES.VALIDATION_FAILED);
384
+ }
385
+ automationValue = options.automation;
386
+ }
387
+ // AC: @spec-task-add-description ac-6 - Omit description if empty string
388
+ const descriptionValue = options.description && options.description.trim() !== ''
389
+ ? options.description
390
+ : undefined;
391
+ const input = {
392
+ title: options.title,
393
+ description: descriptionValue,
394
+ type: options.type,
395
+ spec_ref: options.specRef || null,
396
+ meta_ref: options.metaRef || null,
397
+ priority: parseInt(options.priority, 10),
398
+ slugs: options.slug ? [options.slug] : [],
399
+ tags: options.tag || [],
400
+ automation: automationValue,
401
+ };
402
+ const newTask = createTask(input);
403
+ await saveTask(ctx, newTask);
404
+ await commitIfShadow(ctx.shadow, 'task-add', newTask.slugs[0] || newTask._ulid.slice(0, 8), newTask.title);
405
+ // Build index including the new task for accurate short ULID
406
+ const index = new ReferenceIndex([...tasks, newTask], items, allMetaItems);
407
+ success(`Created task: ${index.shortUlid(newTask._ulid)}`, { task: newTask });
408
+ }
409
+ catch (err) {
410
+ error(errors.failures.createTask, err);
411
+ process.exit(EXIT_CODES.ERROR);
412
+ }
413
+ });
414
+ // kspec task set <ref>
415
+ task
416
+ .command('set [ref]')
417
+ .description('Update task fields')
418
+ .option('--refs <refs...>', 'Update multiple tasks (AC: @spec-task-set-batch ac-1)')
419
+ .option('--title <title>', 'Update task title')
420
+ .option('--spec-ref <ref>', 'Link to spec item')
421
+ .option('--meta-ref <ref>', 'Link to meta item (workflow, agent, or convention)')
422
+ .option('--priority <n>', 'Set priority (1-5)')
423
+ .option('--slug <slug>', 'Add a slug alias')
424
+ .option('--tag <tag...>', 'Add tags')
425
+ .option('--depends-on <refs...>', 'Set dependencies (replaces existing)')
426
+ .option('--clear-deps', 'Clear all dependencies')
427
+ .option('--automation <status>', 'Set automation eligibility (eligible, needs_review, manual_only)')
428
+ .option('--no-automation', 'Clear automation status (return to unassessed)')
429
+ .option('--reason <reason>', 'Reason for status change (required when setting needs_review)')
430
+ .option('--status <status>', 'Reject with error - use state transition commands instead')
431
+ .action(async (ref, options) => {
432
+ try {
433
+ // AC: @spec-task-set-batch ac-3 - Reject --status flag
434
+ if (options.status !== undefined) {
435
+ error('Use state transition commands (start, complete, block, etc.) to change status');
436
+ process.exit(EXIT_CODES.USAGE_ERROR);
437
+ }
438
+ // AC: @spec-task-clear-deps ac-3 - Mutual exclusivity check
439
+ if (options.clearDeps && options.dependsOn) {
440
+ error('Cannot use --clear-deps and --depends-on together');
441
+ process.exit(EXIT_CODES.USAGE_ERROR);
442
+ }
443
+ const ctx = await initContext();
444
+ const tasks = await loadAllTasks(ctx);
445
+ const items = await loadAllItems(ctx);
446
+ // Load meta items for validation
447
+ const { loadMetaContext } = await import('../../parser/meta.js');
448
+ const metaContext = await loadMetaContext(ctx);
449
+ const allMetaItems = [
450
+ ...metaContext.agents,
451
+ ...metaContext.workflows,
452
+ ...metaContext.conventions,
453
+ ...metaContext.observations,
454
+ ];
455
+ const index = new ReferenceIndex(tasks, items, allMetaItems);
456
+ // AC: @trait-multi-ref-batch ac-8 - Deduplicate refs
457
+ const refsFlag = options.refs ? [...new Set(options.refs)] : undefined;
458
+ // Batch mode or single mode?
459
+ if (refsFlag && refsFlag.length > 0) {
460
+ // Batch mode - AC: @spec-task-set-batch ac-1, ac-2, ac-5
461
+ const result = await executeBatchOperation({
462
+ positionalRef: ref,
463
+ refsFlag,
464
+ context: { ctx, tasks, items, allMetaItems, index, options },
465
+ items: tasks,
466
+ index,
467
+ resolveRef: (refStr, taskList, idx) => {
468
+ const result = resolveTaskRefForBatch(refStr, taskList, idx);
469
+ return { item: result.task, error: result.error };
470
+ },
471
+ executeOperation: async (task, context) => {
472
+ return await setTaskFields(task, context.ctx, context.tasks, context.items, context.allMetaItems, context.index, context.options);
473
+ },
474
+ getUlid: (task) => task._ulid,
475
+ });
476
+ formatBatchOutput(result, 'Set');
477
+ }
478
+ else {
479
+ // Single mode - existing behavior
480
+ if (!ref) {
481
+ error('Either provide a positional ref or use --refs flag');
482
+ process.exit(EXIT_CODES.USAGE_ERROR);
483
+ }
484
+ const foundTask = resolveTaskRef(ref, tasks, index);
485
+ const result = await setTaskFields(foundTask, ctx, tasks, items, allMetaItems, index, options);
486
+ if (!result.success) {
487
+ error(result.error || 'Failed to update task');
488
+ process.exit(EXIT_CODES.ERROR);
489
+ }
490
+ if (result.message) {
491
+ // AC: @spec-task-set-batch ac-4 - Warn on no changes
492
+ if (result.message.includes('No changes')) {
493
+ if (isJsonMode()) {
494
+ output({ success: true, message: result.message });
495
+ }
496
+ else {
497
+ warn(result.message);
498
+ }
499
+ }
500
+ else {
501
+ success(result.message, result.data);
502
+ }
503
+ }
504
+ }
505
+ }
506
+ catch (err) {
507
+ error(errors.failures.updateTask, err);
508
+ process.exit(EXIT_CODES.ERROR);
509
+ }
510
+ });
511
+ // kspec task patch <ref>
512
+ task
513
+ .command('patch <ref>')
514
+ .description('Update task with JSON data')
515
+ .option('--data <json>', 'JSON object with fields to update')
516
+ .option('--dry-run', 'Show what would change without writing')
517
+ .option('--allow-unknown', 'Allow unknown fields (for extending format)')
518
+ .action(async (ref, options) => {
519
+ try {
520
+ const ctx = await initContext();
521
+ const tasks = await loadAllTasks(ctx);
522
+ const items = await loadAllItems(ctx);
523
+ // Load meta items for validation
524
+ const { loadMetaContext } = await import('../../parser/meta.js');
525
+ const metaContext = await loadMetaContext(ctx);
526
+ const allMetaItems = [
527
+ ...metaContext.agents,
528
+ ...metaContext.workflows,
529
+ ...metaContext.conventions,
530
+ ...metaContext.observations,
531
+ ];
532
+ const index = new ReferenceIndex(tasks, items, allMetaItems);
533
+ const foundTask = resolveTaskRef(ref, tasks, index);
534
+ // Get JSON data from --data flag or stdin
535
+ let jsonData;
536
+ if (options.data) {
537
+ jsonData = options.data;
538
+ }
539
+ else {
540
+ // Read from stdin
541
+ const chunks = [];
542
+ for await (const chunk of process.stdin) {
543
+ chunks.push(chunk);
544
+ }
545
+ jsonData = Buffer.concat(chunks).toString('utf-8');
546
+ }
547
+ // Parse JSON
548
+ let patchData;
549
+ try {
550
+ patchData = JSON.parse(jsonData);
551
+ }
552
+ catch (parseErr) {
553
+ error(errors.validation.invalidJson, parseErr);
554
+ process.exit(EXIT_CODES.ERROR);
555
+ }
556
+ // Validate against TaskInputSchema (partial)
557
+ const { TaskInputSchema } = await import('../../schema/index.js');
558
+ // Create a partial schema for validation
559
+ const partialSchema = options.allowUnknown
560
+ ? TaskInputSchema.partial().passthrough()
561
+ : TaskInputSchema.partial().strict();
562
+ let validatedPatch;
563
+ try {
564
+ validatedPatch = partialSchema.parse(patchData);
565
+ }
566
+ catch (validationErr) {
567
+ error(errors.validation.invalidPatchData(String(validationErr)), validationErr);
568
+ process.exit(EXIT_CODES.ERROR);
569
+ }
570
+ // Check for unknown fields if strict mode
571
+ if (!options.allowUnknown) {
572
+ const knownFields = Object.keys(TaskInputSchema.shape);
573
+ const providedFields = Object.keys(patchData);
574
+ const unknownFields = providedFields.filter(f => !knownFields.includes(f));
575
+ if (unknownFields.length > 0) {
576
+ error(errors.validation.unknownFields(unknownFields));
577
+ process.exit(EXIT_CODES.ERROR);
578
+ }
579
+ }
580
+ // Build updated task
581
+ const updatedTask = { ...foundTask, ...validatedPatch };
582
+ // Track changes for output
583
+ const changes = Object.keys(validatedPatch);
584
+ if (options.dryRun) {
585
+ info('Dry run - no changes will be written');
586
+ info(`Would update: ${changes.join(', ')}`);
587
+ output({ changes, updated: updatedTask }, () => {
588
+ console.log(`\nChanges: ${changes.join(', ')}\n`);
589
+ return formatTaskDetails(updatedTask, index);
590
+ });
591
+ return;
592
+ }
593
+ await saveTask(ctx, updatedTask);
594
+ await commitIfShadow(ctx.shadow, 'task-patch', foundTask.slugs[0] || index.shortUlid(foundTask._ulid), changes.join(', '));
595
+ success(`Patched task: ${index.shortUlid(updatedTask._ulid)} (${changes.join(', ')})`, { task: updatedTask });
596
+ }
597
+ catch (err) {
598
+ error(errors.failures.patchTask, err);
599
+ process.exit(EXIT_CODES.ERROR);
600
+ }
601
+ });
602
+ // kspec task start <ref>
603
+ task
604
+ .command('start <ref>')
605
+ .description('Start working on a task (pending -> in_progress)')
606
+ .option('--no-sync', 'Skip syncing spec implementation status')
607
+ .action(async (ref, options) => {
608
+ try {
609
+ const ctx = await initContext();
610
+ const tasks = await loadAllTasks(ctx);
611
+ const items = await loadAllItems(ctx);
612
+ const index = new ReferenceIndex(tasks, items);
613
+ const foundTask = resolveTaskRef(ref, tasks, index);
614
+ if (foundTask.status === 'in_progress') {
615
+ warn('Task is already in progress');
616
+ output(foundTask, () => formatTaskDetails(foundTask));
617
+ return;
618
+ }
619
+ if (foundTask.status !== 'pending') {
620
+ error(errors.status.cannotStart(foundTask.status));
621
+ process.exit(EXIT_CODES.VALIDATION_FAILED); // Exit code 4 = invalid state
622
+ }
623
+ // Update status
624
+ const updatedTask = {
625
+ ...foundTask,
626
+ status: 'in_progress',
627
+ started_at: new Date().toISOString(),
628
+ };
629
+ await saveTask(ctx, updatedTask);
630
+ await commitIfShadow(ctx.shadow, 'task-start', foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
631
+ success(`Started task: ${index.shortUlid(updatedTask._ulid)}`, { task: updatedTask });
632
+ // Show spec context and AC guidance (suppressed in JSON mode)
633
+ if (!isJsonMode() && foundTask.spec_ref) {
634
+ const specResult = index.resolve(foundTask.spec_ref);
635
+ if (specResult.ok) {
636
+ const specItem = items.find(i => i._ulid === specResult.ulid);
637
+ if (specItem) {
638
+ console.log('');
639
+ console.log('--- Spec Context ---');
640
+ console.log(`Implementing: ${specItem.title}`);
641
+ if (specItem.description) {
642
+ console.log(`\n${specItem.description}`);
643
+ }
644
+ if (specItem.acceptance_criteria && specItem.acceptance_criteria.length > 0) {
645
+ console.log(`\nAcceptance Criteria (${specItem.acceptance_criteria.length}):`);
646
+ for (const ac of specItem.acceptance_criteria) {
647
+ console.log(` [${ac.id}]`);
648
+ console.log(` Given: ${ac.given}`);
649
+ console.log(` When: ${ac.when}`);
650
+ console.log(` Then: ${ac.then}`);
651
+ }
652
+ console.log('');
653
+ console.log('Remember: Add test coverage for each AC and mark tests with // AC: @spec-ref ac-N');
654
+ }
655
+ console.log('');
656
+ }
657
+ }
658
+ }
659
+ // Sync spec implementation status (unless --no-sync)
660
+ if (options.sync !== false && foundTask.spec_ref) {
661
+ const updatedTasks = tasks.map(t => t._ulid === updatedTask._ulid ? { ...t, ...updatedTask } : t);
662
+ const syncResult = await syncSpecImplementationStatus(ctx, updatedTask, updatedTasks, items, index);
663
+ if (syncResult) {
664
+ info(`Synced spec "${syncResult.specTitle}" implementation: ${syncResult.previousStatus} -> ${syncResult.newStatus}`);
665
+ // Commit the spec status change
666
+ await commitIfShadow(ctx.shadow, 'spec-sync', syncResult.specUlid.slice(0, 8), `${syncResult.previousStatus} -> ${syncResult.newStatus}`);
667
+ }
668
+ }
669
+ }
670
+ catch (err) {
671
+ error(errors.failures.startTask, err);
672
+ process.exit(EXIT_CODES.ERROR);
673
+ }
674
+ });
675
+ // kspec task complete <ref> | --refs <refs...>
676
+ // AC: @multi-ref-batch ac-1 - Basic multi-ref syntax
677
+ // AC: @multi-ref-batch ac-2 - Backward compatibility
678
+ task
679
+ .command('complete [ref]')
680
+ .description('Complete a task (pending_review -> completed)')
681
+ .option('--refs <refs...>', 'Complete multiple tasks by ref')
682
+ .option('--reason <reason>', 'Completion reason/notes')
683
+ .option('--skip-review', 'Skip review requirement (requires --reason)')
684
+ .option('--no-sync', 'Skip syncing spec implementation status')
685
+ .action(async (ref, options) => {
686
+ try {
687
+ const ctx = await initContext();
688
+ const tasks = await loadAllTasks(ctx);
689
+ const items = await loadAllItems(ctx);
690
+ const index = new ReferenceIndex(tasks, items);
691
+ // AC: @spec-completion-enforcement ac-8
692
+ if (options.skipReview && !options.reason) {
693
+ error(errors.status.skipReviewRequiresReason);
694
+ process.exit(EXIT_CODES.ERROR);
695
+ }
696
+ // AC: @multi-ref-batch ac-1, ac-2, ac-3, ac-4
697
+ const result = await executeBatchOperation({
698
+ positionalRef: ref,
699
+ refsFlag: options.refs,
700
+ context: { ctx, tasks, items, index, options },
701
+ items: tasks,
702
+ index,
703
+ resolveRef: (refStr, taskList, idx) => {
704
+ const resolved = resolveTaskRefForBatch(refStr, taskList, idx);
705
+ return { item: resolved.task, error: resolved.error };
706
+ },
707
+ executeOperation: async (foundTask, { ctx, tasks, items, index, options }) => {
708
+ try {
709
+ // AC: @spec-completion-enforcement ac-6
710
+ if (foundTask.status === 'completed') {
711
+ return {
712
+ success: false,
713
+ error: errors.status.completeAlreadyCompleted,
714
+ };
715
+ }
716
+ // AC: @spec-completion-enforcement ac-7 - Allow skip-review bypass
717
+ if (!options.skipReview) {
718
+ // AC: @spec-completion-enforcement ac-2
719
+ if (foundTask.status === 'in_progress') {
720
+ return {
721
+ success: false,
722
+ error: errors.status.completeRequiresReview,
723
+ };
724
+ }
725
+ // AC: @spec-completion-enforcement ac-3
726
+ if (foundTask.status === 'pending') {
727
+ return {
728
+ success: false,
729
+ error: errors.status.completeRequiresStart,
730
+ };
731
+ }
732
+ // AC: @spec-completion-enforcement ac-4
733
+ if (foundTask.status === 'blocked') {
734
+ return {
735
+ success: false,
736
+ error: errors.status.completeBlockedTask,
737
+ };
738
+ }
739
+ // AC: @spec-completion-enforcement ac-5
740
+ if (foundTask.status === 'cancelled') {
741
+ return {
742
+ success: false,
743
+ error: errors.status.completeCancelledTask,
744
+ };
745
+ }
746
+ // AC: @spec-completion-enforcement ac-1 - Only pending_review allowed
747
+ if (foundTask.status !== 'pending_review') {
748
+ return {
749
+ success: false,
750
+ error: errors.status.cannotComplete(foundTask.status),
751
+ };
752
+ }
753
+ }
754
+ const now = new Date().toISOString();
755
+ // AC: @spec-completion-enforcement ac-7 - Document skip-review reason
756
+ let taskNotes = foundTask.notes;
757
+ if (options.skipReview && options.reason) {
758
+ const skipNote = createNote(`Completed with --skip-review: ${options.reason}`, '@human');
759
+ taskNotes = [...taskNotes, skipNote];
760
+ }
761
+ // Update status
762
+ const updatedTask = {
763
+ ...foundTask,
764
+ status: 'completed',
765
+ completed_at: now,
766
+ closed_reason: options.reason || null,
767
+ started_at: foundTask.started_at || now,
768
+ notes: taskNotes,
769
+ };
770
+ await saveTask(ctx, updatedTask);
771
+ await commitIfShadow(ctx.shadow, 'task-complete', foundTask.slugs[0] || index.shortUlid(foundTask._ulid), options.reason);
772
+ // Sync spec implementation status (unless --no-sync)
773
+ if (options.sync !== false && foundTask.spec_ref) {
774
+ const updatedTasks = tasks.map(t => t._ulid === updatedTask._ulid ? { ...t, ...updatedTask } : t);
775
+ const syncResult = await syncSpecImplementationStatus(ctx, updatedTask, updatedTasks, items, index);
776
+ if (syncResult && !isJsonMode()) {
777
+ info(`Synced spec "${syncResult.specTitle}" implementation: ${syncResult.previousStatus} -> ${syncResult.newStatus}`);
778
+ await commitIfShadow(ctx.shadow, 'spec-sync', syncResult.specUlid.slice(0, 8), `${syncResult.previousStatus} -> ${syncResult.newStatus}`);
779
+ }
780
+ }
781
+ // Show AC reminder for single-ref mode only (not in batch)
782
+ if (!options.refs && foundTask.spec_ref && !isJsonMode()) {
783
+ const specResult = index.resolve(foundTask.spec_ref);
784
+ if (specResult.ok && specResult.item) {
785
+ const specItem = items.find(i => i._ulid === specResult.ulid);
786
+ if (specItem && specItem.acceptance_criteria && specItem.acceptance_criteria.length > 0) {
787
+ const count = specItem.acceptance_criteria.length;
788
+ console.log(`\n⚠ Linked spec ${foundTask.spec_ref} has ${count} acceptance criteri${count === 1 ? 'on' : 'a'} - verify they are covered\n`);
789
+ }
790
+ }
791
+ }
792
+ return {
793
+ success: true,
794
+ message: `Completed task: ${index.shortUlid(updatedTask._ulid)}`,
795
+ data: updatedTask,
796
+ };
797
+ }
798
+ catch (err) {
799
+ return {
800
+ success: false,
801
+ error: err instanceof Error ? err.message : String(err),
802
+ };
803
+ }
804
+ },
805
+ getUlid: (task) => task._ulid,
806
+ });
807
+ // AC: @multi-ref-batch ac-5, ac-6
808
+ formatBatchOutput(result, 'Complete');
809
+ // Show commit guidance for single-ref mode only
810
+ if (!options.refs && result.success && result.results.length === 1 && !isJsonMode()) {
811
+ const taskData = result.results[0].data;
812
+ if (taskData) {
813
+ const guidance = formatCommitGuidance(taskData);
814
+ printCommitGuidance(guidance);
815
+ }
816
+ }
817
+ }
818
+ catch (err) {
819
+ error(errors.failures.completeTask, err);
820
+ process.exit(EXIT_CODES.ERROR);
821
+ }
822
+ });
823
+ // kspec task submit <ref>
824
+ // Transitions in_progress → pending_review (code done, awaiting merge)
825
+ task
826
+ .command('submit <ref>')
827
+ .description('Submit task for review (transitions to pending_review)')
828
+ .action(async (ref) => {
829
+ try {
830
+ const ctx = await initContext();
831
+ const tasks = await loadAllTasks(ctx);
832
+ const items = await loadAllItems(ctx);
833
+ const index = new ReferenceIndex(tasks, items);
834
+ const foundTask = resolveTaskRef(ref, tasks, index);
835
+ if (foundTask.status !== 'in_progress') {
836
+ error(`Cannot submit task with status: ${foundTask.status}. Task must be in_progress.`);
837
+ process.exit(EXIT_CODES.VALIDATION_FAILED);
838
+ }
839
+ const updatedTask = {
840
+ ...foundTask,
841
+ status: 'pending_review',
842
+ };
843
+ await saveTask(ctx, updatedTask);
844
+ await commitIfShadow(ctx.shadow, 'task-submit', foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
845
+ success(`Submitted task for review: ${index.shortUlid(updatedTask._ulid)}`, { task: updatedTask });
846
+ }
847
+ catch (err) {
848
+ error(errors.failures.updateTask, err);
849
+ process.exit(EXIT_CODES.ERROR);
850
+ }
851
+ });
852
+ // kspec task block <ref>
853
+ task
854
+ .command('block <ref>')
855
+ .description('Block a task')
856
+ .requiredOption('--reason <reason>', 'Reason for blocking')
857
+ .action(async (ref, options) => {
858
+ try {
859
+ const ctx = await initContext();
860
+ const tasks = await loadAllTasks(ctx);
861
+ const items = await loadAllItems(ctx);
862
+ const index = new ReferenceIndex(tasks, items);
863
+ const foundTask = resolveTaskRef(ref, tasks, index);
864
+ if (foundTask.status === 'completed' || foundTask.status === 'cancelled') {
865
+ error(errors.status.cannotBlock(foundTask.status));
866
+ process.exit(EXIT_CODES.VALIDATION_FAILED);
867
+ }
868
+ const updatedTask = {
869
+ ...foundTask,
870
+ status: 'blocked',
871
+ blocked_by: [...foundTask.blocked_by, options.reason],
872
+ };
873
+ await saveTask(ctx, updatedTask);
874
+ await commitIfShadow(ctx.shadow, 'task-block', foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
875
+ success(`Blocked task: ${index.shortUlid(updatedTask._ulid)}`, { task: updatedTask });
876
+ }
877
+ catch (err) {
878
+ error(errors.failures.blockTask, err);
879
+ process.exit(EXIT_CODES.ERROR);
880
+ }
881
+ });
882
+ // kspec task unblock <ref>
883
+ task
884
+ .command('unblock <ref>')
885
+ .description('Unblock a task')
886
+ .action(async (ref) => {
887
+ try {
888
+ const ctx = await initContext();
889
+ const tasks = await loadAllTasks(ctx);
890
+ const items = await loadAllItems(ctx);
891
+ const index = new ReferenceIndex(tasks, items);
892
+ const foundTask = resolveTaskRef(ref, tasks, index);
893
+ if (foundTask.status !== 'blocked') {
894
+ warn('Task is not blocked');
895
+ return;
896
+ }
897
+ const updatedTask = {
898
+ ...foundTask,
899
+ status: 'pending',
900
+ blocked_by: [],
901
+ };
902
+ await saveTask(ctx, updatedTask);
903
+ await commitIfShadow(ctx.shadow, 'task-unblock', foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
904
+ success(`Unblocked task: ${index.shortUlid(updatedTask._ulid)}`, { task: updatedTask });
905
+ }
906
+ catch (err) {
907
+ error(errors.failures.unblockTask, err);
908
+ process.exit(EXIT_CODES.ERROR);
909
+ }
910
+ });
911
+ // kspec task cancel <ref> | --refs <refs...>
912
+ // AC: @multi-ref-batch ac-1, ac-2
913
+ task
914
+ .command('cancel [ref]')
915
+ .description('Cancel a task')
916
+ .option('--refs <refs...>', 'Cancel multiple tasks by ref')
917
+ .option('--reason <reason>', 'Cancellation reason')
918
+ .action(async (ref, options) => {
919
+ try {
920
+ const ctx = await initContext();
921
+ const tasks = await loadAllTasks(ctx);
922
+ const items = await loadAllItems(ctx);
923
+ const index = new ReferenceIndex(tasks, items);
924
+ const result = await executeBatchOperation({
925
+ positionalRef: ref,
926
+ refsFlag: options.refs,
927
+ context: { ctx, tasks, items, index, options },
928
+ items: tasks,
929
+ index,
930
+ resolveRef: (refStr, taskList, idx) => {
931
+ const resolved = resolveTaskRefForBatch(refStr, taskList, idx);
932
+ return { item: resolved.task, error: resolved.error };
933
+ },
934
+ executeOperation: async (foundTask, { ctx, index, options }) => {
935
+ try {
936
+ if (foundTask.status === 'completed' || foundTask.status === 'cancelled') {
937
+ return {
938
+ success: false,
939
+ error: `Task is already ${foundTask.status}`,
940
+ };
941
+ }
942
+ const updatedTask = {
943
+ ...foundTask,
944
+ status: 'cancelled',
945
+ closed_reason: options.reason || null,
946
+ };
947
+ await saveTask(ctx, updatedTask);
948
+ await commitIfShadow(ctx.shadow, 'task-cancel', foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
949
+ return {
950
+ success: true,
951
+ message: `Cancelled task: ${index.shortUlid(updatedTask._ulid)}`,
952
+ data: updatedTask,
953
+ };
954
+ }
955
+ catch (err) {
956
+ return {
957
+ success: false,
958
+ error: err instanceof Error ? err.message : String(err),
959
+ };
960
+ }
961
+ },
962
+ getUlid: (task) => task._ulid,
963
+ });
964
+ formatBatchOutput(result, 'Cancel');
965
+ }
966
+ catch (err) {
967
+ error(errors.failures.cancelTask, err);
968
+ process.exit(EXIT_CODES.ERROR);
969
+ }
970
+ });
971
+ // kspec task reset <ref>
972
+ // AC: @spec-task-reset ac-1, ac-2, ac-3, ac-4, ac-5, ac-6
973
+ task
974
+ .command('reset <ref>')
975
+ .description('Reset a task to pending state')
976
+ .action(async (ref) => {
977
+ try {
978
+ const ctx = await initContext();
979
+ const tasks = await loadAllTasks(ctx);
980
+ const items = await loadAllItems(ctx);
981
+ const index = new ReferenceIndex(tasks, items);
982
+ const foundTask = resolveTaskRef(ref, tasks, index);
983
+ // AC: @spec-task-reset ac-2 - Error if already pending
984
+ if (foundTask.status === 'pending') {
985
+ error('Task is already pending');
986
+ process.exit(EXIT_CODES.VALIDATION_FAILED);
987
+ }
988
+ // Track previous status and reason for note (AC-4)
989
+ const previousStatus = foundTask.status;
990
+ const hadCancelReason = foundTask.closed_reason && foundTask.status === 'cancelled';
991
+ const cancelReasonText = hadCancelReason ? ` (was cancelled: ${foundTask.closed_reason})` : '';
992
+ // AC: @spec-task-reset ac-1 - Reset to pending, clear completion-related fields
993
+ const clearedFields = [];
994
+ const updatedTask = {
995
+ ...foundTask,
996
+ status: 'pending',
997
+ };
998
+ // Clear timestamps and reasons based on previous status
999
+ if (foundTask.completed_at !== undefined && foundTask.completed_at !== null) {
1000
+ updatedTask.completed_at = null;
1001
+ clearedFields.push('completed_at');
1002
+ }
1003
+ if (foundTask.started_at !== undefined && foundTask.started_at !== null) {
1004
+ updatedTask.started_at = null;
1005
+ clearedFields.push('started_at');
1006
+ }
1007
+ if (foundTask.closed_reason !== undefined && foundTask.closed_reason !== null) {
1008
+ updatedTask.closed_reason = null;
1009
+ clearedFields.push('closed_reason');
1010
+ }
1011
+ if (foundTask.blocked_by.length > 0) {
1012
+ updatedTask.blocked_by = [];
1013
+ clearedFields.push('blocked_by');
1014
+ }
1015
+ // AC: @spec-task-reset ac-4 - Add note documenting the reset
1016
+ const noteContent = `Reset from ${previousStatus} to pending${cancelReasonText}`;
1017
+ const note = createNote(noteContent, '@human');
1018
+ updatedTask.notes = [...updatedTask.notes, note];
1019
+ await saveTask(ctx, updatedTask);
1020
+ // AC: @spec-task-reset ac-3 - Shadow commit with message task-reset
1021
+ await commitIfShadow(ctx.shadow, 'task-reset', foundTask.slugs[0] || index.shortUlid(foundTask._ulid), `from ${previousStatus}`);
1022
+ // AC: @spec-task-reset ac-6 - JSON output includes previous_status, new_status, cleared_fields
1023
+ const jsonOutput = {
1024
+ task: updatedTask,
1025
+ previous_status: previousStatus,
1026
+ new_status: 'pending',
1027
+ cleared_fields: clearedFields,
1028
+ };
1029
+ output(jsonOutput, () => {
1030
+ success(`Reset task: ${index.shortUlid(updatedTask._ulid)} (${previousStatus} → pending)`, undefined);
1031
+ if (clearedFields.length > 0) {
1032
+ info(`Cleared fields: ${clearedFields.join(', ')}`);
1033
+ }
1034
+ });
1035
+ }
1036
+ catch (err) {
1037
+ error('Failed to reset task', err);
1038
+ process.exit(EXIT_CODES.ERROR);
1039
+ }
1040
+ });
1041
+ // kspec task delete <ref> | --refs <refs...>
1042
+ // AC: @multi-ref-batch ac-1, ac-2
1043
+ task
1044
+ .command('delete [ref]')
1045
+ .description('Delete a task permanently')
1046
+ .option('--refs <refs...>', 'Delete multiple tasks by ref')
1047
+ .option('--force', 'Skip confirmation (required for --refs)')
1048
+ .option('--dry-run', 'Show what would be deleted without deleting')
1049
+ .action(async (ref, options) => {
1050
+ try {
1051
+ const ctx = await initContext();
1052
+ const tasks = await loadAllTasks(ctx);
1053
+ const items = await loadAllItems(ctx);
1054
+ const index = new ReferenceIndex(tasks, items);
1055
+ // For batch mode (--refs), require --force
1056
+ if (options.refs && options.refs.length > 0 && !options.force && !options.dryRun) {
1057
+ error('Batch delete requires --force flag');
1058
+ process.exit(EXIT_CODES.USAGE_ERROR);
1059
+ }
1060
+ const result = await executeBatchOperation({
1061
+ positionalRef: ref,
1062
+ refsFlag: options.refs,
1063
+ context: { ctx, tasks, items, index, options },
1064
+ items: tasks,
1065
+ index,
1066
+ resolveRef: (refStr, taskList, idx) => {
1067
+ const resolved = resolveTaskRefForBatch(refStr, taskList, idx);
1068
+ return { item: resolved.task, error: resolved.error };
1069
+ },
1070
+ executeOperation: async (foundTask, { ctx, index, options }) => {
1071
+ try {
1072
+ const taskDisplay = `${foundTask.title} (${index.shortUlid(foundTask._ulid)})`;
1073
+ if (options.dryRun) {
1074
+ return {
1075
+ success: true,
1076
+ message: `Would delete: ${taskDisplay}`,
1077
+ };
1078
+ }
1079
+ // For single-ref mode (not --refs), prompt for confirmation unless --force
1080
+ if (!options.refs && !options.force) {
1081
+ const readline = await import('readline');
1082
+ const rl = readline.createInterface({
1083
+ input: process.stdin,
1084
+ output: process.stdout,
1085
+ });
1086
+ const answer = await new Promise((resolve) => {
1087
+ rl.question(`Delete task "${taskDisplay}"? [y/N] `, resolve);
1088
+ });
1089
+ rl.close();
1090
+ if (answer.toLowerCase() !== 'y') {
1091
+ return {
1092
+ success: false,
1093
+ error: 'Deletion cancelled by user',
1094
+ };
1095
+ }
1096
+ }
1097
+ await deleteTask(ctx, foundTask);
1098
+ await commitIfShadow(ctx.shadow, 'task-delete', foundTask.slugs[0] || index.shortUlid(foundTask._ulid), foundTask.title);
1099
+ return {
1100
+ success: true,
1101
+ message: `Deleted task: ${taskDisplay}`,
1102
+ };
1103
+ }
1104
+ catch (err) {
1105
+ return {
1106
+ success: false,
1107
+ error: err instanceof Error ? err.message : String(err),
1108
+ };
1109
+ }
1110
+ },
1111
+ getUlid: (task) => task._ulid,
1112
+ });
1113
+ formatBatchOutput(result, 'Delete');
1114
+ }
1115
+ catch (err) {
1116
+ error(errors.failures.deleteTask, err);
1117
+ process.exit(EXIT_CODES.ERROR);
1118
+ }
1119
+ });
1120
+ // kspec task note <ref> <message>
1121
+ task
1122
+ .command('note <ref> <message>')
1123
+ .description('Add a note to a task')
1124
+ .option('--author <author>', 'Note author')
1125
+ .option('--supersedes <ulid>', 'ULID of note this supersedes')
1126
+ .action(async (ref, message, options) => {
1127
+ try {
1128
+ const ctx = await initContext();
1129
+ const tasks = await loadAllTasks(ctx);
1130
+ const items = await loadAllItems(ctx);
1131
+ const index = new ReferenceIndex(tasks, items);
1132
+ const foundTask = resolveTaskRef(ref, tasks, index);
1133
+ const note = createNote(message, options.author, options.supersedes);
1134
+ const updatedTask = {
1135
+ ...foundTask,
1136
+ notes: [...foundTask.notes, note],
1137
+ };
1138
+ await saveTask(ctx, updatedTask);
1139
+ await commitIfShadow(ctx.shadow, 'task-note', foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1140
+ success(`Added note to task: ${index.shortUlid(updatedTask._ulid)}`, { note });
1141
+ // Proactive alignment guidance for tasks with spec_ref
1142
+ if (foundTask.spec_ref) {
1143
+ console.log('');
1144
+ console.log(alignmentCheck.header);
1145
+ console.log(alignmentCheck.beyondSpec);
1146
+ console.log(alignmentCheck.updateSpec(foundTask.spec_ref));
1147
+ console.log(alignmentCheck.addAC);
1148
+ // Check if linked spec has acceptance criteria and remind about test coverage
1149
+ const specResult = index.resolve(foundTask.spec_ref);
1150
+ if (specResult.ok && specResult.item) {
1151
+ const specItem = specResult.item;
1152
+ if (specItem.acceptance_criteria && specItem.acceptance_criteria.length > 0) {
1153
+ console.log('');
1154
+ console.log(alignmentCheck.testCoverage(specItem.acceptance_criteria.length));
1155
+ }
1156
+ }
1157
+ }
1158
+ }
1159
+ catch (err) {
1160
+ error(errors.failures.addNote, err);
1161
+ process.exit(EXIT_CODES.ERROR);
1162
+ }
1163
+ });
1164
+ // kspec task notes <ref>
1165
+ task
1166
+ .command('notes <ref>')
1167
+ .description('Show notes for a task')
1168
+ .action(async (ref) => {
1169
+ try {
1170
+ const ctx = await initContext();
1171
+ const tasks = await loadAllTasks(ctx);
1172
+ const items = await loadAllItems(ctx);
1173
+ const index = new ReferenceIndex(tasks, items);
1174
+ const foundTask = resolveTaskRef(ref, tasks, index);
1175
+ output(foundTask.notes, () => {
1176
+ if (foundTask.notes.length === 0) {
1177
+ console.log('No notes');
1178
+ }
1179
+ else {
1180
+ for (const note of foundTask.notes) {
1181
+ const author = note.author || 'unknown';
1182
+ console.log(`[${note.created_at}] ${author}:`);
1183
+ console.log(note.content);
1184
+ console.log('');
1185
+ }
1186
+ }
1187
+ });
1188
+ }
1189
+ catch (err) {
1190
+ error(errors.failures.getNotes, err);
1191
+ process.exit(EXIT_CODES.ERROR);
1192
+ }
1193
+ });
1194
+ // kspec task review <ref>
1195
+ task
1196
+ .command('review <ref>')
1197
+ .description('Get task context for review (task details, spec, ACs, git diff)')
1198
+ .action(async (ref) => {
1199
+ try {
1200
+ const ctx = await initContext();
1201
+ const tasks = await loadAllTasks(ctx);
1202
+ const items = await loadAllItems(ctx);
1203
+ const index = new ReferenceIndex(tasks, items);
1204
+ const foundTask = resolveTaskRef(ref, tasks, index);
1205
+ // Import getDiffSince from utils
1206
+ const { getDiffSince } = await import('../../utils/index.js');
1207
+ // Import scanTestCoverage (we'll need to export it from validate.ts)
1208
+ // For now, duplicate the logic here
1209
+ const scanTestCoverage = async (rootDir) => {
1210
+ const coveredACs = new Set();
1211
+ const testsDir = path.join(rootDir, 'tests');
1212
+ const fs = await import('node:fs/promises');
1213
+ try {
1214
+ await fs.access(testsDir);
1215
+ const files = await fs.readdir(testsDir);
1216
+ const testFiles = files.filter(f => f.endsWith('.test.ts') || f.endsWith('.test.js'));
1217
+ for (const file of testFiles) {
1218
+ const filePath = path.join(testsDir, file);
1219
+ const content = await fs.readFile(filePath, 'utf-8');
1220
+ const acPattern = /\/\/\s*AC:\s*(@[\w-]+)(?:\s+(ac-\d+(?:\s*,\s*ac-\d+)*))?/g;
1221
+ let match;
1222
+ while ((match = acPattern.exec(content)) !== null) {
1223
+ const specRef = match[1];
1224
+ const acList = match[2];
1225
+ if (acList) {
1226
+ const acs = acList.split(',').map(ac => ac.trim());
1227
+ for (const ac of acs) {
1228
+ coveredACs.add(`${specRef} ${ac}`);
1229
+ }
1230
+ }
1231
+ else {
1232
+ coveredACs.add(specRef);
1233
+ }
1234
+ }
1235
+ }
1236
+ }
1237
+ catch (err) {
1238
+ // Tests directory doesn't exist or can't be read
1239
+ }
1240
+ return coveredACs;
1241
+ };
1242
+ // Gather review context
1243
+ const reviewContext = {
1244
+ task: foundTask,
1245
+ spec: null,
1246
+ diff: null,
1247
+ started_at: foundTask.started_at || null,
1248
+ };
1249
+ // Get spec item if task has spec_ref
1250
+ if (foundTask.spec_ref) {
1251
+ const specResult = index.resolve(foundTask.spec_ref);
1252
+ if (specResult.ok) {
1253
+ const specItem = items.find(i => i._ulid === specResult.ulid);
1254
+ reviewContext.spec = specItem || null;
1255
+ // Check test coverage for ACs if spec has them
1256
+ if (specItem && specItem.acceptance_criteria && specItem.acceptance_criteria.length > 0) {
1257
+ const coveredACs = await scanTestCoverage(ctx.rootDir);
1258
+ const covered = [];
1259
+ const uncovered = [];
1260
+ for (const ac of specItem.acceptance_criteria) {
1261
+ // Build possible references
1262
+ const possibleRefs = [];
1263
+ if (specItem.slugs && specItem.slugs.length > 0) {
1264
+ possibleRefs.push(`@${specItem.slugs[0]} ${ac.id}`);
1265
+ possibleRefs.push(`@${specItem.slugs[0]}`);
1266
+ }
1267
+ possibleRefs.push(`@${specItem._ulid.slice(0, 8)} ${ac.id}`);
1268
+ possibleRefs.push(`@${specItem._ulid.slice(0, 8)}`);
1269
+ const isCovered = possibleRefs.some(ref => coveredACs.has(ref));
1270
+ if (isCovered) {
1271
+ covered.push(ac.id);
1272
+ }
1273
+ else {
1274
+ uncovered.push(ac.id);
1275
+ }
1276
+ }
1277
+ reviewContext.testCoverage = { covered, uncovered };
1278
+ }
1279
+ }
1280
+ }
1281
+ // Get git diff since task started
1282
+ if (foundTask.started_at) {
1283
+ const startedDate = new Date(foundTask.started_at);
1284
+ reviewContext.diff = getDiffSince(startedDate, ctx.rootDir);
1285
+ }
1286
+ output(reviewContext, () => {
1287
+ console.log('='.repeat(60));
1288
+ console.log('Task Review Context');
1289
+ console.log('='.repeat(60));
1290
+ console.log();
1291
+ // Task details
1292
+ console.log('TASK DETAILS');
1293
+ console.log('-'.repeat(60));
1294
+ console.log(formatTaskDetails(foundTask, index));
1295
+ console.log();
1296
+ // Spec details
1297
+ if (reviewContext.spec) {
1298
+ console.log('LINKED SPEC');
1299
+ console.log('-'.repeat(60));
1300
+ console.log(`Title: ${reviewContext.spec.title}`);
1301
+ console.log(`Type: ${reviewContext.spec.type}`);
1302
+ if (reviewContext.spec.description) {
1303
+ console.log(`\nDescription:\n${reviewContext.spec.description}`);
1304
+ }
1305
+ if (reviewContext.spec.acceptance_criteria && reviewContext.spec.acceptance_criteria.length > 0) {
1306
+ console.log(`\nAcceptance Criteria (${reviewContext.spec.acceptance_criteria.length}):`);
1307
+ for (const ac of reviewContext.spec.acceptance_criteria) {
1308
+ const isCovered = reviewContext.testCoverage?.covered.includes(ac.id);
1309
+ const coverageMarker = isCovered ? chalk.green('✓') : chalk.yellow('○');
1310
+ console.log(` ${coverageMarker} [${ac.id}]`);
1311
+ console.log(` Given: ${ac.given}`);
1312
+ console.log(` When: ${ac.when}`);
1313
+ console.log(` Then: ${ac.then}`);
1314
+ }
1315
+ // Test coverage summary
1316
+ if (reviewContext.testCoverage) {
1317
+ const { covered, uncovered } = reviewContext.testCoverage;
1318
+ console.log();
1319
+ if (uncovered.length === 0) {
1320
+ console.log(chalk.green(` ✓ All ${covered.length} AC(s) have test coverage`));
1321
+ }
1322
+ else {
1323
+ console.log(chalk.yellow(` Test coverage: ${covered.length}/${covered.length + uncovered.length} ACs covered`));
1324
+ console.log(chalk.yellow(` Missing coverage for: ${uncovered.join(', ')}`));
1325
+ }
1326
+ }
1327
+ }
1328
+ console.log();
1329
+ }
1330
+ // Git diff
1331
+ if (reviewContext.diff) {
1332
+ console.log('CHANGES SINCE TASK STARTED');
1333
+ console.log('-'.repeat(60));
1334
+ console.log(`Started at: ${foundTask.started_at}`);
1335
+ console.log();
1336
+ console.log(reviewContext.diff);
1337
+ console.log();
1338
+ }
1339
+ else if (foundTask.started_at) {
1340
+ console.log('CHANGES SINCE TASK STARTED');
1341
+ console.log('-'.repeat(60));
1342
+ console.log(`Started at: ${foundTask.started_at}`);
1343
+ console.log('No changes detected');
1344
+ console.log();
1345
+ }
1346
+ console.log('='.repeat(60));
1347
+ console.log('Review Checklist:');
1348
+ console.log('- Does the implementation match the task description?');
1349
+ if (reviewContext.spec) {
1350
+ console.log('- Are all acceptance criteria covered?');
1351
+ console.log('- Is test coverage adequate?');
1352
+ }
1353
+ console.log('- Are there any gaps or issues?');
1354
+ console.log('='.repeat(60));
1355
+ });
1356
+ }
1357
+ catch (err) {
1358
+ error('Failed to generate review context', err);
1359
+ process.exit(EXIT_CODES.ERROR);
1360
+ }
1361
+ });
1362
+ // kspec task todos <ref>
1363
+ task
1364
+ .command('todos <ref>')
1365
+ .description('Show todos for a task')
1366
+ .action(async (ref) => {
1367
+ try {
1368
+ const ctx = await initContext();
1369
+ const tasks = await loadAllTasks(ctx);
1370
+ const items = await loadAllItems(ctx);
1371
+ const index = new ReferenceIndex(tasks, items);
1372
+ const foundTask = resolveTaskRef(ref, tasks, index);
1373
+ output(foundTask.todos, () => {
1374
+ if (foundTask.todos.length === 0) {
1375
+ console.log('No todos');
1376
+ }
1377
+ else {
1378
+ for (const todo of foundTask.todos) {
1379
+ const status = todo.done ? '[x]' : '[ ]';
1380
+ const doneInfo = todo.done && todo.done_at ? ` (done ${todo.done_at})` : '';
1381
+ console.log(`${status} ${todo.id}. ${todo.text}${doneInfo}`);
1382
+ }
1383
+ }
1384
+ });
1385
+ }
1386
+ catch (err) {
1387
+ error(errors.failures.getTodos, err);
1388
+ process.exit(EXIT_CODES.ERROR);
1389
+ }
1390
+ });
1391
+ // Create subcommand group for todo operations
1392
+ const todoCmd = task
1393
+ .command('todo')
1394
+ .description('Manage task todos');
1395
+ // kspec task todo add <ref> <text>
1396
+ todoCmd
1397
+ .command('add <ref> <text>')
1398
+ .description('Add a todo to a task')
1399
+ .option('--author <author>', 'Todo author')
1400
+ .action(async (ref, text, options) => {
1401
+ try {
1402
+ const ctx = await initContext();
1403
+ const tasks = await loadAllTasks(ctx);
1404
+ const items = await loadAllItems(ctx);
1405
+ const index = new ReferenceIndex(tasks, items);
1406
+ const foundTask = resolveTaskRef(ref, tasks, index);
1407
+ // Calculate next ID (max existing + 1, or 1 if none)
1408
+ const nextId = foundTask.todos.length > 0
1409
+ ? Math.max(...foundTask.todos.map(t => t.id)) + 1
1410
+ : 1;
1411
+ const todo = createTodo(nextId, text, options.author);
1412
+ const updatedTask = {
1413
+ ...foundTask,
1414
+ todos: [...foundTask.todos, todo],
1415
+ };
1416
+ await saveTask(ctx, updatedTask);
1417
+ await commitIfShadow(ctx.shadow, 'task-note', foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1418
+ success(`Added todo #${todo.id} to task: ${index.shortUlid(updatedTask._ulid)}`, { todo });
1419
+ }
1420
+ catch (err) {
1421
+ error(errors.failures.addTodo, err);
1422
+ process.exit(EXIT_CODES.ERROR);
1423
+ }
1424
+ });
1425
+ // kspec task todo done <ref> <id>
1426
+ todoCmd
1427
+ .command('done <ref> <id>')
1428
+ .description('Mark a todo as done')
1429
+ .action(async (ref, idStr) => {
1430
+ try {
1431
+ const ctx = await initContext();
1432
+ const tasks = await loadAllTasks(ctx);
1433
+ const items = await loadAllItems(ctx);
1434
+ const index = new ReferenceIndex(tasks, items);
1435
+ const foundTask = resolveTaskRef(ref, tasks, index);
1436
+ const id = parseInt(idStr, 10);
1437
+ if (isNaN(id)) {
1438
+ error(errors.todo.invalidId(idStr));
1439
+ process.exit(EXIT_CODES.USAGE_ERROR);
1440
+ }
1441
+ const todoIndex = foundTask.todos.findIndex(t => t.id === id);
1442
+ if (todoIndex === -1) {
1443
+ error(errors.todo.notFound(id));
1444
+ process.exit(EXIT_CODES.NOT_FOUND);
1445
+ }
1446
+ if (foundTask.todos[todoIndex].done) {
1447
+ warn(`Todo #${id} is already done`);
1448
+ return;
1449
+ }
1450
+ const updatedTodos = [...foundTask.todos];
1451
+ updatedTodos[todoIndex] = {
1452
+ ...updatedTodos[todoIndex],
1453
+ done: true,
1454
+ done_at: new Date().toISOString(),
1455
+ };
1456
+ const updatedTask = {
1457
+ ...foundTask,
1458
+ todos: updatedTodos,
1459
+ };
1460
+ await saveTask(ctx, updatedTask);
1461
+ await commitIfShadow(ctx.shadow, 'task-note', foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1462
+ success(`Marked todo #${id} as done`, { todo: updatedTodos[todoIndex] });
1463
+ }
1464
+ catch (err) {
1465
+ error(errors.failures.markTodoDone, err);
1466
+ process.exit(EXIT_CODES.ERROR);
1467
+ }
1468
+ });
1469
+ // kspec task todo undone <ref> <id>
1470
+ todoCmd
1471
+ .command('undone <ref> <id>')
1472
+ .description('Mark a todo as not done')
1473
+ .action(async (ref, idStr) => {
1474
+ try {
1475
+ const ctx = await initContext();
1476
+ const tasks = await loadAllTasks(ctx);
1477
+ const items = await loadAllItems(ctx);
1478
+ const index = new ReferenceIndex(tasks, items);
1479
+ const foundTask = resolveTaskRef(ref, tasks, index);
1480
+ const id = parseInt(idStr, 10);
1481
+ if (isNaN(id)) {
1482
+ error(errors.todo.invalidId(idStr));
1483
+ process.exit(EXIT_CODES.USAGE_ERROR);
1484
+ }
1485
+ const todoIndex = foundTask.todos.findIndex(t => t.id === id);
1486
+ if (todoIndex === -1) {
1487
+ error(errors.todo.notFound(id));
1488
+ process.exit(EXIT_CODES.NOT_FOUND);
1489
+ }
1490
+ if (!foundTask.todos[todoIndex].done) {
1491
+ warn(`Todo #${id} is not done`);
1492
+ return;
1493
+ }
1494
+ const updatedTodos = [...foundTask.todos];
1495
+ updatedTodos[todoIndex] = {
1496
+ ...updatedTodos[todoIndex],
1497
+ done: false,
1498
+ done_at: undefined,
1499
+ };
1500
+ const updatedTask = {
1501
+ ...foundTask,
1502
+ todos: updatedTodos,
1503
+ };
1504
+ await saveTask(ctx, updatedTask);
1505
+ await commitIfShadow(ctx.shadow, 'task-note', foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
1506
+ success(`Marked todo #${id} as not done`, { todo: updatedTodos[todoIndex] });
1507
+ }
1508
+ catch (err) {
1509
+ error(errors.failures.markTodoNotDone, err);
1510
+ process.exit(EXIT_CODES.ERROR);
1511
+ }
1512
+ });
1513
+ }
1514
+ //# sourceMappingURL=task.js.map