@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,1383 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import * as YAML from 'yaml';
|
|
5
|
+
import { ulid } from 'ulid';
|
|
6
|
+
import { TaskSchema, TasksFileSchema, ManifestSchema, SpecItemSchema, InboxItemSchema, InboxFileSchema, } from '../schema/index.js';
|
|
7
|
+
import { ReferenceIndex } from './refs.js';
|
|
8
|
+
import { ItemIndex } from './items.js';
|
|
9
|
+
import { TraitIndex } from './traits.js';
|
|
10
|
+
import { detectShadow, detectRunningFromShadowWorktree, ShadowError, } from './shadow.js';
|
|
11
|
+
import { errors } from '../strings/index.js';
|
|
12
|
+
/**
|
|
13
|
+
* Parse YAML content into an object
|
|
14
|
+
* Uses the modern yaml library which has consistent type handling
|
|
15
|
+
*/
|
|
16
|
+
export function parseYaml(content) {
|
|
17
|
+
return YAML.parse(content);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Serialize object to YAML
|
|
21
|
+
* Uses the modern yaml library for consistent formatting.
|
|
22
|
+
*
|
|
23
|
+
* WORKAROUND: The 'yaml' library (v2.8.2+) has a known behavior where block scalars
|
|
24
|
+
* containing whitespace-only lines accumulate extra blank lines on each parse-stringify
|
|
25
|
+
* cycle. The library's blockString() function adds indentation after newlines, which
|
|
26
|
+
* causes lines containing only spaces to grow. We post-process the output to filter
|
|
27
|
+
* these whitespace-only lines. See: https://github.com/eemeli/yaml - stringifyString.ts
|
|
28
|
+
*/
|
|
29
|
+
export function toYaml(obj) {
|
|
30
|
+
let yamlString = YAML.stringify(obj, {
|
|
31
|
+
indent: 2,
|
|
32
|
+
lineWidth: 100,
|
|
33
|
+
sortMapEntries: false,
|
|
34
|
+
});
|
|
35
|
+
// Post-process to fix yaml library blank line accumulation bug.
|
|
36
|
+
// Filter out lines that contain only spaces/tabs (not truly empty lines).
|
|
37
|
+
yamlString = yamlString
|
|
38
|
+
.split('\n')
|
|
39
|
+
.filter(line => !/^[ \t]+$/.test(line))
|
|
40
|
+
.join('\n');
|
|
41
|
+
return yamlString;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read and parse a YAML file
|
|
45
|
+
*/
|
|
46
|
+
export async function readYamlFile(filePath) {
|
|
47
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
48
|
+
return parseYaml(content);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Write object to YAML file
|
|
52
|
+
*/
|
|
53
|
+
export async function writeYamlFile(filePath, data) {
|
|
54
|
+
const content = toYaml(data);
|
|
55
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Write object to YAML file while preserving formatting and comments.
|
|
59
|
+
*
|
|
60
|
+
* Note: This function is now equivalent to writeYamlFile() - the "preserve format"
|
|
61
|
+
* naming is historical. Both use toYaml() which includes the whitespace-only line
|
|
62
|
+
* fix. Kept for backwards compatibility with existing callers.
|
|
63
|
+
*/
|
|
64
|
+
export async function writeYamlFilePreserveFormat(filePath, data) {
|
|
65
|
+
const content = toYaml(data);
|
|
66
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Find task files in a directory
|
|
70
|
+
*/
|
|
71
|
+
export async function findTaskFiles(dir) {
|
|
72
|
+
const files = [];
|
|
73
|
+
try {
|
|
74
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const fullPath = path.join(dir, entry.name);
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
// Recurse into subdirectories
|
|
79
|
+
const subFiles = await findTaskFiles(fullPath);
|
|
80
|
+
files.push(...subFiles);
|
|
81
|
+
}
|
|
82
|
+
else if (entry.isFile() && entry.name.endsWith('.tasks.yaml')) {
|
|
83
|
+
files.push(fullPath);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
// Directory doesn't exist or not readable
|
|
89
|
+
}
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Find the manifest file (kynetic.yaml or kynetic.spec.yaml)
|
|
94
|
+
*/
|
|
95
|
+
export async function findManifest(startDir) {
|
|
96
|
+
let dir = startDir;
|
|
97
|
+
while (true) {
|
|
98
|
+
const candidates = ['kynetic.yaml', 'kynetic.spec.yaml'];
|
|
99
|
+
for (const candidate of candidates) {
|
|
100
|
+
const filePath = path.join(dir, candidate);
|
|
101
|
+
try {
|
|
102
|
+
await fs.access(filePath);
|
|
103
|
+
return filePath;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// File doesn't exist, try next
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Also check in spec/ subdirectory
|
|
110
|
+
const specDir = path.join(dir, 'spec');
|
|
111
|
+
for (const candidate of candidates) {
|
|
112
|
+
const filePath = path.join(specDir, candidate);
|
|
113
|
+
try {
|
|
114
|
+
await fs.access(filePath);
|
|
115
|
+
return filePath;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// File doesn't exist, try next
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const parentDir = path.dirname(dir);
|
|
122
|
+
if (parentDir === dir) {
|
|
123
|
+
// Reached root
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
dir = parentDir;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Initialize context by finding manifest.
|
|
131
|
+
*
|
|
132
|
+
* Detection order:
|
|
133
|
+
* 1. Check for shadow branch (.kspec/ directory)
|
|
134
|
+
* 2. Fall back to traditional spec/ directory
|
|
135
|
+
*
|
|
136
|
+
* When shadow is detected, all operations use .kspec/ as specDir.
|
|
137
|
+
*/
|
|
138
|
+
export async function initContext(startDir) {
|
|
139
|
+
const cwd = startDir || process.cwd();
|
|
140
|
+
// Check if running from inside the shadow worktree
|
|
141
|
+
const mainProjectRoot = await detectRunningFromShadowWorktree(cwd);
|
|
142
|
+
if (mainProjectRoot) {
|
|
143
|
+
throw new ShadowError(errors.project.runningFromShadow, 'RUNNING_FROM_SHADOW', `Run from project root: cd ${path.relative(cwd, mainProjectRoot) || mainProjectRoot}`);
|
|
144
|
+
}
|
|
145
|
+
// Try to detect shadow branch first
|
|
146
|
+
const shadow = await detectShadow(cwd);
|
|
147
|
+
if (shadow?.enabled) {
|
|
148
|
+
// Shadow mode: use .kspec/ for everything
|
|
149
|
+
const specDir = shadow.worktreeDir;
|
|
150
|
+
const manifestPath = await findManifestInDir(specDir);
|
|
151
|
+
let manifest = null;
|
|
152
|
+
if (manifestPath) {
|
|
153
|
+
try {
|
|
154
|
+
const rawManifest = await readYamlFile(manifestPath);
|
|
155
|
+
manifest = ManifestSchema.parse(rawManifest);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Manifest exists but may be invalid
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
rootDir: shadow.projectRoot,
|
|
163
|
+
specDir,
|
|
164
|
+
manifestPath,
|
|
165
|
+
manifest,
|
|
166
|
+
shadow,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// Traditional mode: find manifest in spec/ or current directory
|
|
170
|
+
const manifestPath = await findManifest(cwd);
|
|
171
|
+
let manifest = null;
|
|
172
|
+
let rootDir = cwd;
|
|
173
|
+
let specDir = cwd;
|
|
174
|
+
if (manifestPath) {
|
|
175
|
+
const manifestDir = path.dirname(manifestPath);
|
|
176
|
+
// Handle spec/ subdirectory
|
|
177
|
+
if (path.basename(manifestDir) === 'spec') {
|
|
178
|
+
rootDir = path.dirname(manifestDir);
|
|
179
|
+
specDir = manifestDir;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
rootDir = manifestDir;
|
|
183
|
+
specDir = manifestDir;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const rawManifest = await readYamlFile(manifestPath);
|
|
187
|
+
manifest = ManifestSchema.parse(rawManifest);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Manifest exists but may be invalid
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { rootDir, specDir, manifestPath, manifest, shadow: null };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Find manifest file within a specific directory (no parent traversal).
|
|
197
|
+
* Used for shadow mode where we know exactly where to look.
|
|
198
|
+
*/
|
|
199
|
+
async function findManifestInDir(dir) {
|
|
200
|
+
const candidates = ['kynetic.yaml', 'kynetic.spec.yaml'];
|
|
201
|
+
for (const candidate of candidates) {
|
|
202
|
+
const filePath = path.join(dir, candidate);
|
|
203
|
+
try {
|
|
204
|
+
await fs.access(filePath);
|
|
205
|
+
return filePath;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// File doesn't exist, try next
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Load tasks from a single file.
|
|
215
|
+
* Helper function used by loadAllTasks.
|
|
216
|
+
*/
|
|
217
|
+
async function loadTasksFromFile(filePath) {
|
|
218
|
+
const tasks = [];
|
|
219
|
+
try {
|
|
220
|
+
const raw = await readYamlFile(filePath);
|
|
221
|
+
// Handle both array format and object format
|
|
222
|
+
let taskList;
|
|
223
|
+
if (Array.isArray(raw)) {
|
|
224
|
+
taskList = raw;
|
|
225
|
+
}
|
|
226
|
+
else if (raw && typeof raw === 'object' && 'tasks' in raw) {
|
|
227
|
+
const parsed = TasksFileSchema.safeParse(raw);
|
|
228
|
+
if (parsed.success) {
|
|
229
|
+
// Add _sourceFile to each task from this file
|
|
230
|
+
for (const task of parsed.data.tasks) {
|
|
231
|
+
tasks.push({ ...task, _sourceFile: filePath });
|
|
232
|
+
}
|
|
233
|
+
return tasks;
|
|
234
|
+
}
|
|
235
|
+
taskList = raw.tasks || [];
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
// Single task object
|
|
239
|
+
taskList = [raw];
|
|
240
|
+
}
|
|
241
|
+
for (const taskData of taskList) {
|
|
242
|
+
const result = TaskSchema.safeParse(taskData);
|
|
243
|
+
if (result.success) {
|
|
244
|
+
// Add _sourceFile metadata
|
|
245
|
+
tasks.push({ ...result.data, _sourceFile: filePath });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Skip invalid files
|
|
251
|
+
}
|
|
252
|
+
return tasks;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Load all tasks from the project.
|
|
256
|
+
* Each task includes _sourceFile metadata for write-back routing.
|
|
257
|
+
*
|
|
258
|
+
* When shadow is enabled, tasks are loaded from .kspec/ (ctx.specDir).
|
|
259
|
+
* Otherwise, searches in traditional locations (rootDir, spec/, tasks/).
|
|
260
|
+
*/
|
|
261
|
+
export async function loadAllTasks(ctx) {
|
|
262
|
+
const tasks = [];
|
|
263
|
+
// When shadow is enabled, look only in specDir
|
|
264
|
+
if (ctx.shadow?.enabled) {
|
|
265
|
+
const taskFiles = await findTaskFiles(ctx.specDir);
|
|
266
|
+
// Also check for standalone files in specDir
|
|
267
|
+
const standaloneLocations = [
|
|
268
|
+
path.join(ctx.specDir, 'tasks.yaml'),
|
|
269
|
+
path.join(ctx.specDir, 'project.tasks.yaml'),
|
|
270
|
+
path.join(ctx.specDir, 'kynetic.tasks.yaml'),
|
|
271
|
+
path.join(ctx.specDir, 'backlog.tasks.yaml'),
|
|
272
|
+
path.join(ctx.specDir, 'active.tasks.yaml'),
|
|
273
|
+
];
|
|
274
|
+
for (const loc of standaloneLocations) {
|
|
275
|
+
try {
|
|
276
|
+
await fs.access(loc);
|
|
277
|
+
if (!taskFiles.includes(loc)) {
|
|
278
|
+
taskFiles.push(loc);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// File doesn't exist
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Deduplicate and load
|
|
286
|
+
const uniqueFiles = [...new Set(taskFiles)];
|
|
287
|
+
for (const filePath of uniqueFiles) {
|
|
288
|
+
const fileTasks = await loadTasksFromFile(filePath);
|
|
289
|
+
tasks.push(...fileTasks);
|
|
290
|
+
}
|
|
291
|
+
return tasks;
|
|
292
|
+
}
|
|
293
|
+
// Traditional mode: look in multiple locations
|
|
294
|
+
const taskFiles = await findTaskFiles(ctx.rootDir);
|
|
295
|
+
// Also check common locations
|
|
296
|
+
const additionalPaths = [
|
|
297
|
+
path.join(ctx.rootDir, 'tasks'),
|
|
298
|
+
path.join(ctx.rootDir, 'spec'),
|
|
299
|
+
];
|
|
300
|
+
for (const additionalPath of additionalPaths) {
|
|
301
|
+
const files = await findTaskFiles(additionalPath);
|
|
302
|
+
taskFiles.push(...files);
|
|
303
|
+
}
|
|
304
|
+
// Also look for standalone tasks.yaml and project.tasks.yaml
|
|
305
|
+
const standaloneLocations = [
|
|
306
|
+
path.join(ctx.rootDir, 'tasks.yaml'),
|
|
307
|
+
path.join(ctx.rootDir, 'project.tasks.yaml'),
|
|
308
|
+
path.join(ctx.rootDir, 'spec', 'project.tasks.yaml'),
|
|
309
|
+
path.join(ctx.rootDir, 'backlog.tasks.yaml'),
|
|
310
|
+
path.join(ctx.rootDir, 'active.tasks.yaml'),
|
|
311
|
+
];
|
|
312
|
+
for (const loc of standaloneLocations) {
|
|
313
|
+
try {
|
|
314
|
+
await fs.access(loc);
|
|
315
|
+
if (!taskFiles.includes(loc)) {
|
|
316
|
+
taskFiles.push(loc);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// File doesn't exist
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Deduplicate and load
|
|
324
|
+
const uniqueFiles = [...new Set(taskFiles)];
|
|
325
|
+
for (const filePath of uniqueFiles) {
|
|
326
|
+
const fileTasks = await loadTasksFromFile(filePath);
|
|
327
|
+
tasks.push(...fileTasks);
|
|
328
|
+
}
|
|
329
|
+
return tasks;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Find a task by reference (ULID, slug, or short reference)
|
|
333
|
+
*/
|
|
334
|
+
export function findTaskByRef(tasks, ref) {
|
|
335
|
+
// Remove @ prefix if present
|
|
336
|
+
const cleanRef = ref.startsWith('@') ? ref.slice(1) : ref;
|
|
337
|
+
return tasks.find(task => {
|
|
338
|
+
// Match full ULID
|
|
339
|
+
if (task._ulid === cleanRef)
|
|
340
|
+
return true;
|
|
341
|
+
// Match short ULID (prefix)
|
|
342
|
+
if (task._ulid.toLowerCase().startsWith(cleanRef.toLowerCase()))
|
|
343
|
+
return true;
|
|
344
|
+
// Match slug
|
|
345
|
+
if (task.slugs.includes(cleanRef))
|
|
346
|
+
return true;
|
|
347
|
+
return false;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Get the default task file path for new tasks without a spec_ref.
|
|
352
|
+
*
|
|
353
|
+
* When shadow enabled: .kspec/project.tasks.yaml
|
|
354
|
+
* Otherwise: spec/project.tasks.yaml
|
|
355
|
+
*/
|
|
356
|
+
export function getDefaultTaskFilePath(ctx) {
|
|
357
|
+
return path.join(ctx.specDir, 'project.tasks.yaml');
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Strip runtime metadata before serialization
|
|
361
|
+
*/
|
|
362
|
+
function stripRuntimeMetadata(task) {
|
|
363
|
+
const { _sourceFile, ...cleanTask } = task;
|
|
364
|
+
return cleanTask;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Save a task to its source file (or default location for new tasks).
|
|
368
|
+
* Preserves file format (tasks: [...] wrapper vs plain array).
|
|
369
|
+
*/
|
|
370
|
+
export async function saveTask(ctx, task) {
|
|
371
|
+
// Determine target file: use _sourceFile if present, otherwise default
|
|
372
|
+
const taskFilePath = task._sourceFile || getDefaultTaskFilePath(ctx);
|
|
373
|
+
// Ensure directory exists
|
|
374
|
+
const dir = path.dirname(taskFilePath);
|
|
375
|
+
await fs.mkdir(dir, { recursive: true });
|
|
376
|
+
// Load existing tasks from the target file
|
|
377
|
+
let existingRaw = null;
|
|
378
|
+
let useTasksWrapper = false;
|
|
379
|
+
try {
|
|
380
|
+
existingRaw = await readYamlFile(taskFilePath);
|
|
381
|
+
// Detect if file uses { tasks: [...] } format
|
|
382
|
+
if (existingRaw && typeof existingRaw === 'object' && 'tasks' in existingRaw) {
|
|
383
|
+
useTasksWrapper = true;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// File doesn't exist, start fresh
|
|
388
|
+
}
|
|
389
|
+
// Parse existing tasks from file
|
|
390
|
+
let fileTasks = [];
|
|
391
|
+
if (existingRaw) {
|
|
392
|
+
if (Array.isArray(existingRaw)) {
|
|
393
|
+
for (const t of existingRaw) {
|
|
394
|
+
const result = TaskSchema.safeParse(t);
|
|
395
|
+
if (result.success) {
|
|
396
|
+
fileTasks.push(result.data);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
else if (useTasksWrapper) {
|
|
401
|
+
// Try TasksFileSchema first (has kynetic_tasks version)
|
|
402
|
+
const parsed = TasksFileSchema.safeParse(existingRaw);
|
|
403
|
+
if (parsed.success) {
|
|
404
|
+
fileTasks = parsed.data.tasks;
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
// Fall back to raw tasks array (common format without version field)
|
|
408
|
+
const rawTasks = existingRaw.tasks;
|
|
409
|
+
if (Array.isArray(rawTasks)) {
|
|
410
|
+
for (const t of rawTasks) {
|
|
411
|
+
const result = TaskSchema.safeParse(t);
|
|
412
|
+
if (result.success) {
|
|
413
|
+
fileTasks.push(result.data);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Strip runtime metadata before saving
|
|
421
|
+
const cleanTask = stripRuntimeMetadata(task);
|
|
422
|
+
// Update existing or add new
|
|
423
|
+
const existingIndex = fileTasks.findIndex(t => t._ulid === task._ulid);
|
|
424
|
+
if (existingIndex >= 0) {
|
|
425
|
+
fileTasks[existingIndex] = cleanTask;
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
fileTasks.push(cleanTask);
|
|
429
|
+
}
|
|
430
|
+
// Save in the same format as original (or tasks: wrapper for new files)
|
|
431
|
+
// Use format-preserving write to maintain formatting and comments
|
|
432
|
+
if (useTasksWrapper) {
|
|
433
|
+
await writeYamlFilePreserveFormat(taskFilePath, { tasks: fileTasks });
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
await writeYamlFilePreserveFormat(taskFilePath, fileTasks);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Delete a task from its source file.
|
|
441
|
+
* Requires _sourceFile to know which file to modify.
|
|
442
|
+
*/
|
|
443
|
+
export async function deleteTask(ctx, task) {
|
|
444
|
+
if (!task._sourceFile) {
|
|
445
|
+
throw new Error('Cannot delete task without _sourceFile metadata');
|
|
446
|
+
}
|
|
447
|
+
const taskFilePath = task._sourceFile;
|
|
448
|
+
// Load existing file
|
|
449
|
+
let existingRaw = null;
|
|
450
|
+
let useTasksWrapper = false;
|
|
451
|
+
try {
|
|
452
|
+
existingRaw = await readYamlFile(taskFilePath);
|
|
453
|
+
if (existingRaw && typeof existingRaw === 'object' && 'tasks' in existingRaw) {
|
|
454
|
+
useTasksWrapper = true;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
throw new Error(`Task file not found: ${taskFilePath}`);
|
|
459
|
+
}
|
|
460
|
+
// Parse existing tasks
|
|
461
|
+
let fileTasks = [];
|
|
462
|
+
if (existingRaw) {
|
|
463
|
+
if (Array.isArray(existingRaw)) {
|
|
464
|
+
for (const t of existingRaw) {
|
|
465
|
+
const result = TaskSchema.safeParse(t);
|
|
466
|
+
if (result.success) {
|
|
467
|
+
fileTasks.push(result.data);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else if (useTasksWrapper) {
|
|
472
|
+
const parsed = TasksFileSchema.safeParse(existingRaw);
|
|
473
|
+
if (parsed.success) {
|
|
474
|
+
fileTasks = parsed.data.tasks;
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
const rawTasks = existingRaw.tasks;
|
|
478
|
+
if (Array.isArray(rawTasks)) {
|
|
479
|
+
for (const t of rawTasks) {
|
|
480
|
+
const result = TaskSchema.safeParse(t);
|
|
481
|
+
if (result.success) {
|
|
482
|
+
fileTasks.push(result.data);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Remove the task
|
|
490
|
+
const originalCount = fileTasks.length;
|
|
491
|
+
fileTasks = fileTasks.filter(t => t._ulid !== task._ulid);
|
|
492
|
+
if (fileTasks.length === originalCount) {
|
|
493
|
+
throw new Error(`Task not found in file: ${task._ulid}`);
|
|
494
|
+
}
|
|
495
|
+
// Save the modified file with format preservation
|
|
496
|
+
if (useTasksWrapper) {
|
|
497
|
+
await writeYamlFilePreserveFormat(taskFilePath, { tasks: fileTasks });
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
await writeYamlFilePreserveFormat(taskFilePath, fileTasks);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Create a new task with auto-generated fields
|
|
505
|
+
*/
|
|
506
|
+
export function createTask(input) {
|
|
507
|
+
const now = new Date().toISOString();
|
|
508
|
+
return {
|
|
509
|
+
...input,
|
|
510
|
+
_ulid: input._ulid || ulid(),
|
|
511
|
+
slugs: input.slugs || [],
|
|
512
|
+
type: input.type || 'task',
|
|
513
|
+
status: input.status || 'pending',
|
|
514
|
+
blocked_by: input.blocked_by || [],
|
|
515
|
+
depends_on: input.depends_on || [],
|
|
516
|
+
context: input.context || [],
|
|
517
|
+
priority: input.priority || 3,
|
|
518
|
+
tags: input.tags || [],
|
|
519
|
+
vcs_refs: input.vcs_refs || [],
|
|
520
|
+
created_at: input.created_at || now,
|
|
521
|
+
notes: input.notes || [],
|
|
522
|
+
todos: input.todos || [],
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Get author from environment with fallback chain.
|
|
527
|
+
* Priority:
|
|
528
|
+
* 1. KSPEC_AUTHOR env var (explicit config, agent-agnostic)
|
|
529
|
+
* 2. git user.name (developer identity)
|
|
530
|
+
* 3. USER/USERNAME env var (system user)
|
|
531
|
+
* 4. undefined (will show as 'unknown' in output)
|
|
532
|
+
*
|
|
533
|
+
* For Claude Code integration, add to ~/.claude/settings.json:
|
|
534
|
+
* { "env": { "KSPEC_AUTHOR": "@claude" } }
|
|
535
|
+
*/
|
|
536
|
+
export function getAuthor() {
|
|
537
|
+
// 1. Explicit config (works for any agent)
|
|
538
|
+
if (process.env.KSPEC_AUTHOR) {
|
|
539
|
+
return process.env.KSPEC_AUTHOR;
|
|
540
|
+
}
|
|
541
|
+
// 2. Git user.name
|
|
542
|
+
try {
|
|
543
|
+
const gitUser = execSync('git config user.name', {
|
|
544
|
+
encoding: 'utf-8',
|
|
545
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
546
|
+
}).trim();
|
|
547
|
+
if (gitUser) {
|
|
548
|
+
return gitUser;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// git not available or not in a repo
|
|
553
|
+
}
|
|
554
|
+
// 3. System user
|
|
555
|
+
const systemUser = process.env.USER || process.env.USERNAME;
|
|
556
|
+
if (systemUser) {
|
|
557
|
+
return systemUser;
|
|
558
|
+
}
|
|
559
|
+
// 4. No author available
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Create a new note entry.
|
|
564
|
+
* If author is not provided, attempts to auto-detect from environment.
|
|
565
|
+
*/
|
|
566
|
+
export function createNote(content, author, supersedes) {
|
|
567
|
+
return {
|
|
568
|
+
_ulid: ulid(),
|
|
569
|
+
created_at: new Date().toISOString(),
|
|
570
|
+
author: author ?? getAuthor(),
|
|
571
|
+
// Trim content to prevent whitespace-only lines from accumulating
|
|
572
|
+
// in block scalars during YAML parse-stringify cycles
|
|
573
|
+
content: content.trim(),
|
|
574
|
+
supersedes: supersedes || null,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Create a new todo item.
|
|
579
|
+
* The id should be the next available id for the task's todos array.
|
|
580
|
+
*/
|
|
581
|
+
export function createTodo(id, text, addedBy) {
|
|
582
|
+
return {
|
|
583
|
+
id,
|
|
584
|
+
// Trim text to prevent whitespace-only lines from accumulating
|
|
585
|
+
// in block scalars during YAML parse-stringify cycles
|
|
586
|
+
text: text.trim(),
|
|
587
|
+
done: false,
|
|
588
|
+
added_at: new Date().toISOString(),
|
|
589
|
+
added_by: addedBy ?? getAuthor(),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Check if task dependencies are met
|
|
594
|
+
*/
|
|
595
|
+
export function areDependenciesMet(task, allTasks) {
|
|
596
|
+
if (task.depends_on.length === 0)
|
|
597
|
+
return true;
|
|
598
|
+
for (const depRef of task.depends_on) {
|
|
599
|
+
const depTask = findTaskByRef(allTasks, depRef);
|
|
600
|
+
if (!depTask || depTask.status !== 'completed') {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Check if task is ready (pending + deps met + not blocked)
|
|
608
|
+
*/
|
|
609
|
+
export function isTaskReady(task, allTasks) {
|
|
610
|
+
if (task.status !== 'pending')
|
|
611
|
+
return false;
|
|
612
|
+
if (task.blocked_by.length > 0)
|
|
613
|
+
return false;
|
|
614
|
+
return areDependenciesMet(task, allTasks);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Get ready tasks (pending + deps met + not blocked), sorted by priority then creation time.
|
|
618
|
+
* Within the same priority tier, older tasks come first (FIFO).
|
|
619
|
+
*/
|
|
620
|
+
export function getReadyTasks(tasks) {
|
|
621
|
+
return tasks
|
|
622
|
+
.filter(task => isTaskReady(task, tasks))
|
|
623
|
+
.sort((a, b) => {
|
|
624
|
+
// Primary: priority (lower number = higher priority)
|
|
625
|
+
if (a.priority !== b.priority) {
|
|
626
|
+
return a.priority - b.priority;
|
|
627
|
+
}
|
|
628
|
+
// Secondary: creation time (older first - FIFO within priority)
|
|
629
|
+
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
// ============================================================
|
|
633
|
+
// SPEC ITEM LOADING
|
|
634
|
+
// ============================================================
|
|
635
|
+
/**
|
|
636
|
+
* Expand a glob-like include pattern to file paths.
|
|
637
|
+
* Supports simple patterns like "modules/*.yaml" or "**\/*.yaml"
|
|
638
|
+
*/
|
|
639
|
+
export async function expandIncludePattern(pattern, baseDir) {
|
|
640
|
+
const fullPattern = path.isAbsolute(pattern) ? pattern : path.join(baseDir, pattern);
|
|
641
|
+
// If no glob characters, just return the path if it exists
|
|
642
|
+
if (!pattern.includes('*')) {
|
|
643
|
+
try {
|
|
644
|
+
await fs.access(fullPattern);
|
|
645
|
+
return [fullPattern];
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return [];
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// Split pattern into directory part and file pattern
|
|
652
|
+
const parts = pattern.split('/');
|
|
653
|
+
let currentDir = baseDir;
|
|
654
|
+
const result = [];
|
|
655
|
+
// Find the first part with a glob
|
|
656
|
+
let globIndex = parts.findIndex(p => p.includes('*'));
|
|
657
|
+
// Navigate to the directory before the glob
|
|
658
|
+
if (globIndex > 0) {
|
|
659
|
+
currentDir = path.join(baseDir, ...parts.slice(0, globIndex));
|
|
660
|
+
}
|
|
661
|
+
// Get the remaining pattern
|
|
662
|
+
const remainingPattern = parts.slice(globIndex).join('/');
|
|
663
|
+
await expandGlobRecursive(currentDir, remainingPattern, result);
|
|
664
|
+
return result;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Recursively expand glob patterns
|
|
668
|
+
*/
|
|
669
|
+
async function expandGlobRecursive(dir, pattern, result) {
|
|
670
|
+
const parts = pattern.split('/');
|
|
671
|
+
const currentPattern = parts[0];
|
|
672
|
+
const remainingPattern = parts.slice(1).join('/');
|
|
673
|
+
try {
|
|
674
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
675
|
+
for (const entry of entries) {
|
|
676
|
+
const matches = matchGlobPart(entry.name, currentPattern);
|
|
677
|
+
if (matches) {
|
|
678
|
+
const fullPath = path.join(dir, entry.name);
|
|
679
|
+
if (remainingPattern) {
|
|
680
|
+
// More pattern parts to process
|
|
681
|
+
if (entry.isDirectory()) {
|
|
682
|
+
await expandGlobRecursive(fullPath, remainingPattern, result);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
// This is the final pattern part
|
|
687
|
+
if (currentPattern === '**') {
|
|
688
|
+
// ** matches any depth - need special handling
|
|
689
|
+
if (entry.isDirectory()) {
|
|
690
|
+
await expandGlobRecursive(fullPath, '**', result);
|
|
691
|
+
}
|
|
692
|
+
// Also match files at this level
|
|
693
|
+
result.push(fullPath);
|
|
694
|
+
}
|
|
695
|
+
else if (entry.isFile()) {
|
|
696
|
+
result.push(fullPath);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Handle ** - also recurse into directories without consuming the pattern
|
|
701
|
+
if (currentPattern === '**' && entry.isDirectory()) {
|
|
702
|
+
const fullPath = path.join(dir, entry.name);
|
|
703
|
+
await expandGlobRecursive(fullPath, pattern, result);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
// Directory doesn't exist or not readable
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Match a single path component against a glob pattern part
|
|
713
|
+
*/
|
|
714
|
+
function matchGlobPart(name, pattern) {
|
|
715
|
+
if (pattern === '*')
|
|
716
|
+
return true;
|
|
717
|
+
if (pattern === '**')
|
|
718
|
+
return true;
|
|
719
|
+
// Convert glob pattern to regex
|
|
720
|
+
const regexPattern = pattern
|
|
721
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
|
|
722
|
+
.replace(/\*/g, '.*') // * matches anything
|
|
723
|
+
.replace(/\?/g, '.'); // ? matches single char
|
|
724
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
725
|
+
return regex.test(name);
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Fields that may contain nested spec items
|
|
729
|
+
*/
|
|
730
|
+
const NESTED_ITEM_FIELDS = [
|
|
731
|
+
'modules',
|
|
732
|
+
'features',
|
|
733
|
+
'requirements',
|
|
734
|
+
'constraints',
|
|
735
|
+
'decisions',
|
|
736
|
+
'traits',
|
|
737
|
+
'acceptance_criteria',
|
|
738
|
+
];
|
|
739
|
+
/**
|
|
740
|
+
* Recursively extract all spec items from a raw YAML structure.
|
|
741
|
+
* Items can be nested under modules/features/requirements/etc.
|
|
742
|
+
* Tracks the path within the file for each item.
|
|
743
|
+
*/
|
|
744
|
+
export function extractItemsFromRaw(raw, sourceFile, items = [], currentPath = '') {
|
|
745
|
+
if (!raw || typeof raw !== 'object') {
|
|
746
|
+
return items;
|
|
747
|
+
}
|
|
748
|
+
// Check if this object is itself a spec item (has _ulid)
|
|
749
|
+
if ('_ulid' in raw && typeof raw._ulid === 'string') {
|
|
750
|
+
const result = SpecItemSchema.safeParse(raw);
|
|
751
|
+
if (result.success) {
|
|
752
|
+
items.push({
|
|
753
|
+
...result.data,
|
|
754
|
+
_sourceFile: sourceFile,
|
|
755
|
+
_path: currentPath || undefined,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
// Even if the item itself was added, also extract nested items
|
|
759
|
+
const rawObj = raw;
|
|
760
|
+
for (const field of NESTED_ITEM_FIELDS) {
|
|
761
|
+
if (field in rawObj && Array.isArray(rawObj[field])) {
|
|
762
|
+
const arr = rawObj[field];
|
|
763
|
+
for (let i = 0; i < arr.length; i++) {
|
|
764
|
+
const nestedPath = currentPath ? `${currentPath}.${field}[${i}]` : `${field}[${i}]`;
|
|
765
|
+
extractItemsFromRaw(arr[i], sourceFile, items, nestedPath);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
else if (Array.isArray(raw)) {
|
|
771
|
+
// Array of items at root level
|
|
772
|
+
for (let i = 0; i < raw.length; i++) {
|
|
773
|
+
const itemPath = currentPath ? `${currentPath}[${i}]` : `[${i}]`;
|
|
774
|
+
extractItemsFromRaw(raw[i], sourceFile, items, itemPath);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
// Object that might contain item arrays (like manifest with modules/features/etc)
|
|
779
|
+
const rawObj = raw;
|
|
780
|
+
for (const field of NESTED_ITEM_FIELDS) {
|
|
781
|
+
if (field in rawObj && Array.isArray(rawObj[field])) {
|
|
782
|
+
const arr = rawObj[field];
|
|
783
|
+
for (let i = 0; i < arr.length; i++) {
|
|
784
|
+
const nestedPath = currentPath ? `${currentPath}.${field}[${i}]` : `${field}[${i}]`;
|
|
785
|
+
extractItemsFromRaw(arr[i], sourceFile, items, nestedPath);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return items;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Load spec items from a single file.
|
|
794
|
+
* Handles module files (the file itself is an item with nested children).
|
|
795
|
+
*/
|
|
796
|
+
export async function loadSpecFile(filePath) {
|
|
797
|
+
try {
|
|
798
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
799
|
+
const items = [];
|
|
800
|
+
// Parse all YAML documents in the file (handles files with ---)
|
|
801
|
+
const documents = YAML.parseAllDocuments(content);
|
|
802
|
+
for (const doc of documents) {
|
|
803
|
+
if (doc.errors.length > 0) {
|
|
804
|
+
// Skip documents with parse errors
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
const raw = doc.toJS();
|
|
808
|
+
if (raw) {
|
|
809
|
+
const docItems = extractItemsFromRaw(raw, filePath);
|
|
810
|
+
items.push(...docItems);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return items;
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
// File doesn't exist or parse error
|
|
817
|
+
return [];
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Load all spec items from the project.
|
|
822
|
+
* Parses manifest, follows includes, and builds unified collection.
|
|
823
|
+
*/
|
|
824
|
+
export async function loadAllItems(ctx) {
|
|
825
|
+
const items = [];
|
|
826
|
+
if (!ctx.manifest || !ctx.manifestPath) {
|
|
827
|
+
return items;
|
|
828
|
+
}
|
|
829
|
+
const manifestDir = path.dirname(ctx.manifestPath);
|
|
830
|
+
// Extract items from manifest itself (inline modules/features/etc)
|
|
831
|
+
const manifestItems = extractItemsFromRaw(ctx.manifest, ctx.manifestPath);
|
|
832
|
+
items.push(...manifestItems);
|
|
833
|
+
// Process includes
|
|
834
|
+
const includes = ctx.manifest.includes || [];
|
|
835
|
+
for (const include of includes) {
|
|
836
|
+
const expandedPaths = await expandIncludePattern(include, manifestDir);
|
|
837
|
+
for (const filePath of expandedPaths) {
|
|
838
|
+
const fileItems = await loadSpecFile(filePath);
|
|
839
|
+
items.push(...fileItems);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return items;
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Find a spec item by reference (ULID, slug, or short reference)
|
|
846
|
+
*/
|
|
847
|
+
export function findItemByRef(items, ref) {
|
|
848
|
+
// Remove @ prefix if present
|
|
849
|
+
const cleanRef = ref.startsWith('@') ? ref.slice(1) : ref;
|
|
850
|
+
return items.find(item => {
|
|
851
|
+
// Match full ULID
|
|
852
|
+
if (item._ulid === cleanRef)
|
|
853
|
+
return true;
|
|
854
|
+
// Match short ULID (prefix)
|
|
855
|
+
if (item._ulid.toLowerCase().startsWith(cleanRef.toLowerCase()))
|
|
856
|
+
return true;
|
|
857
|
+
// Match slug
|
|
858
|
+
if (item.slugs.includes(cleanRef))
|
|
859
|
+
return true;
|
|
860
|
+
return false;
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Find any item (task or spec item) by reference
|
|
865
|
+
*/
|
|
866
|
+
export function findAnyItemByRef(tasks, items, ref) {
|
|
867
|
+
// Try tasks first (more commonly referenced)
|
|
868
|
+
const task = findTaskByRef(tasks, ref);
|
|
869
|
+
if (task)
|
|
870
|
+
return task;
|
|
871
|
+
// Then try spec items
|
|
872
|
+
return findItemByRef(items, ref);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Build a ReferenceIndex from context.
|
|
876
|
+
* Loads all tasks and spec items, then builds the index.
|
|
877
|
+
*/
|
|
878
|
+
export async function buildReferenceIndex(ctx) {
|
|
879
|
+
const tasks = await loadAllTasks(ctx);
|
|
880
|
+
const items = await loadAllItems(ctx);
|
|
881
|
+
const index = new ReferenceIndex(tasks, items);
|
|
882
|
+
return { index, tasks, items };
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Build both ReferenceIndex and ItemIndex from context.
|
|
886
|
+
* Use this when you need query capabilities in addition to reference resolution.
|
|
887
|
+
*/
|
|
888
|
+
export async function buildIndexes(ctx) {
|
|
889
|
+
const tasks = await loadAllTasks(ctx);
|
|
890
|
+
const items = await loadAllItems(ctx);
|
|
891
|
+
const refIndex = new ReferenceIndex(tasks, items);
|
|
892
|
+
const itemIndex = new ItemIndex(tasks, items);
|
|
893
|
+
const traitIndex = new TraitIndex(items, refIndex);
|
|
894
|
+
return { refIndex, itemIndex, traitIndex, tasks, items };
|
|
895
|
+
}
|
|
896
|
+
// ============================================================
|
|
897
|
+
// SPEC ITEM CRUD (supports nested structures)
|
|
898
|
+
// ============================================================
|
|
899
|
+
/**
|
|
900
|
+
* Strip runtime metadata from spec item before serialization
|
|
901
|
+
*/
|
|
902
|
+
function stripSpecItemMetadata(item) {
|
|
903
|
+
const { _sourceFile, _path, ...cleanItem } = item;
|
|
904
|
+
return cleanItem;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Parse a path string into segments.
|
|
908
|
+
* e.g., "features[0].requirements[2]" -> [["features", 0], ["requirements", 2]]
|
|
909
|
+
*/
|
|
910
|
+
function parsePath(pathStr) {
|
|
911
|
+
const segments = [];
|
|
912
|
+
const regex = /(\w+)\[(\d+)\]/g;
|
|
913
|
+
let match;
|
|
914
|
+
while ((match = regex.exec(pathStr)) !== null) {
|
|
915
|
+
segments.push([match[1], parseInt(match[2], 10)]);
|
|
916
|
+
}
|
|
917
|
+
return segments;
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Navigate to a location in a YAML structure using a path.
|
|
921
|
+
* Returns the parent object and the array containing the target item.
|
|
922
|
+
*/
|
|
923
|
+
function navigateToPath(root, pathStr) {
|
|
924
|
+
if (!pathStr)
|
|
925
|
+
return null;
|
|
926
|
+
const segments = parsePath(pathStr);
|
|
927
|
+
if (segments.length === 0)
|
|
928
|
+
return null;
|
|
929
|
+
let current = root;
|
|
930
|
+
// Navigate to the parent of the last segment
|
|
931
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
932
|
+
const [field, index] = segments[i];
|
|
933
|
+
if (typeof current !== 'object' || current === null)
|
|
934
|
+
return null;
|
|
935
|
+
const obj = current;
|
|
936
|
+
if (!Array.isArray(obj[field]))
|
|
937
|
+
return null;
|
|
938
|
+
current = obj[field][index];
|
|
939
|
+
}
|
|
940
|
+
// Get the final array and index
|
|
941
|
+
const [finalField, finalIndex] = segments[segments.length - 1];
|
|
942
|
+
if (typeof current !== 'object' || current === null)
|
|
943
|
+
return null;
|
|
944
|
+
const parent = current;
|
|
945
|
+
if (!Array.isArray(parent[finalField]))
|
|
946
|
+
return null;
|
|
947
|
+
return {
|
|
948
|
+
parent,
|
|
949
|
+
array: parent[finalField],
|
|
950
|
+
index: finalIndex,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Find an item by ULID in a nested YAML structure.
|
|
955
|
+
* Returns the path segments to reach it.
|
|
956
|
+
*/
|
|
957
|
+
function findItemInStructure(root, ulid, currentPath = '') {
|
|
958
|
+
if (!root || typeof root !== 'object')
|
|
959
|
+
return null;
|
|
960
|
+
const obj = root;
|
|
961
|
+
// Check if this is the item we're looking for
|
|
962
|
+
if (obj._ulid === ulid) {
|
|
963
|
+
return { path: currentPath, item: obj };
|
|
964
|
+
}
|
|
965
|
+
// Search nested item fields
|
|
966
|
+
for (const field of NESTED_ITEM_FIELDS) {
|
|
967
|
+
if (Array.isArray(obj[field])) {
|
|
968
|
+
const arr = obj[field];
|
|
969
|
+
for (let i = 0; i < arr.length; i++) {
|
|
970
|
+
const nestedPath = currentPath ? `${currentPath}.${field}[${i}]` : `${field}[${i}]`;
|
|
971
|
+
const result = findItemInStructure(arr[i], ulid, nestedPath);
|
|
972
|
+
if (result)
|
|
973
|
+
return result;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Create a new spec item with auto-generated fields
|
|
981
|
+
*/
|
|
982
|
+
export function createSpecItem(input) {
|
|
983
|
+
return {
|
|
984
|
+
_ulid: input._ulid || ulid(),
|
|
985
|
+
slugs: input.slugs || [],
|
|
986
|
+
title: input.title,
|
|
987
|
+
type: input.type,
|
|
988
|
+
status: input.status,
|
|
989
|
+
priority: input.priority,
|
|
990
|
+
tags: input.tags || [],
|
|
991
|
+
description: input.description,
|
|
992
|
+
depends_on: input.depends_on || [],
|
|
993
|
+
implements: input.implements || [],
|
|
994
|
+
relates_to: input.relates_to || [],
|
|
995
|
+
tests: input.tests || [],
|
|
996
|
+
traits: input.traits || [],
|
|
997
|
+
notes: input.notes || [],
|
|
998
|
+
created: input.created || new Date().toISOString(),
|
|
999
|
+
created_by: input.created_by,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Map from item type to the field name used to store children of that type.
|
|
1004
|
+
*/
|
|
1005
|
+
const TYPE_TO_CHILD_FIELD = {
|
|
1006
|
+
feature: 'features',
|
|
1007
|
+
requirement: 'requirements',
|
|
1008
|
+
constraint: 'constraints',
|
|
1009
|
+
decision: 'decisions',
|
|
1010
|
+
module: 'modules',
|
|
1011
|
+
trait: 'traits',
|
|
1012
|
+
};
|
|
1013
|
+
/**
|
|
1014
|
+
* Add a spec item as a child of a parent item.
|
|
1015
|
+
* @param parent The parent item to add under
|
|
1016
|
+
* @param child The new child item to add
|
|
1017
|
+
* @param childField Optional field name override (defaults based on child.type)
|
|
1018
|
+
*/
|
|
1019
|
+
export async function addChildItem(ctx, parent, child, childField) {
|
|
1020
|
+
if (!parent._sourceFile) {
|
|
1021
|
+
throw new Error('Parent item has no source file');
|
|
1022
|
+
}
|
|
1023
|
+
const field = childField || TYPE_TO_CHILD_FIELD[child.type || 'feature'] || 'features';
|
|
1024
|
+
// Load the raw YAML
|
|
1025
|
+
const raw = await readYamlFile(parent._sourceFile);
|
|
1026
|
+
// Find the parent in the structure
|
|
1027
|
+
let parentObj;
|
|
1028
|
+
let parentPath;
|
|
1029
|
+
if (parent._path) {
|
|
1030
|
+
const nav = navigateToPath(raw, parent._path);
|
|
1031
|
+
if (!nav) {
|
|
1032
|
+
throw new Error(`Could not navigate to parent path: ${parent._path}`);
|
|
1033
|
+
}
|
|
1034
|
+
parentObj = nav.array[nav.index];
|
|
1035
|
+
parentPath = parent._path;
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
// Parent is the root item
|
|
1039
|
+
parentObj = raw;
|
|
1040
|
+
parentPath = '';
|
|
1041
|
+
}
|
|
1042
|
+
// Ensure the child field array exists
|
|
1043
|
+
if (!Array.isArray(parentObj[field])) {
|
|
1044
|
+
parentObj[field] = [];
|
|
1045
|
+
}
|
|
1046
|
+
// Add the child
|
|
1047
|
+
const childArray = parentObj[field];
|
|
1048
|
+
const cleanChild = stripSpecItemMetadata(child);
|
|
1049
|
+
childArray.push(cleanChild);
|
|
1050
|
+
// Calculate the new child's path
|
|
1051
|
+
const childIndex = childArray.length - 1;
|
|
1052
|
+
const childPath = parentPath ? `${parentPath}.${field}[${childIndex}]` : `${field}[${childIndex}]`;
|
|
1053
|
+
// Write back with format preservation
|
|
1054
|
+
await writeYamlFilePreserveFormat(parent._sourceFile, raw);
|
|
1055
|
+
return { item: cleanChild, path: childPath };
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Update a spec item in place within its source file.
|
|
1059
|
+
* Works with nested structures using the _path field.
|
|
1060
|
+
*/
|
|
1061
|
+
export async function updateSpecItem(ctx, item, updates) {
|
|
1062
|
+
if (!item._sourceFile) {
|
|
1063
|
+
throw new Error('Item has no source file');
|
|
1064
|
+
}
|
|
1065
|
+
// Load the raw YAML
|
|
1066
|
+
const raw = await readYamlFile(item._sourceFile);
|
|
1067
|
+
// Find the item in the structure (use stored path or search by ULID)
|
|
1068
|
+
let targetObj;
|
|
1069
|
+
if (item._path) {
|
|
1070
|
+
const nav = navigateToPath(raw, item._path);
|
|
1071
|
+
if (!nav) {
|
|
1072
|
+
throw new Error(`Could not navigate to path: ${item._path}`);
|
|
1073
|
+
}
|
|
1074
|
+
targetObj = nav.array[nav.index];
|
|
1075
|
+
}
|
|
1076
|
+
else {
|
|
1077
|
+
// Item might be the root, or we need to find it
|
|
1078
|
+
const found = findItemInStructure(raw, item._ulid);
|
|
1079
|
+
if (found) {
|
|
1080
|
+
targetObj = found.item;
|
|
1081
|
+
}
|
|
1082
|
+
else if (raw._ulid === item._ulid) {
|
|
1083
|
+
targetObj = raw;
|
|
1084
|
+
}
|
|
1085
|
+
else {
|
|
1086
|
+
throw new Error(`Could not find item ${item._ulid} in structure`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// Apply updates (but never change _ulid)
|
|
1090
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
1091
|
+
if (key !== '_ulid' && key !== '_sourceFile' && key !== '_path') {
|
|
1092
|
+
targetObj[key] = value;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
// Write back with format preservation
|
|
1096
|
+
await writeYamlFilePreserveFormat(item._sourceFile, raw);
|
|
1097
|
+
return { ...item, ...updates, _ulid: item._ulid };
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Check if an item is a trait with implementors.
|
|
1101
|
+
* Returns array of items that use this trait via the 'traits' field.
|
|
1102
|
+
*/
|
|
1103
|
+
export function findTraitImplementors(trait, allItems) {
|
|
1104
|
+
// Check if the item is actually a trait
|
|
1105
|
+
if (trait.type !== 'trait') {
|
|
1106
|
+
return [];
|
|
1107
|
+
}
|
|
1108
|
+
// Find all items that reference this trait in their 'traits' array
|
|
1109
|
+
const traitRefs = ['@' + trait._ulid, ...trait.slugs.map(s => '@' + s)];
|
|
1110
|
+
return allItems.filter(item => {
|
|
1111
|
+
if (!item.traits || item.traits.length === 0)
|
|
1112
|
+
return false;
|
|
1113
|
+
return item.traits.some((traitRef) => traitRefs.includes(traitRef));
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Delete a spec item from its source file.
|
|
1118
|
+
* Works with nested structures using the _path field.
|
|
1119
|
+
*/
|
|
1120
|
+
export async function deleteSpecItem(ctx, item) {
|
|
1121
|
+
if (!item._sourceFile) {
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
1124
|
+
try {
|
|
1125
|
+
const raw = await readYamlFile(item._sourceFile);
|
|
1126
|
+
// If item has a path, navigate to it and remove from parent array
|
|
1127
|
+
if (item._path) {
|
|
1128
|
+
const nav = navigateToPath(raw, item._path);
|
|
1129
|
+
if (!nav) {
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1132
|
+
// Remove the item from the array
|
|
1133
|
+
nav.array.splice(nav.index, 1);
|
|
1134
|
+
await writeYamlFilePreserveFormat(item._sourceFile, raw);
|
|
1135
|
+
return true;
|
|
1136
|
+
}
|
|
1137
|
+
// No path - try to find it by ULID
|
|
1138
|
+
const found = findItemInStructure(raw, item._ulid);
|
|
1139
|
+
if (found && found.path) {
|
|
1140
|
+
const nav = navigateToPath(raw, found.path);
|
|
1141
|
+
if (nav) {
|
|
1142
|
+
nav.array.splice(nav.index, 1);
|
|
1143
|
+
await writeYamlFilePreserveFormat(item._sourceFile, raw);
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
// Maybe it's a root-level array item
|
|
1148
|
+
if (Array.isArray(raw)) {
|
|
1149
|
+
const index = raw.findIndex((i) => typeof i === 'object' && i !== null && i._ulid === item._ulid);
|
|
1150
|
+
if (index >= 0) {
|
|
1151
|
+
raw.splice(index, 1);
|
|
1152
|
+
await writeYamlFilePreserveFormat(item._sourceFile, raw);
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
return false;
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Save a spec item - either updates existing or adds to parent.
|
|
1164
|
+
* For new items, use addChildItem instead.
|
|
1165
|
+
*/
|
|
1166
|
+
export async function saveSpecItem(ctx, item) {
|
|
1167
|
+
// If item has a source file and path, it's an update
|
|
1168
|
+
if (item._sourceFile && item._path) {
|
|
1169
|
+
await updateSpecItem(ctx, item, item);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
// Otherwise, this is more complex - would need a parent
|
|
1173
|
+
throw new Error('Cannot save new item without parent. Use addChildItem instead.');
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Get the inbox file path.
|
|
1177
|
+
*
|
|
1178
|
+
* When shadow enabled: .kspec/project.inbox.yaml
|
|
1179
|
+
* Otherwise: spec/project.inbox.yaml
|
|
1180
|
+
*/
|
|
1181
|
+
export function getInboxFilePath(ctx) {
|
|
1182
|
+
return path.join(ctx.specDir, 'project.inbox.yaml');
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Load all inbox items from the project.
|
|
1186
|
+
*/
|
|
1187
|
+
export async function loadInboxItems(ctx) {
|
|
1188
|
+
const inboxPath = getInboxFilePath(ctx);
|
|
1189
|
+
try {
|
|
1190
|
+
const raw = await readYamlFile(inboxPath);
|
|
1191
|
+
// Handle { inbox: [...] } format
|
|
1192
|
+
if (raw && typeof raw === 'object' && 'inbox' in raw) {
|
|
1193
|
+
const parsed = InboxFileSchema.safeParse(raw);
|
|
1194
|
+
if (parsed.success) {
|
|
1195
|
+
return parsed.data.inbox.map(item => ({ ...item, _sourceFile: inboxPath }));
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// Handle plain array format
|
|
1199
|
+
if (Array.isArray(raw)) {
|
|
1200
|
+
const items = [];
|
|
1201
|
+
for (const item of raw) {
|
|
1202
|
+
const result = InboxItemSchema.safeParse(item);
|
|
1203
|
+
if (result.success) {
|
|
1204
|
+
items.push({ ...result.data, _sourceFile: inboxPath });
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
return items;
|
|
1208
|
+
}
|
|
1209
|
+
return [];
|
|
1210
|
+
}
|
|
1211
|
+
catch {
|
|
1212
|
+
// File doesn't exist or parse error
|
|
1213
|
+
return [];
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Create a new inbox item with auto-generated fields.
|
|
1218
|
+
*/
|
|
1219
|
+
export function createInboxItem(input) {
|
|
1220
|
+
return {
|
|
1221
|
+
_ulid: input._ulid || ulid(),
|
|
1222
|
+
text: input.text,
|
|
1223
|
+
created_at: input.created_at || new Date().toISOString(),
|
|
1224
|
+
tags: input.tags || [],
|
|
1225
|
+
added_by: input.added_by ?? getAuthor(),
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Strip runtime metadata before serialization.
|
|
1230
|
+
*/
|
|
1231
|
+
function stripInboxMetadata(item) {
|
|
1232
|
+
const { _sourceFile, ...cleanItem } = item;
|
|
1233
|
+
return cleanItem;
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Save an inbox item (add or update).
|
|
1237
|
+
*/
|
|
1238
|
+
export async function saveInboxItem(ctx, item) {
|
|
1239
|
+
const inboxPath = getInboxFilePath(ctx);
|
|
1240
|
+
// Ensure directory exists
|
|
1241
|
+
const dir = path.dirname(inboxPath);
|
|
1242
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1243
|
+
// Load existing items
|
|
1244
|
+
let existingItems = [];
|
|
1245
|
+
try {
|
|
1246
|
+
const raw = await readYamlFile(inboxPath);
|
|
1247
|
+
if (raw && typeof raw === 'object' && 'inbox' in raw) {
|
|
1248
|
+
const parsed = InboxFileSchema.safeParse(raw);
|
|
1249
|
+
if (parsed.success) {
|
|
1250
|
+
existingItems = parsed.data.inbox;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
else if (Array.isArray(raw)) {
|
|
1254
|
+
for (const i of raw) {
|
|
1255
|
+
const result = InboxItemSchema.safeParse(i);
|
|
1256
|
+
if (result.success) {
|
|
1257
|
+
existingItems.push(result.data);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
// File doesn't exist, start fresh
|
|
1264
|
+
}
|
|
1265
|
+
const cleanItem = stripInboxMetadata(item);
|
|
1266
|
+
// Update existing or add new
|
|
1267
|
+
const existingIndex = existingItems.findIndex(i => i._ulid === item._ulid);
|
|
1268
|
+
if (existingIndex >= 0) {
|
|
1269
|
+
existingItems[existingIndex] = cleanItem;
|
|
1270
|
+
}
|
|
1271
|
+
else {
|
|
1272
|
+
existingItems.push(cleanItem);
|
|
1273
|
+
}
|
|
1274
|
+
// Save with { inbox: [...] } format and format preservation
|
|
1275
|
+
await writeYamlFilePreserveFormat(inboxPath, { inbox: existingItems });
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Delete an inbox item by ULID.
|
|
1279
|
+
*/
|
|
1280
|
+
export async function deleteInboxItem(ctx, ulid) {
|
|
1281
|
+
const inboxPath = getInboxFilePath(ctx);
|
|
1282
|
+
try {
|
|
1283
|
+
const raw = await readYamlFile(inboxPath);
|
|
1284
|
+
let existingItems = [];
|
|
1285
|
+
if (raw && typeof raw === 'object' && 'inbox' in raw) {
|
|
1286
|
+
const parsed = InboxFileSchema.safeParse(raw);
|
|
1287
|
+
if (parsed.success) {
|
|
1288
|
+
existingItems = parsed.data.inbox;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
const index = existingItems.findIndex(i => i._ulid === ulid);
|
|
1292
|
+
if (index < 0) {
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
existingItems.splice(index, 1);
|
|
1296
|
+
await writeYamlFilePreserveFormat(inboxPath, { inbox: existingItems });
|
|
1297
|
+
return true;
|
|
1298
|
+
}
|
|
1299
|
+
catch {
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Find an inbox item by reference (ULID or short ULID).
|
|
1305
|
+
*/
|
|
1306
|
+
export function findInboxItemByRef(items, ref) {
|
|
1307
|
+
const cleanRef = ref.startsWith('@') ? ref.slice(1) : ref;
|
|
1308
|
+
return items.find(item => {
|
|
1309
|
+
// Match full ULID
|
|
1310
|
+
if (item._ulid === cleanRef)
|
|
1311
|
+
return true;
|
|
1312
|
+
// Match short ULID (prefix)
|
|
1313
|
+
if (item._ulid.toLowerCase().startsWith(cleanRef.toLowerCase()))
|
|
1314
|
+
return true;
|
|
1315
|
+
return false;
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Bulk patch spec items.
|
|
1320
|
+
* Resolves refs, validates data, applies patches.
|
|
1321
|
+
* Continues on error by default (use failFast to stop on first error).
|
|
1322
|
+
*/
|
|
1323
|
+
export async function patchSpecItems(ctx, refIndex, items, patches, options = {}) {
|
|
1324
|
+
const results = [];
|
|
1325
|
+
let stopProcessing = false;
|
|
1326
|
+
for (const patch of patches) {
|
|
1327
|
+
if (stopProcessing) {
|
|
1328
|
+
results.push({ ref: patch.ref, status: 'skipped' });
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
// Resolve ref
|
|
1332
|
+
const resolved = refIndex.resolve(patch.ref);
|
|
1333
|
+
if (!resolved.ok) {
|
|
1334
|
+
const errorMsg = resolved.error === 'not_found'
|
|
1335
|
+
? `Item not found: ${patch.ref}`
|
|
1336
|
+
: resolved.error === 'ambiguous'
|
|
1337
|
+
? `Ambiguous ref: ${patch.ref}`
|
|
1338
|
+
: `Duplicate slug: ${patch.ref}`;
|
|
1339
|
+
results.push({ ref: patch.ref, status: 'error', error: errorMsg });
|
|
1340
|
+
if (options.failFast) {
|
|
1341
|
+
stopProcessing = true;
|
|
1342
|
+
}
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
// Find the item
|
|
1346
|
+
const item = items.find(i => i._ulid === resolved.ulid);
|
|
1347
|
+
if (!item) {
|
|
1348
|
+
// Ref resolved but it's not a spec item (might be a task)
|
|
1349
|
+
results.push({ ref: patch.ref, status: 'error', error: 'Not a spec item' });
|
|
1350
|
+
if (options.failFast) {
|
|
1351
|
+
stopProcessing = true;
|
|
1352
|
+
}
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
// Dry run - just record what would happen
|
|
1356
|
+
if (options.dryRun) {
|
|
1357
|
+
results.push({ ref: patch.ref, status: 'updated', ulid: item._ulid });
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
// Apply the patch
|
|
1361
|
+
try {
|
|
1362
|
+
await updateSpecItem(ctx, item, patch.data);
|
|
1363
|
+
results.push({ ref: patch.ref, status: 'updated', ulid: item._ulid });
|
|
1364
|
+
}
|
|
1365
|
+
catch (err) {
|
|
1366
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1367
|
+
results.push({ ref: patch.ref, status: 'error', error: errorMsg });
|
|
1368
|
+
if (options.failFast) {
|
|
1369
|
+
stopProcessing = true;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
return {
|
|
1374
|
+
results,
|
|
1375
|
+
summary: {
|
|
1376
|
+
total: patches.length,
|
|
1377
|
+
updated: results.filter(r => r.status === 'updated').length,
|
|
1378
|
+
failed: results.filter(r => r.status === 'error').length,
|
|
1379
|
+
skipped: results.filter(r => r.status === 'skipped').length,
|
|
1380
|
+
},
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
//# sourceMappingURL=yaml.js.map
|