@e0ipso/ai-task-manager 1.26.2 → 1.26.4

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.
@@ -0,0 +1,418 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Validate that a task manager root is correctly initialized
8
+ * @internal
9
+ * @param {string} taskManagerPath - Path to .ai/task-manager
10
+ * @returns {boolean} True if root is valid, false otherwise
11
+ */
12
+ function isValidTaskManagerRoot(taskManagerPath) {
13
+ try {
14
+ if (!fs.existsSync(taskManagerPath)) return false;
15
+ if (!fs.lstatSync(taskManagerPath).isDirectory()) return false;
16
+
17
+ // Must contain .init-metadata.json with valid version prop
18
+ const metadataPath = path.join(taskManagerPath, '.init-metadata.json');
19
+ if (!fs.existsSync(metadataPath)) return false;
20
+
21
+ const metadataContent = fs.readFileSync(metadataPath, 'utf8');
22
+ const metadata = JSON.parse(metadataContent);
23
+
24
+ return metadata && typeof metadata === 'object' && 'version' in metadata;
25
+ } catch (err) {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Check if a directory contains a valid task manager root
32
+ * @internal
33
+ * @param {string} directory - Directory to check
34
+ * @returns {string|null} Task manager path if valid, otherwise null
35
+ */
36
+ function getTaskManagerAt(directory) {
37
+ const taskManagerPath = path.join(directory, '.ai', 'task-manager');
38
+ return isValidTaskManagerRoot(taskManagerPath) ? taskManagerPath : null;
39
+ }
40
+
41
+ /**
42
+ * Get all parent directories from a start path up to the filesystem root (recursive)
43
+ * @private
44
+ * @param {string} currentPath - Path to start from
45
+ * @param {string[]} [acc=[]] - Accumulator for paths
46
+ * @returns {string[]} Array of paths from start to root
47
+ */
48
+ function _getParentPaths(currentPath, acc = []) {
49
+ const absolutePath = path.resolve(currentPath);
50
+ const nextAcc = [...acc, absolutePath];
51
+ const parentPath = path.dirname(absolutePath);
52
+
53
+ if (parentPath === absolutePath) {
54
+ return nextAcc;
55
+ }
56
+
57
+ return _getParentPaths(parentPath, nextAcc);
58
+ }
59
+
60
+ /**
61
+ * Find the task manager root directory by traversing up from an optional start path
62
+ * @param {string} [startPath=process.cwd()] - Starting path for root discovery (defaults to current working directory)
63
+ * @returns {string|null} Path to task manager root or null if not found
64
+ */
65
+ function findTaskManagerRoot(startPath = process.cwd()) {
66
+ const paths = _getParentPaths(startPath);
67
+ const foundPath = paths.find(p => getTaskManagerAt(p));
68
+ return foundPath ? getTaskManagerAt(foundPath) : null;
69
+ }
70
+
71
+ /**
72
+ * Check if the path matches the standard .ai/task-manager structure
73
+ * @param {string} filePath - Path to plan file
74
+ * @returns {string|null} The possible root path if matches, otherwise null
75
+ */
76
+ function checkStandardRootShortcut(filePath) {
77
+ const planDir = path.dirname(filePath);
78
+ const parentDir = path.dirname(planDir);
79
+ const possibleRoot = path.dirname(parentDir);
80
+
81
+ const parentBase = path.basename(parentDir);
82
+ const isPlansOrArchive = parentBase === 'plans' || parentBase === 'archive';
83
+ if (!isPlansOrArchive) return null;
84
+
85
+ if (path.basename(possibleRoot) !== 'task-manager') return null;
86
+
87
+ const dotAiDir = path.dirname(possibleRoot);
88
+ if (path.basename(dotAiDir) !== '.ai') return null;
89
+
90
+ return isValidTaskManagerRoot(possibleRoot) ? possibleRoot : null;
91
+ }
92
+
93
+ /**
94
+ * Parse YAML frontmatter for ID
95
+ * @param {string} content - File content
96
+ * @param {string} [filePath] - Optional file path for error context
97
+ * @returns {number|null} Extracted ID or null
98
+ */
99
+ function extractIdFromFrontmatter(content, filePath = 'unknown') {
100
+ // Check for frontmatter block existence
101
+ const frontmatterMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
102
+ if (!frontmatterMatch) {
103
+ return null;
104
+ }
105
+
106
+ const frontmatterText = frontmatterMatch[1];
107
+
108
+ // Enhanced patterns to handle various YAML formats:
109
+ // - id: 5 (simple numeric)
110
+ // - id: "5" (double quoted)
111
+ // - id: '5' (single quoted)
112
+ // - "id": 5 (quoted key)
113
+ // - 'id': 5 (single quoted key)
114
+ // - id : 5 (extra spaces)
115
+ // - id: 05 (zero-padded)
116
+ // - id: +5 (explicit positive)
117
+ // - Mixed quotes: 'id': "5" (different quote types)
118
+ const patterns = [
119
+ // Most flexible pattern - handles quoted/unquoted keys and values with optional spaces
120
+ /^\s*["']?id["']?\s*:\s*["']?([+-]?\d+)["']?\s*(?:#.*)?$/mi,
121
+ // Simple numeric with optional whitespace and comments
122
+ /^\s*id\s*:\s*([+-]?\d+)\s*(?:#.*)?$/mi,
123
+ // Double quoted values
124
+ /^\s*["']?id["']?\s*:\s*"([+-]?\d+)"\s*(?:#.*)?$/mi,
125
+ // Single quoted values
126
+ /^\s*["']?id["']?\s*:\s*'([+-]?\d+)'\s*(?:#.*)?$/mi,
127
+ // Mixed quotes - quoted key, unquoted value
128
+ /^\s*["']id["']\s*:\s*([+-]?\d+)\s*(?:#.*)?$/mi,
129
+ // YAML-style with pipe or greater-than indicators (edge case)
130
+ /^\s*id\s*:\s*[|>]\s*([+-]?\d+)\s*$/mi
131
+ ];
132
+
133
+ // Try each pattern in order using functional find
134
+ const foundPattern = patterns
135
+ .map(regex => ({ regex, match: frontmatterText.match(regex) }))
136
+ .find(({ match }) => match);
137
+
138
+ if (!foundPattern) return null;
139
+
140
+ const { match, regex } = foundPattern;
141
+ const rawId = match[1];
142
+ const id = parseInt(rawId, 10);
143
+
144
+ // Validate the parsed ID
145
+ if (isNaN(id)) {
146
+ console.error(`[ERROR] Invalid ID value "${rawId}" in ${filePath} - not a valid number`);
147
+ return null;
148
+ }
149
+
150
+ if (id < 0) {
151
+ console.error(`[ERROR] Invalid ID value ${id} in ${filePath} - ID must be non-negative`);
152
+ return null;
153
+ }
154
+
155
+ if (id > Number.MAX_SAFE_INTEGER) {
156
+ console.error(`[ERROR] Invalid ID value ${id} in ${filePath} - ID exceeds maximum safe integer`);
157
+ return null;
158
+ }
159
+
160
+ return id;
161
+ }
162
+
163
+ /**
164
+ * Parse YAML frontmatter from markdown content
165
+ * Returns the frontmatter text as a string (not parsed as YAML)
166
+ * @param {string} content - The markdown content with frontmatter
167
+ * @returns {string} Frontmatter text or empty string if not found
168
+ */
169
+ function parseFrontmatter(content) {
170
+ const lines = content.split('\n');
171
+
172
+ const result = lines.reduce((acc, line) => {
173
+ if (acc.done) return acc;
174
+
175
+ if (line.trim() === '---') {
176
+ const nextDelimiterCount = acc.delimiterCount + 1;
177
+ if (nextDelimiterCount === 2) {
178
+ return { ...acc, delimiterCount: nextDelimiterCount, done: true };
179
+ }
180
+ return { ...acc, delimiterCount: nextDelimiterCount };
181
+ }
182
+
183
+ if (acc.delimiterCount === 1) {
184
+ return { ...acc, frontmatterLines: [...acc.frontmatterLines, line] };
185
+ }
186
+
187
+ return acc;
188
+ }, { delimiterCount: 0, frontmatterLines: [], done: false });
189
+
190
+ return result.frontmatterLines.join('\n');
191
+ }
192
+
193
+ /**
194
+ * Find plan file and directory for a given plan ID
195
+ * @param {string|number} planId - Plan ID to search for
196
+ * @param {string} [taskManagerRoot] - Optional task manager root path (uses findTaskManagerRoot() if not provided)
197
+ * @returns {Object|null} Object with planFile and planDir, or null if not found
198
+ */
199
+ function findPlanById(planId, taskManagerRoot) {
200
+ const numericPlanId = parseInt(planId, 10);
201
+ if (isNaN(numericPlanId)) return null;
202
+
203
+ const plans = getAllPlans(taskManagerRoot);
204
+ const plan = plans.find(p => p.id === numericPlanId);
205
+
206
+ if (!plan) return null;
207
+
208
+ return {
209
+ planFile: plan.file,
210
+ planDir: plan.dir,
211
+ isArchive: plan.isArchive
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Count task files in a plan's tasks directory
217
+ * @param {string} planDir - Plan directory path
218
+ * @returns {number} Number of task files found
219
+ */
220
+ function countTasks(planDir) {
221
+ const tasksDir = path.join(planDir, 'tasks');
222
+
223
+ if (!fs.existsSync(tasksDir)) {
224
+ return 0;
225
+ }
226
+
227
+ try {
228
+ const stats = fs.lstatSync(tasksDir);
229
+ if (!stats.isDirectory()) {
230
+ return 0;
231
+ }
232
+
233
+ const files = fs.readdirSync(tasksDir).filter(f => f.endsWith('.md'));
234
+ return files.length;
235
+ } catch (err) {
236
+ return 0;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Check if execution blueprint section exists in plan file
242
+ * @param {string} planFile - Path to plan file
243
+ * @returns {boolean} True if blueprint section exists, false otherwise
244
+ */
245
+ function checkBlueprintExists(planFile) {
246
+ try {
247
+ const planContent = fs.readFileSync(planFile, 'utf8');
248
+ const blueprintExists = /^## Execution Blueprint/m.test(planContent);
249
+ return blueprintExists;
250
+ } catch (err) {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Validate plan file frontmatter
257
+ * @param {string} filePath - Path to plan file
258
+ * @returns {number|null} Plan ID from frontmatter or null if invalid
259
+ */
260
+ function validatePlanFile(filePath) {
261
+ try {
262
+ if (!fs.existsSync(filePath)) {
263
+ return null;
264
+ }
265
+
266
+ const content = fs.readFileSync(filePath, 'utf8');
267
+ const frontmatter = parseFrontmatter(content);
268
+
269
+ // Check for required fields
270
+ if (!frontmatter) {
271
+ return null;
272
+ }
273
+
274
+ // Check for 'created' field
275
+ if (!/\bcreated\b/i.test(frontmatter)) {
276
+ return null;
277
+ }
278
+
279
+ // Extract and return ID
280
+ const id = extractIdFromFrontmatter(content, filePath);
281
+ return id;
282
+ } catch (err) {
283
+ return null;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Get all plans (active and archived) in a task manager root
289
+ * @param {string} [taskManagerRoot] - Task manager root path
290
+ * @returns {Array<Object>} Array of plan objects { id, file, dir, isArchive }
291
+ */
292
+ function getAllPlans(taskManagerRoot) {
293
+ const root = taskManagerRoot || findTaskManagerRoot();
294
+ if (!root) return [];
295
+
296
+ const types = [
297
+ { dir: path.join(root, 'plans'), isArchive: false },
298
+ { dir: path.join(root, 'archive'), isArchive: true }
299
+ ];
300
+
301
+ return types.flatMap(({ dir, isArchive }) => {
302
+ if (!fs.existsSync(dir)) return [];
303
+
304
+ try {
305
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
306
+ return entries.flatMap(entry => {
307
+ if (!entry.isDirectory()) return [];
308
+
309
+ const planDirPath = path.join(dir, entry.name);
310
+
311
+ try {
312
+ const planDirEntries = fs.readdirSync(planDirPath, { withFileTypes: true });
313
+ return planDirEntries
314
+ .filter(planEntry => planEntry.isFile() && planEntry.name.endsWith('.md'))
315
+ .flatMap(planEntry => {
316
+ const filePath = path.join(planDirPath, planEntry.name);
317
+ try {
318
+ const content = fs.readFileSync(filePath, 'utf8');
319
+ const id = extractIdFromFrontmatter(content, filePath);
320
+
321
+ if (id !== null) {
322
+ return {
323
+ id,
324
+ file: filePath,
325
+ dir: planDirPath,
326
+ isArchive,
327
+ name: entry.name
328
+ };
329
+ }
330
+ } catch (err) {
331
+ // Skip files that can't be read
332
+ }
333
+ return [];
334
+ });
335
+ } catch (err) {
336
+ return [];
337
+ }
338
+ });
339
+ } catch (err) {
340
+ return [];
341
+ }
342
+ });
343
+ }
344
+
345
+ /**
346
+ * Resolve plan information from either a numeric ID or an absolute path
347
+ * @param {string|number} input - Numeric ID or absolute path
348
+ * @param {string} [startPath=process.cwd()] - Starting path for hierarchical search
349
+ * @returns {Object|null} { planFile, planDir, taskManagerRoot, planId } or null if not found
350
+ */
351
+ function resolvePlan(input, startPath = process.cwd()) {
352
+ if (!input) return null;
353
+ const inputStr = String(input);
354
+
355
+ // 1. Handle Absolute Path
356
+ if (inputStr.startsWith('/')) {
357
+ const planId = validatePlanFile(inputStr);
358
+ if (planId === null) return null;
359
+
360
+ const tmRoot = checkStandardRootShortcut(inputStr) || findTaskManagerRoot(path.dirname(inputStr));
361
+ if (!tmRoot) return null;
362
+
363
+ return {
364
+ planFile: inputStr,
365
+ planDir: path.dirname(inputStr),
366
+ taskManagerRoot: tmRoot,
367
+ planId
368
+ };
369
+ }
370
+
371
+ // 2. Handle Numeric ID with Hierarchical Search
372
+ const planId = parseInt(inputStr, 10);
373
+ if (isNaN(planId)) return null;
374
+
375
+ const findInAncestry = (currentPath, searched = new Set()) => {
376
+ const tmRoot = findTaskManagerRoot(currentPath);
377
+ if (!tmRoot) return null;
378
+
379
+ const normalized = path.normalize(tmRoot);
380
+ if (searched.has(normalized)) {
381
+ return null;
382
+ }
383
+ searched.add(normalized);
384
+
385
+ const plan = findPlanById(planId, tmRoot);
386
+ if (plan) {
387
+ return {
388
+ planFile: plan.planFile,
389
+ planDir: plan.planDir,
390
+ taskManagerRoot: tmRoot,
391
+ planId
392
+ };
393
+ }
394
+
395
+ // Move to parent directory (parent of the directory containing task-manager)
396
+ const parentOfRoot = path.dirname(path.dirname(tmRoot));
397
+ if (parentOfRoot === tmRoot) return null;
398
+ return findInAncestry(parentOfRoot, searched);
399
+ };
400
+
401
+ return findInAncestry(startPath);
402
+ }
403
+
404
+ module.exports = {
405
+ findTaskManagerRoot,
406
+ isValidTaskManagerRoot,
407
+ getTaskManagerAt,
408
+ checkStandardRootShortcut,
409
+ validatePlanFile,
410
+ extractIdFromFrontmatter,
411
+ parseFrontmatter,
412
+ findPlanById,
413
+ countTasks,
414
+ checkBlueprintExists,
415
+ getAllPlans,
416
+ _getParentPaths,
417
+ resolvePlan
418
+ };