@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.
- package/README.md +263 -0
- package/dist/acp/client.d.ts +159 -0
- package/dist/acp/client.d.ts.map +1 -0
- package/dist/acp/client.js +255 -0
- package/dist/acp/client.js.map +1 -0
- package/dist/acp/framing.d.ts +119 -0
- package/dist/acp/framing.d.ts.map +1 -0
- package/dist/acp/framing.js +302 -0
- package/dist/acp/framing.js.map +1 -0
- package/dist/acp/index.d.ts +14 -0
- package/dist/acp/index.d.ts.map +1 -0
- package/dist/acp/index.js +13 -0
- package/dist/acp/index.js.map +1 -0
- package/dist/acp/types.d.ts +89 -0
- package/dist/acp/types.d.ts.map +1 -0
- package/dist/acp/types.js +99 -0
- package/dist/acp/types.js.map +1 -0
- package/dist/agents/adapters.d.ts +55 -0
- package/dist/agents/adapters.d.ts.map +1 -0
- package/dist/agents/adapters.js +84 -0
- package/dist/agents/adapters.js.map +1 -0
- package/dist/agents/index.d.ts +8 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +10 -0
- package/dist/agents/index.js.map +1 -0
- package/dist/agents/spawner.d.ts +53 -0
- package/dist/agents/spawner.d.ts.map +1 -0
- package/dist/agents/spawner.js +83 -0
- package/dist/agents/spawner.js.map +1 -0
- package/dist/cli/batch.d.ts +82 -0
- package/dist/cli/batch.d.ts.map +1 -0
- package/dist/cli/batch.js +162 -0
- package/dist/cli/batch.js.map +1 -0
- package/dist/cli/commands/clone-for-testing.d.ts +6 -0
- package/dist/cli/commands/clone-for-testing.d.ts.map +1 -0
- package/dist/cli/commands/clone-for-testing.js +176 -0
- package/dist/cli/commands/clone-for-testing.js.map +1 -0
- package/dist/cli/commands/derive.d.ts +6 -0
- package/dist/cli/commands/derive.d.ts.map +1 -0
- package/dist/cli/commands/derive.js +450 -0
- package/dist/cli/commands/derive.js.map +1 -0
- package/dist/cli/commands/help.d.ts +6 -0
- package/dist/cli/commands/help.d.ts.map +1 -0
- package/dist/cli/commands/help.js +196 -0
- package/dist/cli/commands/help.js.map +1 -0
- package/dist/cli/commands/inbox.d.ts +6 -0
- package/dist/cli/commands/inbox.d.ts.map +1 -0
- package/dist/cli/commands/inbox.js +235 -0
- package/dist/cli/commands/inbox.js.map +1 -0
- package/dist/cli/commands/index.d.ts +20 -0
- package/dist/cli/commands/index.d.ts.map +1 -0
- package/dist/cli/commands/index.js +21 -0
- package/dist/cli/commands/index.js.map +1 -0
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +245 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/item.d.ts +6 -0
- package/dist/cli/commands/item.d.ts.map +1 -0
- package/dist/cli/commands/item.js +1311 -0
- package/dist/cli/commands/item.js.map +1 -0
- package/dist/cli/commands/link.d.ts +6 -0
- package/dist/cli/commands/link.d.ts.map +1 -0
- package/dist/cli/commands/link.js +288 -0
- package/dist/cli/commands/link.js.map +1 -0
- package/dist/cli/commands/log.d.ts +16 -0
- package/dist/cli/commands/log.d.ts.map +1 -0
- package/dist/cli/commands/log.js +291 -0
- package/dist/cli/commands/log.js.map +1 -0
- package/dist/cli/commands/meta.d.ts +15 -0
- package/dist/cli/commands/meta.d.ts.map +1 -0
- package/dist/cli/commands/meta.js +1378 -0
- package/dist/cli/commands/meta.js.map +1 -0
- package/dist/cli/commands/module.d.ts +6 -0
- package/dist/cli/commands/module.d.ts.map +1 -0
- package/dist/cli/commands/module.js +102 -0
- package/dist/cli/commands/module.js.map +1 -0
- package/dist/cli/commands/ralph.d.ts +9 -0
- package/dist/cli/commands/ralph.d.ts.map +1 -0
- package/dist/cli/commands/ralph.js +465 -0
- package/dist/cli/commands/ralph.js.map +1 -0
- package/dist/cli/commands/search.d.ts +6 -0
- package/dist/cli/commands/search.d.ts.map +1 -0
- package/dist/cli/commands/search.js +134 -0
- package/dist/cli/commands/search.js.map +1 -0
- package/dist/cli/commands/session.d.ts +164 -0
- package/dist/cli/commands/session.d.ts.map +1 -0
- package/dist/cli/commands/session.js +745 -0
- package/dist/cli/commands/session.js.map +1 -0
- package/dist/cli/commands/setup.d.ts +26 -0
- package/dist/cli/commands/setup.d.ts.map +1 -0
- package/dist/cli/commands/setup.js +586 -0
- package/dist/cli/commands/setup.js.map +1 -0
- package/dist/cli/commands/shadow.d.ts +6 -0
- package/dist/cli/commands/shadow.d.ts.map +1 -0
- package/dist/cli/commands/shadow.js +299 -0
- package/dist/cli/commands/shadow.js.map +1 -0
- package/dist/cli/commands/task.d.ts +6 -0
- package/dist/cli/commands/task.d.ts.map +1 -0
- package/dist/cli/commands/task.js +1514 -0
- package/dist/cli/commands/task.js.map +1 -0
- package/dist/cli/commands/tasks.d.ts +6 -0
- package/dist/cli/commands/tasks.d.ts.map +1 -0
- package/dist/cli/commands/tasks.js +347 -0
- package/dist/cli/commands/tasks.js.map +1 -0
- package/dist/cli/commands/trait.d.ts +10 -0
- package/dist/cli/commands/trait.d.ts.map +1 -0
- package/dist/cli/commands/trait.js +295 -0
- package/dist/cli/commands/trait.js.map +1 -0
- package/dist/cli/commands/validate.d.ts +6 -0
- package/dist/cli/commands/validate.d.ts.map +1 -0
- package/dist/cli/commands/validate.js +626 -0
- package/dist/cli/commands/validate.js.map +1 -0
- package/dist/cli/exit-codes.d.ts +62 -0
- package/dist/cli/exit-codes.d.ts.map +1 -0
- package/dist/cli/exit-codes.js +65 -0
- package/dist/cli/exit-codes.js.map +1 -0
- package/dist/cli/help/content.d.ts +35 -0
- package/dist/cli/help/content.d.ts.map +1 -0
- package/dist/cli/help/content.js +312 -0
- package/dist/cli/help/content.js.map +1 -0
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +85 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/introspection.d.ts +87 -0
- package/dist/cli/introspection.d.ts.map +1 -0
- package/dist/cli/introspection.js +127 -0
- package/dist/cli/introspection.js.map +1 -0
- package/dist/cli/output.d.ts +56 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +467 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/suggest.d.ts +16 -0
- package/dist/cli/suggest.d.ts.map +1 -0
- package/dist/cli/suggest.js +72 -0
- package/dist/cli/suggest.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/alignment.d.ts +113 -0
- package/dist/parser/alignment.d.ts.map +1 -0
- package/dist/parser/alignment.js +261 -0
- package/dist/parser/alignment.js.map +1 -0
- package/dist/parser/assess.d.ts +81 -0
- package/dist/parser/assess.d.ts.map +1 -0
- package/dist/parser/assess.js +197 -0
- package/dist/parser/assess.js.map +1 -0
- package/dist/parser/convention-validation.d.ts +48 -0
- package/dist/parser/convention-validation.d.ts.map +1 -0
- package/dist/parser/convention-validation.js +167 -0
- package/dist/parser/convention-validation.js.map +1 -0
- package/dist/parser/fix.d.ts +38 -0
- package/dist/parser/fix.d.ts.map +1 -0
- package/dist/parser/fix.js +185 -0
- package/dist/parser/fix.js.map +1 -0
- package/dist/parser/index.d.ts +12 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +13 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/items.d.ts +138 -0
- package/dist/parser/items.d.ts.map +1 -0
- package/dist/parser/items.js +321 -0
- package/dist/parser/items.js.map +1 -0
- package/dist/parser/meta.d.ts +120 -0
- package/dist/parser/meta.d.ts.map +1 -0
- package/dist/parser/meta.js +441 -0
- package/dist/parser/meta.js.map +1 -0
- package/dist/parser/refs.d.ts +185 -0
- package/dist/parser/refs.d.ts.map +1 -0
- package/dist/parser/refs.js +404 -0
- package/dist/parser/refs.js.map +1 -0
- package/dist/parser/shadow.d.ts +253 -0
- package/dist/parser/shadow.d.ts.map +1 -0
- package/dist/parser/shadow.js +1053 -0
- package/dist/parser/shadow.js.map +1 -0
- package/dist/parser/traits.d.ts +72 -0
- package/dist/parser/traits.d.ts.map +1 -0
- package/dist/parser/traits.js +120 -0
- package/dist/parser/traits.js.map +1 -0
- package/dist/parser/validate.d.ts +89 -0
- package/dist/parser/validate.d.ts.map +1 -0
- package/dist/parser/validate.js +817 -0
- package/dist/parser/validate.js.map +1 -0
- package/dist/parser/yaml.d.ts +326 -0
- package/dist/parser/yaml.d.ts.map +1 -0
- package/dist/parser/yaml.js +1383 -0
- package/dist/parser/yaml.js.map +1 -0
- package/dist/ralph/cli-renderer.d.ts +20 -0
- package/dist/ralph/cli-renderer.d.ts.map +1 -0
- package/dist/ralph/cli-renderer.js +179 -0
- package/dist/ralph/cli-renderer.js.map +1 -0
- package/dist/ralph/events.d.ts +65 -0
- package/dist/ralph/events.d.ts.map +1 -0
- package/dist/ralph/events.js +397 -0
- package/dist/ralph/events.js.map +1 -0
- package/dist/ralph/index.d.ts +8 -0
- package/dist/ralph/index.d.ts.map +1 -0
- package/dist/ralph/index.js +10 -0
- package/dist/ralph/index.js.map +1 -0
- package/dist/schema/common.d.ts +46 -0
- package/dist/schema/common.d.ts.map +1 -0
- package/dist/schema/common.js +71 -0
- package/dist/schema/common.js.map +1 -0
- package/dist/schema/inbox.d.ts +90 -0
- package/dist/schema/inbox.d.ts.map +1 -0
- package/dist/schema/inbox.js +30 -0
- package/dist/schema/inbox.js.map +1 -0
- package/dist/schema/index.d.ts +6 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +7 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/meta.d.ts +762 -0
- package/dist/schema/meta.d.ts.map +1 -0
- package/dist/schema/meta.js +144 -0
- package/dist/schema/meta.js.map +1 -0
- package/dist/schema/spec.d.ts +912 -0
- package/dist/schema/spec.d.ts.map +1 -0
- package/dist/schema/spec.js +104 -0
- package/dist/schema/spec.js.map +1 -0
- package/dist/schema/task.d.ts +664 -0
- package/dist/schema/task.d.ts.map +1 -0
- package/dist/schema/task.js +130 -0
- package/dist/schema/task.js.map +1 -0
- package/dist/sessions/index.d.ts +11 -0
- package/dist/sessions/index.d.ts.map +1 -0
- package/dist/sessions/index.js +13 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/sessions/store.d.ts +144 -0
- package/dist/sessions/store.d.ts.map +1 -0
- package/dist/sessions/store.js +325 -0
- package/dist/sessions/store.js.map +1 -0
- package/dist/sessions/types.d.ts +157 -0
- package/dist/sessions/types.d.ts.map +1 -0
- package/dist/sessions/types.js +90 -0
- package/dist/sessions/types.js.map +1 -0
- package/dist/strings/errors.d.ts +420 -0
- package/dist/strings/errors.d.ts.map +1 -0
- package/dist/strings/errors.js +282 -0
- package/dist/strings/errors.js.map +1 -0
- package/dist/strings/guidance.d.ts +65 -0
- package/dist/strings/guidance.d.ts.map +1 -0
- package/dist/strings/guidance.js +66 -0
- package/dist/strings/guidance.js.map +1 -0
- package/dist/strings/index.d.ts +12 -0
- package/dist/strings/index.d.ts.map +1 -0
- package/dist/strings/index.js +12 -0
- package/dist/strings/index.js.map +1 -0
- package/dist/strings/labels.d.ts +74 -0
- package/dist/strings/labels.d.ts.map +1 -0
- package/dist/strings/labels.js +75 -0
- package/dist/strings/labels.js.map +1 -0
- package/dist/strings/validation.d.ts +126 -0
- package/dist/strings/validation.d.ts.map +1 -0
- package/dist/strings/validation.js +135 -0
- package/dist/strings/validation.js.map +1 -0
- package/dist/utils/commit.d.ts +23 -0
- package/dist/utils/commit.d.ts.map +1 -0
- package/dist/utils/commit.js +67 -0
- package/dist/utils/commit.js.map +1 -0
- package/dist/utils/git.d.ts +57 -0
- package/dist/utils/git.d.ts.map +1 -0
- package/dist/utils/git.js +192 -0
- package/dist/utils/git.js.map +1 -0
- package/dist/utils/grep.d.ts +28 -0
- package/dist/utils/grep.d.ts.map +1 -0
- package/dist/utils/grep.js +86 -0
- package/dist/utils/grep.js.map +1 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +6 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/time.d.ts +18 -0
- package/dist/utils/time.d.ts.map +1 -0
- package/dist/utils/time.js +61 -0
- package/dist/utils/time.js.map +1 -0
- 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
|