@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,1311 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { initContext, buildIndexes, createSpecItem, deleteSpecItem, updateSpecItem, addChildItem, loadAllItems, loadAllTasks, ReferenceIndex, AlignmentIndex, checkSlugUniqueness, patchSpecItems, findChildItems, findTraitImplementors, createNote, } from '../../parser/index.js';
|
|
3
|
+
import { commitIfShadow } from '../../parser/shadow.js';
|
|
4
|
+
import { SpecItemPatchSchema } from '../../schema/index.js';
|
|
5
|
+
import { output, error, success, warn, isJsonMode } from '../output.js';
|
|
6
|
+
import { grepItem, formatMatchedFields } from '../../utils/grep.js';
|
|
7
|
+
import { errors } from '../../strings/errors.js';
|
|
8
|
+
import { fieldLabels, sectionHeaders } from '../../strings/labels.js';
|
|
9
|
+
import { EXIT_CODES } from '../exit-codes.js';
|
|
10
|
+
/**
|
|
11
|
+
* Format a spec item for display
|
|
12
|
+
*/
|
|
13
|
+
function formatItem(item, verbose = false, grepPattern) {
|
|
14
|
+
const shortId = item._ulid.slice(0, 8);
|
|
15
|
+
const slugStr = item.slugs.length > 0 ? chalk.cyan(`@${item.slugs[0]}`) : '';
|
|
16
|
+
const typeStr = chalk.gray(`[${item.type}]`);
|
|
17
|
+
let status = '';
|
|
18
|
+
if (item.status && typeof item.status === 'object') {
|
|
19
|
+
const s = item.status;
|
|
20
|
+
if (s.implementation) {
|
|
21
|
+
const implColor = s.implementation === 'verified' ? chalk.green
|
|
22
|
+
: s.implementation === 'implemented' ? chalk.cyan
|
|
23
|
+
: s.implementation === 'in_progress' ? chalk.yellow
|
|
24
|
+
: chalk.gray;
|
|
25
|
+
status = implColor(s.implementation);
|
|
26
|
+
}
|
|
27
|
+
else if (s.maturity) {
|
|
28
|
+
status = chalk.gray(s.maturity);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
let line = `${chalk.gray(shortId)} ${typeStr} ${item.title}`;
|
|
32
|
+
if (slugStr)
|
|
33
|
+
line += ` ${slugStr}`;
|
|
34
|
+
if (status)
|
|
35
|
+
line += ` ${status}`;
|
|
36
|
+
if (verbose) {
|
|
37
|
+
const tags = 'tags' in item && Array.isArray(item.tags) ? item.tags : [];
|
|
38
|
+
if (tags.length > 0) {
|
|
39
|
+
line += chalk.blue(` #${tags.join(' #')}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Show matched fields if grep pattern provided
|
|
43
|
+
if (grepPattern) {
|
|
44
|
+
const match = grepItem(item, grepPattern);
|
|
45
|
+
if (match && match.matchedFields.length > 0) {
|
|
46
|
+
line += '\n ' + chalk.gray(`matched: ${formatMatchedFields(match.matchedFields)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return line;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Format item list for display
|
|
53
|
+
*/
|
|
54
|
+
function formatItemList(items, verbose = false, grepPattern) {
|
|
55
|
+
if (items.length === 0) {
|
|
56
|
+
console.log(chalk.gray('No items found'));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
console.log(formatItem(item, verbose, grepPattern));
|
|
61
|
+
}
|
|
62
|
+
console.log(chalk.gray(`\n${items.length} item(s)`));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Format item list as a tree showing parent/child hierarchy
|
|
66
|
+
*/
|
|
67
|
+
function formatItemTree(items, verbose = false, grepPattern) {
|
|
68
|
+
if (items.length === 0) {
|
|
69
|
+
console.log(chalk.gray('No items found'));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Build parent-child map
|
|
73
|
+
const childrenMap = new Map();
|
|
74
|
+
const rootItems = [];
|
|
75
|
+
for (const item of items) {
|
|
76
|
+
const path = item._path || '';
|
|
77
|
+
// Determine parent path
|
|
78
|
+
let parentPath = '';
|
|
79
|
+
if (path) {
|
|
80
|
+
// Extract parent path from current path
|
|
81
|
+
// e.g., "features[0].requirements[1]" -> "features[0]"
|
|
82
|
+
const lastDotIndex = path.lastIndexOf('.');
|
|
83
|
+
if (lastDotIndex !== -1) {
|
|
84
|
+
parentPath = path.substring(0, lastDotIndex);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (parentPath === '') {
|
|
88
|
+
// Root level item
|
|
89
|
+
rootItems.push(item);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
// Find parent by path
|
|
93
|
+
const parent = items.find(i => i._path === parentPath);
|
|
94
|
+
if (parent) {
|
|
95
|
+
const parentUlid = parent._ulid;
|
|
96
|
+
if (!childrenMap.has(parentUlid)) {
|
|
97
|
+
childrenMap.set(parentUlid, []);
|
|
98
|
+
}
|
|
99
|
+
childrenMap.get(parentUlid).push(item);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// Parent not in filtered list, show at root
|
|
103
|
+
rootItems.push(item);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Recursive function to print tree
|
|
108
|
+
function printTree(item, prefix = '', isLast = true) {
|
|
109
|
+
// Print current item with tree prefix
|
|
110
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
111
|
+
const itemLine = formatItem(item, verbose, grepPattern);
|
|
112
|
+
console.log(prefix + connector + itemLine);
|
|
113
|
+
// Print children
|
|
114
|
+
const children = childrenMap.get(item._ulid) || [];
|
|
115
|
+
const childPrefix = prefix + (isLast ? ' ' : '│ ');
|
|
116
|
+
children.forEach((child, index) => {
|
|
117
|
+
const isLastChild = index === children.length - 1;
|
|
118
|
+
printTree(child, childPrefix, isLastChild);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
// Print all root items
|
|
122
|
+
rootItems.forEach((item, index) => {
|
|
123
|
+
const isLast = index === rootItems.length - 1;
|
|
124
|
+
printTree(item, '', isLast);
|
|
125
|
+
});
|
|
126
|
+
console.log(chalk.gray(`\n${items.length} item(s)`));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Handle cascading status updates to child items
|
|
130
|
+
* Returns array of updated child items
|
|
131
|
+
*/
|
|
132
|
+
async function handleStatusCascade(ctx, parent, newStatus, allItems, refIndex) {
|
|
133
|
+
// Find direct children
|
|
134
|
+
const children = findChildItems(parent, allItems);
|
|
135
|
+
if (children.length === 0) {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
// Skip prompt in JSON mode
|
|
139
|
+
if (isJsonMode()) {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
// Prompt user for cascade
|
|
143
|
+
const readline = await import('readline');
|
|
144
|
+
const rl = readline.createInterface({
|
|
145
|
+
input: process.stdin,
|
|
146
|
+
output: process.stdout,
|
|
147
|
+
});
|
|
148
|
+
const answer = await new Promise((resolve) => {
|
|
149
|
+
rl.question(`Update ${children.length} child item(s) to ${newStatus}? [y/n] `, resolve);
|
|
150
|
+
});
|
|
151
|
+
rl.close();
|
|
152
|
+
if (answer.toLowerCase() !== 'y') {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
// Update children
|
|
156
|
+
const updatedChildren = [];
|
|
157
|
+
for (const child of children) {
|
|
158
|
+
const currentStatus = child.status && typeof child.status === 'object'
|
|
159
|
+
? child.status
|
|
160
|
+
: { maturity: 'draft', implementation: 'not_started' };
|
|
161
|
+
const updates = {
|
|
162
|
+
status: {
|
|
163
|
+
maturity: currentStatus.maturity || 'draft',
|
|
164
|
+
implementation: newStatus,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
const updated = await updateSpecItem(ctx, child, updates);
|
|
168
|
+
updatedChildren.push(updated);
|
|
169
|
+
// Log each child update (non-JSON mode only)
|
|
170
|
+
const childRef = child.slugs[0] || refIndex.shortUlid(child._ulid);
|
|
171
|
+
console.log(chalk.gray(` ✓ Updated @${childRef}`));
|
|
172
|
+
}
|
|
173
|
+
return updatedChildren;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Register item commands
|
|
177
|
+
*/
|
|
178
|
+
export function registerItemCommands(program) {
|
|
179
|
+
const item = program
|
|
180
|
+
.command('item')
|
|
181
|
+
.description('Spec item commands');
|
|
182
|
+
// kspec item list
|
|
183
|
+
item
|
|
184
|
+
.command('list')
|
|
185
|
+
.description('List spec items with optional filters')
|
|
186
|
+
.option('-t, --type <type>', 'Filter by item type (module, feature, requirement, constraint, decision)')
|
|
187
|
+
.option('-s, --status <status>', 'Filter by implementation status (not_started, in_progress, implemented, verified)')
|
|
188
|
+
.option('-m, --maturity <maturity>', 'Filter by maturity (draft, proposed, stable, deferred, deprecated)')
|
|
189
|
+
.option('--tag <tag>', 'Filter by tag (can specify multiple)', (val, prev) => [...prev, val], [])
|
|
190
|
+
.option('--has <field>', 'Filter items that have field present', (val, prev) => [...prev, val], [])
|
|
191
|
+
.option('-q, --search <text>', 'Search in title')
|
|
192
|
+
.option('-g, --grep <pattern>', 'Search content with regex pattern')
|
|
193
|
+
.option('-v, --verbose', 'Show more details')
|
|
194
|
+
.option('--tree', 'Show parent/child hierarchy')
|
|
195
|
+
.option('--limit <n>', 'Limit results', '50')
|
|
196
|
+
.action(async (options) => {
|
|
197
|
+
try {
|
|
198
|
+
const ctx = await initContext();
|
|
199
|
+
const { itemIndex, items } = await buildIndexes(ctx);
|
|
200
|
+
// Build filter from options
|
|
201
|
+
const filter = {
|
|
202
|
+
specItemsOnly: true, // Only spec items, not tasks
|
|
203
|
+
};
|
|
204
|
+
if (options.type) {
|
|
205
|
+
filter.type = options.type;
|
|
206
|
+
}
|
|
207
|
+
if (options.status) {
|
|
208
|
+
filter.implementation = options.status;
|
|
209
|
+
}
|
|
210
|
+
if (options.maturity) {
|
|
211
|
+
filter.maturity = options.maturity;
|
|
212
|
+
}
|
|
213
|
+
if (options.tag && options.tag.length > 0) {
|
|
214
|
+
filter.tags = options.tag;
|
|
215
|
+
}
|
|
216
|
+
if (options.has && options.has.length > 0) {
|
|
217
|
+
filter.hasFields = options.has;
|
|
218
|
+
}
|
|
219
|
+
if (options.search) {
|
|
220
|
+
filter.titleContains = options.search;
|
|
221
|
+
}
|
|
222
|
+
if (options.grep) {
|
|
223
|
+
filter.grepSearch = options.grep;
|
|
224
|
+
}
|
|
225
|
+
const limit = parseInt(options.limit, 10) || 50;
|
|
226
|
+
const result = itemIndex.queryPaginated(filter, 0, limit);
|
|
227
|
+
// Filter to only LoadedSpecItem (not tasks)
|
|
228
|
+
const specItems = result.items.filter((item) => !('status' in item && typeof item.status === 'string'));
|
|
229
|
+
output({
|
|
230
|
+
items: specItems,
|
|
231
|
+
total: result.total,
|
|
232
|
+
showing: specItems.length,
|
|
233
|
+
grepPattern: options.grep,
|
|
234
|
+
tree: options.tree,
|
|
235
|
+
}, () => {
|
|
236
|
+
if (options.tree) {
|
|
237
|
+
formatItemTree(specItems, options.verbose, options.grep);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
formatItemList(specItems, options.verbose, options.grep);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
error(errors.failures.listItems, err);
|
|
246
|
+
process.exit(EXIT_CODES.ERROR);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
// kspec item get <ref>
|
|
250
|
+
item
|
|
251
|
+
.command('get <ref>')
|
|
252
|
+
.description('Get details for a specific item')
|
|
253
|
+
.action(async (ref) => {
|
|
254
|
+
try {
|
|
255
|
+
const ctx = await initContext();
|
|
256
|
+
const { refIndex, traitIndex, items } = await buildIndexes(ctx);
|
|
257
|
+
const result = refIndex.resolve(ref);
|
|
258
|
+
if (!result.ok) {
|
|
259
|
+
error(errors.reference.itemNotFound(ref));
|
|
260
|
+
process.exit(EXIT_CODES.ERROR);
|
|
261
|
+
}
|
|
262
|
+
const item = result.item;
|
|
263
|
+
// AC: @trait-display ac-2 - JSON mode includes inherited_traits array
|
|
264
|
+
const inheritedTraits = traitIndex.getInheritedAC(item._ulid);
|
|
265
|
+
const traitsByTrait = new Map();
|
|
266
|
+
for (const { trait, ac } of inheritedTraits) {
|
|
267
|
+
if (!traitsByTrait.has(trait.ulid)) {
|
|
268
|
+
traitsByTrait.set(trait.ulid, { trait, acs: [] });
|
|
269
|
+
}
|
|
270
|
+
traitsByTrait.get(trait.ulid).acs.push(ac);
|
|
271
|
+
}
|
|
272
|
+
// Build JSON output with inherited traits
|
|
273
|
+
const jsonOutput = {
|
|
274
|
+
...item,
|
|
275
|
+
inherited_traits: Array.from(traitsByTrait.values()).map(({ trait, acs }) => ({
|
|
276
|
+
ref: `@${trait.slug}`,
|
|
277
|
+
title: trait.title,
|
|
278
|
+
acceptance_criteria: acs,
|
|
279
|
+
})),
|
|
280
|
+
};
|
|
281
|
+
output(jsonOutput, () => {
|
|
282
|
+
console.log(chalk.bold(item.title));
|
|
283
|
+
console.log(chalk.gray('─'.repeat(40)));
|
|
284
|
+
console.log(`${fieldLabels.ulid} ${item._ulid}`);
|
|
285
|
+
if (item.slugs.length > 0) {
|
|
286
|
+
console.log(`${fieldLabels.slugs} ${item.slugs.join(', ')}`);
|
|
287
|
+
}
|
|
288
|
+
console.log(`${fieldLabels.type} ${item.type}`);
|
|
289
|
+
if (item.status && typeof item.status === 'object') {
|
|
290
|
+
const s = item.status;
|
|
291
|
+
if (s.maturity)
|
|
292
|
+
console.log(`${fieldLabels.maturity} ${s.maturity}`);
|
|
293
|
+
if (s.implementation)
|
|
294
|
+
console.log(`${fieldLabels.implementation}${s.implementation}`);
|
|
295
|
+
}
|
|
296
|
+
if ('tags' in item && Array.isArray(item.tags) && item.tags.length > 0) {
|
|
297
|
+
console.log(`${fieldLabels.tags} ${item.tags.join(', ')}`);
|
|
298
|
+
}
|
|
299
|
+
if (item.description) {
|
|
300
|
+
console.log('\n' + sectionHeaders.description);
|
|
301
|
+
console.log(item.description);
|
|
302
|
+
}
|
|
303
|
+
// AC: @trait-display ac-1 - Show own AC first
|
|
304
|
+
if ('acceptance_criteria' in item && Array.isArray(item.acceptance_criteria) && item.acceptance_criteria.length > 0) {
|
|
305
|
+
console.log('\n' + sectionHeaders.acceptanceCriteria);
|
|
306
|
+
for (const ac of item.acceptance_criteria) {
|
|
307
|
+
if (ac && typeof ac === 'object' && 'id' in ac) {
|
|
308
|
+
const acObj = ac;
|
|
309
|
+
console.log(chalk.cyan(` [${acObj.id}]`));
|
|
310
|
+
if (acObj.given)
|
|
311
|
+
console.log(` Given: ${acObj.given}`);
|
|
312
|
+
if (acObj.when)
|
|
313
|
+
console.log(` When: ${acObj.when}`);
|
|
314
|
+
if (acObj.then)
|
|
315
|
+
console.log(` Then: ${acObj.then}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// AC: @trait-display ac-1, ac-4, ac-5 - Show inherited AC per trait in labeled sections
|
|
320
|
+
if (traitsByTrait.size > 0) {
|
|
321
|
+
for (const { trait, acs } of traitsByTrait.values()) {
|
|
322
|
+
console.log(chalk.gray(`\n─── Inherited from @${trait.slug} ───`));
|
|
323
|
+
for (const ac of acs) {
|
|
324
|
+
console.log(chalk.cyan(` [${ac.id}]`) + chalk.gray(` (from @${trait.slug})`));
|
|
325
|
+
if (ac.given)
|
|
326
|
+
console.log(` Given: ${ac.given}`);
|
|
327
|
+
if (ac.when)
|
|
328
|
+
console.log(` When: ${ac.when}`);
|
|
329
|
+
if (ac.then)
|
|
330
|
+
console.log(` Then: ${ac.then}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
error(errors.failures.getItem, err);
|
|
338
|
+
process.exit(EXIT_CODES.ERROR);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
// kspec item types - show available types and counts
|
|
342
|
+
item
|
|
343
|
+
.command('types')
|
|
344
|
+
.description('Show item types and counts')
|
|
345
|
+
.action(async () => {
|
|
346
|
+
try {
|
|
347
|
+
const ctx = await initContext();
|
|
348
|
+
const { itemIndex } = await buildIndexes(ctx);
|
|
349
|
+
const typeCounts = itemIndex.getTypeCounts();
|
|
350
|
+
output(Object.fromEntries(typeCounts), () => {
|
|
351
|
+
console.log(chalk.bold('Item Types'));
|
|
352
|
+
console.log(chalk.gray('─'.repeat(30)));
|
|
353
|
+
for (const [type, count] of typeCounts) {
|
|
354
|
+
console.log(` ${type}: ${count}`);
|
|
355
|
+
}
|
|
356
|
+
console.log(chalk.gray(`\nTotal: ${itemIndex.size} items`));
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
error(errors.failures.getTypes, err);
|
|
361
|
+
process.exit(EXIT_CODES.ERROR);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
// kspec item tags - show available tags and counts
|
|
365
|
+
item
|
|
366
|
+
.command('tags')
|
|
367
|
+
.description('Show tags and counts')
|
|
368
|
+
.action(async () => {
|
|
369
|
+
try {
|
|
370
|
+
const ctx = await initContext();
|
|
371
|
+
const { itemIndex } = await buildIndexes(ctx);
|
|
372
|
+
const tagCounts = itemIndex.getTagCounts();
|
|
373
|
+
output(Object.fromEntries(tagCounts), () => {
|
|
374
|
+
console.log(chalk.bold('Tags'));
|
|
375
|
+
console.log(chalk.gray('─'.repeat(30)));
|
|
376
|
+
for (const [tag, count] of tagCounts) {
|
|
377
|
+
console.log(` #${tag}: ${count}`);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
error(errors.failures.getTags, err);
|
|
383
|
+
process.exit(EXIT_CODES.ERROR);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
// kspec item add - create a new spec item under a parent
|
|
387
|
+
item
|
|
388
|
+
.command('add')
|
|
389
|
+
.description('Create a new spec item under a parent')
|
|
390
|
+
.requiredOption('--under <ref>', 'Parent item reference (e.g., @core-primitives)')
|
|
391
|
+
.requiredOption('--title <title>', 'Item title')
|
|
392
|
+
.option('--type <type>', 'Item type (feature, requirement, constraint, decision)', 'feature')
|
|
393
|
+
.option('--slug <slug>', 'Human-friendly slug')
|
|
394
|
+
.option('--priority <priority>', 'Priority (high, medium, low)')
|
|
395
|
+
.option('--tag <tag...>', 'Tags')
|
|
396
|
+
.option('--description <desc>', 'Description')
|
|
397
|
+
.option('--as <field>', 'Child field override (e.g., requirements, constraints)')
|
|
398
|
+
.action(async (options) => {
|
|
399
|
+
try {
|
|
400
|
+
const ctx = await initContext();
|
|
401
|
+
const { refIndex, items } = await buildIndexes(ctx);
|
|
402
|
+
// Find the parent item
|
|
403
|
+
const parentResult = refIndex.resolve(options.under);
|
|
404
|
+
if (!parentResult.ok) {
|
|
405
|
+
error(errors.reference.itemNotFound(options.under));
|
|
406
|
+
process.exit(EXIT_CODES.ERROR);
|
|
407
|
+
}
|
|
408
|
+
const parent = parentResult.item;
|
|
409
|
+
// Check it's not a task
|
|
410
|
+
if ('status' in parent && typeof parent.status === 'string') {
|
|
411
|
+
error(errors.reference.parentIsTask(options.under));
|
|
412
|
+
process.exit(EXIT_CODES.ERROR);
|
|
413
|
+
}
|
|
414
|
+
// Check slug uniqueness if provided
|
|
415
|
+
if (options.slug) {
|
|
416
|
+
const slugCheck = checkSlugUniqueness(refIndex, [options.slug]);
|
|
417
|
+
if (!slugCheck.ok) {
|
|
418
|
+
error(errors.slug.alreadyExists(slugCheck.slug, slugCheck.existingUlid));
|
|
419
|
+
process.exit(EXIT_CODES.CONFLICT);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const input = {
|
|
423
|
+
title: options.title,
|
|
424
|
+
type: options.type,
|
|
425
|
+
slugs: options.slug ? [options.slug] : [],
|
|
426
|
+
priority: options.priority,
|
|
427
|
+
tags: options.tag || [],
|
|
428
|
+
description: options.description,
|
|
429
|
+
depends_on: [],
|
|
430
|
+
implements: [],
|
|
431
|
+
relates_to: [],
|
|
432
|
+
tests: [],
|
|
433
|
+
traits: [],
|
|
434
|
+
notes: [],
|
|
435
|
+
};
|
|
436
|
+
const newItem = createSpecItem(input);
|
|
437
|
+
const result = await addChildItem(ctx, parent, newItem, options.as);
|
|
438
|
+
// Build index including the new item for accurate short ULID
|
|
439
|
+
const index = new ReferenceIndex([], [...items, result.item]);
|
|
440
|
+
const itemSlug = result.item.slugs?.[0] || index.shortUlid(result.item._ulid);
|
|
441
|
+
await commitIfShadow(ctx.shadow, 'item-add', itemSlug);
|
|
442
|
+
success(`Created item: ${index.shortUlid(result.item._ulid)} under @${parent.slugs[0] || parent._ulid.slice(0, 8)}`, {
|
|
443
|
+
item: result.item,
|
|
444
|
+
path: result.path,
|
|
445
|
+
});
|
|
446
|
+
// Derive hint
|
|
447
|
+
if (!isJsonMode()) {
|
|
448
|
+
const refSlug = result.item.slugs?.[0] || index.shortUlid(result.item._ulid);
|
|
449
|
+
console.log(chalk.gray(`\nDerive implementation task? kspec derive @${refSlug}`));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
error(errors.failures.createItem, err);
|
|
454
|
+
process.exit(EXIT_CODES.ERROR);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
// kspec item set - update a spec item field
|
|
458
|
+
item
|
|
459
|
+
.command('set <ref>')
|
|
460
|
+
.description('Update a spec item field')
|
|
461
|
+
.option('--title <title>', 'Set title')
|
|
462
|
+
.option('--type <type>', 'Set type')
|
|
463
|
+
.option('--slug <slug>', 'Add a slug')
|
|
464
|
+
.option('--remove-slug <slug>', 'Remove a slug')
|
|
465
|
+
.option('--priority <priority>', 'Set priority')
|
|
466
|
+
.option('--tag <tag...>', 'Set tags (replaces existing)')
|
|
467
|
+
.option('--description <desc>', 'Set description')
|
|
468
|
+
.option('--status <status>', 'Set implementation status (not_started, in_progress, implemented, verified)')
|
|
469
|
+
.option('--maturity <maturity>', 'Set maturity (draft, proposed, stable, deferred, deprecated)')
|
|
470
|
+
.action(async (ref, options) => {
|
|
471
|
+
try {
|
|
472
|
+
const ctx = await initContext();
|
|
473
|
+
const { refIndex, items } = await buildIndexes(ctx);
|
|
474
|
+
const result = refIndex.resolve(ref);
|
|
475
|
+
if (!result.ok) {
|
|
476
|
+
error(errors.reference.itemNotFound(ref));
|
|
477
|
+
process.exit(EXIT_CODES.ERROR);
|
|
478
|
+
}
|
|
479
|
+
const foundItem = result.item;
|
|
480
|
+
// Check if it's a task (tasks should use task commands)
|
|
481
|
+
if ('status' in foundItem && typeof foundItem.status === 'string') {
|
|
482
|
+
error(errors.reference.taskUseTaskCommands(ref));
|
|
483
|
+
process.exit(EXIT_CODES.ERROR);
|
|
484
|
+
}
|
|
485
|
+
// Check slug uniqueness if adding a new slug
|
|
486
|
+
if (options.slug) {
|
|
487
|
+
const slugCheck = checkSlugUniqueness(refIndex, [options.slug], foundItem._ulid);
|
|
488
|
+
if (!slugCheck.ok) {
|
|
489
|
+
error(errors.slug.alreadyExists(slugCheck.slug, slugCheck.existingUlid));
|
|
490
|
+
process.exit(EXIT_CODES.CONFLICT);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// Validate --remove-slug
|
|
494
|
+
if (options.removeSlug) {
|
|
495
|
+
const currentSlugs = foundItem.slugs || [];
|
|
496
|
+
if (!currentSlugs.includes(options.removeSlug)) {
|
|
497
|
+
error(errors.slug.notFound(options.removeSlug));
|
|
498
|
+
process.exit(EXIT_CODES.ERROR);
|
|
499
|
+
}
|
|
500
|
+
if (currentSlugs.length === 1) {
|
|
501
|
+
error(errors.slug.cannotRemoveLast(options.removeSlug));
|
|
502
|
+
process.exit(EXIT_CODES.ERROR);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// Build updates object
|
|
506
|
+
const updates = {};
|
|
507
|
+
if (options.title)
|
|
508
|
+
updates.title = options.title;
|
|
509
|
+
if (options.type)
|
|
510
|
+
updates.type = options.type;
|
|
511
|
+
if (options.slug || options.removeSlug) {
|
|
512
|
+
let slugs = [...(foundItem.slugs || [])];
|
|
513
|
+
if (options.removeSlug) {
|
|
514
|
+
slugs = slugs.filter(s => s !== options.removeSlug);
|
|
515
|
+
}
|
|
516
|
+
if (options.slug) {
|
|
517
|
+
slugs.push(options.slug);
|
|
518
|
+
}
|
|
519
|
+
updates.slugs = slugs;
|
|
520
|
+
}
|
|
521
|
+
if (options.priority)
|
|
522
|
+
updates.priority = options.priority;
|
|
523
|
+
if (options.tag)
|
|
524
|
+
updates.tags = options.tag;
|
|
525
|
+
if (options.description)
|
|
526
|
+
updates.description = options.description;
|
|
527
|
+
// Handle status updates
|
|
528
|
+
if (options.status || options.maturity) {
|
|
529
|
+
const currentStatus = foundItem.status && typeof foundItem.status === 'object'
|
|
530
|
+
? foundItem.status
|
|
531
|
+
: {};
|
|
532
|
+
updates.status = {
|
|
533
|
+
...currentStatus,
|
|
534
|
+
...(options.status && { implementation: options.status }),
|
|
535
|
+
...(options.maturity && { maturity: options.maturity }),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
if (Object.keys(updates).length === 0) {
|
|
539
|
+
warn('No updates specified');
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const updated = await updateSpecItem(ctx, foundItem, updates);
|
|
543
|
+
const itemSlug = foundItem.slugs[0] || refIndex.shortUlid(foundItem._ulid);
|
|
544
|
+
// Handle cascade for implementation status updates
|
|
545
|
+
const updatedItems = [updated];
|
|
546
|
+
if (options.status) {
|
|
547
|
+
const cascadeResult = await handleStatusCascade(ctx, updated, options.status, items, refIndex);
|
|
548
|
+
updatedItems.push(...cascadeResult);
|
|
549
|
+
}
|
|
550
|
+
await commitIfShadow(ctx.shadow, 'item-set', itemSlug);
|
|
551
|
+
success(`Updated item: ${refIndex.shortUlid(updated._ulid)}`, { item: updated });
|
|
552
|
+
// Derive hint
|
|
553
|
+
if (!isJsonMode()) {
|
|
554
|
+
const refSlug = updated.slugs?.[0] || refIndex.shortUlid(updated._ulid);
|
|
555
|
+
console.log(chalk.gray(`\nDerive implementation task? kspec derive @${refSlug}`));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
catch (err) {
|
|
559
|
+
error(errors.failures.updateItem, err);
|
|
560
|
+
process.exit(EXIT_CODES.ERROR);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
// kspec item delete - delete a spec item
|
|
564
|
+
item
|
|
565
|
+
.command('delete <ref>')
|
|
566
|
+
.description('Delete a spec item (including nested items)')
|
|
567
|
+
.option('--force', 'Skip confirmation')
|
|
568
|
+
.option('--cascade', 'Delete item and all descendants')
|
|
569
|
+
.action(async (ref, options) => {
|
|
570
|
+
try {
|
|
571
|
+
const ctx = await initContext();
|
|
572
|
+
const { refIndex, items } = await buildIndexes(ctx);
|
|
573
|
+
const result = refIndex.resolve(ref);
|
|
574
|
+
if (!result.ok) {
|
|
575
|
+
error(errors.reference.itemNotFound(ref));
|
|
576
|
+
process.exit(EXIT_CODES.ERROR);
|
|
577
|
+
}
|
|
578
|
+
const foundItem = result.item;
|
|
579
|
+
// Check if it's a task
|
|
580
|
+
if ('status' in foundItem && typeof foundItem.status === 'string') {
|
|
581
|
+
error(errors.reference.itemUseTaskCancel(ref));
|
|
582
|
+
process.exit(EXIT_CODES.ERROR);
|
|
583
|
+
}
|
|
584
|
+
if (!foundItem._sourceFile) {
|
|
585
|
+
error(errors.operation.cannotDeleteNoSource);
|
|
586
|
+
process.exit(EXIT_CODES.ERROR);
|
|
587
|
+
}
|
|
588
|
+
// AC-7: Check if this is a trait with implementors
|
|
589
|
+
const implementors = findTraitImplementors(foundItem, items);
|
|
590
|
+
if (implementors.length > 0) {
|
|
591
|
+
const implementorRefs = implementors.map(i => `@${i.slugs[0] || i._ulid.slice(0, 8)}`).join(', ');
|
|
592
|
+
const errorMsg = `Cannot delete: trait is used by ${implementors.length} specs. Remove trait from specs first: ${implementorRefs}`;
|
|
593
|
+
if (isJsonMode()) {
|
|
594
|
+
error(errorMsg, {
|
|
595
|
+
error: 'trait_in_use',
|
|
596
|
+
implementors: implementors.map(i => ({
|
|
597
|
+
ulid: i._ulid,
|
|
598
|
+
slug: i.slugs[0],
|
|
599
|
+
title: i.title,
|
|
600
|
+
})),
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
error(errorMsg);
|
|
605
|
+
}
|
|
606
|
+
process.exit(EXIT_CODES.ERROR);
|
|
607
|
+
}
|
|
608
|
+
// AC-1/AC-8: Check for child items (nested YAML items, not relates_to refs)
|
|
609
|
+
const children = findChildItems(foundItem, items);
|
|
610
|
+
if (children.length > 0 && !options.cascade) {
|
|
611
|
+
// AC-1: Block deletion if children exist without --cascade
|
|
612
|
+
const errorMsg = `Cannot delete: item has ${children.length} children. Use --cascade to delete recursively`;
|
|
613
|
+
if (isJsonMode()) {
|
|
614
|
+
// AC-10: JSON error includes children array
|
|
615
|
+
error(errorMsg, {
|
|
616
|
+
error: 'has_children',
|
|
617
|
+
children: children.map(c => ({
|
|
618
|
+
ulid: c._ulid,
|
|
619
|
+
slug: c.slugs[0],
|
|
620
|
+
title: c.title,
|
|
621
|
+
ref: `@${c.slugs[0] || c._ulid.slice(0, 8)}`,
|
|
622
|
+
})),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
error(errorMsg);
|
|
627
|
+
}
|
|
628
|
+
process.exit(EXIT_CODES.ERROR);
|
|
629
|
+
}
|
|
630
|
+
// AC-9: Custom confirmation prompt for cascade
|
|
631
|
+
if (children.length > 0 && options.cascade && !options.force) {
|
|
632
|
+
const itemRef = `@${foundItem.slugs[0] || foundItem._ulid.slice(0, 8)}`;
|
|
633
|
+
// Check for JSON mode - requires --force
|
|
634
|
+
if (isJsonMode()) {
|
|
635
|
+
error('Confirmation required. Use --force with --json');
|
|
636
|
+
process.exit(EXIT_CODES.ERROR);
|
|
637
|
+
}
|
|
638
|
+
// Check for non-interactive environment
|
|
639
|
+
const isTTY = process.env.KSPEC_TEST_TTY === 'true' || process.stdin.isTTY;
|
|
640
|
+
if (!isTTY) {
|
|
641
|
+
error('Non-interactive environment. Use --force to proceed');
|
|
642
|
+
process.exit(EXIT_CODES.ERROR);
|
|
643
|
+
}
|
|
644
|
+
// Show confirmation prompt
|
|
645
|
+
const readline = await import('readline');
|
|
646
|
+
const rl = readline.createInterface({
|
|
647
|
+
input: process.stdin,
|
|
648
|
+
output: process.stdout,
|
|
649
|
+
});
|
|
650
|
+
const response = await new Promise(resolve => {
|
|
651
|
+
rl.question(chalk.yellow(`Delete ${itemRef} and ${children.length} descendant items? [y/N] `), answer => {
|
|
652
|
+
rl.close();
|
|
653
|
+
resolve(answer);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
if (response.toLowerCase() !== 'y') {
|
|
657
|
+
console.log(chalk.gray('Operation cancelled'));
|
|
658
|
+
process.exit(EXIT_CODES.USAGE_ERROR);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
// AC-2/AC-3: Delete item and all descendants with cascade
|
|
662
|
+
const itemsToDelete = options.cascade ? [foundItem, ...children] : [foundItem];
|
|
663
|
+
let deletedCount = 0;
|
|
664
|
+
// Delete in reverse order (deepest first) to avoid path issues
|
|
665
|
+
const sortedItems = [...itemsToDelete].sort((a, b) => {
|
|
666
|
+
const aDepth = a._path ? a._path.split('.').length : 0;
|
|
667
|
+
const bDepth = b._path ? b._path.split('.').length : 0;
|
|
668
|
+
return bDepth - aDepth;
|
|
669
|
+
});
|
|
670
|
+
for (const itemToDelete of sortedItems) {
|
|
671
|
+
const deleted = await deleteSpecItem(ctx, itemToDelete);
|
|
672
|
+
if (deleted) {
|
|
673
|
+
deletedCount++;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (deletedCount > 0) {
|
|
677
|
+
// AC-6: Single shadow commit with all deletions
|
|
678
|
+
const itemSlug = foundItem.slugs[0] || refIndex.shortUlid(foundItem._ulid);
|
|
679
|
+
const commitMsg = deletedCount > 1 ? `${deletedCount} items` : itemSlug;
|
|
680
|
+
await commitIfShadow(ctx.shadow, 'item-delete', commitMsg);
|
|
681
|
+
if (deletedCount > 1) {
|
|
682
|
+
success(`Deleted ${deletedCount} items`, { deleted: deletedCount, root_ulid: foundItem._ulid });
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
success(`Deleted item: ${foundItem.title}`, { deleted: true, ulid: foundItem._ulid });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
error(errors.failures.deleteItem);
|
|
690
|
+
console.log(chalk.gray('Edit the source file directly: ' + foundItem._sourceFile));
|
|
691
|
+
process.exit(EXIT_CODES.ERROR);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
error(errors.failures.deleteItem, err);
|
|
696
|
+
process.exit(EXIT_CODES.ERROR);
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
// kspec item patch - update item fields via JSON
|
|
700
|
+
item
|
|
701
|
+
.command('patch [ref]')
|
|
702
|
+
.description('Update spec item fields via JSON patch')
|
|
703
|
+
.option('--data <json>', 'JSON data to patch')
|
|
704
|
+
.option('--bulk', 'Read patches from stdin (JSONL or JSON array)')
|
|
705
|
+
.option('--allow-unknown', 'Allow fields not in schema')
|
|
706
|
+
.option('--dry-run', 'Preview changes without applying')
|
|
707
|
+
.option('--fail-fast', 'Stop on first error (bulk mode)')
|
|
708
|
+
.action(async (ref, options) => {
|
|
709
|
+
try {
|
|
710
|
+
const ctx = await initContext();
|
|
711
|
+
if (options.bulk) {
|
|
712
|
+
// Bulk mode: read from stdin
|
|
713
|
+
const stdin = await readStdinFully();
|
|
714
|
+
if (!stdin) {
|
|
715
|
+
error(errors.validation.noInputProvided);
|
|
716
|
+
process.exit(EXIT_CODES.ERROR);
|
|
717
|
+
}
|
|
718
|
+
let patches;
|
|
719
|
+
try {
|
|
720
|
+
patches = parseBulkInput(stdin);
|
|
721
|
+
}
|
|
722
|
+
catch (err) {
|
|
723
|
+
error(errors.validation.failedToParseBulk(err instanceof Error ? err.message : String(err)));
|
|
724
|
+
process.exit(EXIT_CODES.ERROR);
|
|
725
|
+
}
|
|
726
|
+
if (patches.length === 0) {
|
|
727
|
+
error(errors.validation.noPatchesProvided);
|
|
728
|
+
process.exit(EXIT_CODES.ERROR);
|
|
729
|
+
}
|
|
730
|
+
const { refIndex, items } = await buildIndexes(ctx);
|
|
731
|
+
const result = await patchSpecItems(ctx, refIndex, items, patches, {
|
|
732
|
+
allowUnknown: options.allowUnknown,
|
|
733
|
+
failFast: options.failFast,
|
|
734
|
+
dryRun: options.dryRun,
|
|
735
|
+
});
|
|
736
|
+
// Shadow commit if any updates
|
|
737
|
+
if (!options.dryRun && result.summary.updated > 0) {
|
|
738
|
+
await commitIfShadow(ctx.shadow, 'item-patch', `${result.summary.updated} items`);
|
|
739
|
+
}
|
|
740
|
+
output(result, () => formatBulkPatchResult(result, options.dryRun));
|
|
741
|
+
if (result.summary.failed > 0) {
|
|
742
|
+
process.exit(EXIT_CODES.ERROR);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
// Single item mode
|
|
747
|
+
if (!ref) {
|
|
748
|
+
error(errors.usage.patchNeedRef);
|
|
749
|
+
process.exit(EXIT_CODES.ERROR);
|
|
750
|
+
}
|
|
751
|
+
let data;
|
|
752
|
+
// Get data from --data option or stdin
|
|
753
|
+
if (options.data) {
|
|
754
|
+
try {
|
|
755
|
+
data = JSON.parse(options.data);
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
error(errors.validation.invalidJsonInData(err instanceof Error ? err.message : ''));
|
|
759
|
+
process.exit(EXIT_CODES.ERROR);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
const stdin = await readStdinIfAvailable();
|
|
764
|
+
if (stdin) {
|
|
765
|
+
try {
|
|
766
|
+
data = JSON.parse(stdin.trim());
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
error(errors.validation.invalidJsonFromStdin(err instanceof Error ? err.message : ''));
|
|
770
|
+
process.exit(EXIT_CODES.ERROR);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
error(errors.validation.noPatchData);
|
|
775
|
+
process.exit(EXIT_CODES.ERROR);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// Validate against schema (unless --allow-unknown)
|
|
779
|
+
if (!options.allowUnknown) {
|
|
780
|
+
// Use strict schema (no passthrough)
|
|
781
|
+
const strictSchema = SpecItemPatchSchema.strict();
|
|
782
|
+
const parseResult = strictSchema.safeParse(data);
|
|
783
|
+
if (!parseResult.success) {
|
|
784
|
+
const issues = parseResult.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
|
|
785
|
+
error(errors.validation.invalidPatchDataWithIssues(issues));
|
|
786
|
+
process.exit(EXIT_CODES.ERROR);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const { refIndex, items } = await buildIndexes(ctx);
|
|
790
|
+
// Resolve ref
|
|
791
|
+
const resolved = refIndex.resolve(ref);
|
|
792
|
+
if (!resolved.ok) {
|
|
793
|
+
error(errors.reference.itemNotFound(ref));
|
|
794
|
+
process.exit(EXIT_CODES.ERROR);
|
|
795
|
+
}
|
|
796
|
+
// Find the item
|
|
797
|
+
const foundItem = items.find(i => i._ulid === resolved.ulid);
|
|
798
|
+
if (!foundItem) {
|
|
799
|
+
error(errors.reference.notItem(ref));
|
|
800
|
+
process.exit(EXIT_CODES.ERROR);
|
|
801
|
+
}
|
|
802
|
+
if (options.dryRun) {
|
|
803
|
+
output({ ref, data, wouldApplyTo: foundItem.title, ulid: foundItem._ulid }, () => {
|
|
804
|
+
console.log(chalk.yellow('Would patch:'), foundItem.title);
|
|
805
|
+
console.log(chalk.gray('ULID:'), foundItem._ulid.slice(0, 8));
|
|
806
|
+
console.log(chalk.gray('Changes:'));
|
|
807
|
+
console.log(JSON.stringify(data, null, 2));
|
|
808
|
+
});
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const updated = await updateSpecItem(ctx, foundItem, data);
|
|
812
|
+
const itemSlug = foundItem.slugs[0] || refIndex.shortUlid(foundItem._ulid);
|
|
813
|
+
await commitIfShadow(ctx.shadow, 'item-patch', itemSlug);
|
|
814
|
+
success(`Patched item: ${itemSlug}`, { item: updated });
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
catch (err) {
|
|
818
|
+
error(errors.failures.patchItems, err);
|
|
819
|
+
process.exit(EXIT_CODES.ERROR);
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
// kspec item status - show implementation status with linked tasks
|
|
823
|
+
item
|
|
824
|
+
.command('status <ref>')
|
|
825
|
+
.description('Show implementation status and linked tasks for a spec item')
|
|
826
|
+
.action(async (ref) => {
|
|
827
|
+
try {
|
|
828
|
+
const ctx = await initContext();
|
|
829
|
+
const tasks = await loadAllTasks(ctx);
|
|
830
|
+
const items = await loadAllItems(ctx);
|
|
831
|
+
const refIndex = new ReferenceIndex(tasks, items);
|
|
832
|
+
const result = refIndex.resolve(ref);
|
|
833
|
+
if (!result.ok) {
|
|
834
|
+
error(errors.reference.itemNotFound(ref));
|
|
835
|
+
process.exit(EXIT_CODES.ERROR);
|
|
836
|
+
}
|
|
837
|
+
const foundItem = result.item;
|
|
838
|
+
// Check if it's a task
|
|
839
|
+
if ('status' in foundItem && typeof foundItem.status === 'string') {
|
|
840
|
+
error(errors.reference.notItem(ref));
|
|
841
|
+
process.exit(EXIT_CODES.ERROR);
|
|
842
|
+
}
|
|
843
|
+
// Build alignment index
|
|
844
|
+
const alignmentIndex = new AlignmentIndex(tasks, items);
|
|
845
|
+
alignmentIndex.buildLinks(refIndex);
|
|
846
|
+
const summary = alignmentIndex.getImplementationSummary(foundItem._ulid);
|
|
847
|
+
if (!summary) {
|
|
848
|
+
error(errors.project.couldNotGetImplSummary);
|
|
849
|
+
process.exit(EXIT_CODES.ERROR);
|
|
850
|
+
}
|
|
851
|
+
output(summary, () => {
|
|
852
|
+
console.log(chalk.bold(foundItem.title));
|
|
853
|
+
console.log(chalk.gray('─'.repeat(40)));
|
|
854
|
+
// Status
|
|
855
|
+
const currentColor = summary.currentStatus === 'implemented' ? chalk.green
|
|
856
|
+
: summary.currentStatus === 'in_progress' ? chalk.yellow
|
|
857
|
+
: chalk.gray;
|
|
858
|
+
const expectedColor = summary.expectedStatus === 'implemented' ? chalk.green
|
|
859
|
+
: summary.expectedStatus === 'in_progress' ? chalk.yellow
|
|
860
|
+
: chalk.gray;
|
|
861
|
+
console.log(`Current status: ${currentColor(summary.currentStatus)}`);
|
|
862
|
+
console.log(`Expected status: ${expectedColor(summary.expectedStatus)}`);
|
|
863
|
+
if (!summary.isAligned) {
|
|
864
|
+
console.log(chalk.yellow('\n⚠ Status mismatch - run task complete to sync'));
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
console.log(chalk.green('\n✓ Aligned'));
|
|
868
|
+
}
|
|
869
|
+
// Linked tasks
|
|
870
|
+
console.log(chalk.bold('\nLinked Tasks:'));
|
|
871
|
+
if (summary.linkedTasks.length === 0) {
|
|
872
|
+
console.log(chalk.gray(' No tasks reference this spec item'));
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
for (const task of summary.linkedTasks) {
|
|
876
|
+
const statusColor = task.taskStatus === 'completed' ? chalk.green
|
|
877
|
+
: task.taskStatus === 'in_progress' ? chalk.blue
|
|
878
|
+
: chalk.gray;
|
|
879
|
+
const shortId = task.taskUlid.slice(0, 8);
|
|
880
|
+
const notes = task.hasNotes ? chalk.gray(' (has notes)') : '';
|
|
881
|
+
console.log(` ${statusColor(`[${task.taskStatus}]`)} ${shortId} ${task.taskTitle}${notes}`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
catch (err) {
|
|
887
|
+
error(errors.failures.getItemStatus, err);
|
|
888
|
+
process.exit(EXIT_CODES.ERROR);
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
// kspec item note <ref> <message>
|
|
892
|
+
item
|
|
893
|
+
.command('note <ref> <message>')
|
|
894
|
+
.description('Add a note to a spec item')
|
|
895
|
+
.option('--author <author>', 'Note author')
|
|
896
|
+
.option('--supersedes <ulid>', 'ULID of note this supersedes')
|
|
897
|
+
.action(async (ref, message, options) => {
|
|
898
|
+
try {
|
|
899
|
+
const ctx = await initContext();
|
|
900
|
+
const items = await loadAllItems(ctx);
|
|
901
|
+
const tasks = await loadAllTasks(ctx);
|
|
902
|
+
const refIndex = new ReferenceIndex(tasks, items);
|
|
903
|
+
const result = refIndex.resolve(ref);
|
|
904
|
+
if (!result.ok) {
|
|
905
|
+
error(errors.reference.itemNotFound(ref));
|
|
906
|
+
process.exit(EXIT_CODES.ERROR);
|
|
907
|
+
}
|
|
908
|
+
const foundItem = items.find(i => i._ulid === result.ulid);
|
|
909
|
+
if (!foundItem) {
|
|
910
|
+
error(errors.reference.itemNotFound(ref));
|
|
911
|
+
process.exit(EXIT_CODES.ERROR);
|
|
912
|
+
}
|
|
913
|
+
const note = createNote(message, options.author, options.supersedes);
|
|
914
|
+
const updatedNotes = [...(foundItem.notes || []), note];
|
|
915
|
+
await updateSpecItem(ctx, foundItem, { notes: updatedNotes });
|
|
916
|
+
const itemSlug = foundItem.slugs[0] || refIndex.shortUlid(foundItem._ulid);
|
|
917
|
+
await commitIfShadow(ctx.shadow, 'item-note', itemSlug);
|
|
918
|
+
success(`Added note to spec item: ${refIndex.shortUlid(foundItem._ulid)}`, { note });
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
error(errors.failures.addNote, err);
|
|
922
|
+
process.exit(EXIT_CODES.ERROR);
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
// kspec item notes <ref>
|
|
926
|
+
item
|
|
927
|
+
.command('notes <ref>')
|
|
928
|
+
.description('Show notes for a spec item')
|
|
929
|
+
.action(async (ref) => {
|
|
930
|
+
try {
|
|
931
|
+
const ctx = await initContext();
|
|
932
|
+
const items = await loadAllItems(ctx);
|
|
933
|
+
const tasks = await loadAllTasks(ctx);
|
|
934
|
+
const refIndex = new ReferenceIndex(tasks, items);
|
|
935
|
+
const result = refIndex.resolve(ref);
|
|
936
|
+
if (!result.ok) {
|
|
937
|
+
error(errors.reference.itemNotFound(ref));
|
|
938
|
+
process.exit(EXIT_CODES.ERROR);
|
|
939
|
+
}
|
|
940
|
+
const foundItem = items.find(i => i._ulid === result.ulid);
|
|
941
|
+
if (!foundItem) {
|
|
942
|
+
error(errors.reference.itemNotFound(ref));
|
|
943
|
+
process.exit(EXIT_CODES.ERROR);
|
|
944
|
+
}
|
|
945
|
+
const notes = foundItem.notes || [];
|
|
946
|
+
output(notes, () => {
|
|
947
|
+
if (notes.length === 0) {
|
|
948
|
+
console.log('No notes');
|
|
949
|
+
}
|
|
950
|
+
else {
|
|
951
|
+
for (const note of notes) {
|
|
952
|
+
const author = note.author || 'unknown';
|
|
953
|
+
console.log(`[${note.created_at}] ${author}:`);
|
|
954
|
+
console.log(note.content);
|
|
955
|
+
console.log('');
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
catch (err) {
|
|
961
|
+
error(errors.failures.getNotes, err);
|
|
962
|
+
process.exit(EXIT_CODES.ERROR);
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
// Create subcommand group for acceptance criteria operations
|
|
966
|
+
const acCmd = item
|
|
967
|
+
.command('ac')
|
|
968
|
+
.description('Manage acceptance criteria on spec items');
|
|
969
|
+
// Helper: Generate next AC ID based on existing AC
|
|
970
|
+
function generateNextAcId(existingAc) {
|
|
971
|
+
if (!existingAc || existingAc.length === 0)
|
|
972
|
+
return 'ac-1';
|
|
973
|
+
const numericIds = existingAc
|
|
974
|
+
.map(ac => ac.id.match(/^ac-(\d+)$/)?.[1])
|
|
975
|
+
.filter((id) => id !== null && id !== undefined)
|
|
976
|
+
.map(Number);
|
|
977
|
+
const maxId = numericIds.length > 0 ? Math.max(...numericIds) : 0;
|
|
978
|
+
return `ac-${maxId + 1}`;
|
|
979
|
+
}
|
|
980
|
+
// Helper: Resolve ref to spec item (not task)
|
|
981
|
+
async function resolveSpecItem(ref) {
|
|
982
|
+
const ctx = await initContext();
|
|
983
|
+
const { refIndex, items } = await buildIndexes(ctx);
|
|
984
|
+
const result = refIndex.resolve(ref);
|
|
985
|
+
if (!result.ok) {
|
|
986
|
+
error(errors.reference.itemNotFound(ref));
|
|
987
|
+
process.exit(EXIT_CODES.NOT_FOUND);
|
|
988
|
+
}
|
|
989
|
+
const foundItem = result.item;
|
|
990
|
+
// Check if it's a task
|
|
991
|
+
if ('status' in foundItem && typeof foundItem.status === 'string') {
|
|
992
|
+
error(errors.operation.tasksNoAcceptanceCriteria(ref));
|
|
993
|
+
process.exit(EXIT_CODES.NOT_FOUND);
|
|
994
|
+
}
|
|
995
|
+
return { ctx, item: foundItem, refIndex };
|
|
996
|
+
}
|
|
997
|
+
// kspec item ac list <ref>
|
|
998
|
+
acCmd
|
|
999
|
+
.command('list <ref>')
|
|
1000
|
+
.description('List acceptance criteria for a spec item')
|
|
1001
|
+
.action(async (ref) => {
|
|
1002
|
+
try {
|
|
1003
|
+
const { item, refIndex } = await resolveSpecItem(ref);
|
|
1004
|
+
const ac = item.acceptance_criteria || [];
|
|
1005
|
+
output(ac, () => {
|
|
1006
|
+
console.log(chalk.bold(`Acceptance Criteria for: ${item.title} (@${item.slugs[0] || refIndex.shortUlid(item._ulid)})`));
|
|
1007
|
+
console.log();
|
|
1008
|
+
if (ac.length === 0) {
|
|
1009
|
+
console.log(chalk.gray('No acceptance criteria'));
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
for (const criterion of ac) {
|
|
1013
|
+
console.log(chalk.cyan(` [${criterion.id}]`));
|
|
1014
|
+
console.log(chalk.gray(` Given: ${criterion.given}`));
|
|
1015
|
+
console.log(chalk.gray(` When: ${criterion.when}`));
|
|
1016
|
+
console.log(chalk.gray(` Then: ${criterion.then}`));
|
|
1017
|
+
console.log();
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
console.log(chalk.gray(`${ac.length} acceptance criteria`));
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
catch (err) {
|
|
1024
|
+
error(errors.failures.listAc, err);
|
|
1025
|
+
process.exit(EXIT_CODES.ERROR);
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
// kspec item ac add <ref>
|
|
1029
|
+
acCmd
|
|
1030
|
+
.command('add <ref>')
|
|
1031
|
+
.description('Add an acceptance criterion to a spec item')
|
|
1032
|
+
.option('--id <id>', 'AC identifier (auto-generated if not provided)')
|
|
1033
|
+
.requiredOption('--given <text>', 'The precondition (Given...)')
|
|
1034
|
+
.requiredOption('--when <text>', 'The action/trigger (When...)')
|
|
1035
|
+
.requiredOption('--then <text>', 'The expected outcome (Then...)')
|
|
1036
|
+
.action(async (ref, options) => {
|
|
1037
|
+
try {
|
|
1038
|
+
const { ctx, item, refIndex } = await resolveSpecItem(ref);
|
|
1039
|
+
const existingAc = item.acceptance_criteria || [];
|
|
1040
|
+
// Determine ID
|
|
1041
|
+
const acId = options.id || generateNextAcId(existingAc);
|
|
1042
|
+
// Check for duplicate ID
|
|
1043
|
+
if (existingAc.some(ac => ac.id === acId)) {
|
|
1044
|
+
const itemRef = item.slugs[0] || refIndex.shortUlid(item._ulid);
|
|
1045
|
+
error(errors.conflict.acAlreadyExists(acId, itemRef));
|
|
1046
|
+
process.exit(EXIT_CODES.CONFLICT);
|
|
1047
|
+
}
|
|
1048
|
+
// Create new AC
|
|
1049
|
+
const newAc = {
|
|
1050
|
+
id: acId,
|
|
1051
|
+
given: options.given,
|
|
1052
|
+
when: options.when,
|
|
1053
|
+
then: options.then,
|
|
1054
|
+
};
|
|
1055
|
+
// Update item with new AC
|
|
1056
|
+
const updatedAc = [...existingAc, newAc];
|
|
1057
|
+
await updateSpecItem(ctx, item, { acceptance_criteria: updatedAc });
|
|
1058
|
+
const itemSlug = item.slugs[0] || refIndex.shortUlid(item._ulid);
|
|
1059
|
+
await commitIfShadow(ctx.shadow, 'item-ac-add', itemSlug);
|
|
1060
|
+
success(`Added acceptance criterion: ${acId} to @${itemSlug}`, { ac: newAc });
|
|
1061
|
+
}
|
|
1062
|
+
catch (err) {
|
|
1063
|
+
error(errors.failures.addAc, err);
|
|
1064
|
+
process.exit(EXIT_CODES.ERROR);
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
// kspec item ac set <ref> <ac-id>
|
|
1068
|
+
acCmd
|
|
1069
|
+
.command('set <ref> <acId>')
|
|
1070
|
+
.description('Update an acceptance criterion')
|
|
1071
|
+
.option('--id <newId>', 'Rename the AC ID')
|
|
1072
|
+
.option('--given <text>', 'Update the precondition')
|
|
1073
|
+
.option('--when <text>', 'Update the action/trigger')
|
|
1074
|
+
.option('--then <text>', 'Update the expected outcome')
|
|
1075
|
+
.action(async (ref, acId, options) => {
|
|
1076
|
+
try {
|
|
1077
|
+
const { ctx, item, refIndex } = await resolveSpecItem(ref);
|
|
1078
|
+
const existingAc = item.acceptance_criteria || [];
|
|
1079
|
+
// Find the AC
|
|
1080
|
+
const acIndex = existingAc.findIndex(ac => ac.id === acId);
|
|
1081
|
+
if (acIndex === -1) {
|
|
1082
|
+
const itemRef = item.slugs[0] || refIndex.shortUlid(item._ulid);
|
|
1083
|
+
error(errors.reference.acNotFound(acId, itemRef));
|
|
1084
|
+
process.exit(EXIT_CODES.NOT_FOUND);
|
|
1085
|
+
}
|
|
1086
|
+
// Check for no updates
|
|
1087
|
+
if (!options.id && !options.given && !options.when && !options.then) {
|
|
1088
|
+
warn('No updates specified');
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
// Check for duplicate ID if renaming
|
|
1092
|
+
if (options.id && options.id !== acId && existingAc.some(ac => ac.id === options.id)) {
|
|
1093
|
+
error(errors.conflict.acIdAlreadyExists(options.id));
|
|
1094
|
+
process.exit(EXIT_CODES.CONFLICT);
|
|
1095
|
+
}
|
|
1096
|
+
// Build updated AC
|
|
1097
|
+
const updatedAc = [...existingAc];
|
|
1098
|
+
const updatedFields = [];
|
|
1099
|
+
updatedAc[acIndex] = {
|
|
1100
|
+
...updatedAc[acIndex],
|
|
1101
|
+
...(options.id && { id: options.id }),
|
|
1102
|
+
...(options.given && { given: options.given }),
|
|
1103
|
+
...(options.when && { when: options.when }),
|
|
1104
|
+
...(options.then && { then: options.then }),
|
|
1105
|
+
};
|
|
1106
|
+
if (options.id)
|
|
1107
|
+
updatedFields.push('id');
|
|
1108
|
+
if (options.given)
|
|
1109
|
+
updatedFields.push('given');
|
|
1110
|
+
if (options.when)
|
|
1111
|
+
updatedFields.push('when');
|
|
1112
|
+
if (options.then)
|
|
1113
|
+
updatedFields.push('then');
|
|
1114
|
+
// Update item
|
|
1115
|
+
await updateSpecItem(ctx, item, { acceptance_criteria: updatedAc });
|
|
1116
|
+
const itemSlug = item.slugs[0] || refIndex.shortUlid(item._ulid);
|
|
1117
|
+
await commitIfShadow(ctx.shadow, 'item-ac-set', itemSlug);
|
|
1118
|
+
success(`Updated acceptance criterion: ${acId} on @${itemSlug} (${updatedFields.join(', ')})`, { ac: updatedAc[acIndex] });
|
|
1119
|
+
}
|
|
1120
|
+
catch (err) {
|
|
1121
|
+
error(errors.failures.updateAc, err);
|
|
1122
|
+
process.exit(EXIT_CODES.ERROR);
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
// kspec item ac remove <ref> <ac-id>
|
|
1126
|
+
acCmd
|
|
1127
|
+
.command('remove <ref> <acId>')
|
|
1128
|
+
.description('Remove an acceptance criterion')
|
|
1129
|
+
.option('--force', 'Skip confirmation')
|
|
1130
|
+
.action(async (ref, acId, options) => {
|
|
1131
|
+
try {
|
|
1132
|
+
const { ctx, item, refIndex } = await resolveSpecItem(ref);
|
|
1133
|
+
const existingAc = item.acceptance_criteria || [];
|
|
1134
|
+
// Find the AC
|
|
1135
|
+
const acIndex = existingAc.findIndex(ac => ac.id === acId);
|
|
1136
|
+
if (acIndex === -1) {
|
|
1137
|
+
const itemRef = item.slugs[0] || refIndex.shortUlid(item._ulid);
|
|
1138
|
+
error(errors.reference.acNotFound(acId, itemRef));
|
|
1139
|
+
process.exit(EXIT_CODES.NOT_FOUND);
|
|
1140
|
+
}
|
|
1141
|
+
// Confirmation required unless --force
|
|
1142
|
+
if (!options.force) {
|
|
1143
|
+
// AC-5: JSON mode requires --force
|
|
1144
|
+
if (isJsonMode()) {
|
|
1145
|
+
error('Confirmation required. Use --force with --json');
|
|
1146
|
+
process.exit(EXIT_CODES.ERROR);
|
|
1147
|
+
}
|
|
1148
|
+
// AC-6: Non-interactive environment requires --force
|
|
1149
|
+
// Allow KSPEC_TEST_TTY for testing interactive prompts
|
|
1150
|
+
const isTTY = process.env.KSPEC_TEST_TTY === '1' || process.stdin.isTTY;
|
|
1151
|
+
if (!isTTY) {
|
|
1152
|
+
error('Non-interactive environment. Use --force to proceed');
|
|
1153
|
+
process.exit(EXIT_CODES.ERROR);
|
|
1154
|
+
}
|
|
1155
|
+
// AC-1: Prompt for confirmation
|
|
1156
|
+
const readline = await import('readline');
|
|
1157
|
+
const rl = readline.createInterface({
|
|
1158
|
+
input: process.stdin,
|
|
1159
|
+
output: process.stdout,
|
|
1160
|
+
});
|
|
1161
|
+
const answer = await new Promise((resolve) => {
|
|
1162
|
+
rl.question(`Remove acceptance criterion ${acId}? [y/N] `, resolve);
|
|
1163
|
+
});
|
|
1164
|
+
rl.close();
|
|
1165
|
+
// AC-3: User declines (n, N, or empty)
|
|
1166
|
+
if (answer.toLowerCase() !== 'y') {
|
|
1167
|
+
error('Operation cancelled');
|
|
1168
|
+
process.exit(EXIT_CODES.USAGE_ERROR);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
// AC-4: With --force, proceed immediately without prompt
|
|
1172
|
+
// AC-2: User confirmed, proceed with removal
|
|
1173
|
+
const updatedAc = existingAc.filter(ac => ac.id !== acId);
|
|
1174
|
+
await updateSpecItem(ctx, item, { acceptance_criteria: updatedAc });
|
|
1175
|
+
const itemSlug = item.slugs[0] || refIndex.shortUlid(item._ulid);
|
|
1176
|
+
await commitIfShadow(ctx.shadow, 'item-ac-remove', itemSlug);
|
|
1177
|
+
success(`Removed acceptance criterion: ${acId} from @${itemSlug}`, { removed: acId });
|
|
1178
|
+
}
|
|
1179
|
+
catch (err) {
|
|
1180
|
+
error(errors.failures.removeAc, err);
|
|
1181
|
+
process.exit(EXIT_CODES.ERROR);
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
// ─── Patch Helpers ───────────────────────────────────────────────────────────
|
|
1186
|
+
/**
|
|
1187
|
+
* Read stdin fully with timeout (for bulk input).
|
|
1188
|
+
* Returns null if stdin is a TTY or empty.
|
|
1189
|
+
*/
|
|
1190
|
+
async function readStdinFully() {
|
|
1191
|
+
if (process.stdin.isTTY) {
|
|
1192
|
+
return null;
|
|
1193
|
+
}
|
|
1194
|
+
return new Promise((resolve) => {
|
|
1195
|
+
let data = '';
|
|
1196
|
+
const timeout = setTimeout(() => {
|
|
1197
|
+
process.stdin.removeAllListeners();
|
|
1198
|
+
resolve(data || null);
|
|
1199
|
+
}, 5000); // 5 second timeout for bulk input
|
|
1200
|
+
process.stdin.setEncoding('utf8');
|
|
1201
|
+
process.stdin.on('data', (chunk) => {
|
|
1202
|
+
data += chunk;
|
|
1203
|
+
});
|
|
1204
|
+
process.stdin.on('end', () => {
|
|
1205
|
+
clearTimeout(timeout);
|
|
1206
|
+
resolve(data || null);
|
|
1207
|
+
});
|
|
1208
|
+
process.stdin.on('error', () => {
|
|
1209
|
+
clearTimeout(timeout);
|
|
1210
|
+
resolve(null);
|
|
1211
|
+
});
|
|
1212
|
+
process.stdin.resume();
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Read stdin if available (non-blocking for single item mode).
|
|
1217
|
+
* Returns null quickly if no data available.
|
|
1218
|
+
*/
|
|
1219
|
+
async function readStdinIfAvailable() {
|
|
1220
|
+
if (process.stdin.isTTY) {
|
|
1221
|
+
return null;
|
|
1222
|
+
}
|
|
1223
|
+
return new Promise((resolve) => {
|
|
1224
|
+
let data = '';
|
|
1225
|
+
const timeout = setTimeout(() => {
|
|
1226
|
+
process.stdin.removeAllListeners();
|
|
1227
|
+
resolve(data || null);
|
|
1228
|
+
}, 100); // 100ms timeout for quick check
|
|
1229
|
+
process.stdin.setEncoding('utf8');
|
|
1230
|
+
process.stdin.on('data', (chunk) => {
|
|
1231
|
+
data += chunk;
|
|
1232
|
+
});
|
|
1233
|
+
process.stdin.on('end', () => {
|
|
1234
|
+
clearTimeout(timeout);
|
|
1235
|
+
resolve(data || null);
|
|
1236
|
+
});
|
|
1237
|
+
process.stdin.on('error', () => {
|
|
1238
|
+
clearTimeout(timeout);
|
|
1239
|
+
resolve(null);
|
|
1240
|
+
});
|
|
1241
|
+
process.stdin.resume();
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Parse bulk input (JSONL or JSON array)
|
|
1246
|
+
*/
|
|
1247
|
+
function parseBulkInput(input) {
|
|
1248
|
+
const trimmed = input.trim();
|
|
1249
|
+
// Try JSON array first
|
|
1250
|
+
if (trimmed.startsWith('[')) {
|
|
1251
|
+
const parsed = JSON.parse(trimmed);
|
|
1252
|
+
if (!Array.isArray(parsed)) {
|
|
1253
|
+
throw new Error(errors.validation.expectedJsonArray);
|
|
1254
|
+
}
|
|
1255
|
+
return parsed.map((item, i) => validatePatchOperation(item, i));
|
|
1256
|
+
}
|
|
1257
|
+
// Parse as JSONL (one JSON object per line)
|
|
1258
|
+
const lines = trimmed.split('\n').filter(line => line.trim());
|
|
1259
|
+
return lines.map((line, i) => {
|
|
1260
|
+
try {
|
|
1261
|
+
return validatePatchOperation(JSON.parse(line), i);
|
|
1262
|
+
}
|
|
1263
|
+
catch (err) {
|
|
1264
|
+
throw new Error(errors.validation.jsonLineError(i + 1, err instanceof Error ? err.message : 'Invalid JSON'));
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Validate a patch operation object
|
|
1270
|
+
*/
|
|
1271
|
+
function validatePatchOperation(obj, index) {
|
|
1272
|
+
if (!obj || typeof obj !== 'object') {
|
|
1273
|
+
throw new Error(errors.validation.patchMustBeObject(index));
|
|
1274
|
+
}
|
|
1275
|
+
const op = obj;
|
|
1276
|
+
if (typeof op.ref !== 'string' || !op.ref) {
|
|
1277
|
+
throw new Error(errors.validation.patchMustHaveRef(index));
|
|
1278
|
+
}
|
|
1279
|
+
if (!op.data || typeof op.data !== 'object') {
|
|
1280
|
+
throw new Error(errors.validation.patchMustHaveData(index));
|
|
1281
|
+
}
|
|
1282
|
+
return { ref: op.ref, data: op.data };
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Format bulk patch result for human output
|
|
1286
|
+
*/
|
|
1287
|
+
function formatBulkPatchResult(result, isDryRun = false) {
|
|
1288
|
+
const prefix = isDryRun ? 'Would patch' : 'Patched';
|
|
1289
|
+
for (const r of result.results) {
|
|
1290
|
+
if (r.status === 'updated') {
|
|
1291
|
+
console.log(chalk.green('OK'), `${prefix}: ${r.ref} (${r.ulid?.slice(0, 8)})`);
|
|
1292
|
+
}
|
|
1293
|
+
else if (r.status === 'error') {
|
|
1294
|
+
console.log(chalk.red('ERR'), `${r.ref}: ${r.error}`);
|
|
1295
|
+
}
|
|
1296
|
+
else {
|
|
1297
|
+
console.log(chalk.gray('SKIP'), r.ref);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
console.log('');
|
|
1301
|
+
console.log(chalk.bold('Summary:'));
|
|
1302
|
+
console.log(` Total: ${result.summary.total}`);
|
|
1303
|
+
console.log(chalk.green(` Updated: ${result.summary.updated}`));
|
|
1304
|
+
if (result.summary.failed > 0) {
|
|
1305
|
+
console.log(chalk.red(` Failed: ${result.summary.failed}`));
|
|
1306
|
+
}
|
|
1307
|
+
if (result.summary.skipped > 0) {
|
|
1308
|
+
console.log(chalk.gray(` Skipped: ${result.summary.skipped}`));
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
//# sourceMappingURL=item.js.map
|