@e0ipso/ai-task-manager 1.26.3 → 1.26.5

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.
@@ -2,71 +2,46 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { findTaskManagerRoot, extractIdFromFrontmatter } = require('./shared-utils.cjs');
5
+ const {
6
+ resolvePlan,
7
+ extractIdFromFrontmatter
8
+ } = require('./shared-utils.cjs');
6
9
 
7
10
  /**
8
11
  * Get the next available task ID for a specific plan
9
- * @param {number|string} planId - The plan ID to get next task ID for
12
+ * @private
13
+ * @param {number|string} inputId - The plan ID or path to get next task ID for
10
14
  * @returns {number} Next available task ID
11
15
  */
12
- function getNextTaskId(planId) {
13
- if (!planId) {
14
- console.error('Error: Plan ID is required');
16
+ function _getNextTaskId(inputId) {
17
+ if (!inputId) {
18
+ console.error('Error: Plan ID or path is required');
15
19
  process.exit(1);
16
20
  }
17
21
 
18
- const taskManagerRoot = findTaskManagerRoot();
22
+ const resolved = resolvePlan(inputId);
19
23
 
20
- if (!taskManagerRoot) {
21
- console.error('Error: No .ai/task-manager/plans directory found in current directory or any parent directory.');
22
- console.error('Please ensure you are in a project with task manager initialized.');
24
+ if (!resolved) {
25
+ console.error(`Error: Plan "${inputId}" not found or invalid.`);
23
26
  process.exit(1);
24
27
  }
25
28
 
26
- const plansDir = path.join(taskManagerRoot, 'plans');
27
-
28
- // Find the plan directory (supports both padded and unpadded formats)
29
- const paddedPlanId = String(planId).padStart(2, '0');
30
-
31
- let planDir = null;
32
-
33
- // Optimization: 90% of the time there are no tasks, so check if plans directory exists first
34
- if (!fs.existsSync(plansDir)) {
35
- return 1; // No plans directory = no tasks = start with ID 1
36
- }
37
-
38
- try {
39
- const entries = fs.readdirSync(plansDir, { withFileTypes: true });
40
-
41
- // Look for directory matching the plan ID pattern
42
- for (const entry of entries) {
43
- if (entry.isDirectory()) {
44
- const match = entry.name.match(/^(\d+)--/);
45
- if (match) {
46
- const dirPlanId = match[1].padStart(2, '0');
47
- if (dirPlanId === paddedPlanId) {
48
- const tasksPath = path.join(plansDir, entry.name, 'tasks');
49
- if (fs.existsSync(tasksPath)) {
50
- planDir = tasksPath;
51
- }
52
- break;
53
- }
54
- }
55
- }
56
- }
57
- } catch (err) {
58
- // Directory doesn't exist or can't be read
59
- }
29
+ const {
30
+ planDir
31
+ } = resolved;
32
+ const tasksPath = path.join(planDir, 'tasks');
60
33
 
61
34
  // Optimization: If no tasks directory exists, return 1 immediately (90% case)
62
- if (!planDir) {
35
+ if (!fs.existsSync(tasksPath)) {
63
36
  return 1;
64
37
  }
65
38
 
66
39
  let maxId = 0;
67
40
 
68
41
  try {
69
- const entries = fs.readdirSync(planDir, { withFileTypes: true });
42
+ const entries = fs.readdirSync(tasksPath, {
43
+ withFileTypes: true
44
+ });
70
45
 
71
46
  // Another optimization: If directory is empty, return 1 immediately
72
47
  if (entries.length === 0) {
@@ -76,7 +51,7 @@ function getNextTaskId(planId) {
76
51
  entries.forEach(entry => {
77
52
  if (entry.isFile() && entry.name.endsWith('.md')) {
78
53
  try {
79
- const filePath = path.join(planDir, entry.name);
54
+ const filePath = path.join(tasksPath, entry.name);
80
55
  const content = fs.readFileSync(filePath, 'utf8');
81
56
  const id = extractIdFromFrontmatter(content);
82
57
 
@@ -96,5 +71,11 @@ function getNextTaskId(planId) {
96
71
  }
97
72
 
98
73
  // Get plan ID from command line argument
99
- const planId = process.argv[2];
100
- console.log(getNextTaskId(planId));
74
+ if (require.main === module) {
75
+ const inputId = process.argv[2];
76
+ console.log(_getNextTaskId(inputId));
77
+ }
78
+
79
+ module.exports = {
80
+ _getNextTaskId
81
+ };
@@ -4,61 +4,90 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
6
  /**
7
- * Find the task manager root directory by traversing up from current working directory
8
- * @returns {string|null} Path to task manager root or null if not found
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
9
11
  */
10
- function findTaskManagerRoot() {
11
- let currentPath = process.cwd();
12
- const filesystemRoot = path.parse(currentPath).root;
12
+ function isValidTaskManagerRoot(taskManagerPath) {
13
+ try {
14
+ if (!fs.existsSync(taskManagerPath)) return false;
15
+ if (!fs.lstatSync(taskManagerPath).isDirectory()) return false;
13
16
 
14
- // Traverse upward through parent directories until we reach the filesystem root
15
- while (currentPath !== filesystemRoot) {
16
- const taskManagerPlansPath = path.join(currentPath, '.ai', 'task-manager', 'plans');
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;
17
20
 
18
- try {
19
- // Check if this is a valid task manager directory
20
- if (fs.existsSync(taskManagerPlansPath)) {
21
- // Verify it's a directory, not a file
22
- const stats = fs.lstatSync(taskManagerPlansPath);
23
- if (stats.isDirectory()) {
24
- const taskManagerRoot = path.join(currentPath, '.ai', 'task-manager');
25
- return taskManagerRoot;
26
- }
27
- }
28
- } catch (err) {
29
- // Handle permission errors or other filesystem issues gracefully
30
- // Continue searching in parent directories
31
- if (err.code === 'EPERM' || err.code === 'EACCES') {
32
- // Silently continue - permission errors are expected in some directories
33
- }
34
- }
21
+ const metadataContent = fs.readFileSync(metadataPath, 'utf8');
22
+ const metadata = JSON.parse(metadataContent);
35
23
 
36
- // Move up to parent directory
37
- const parentPath = path.dirname(currentPath);
24
+ return metadata && typeof metadata === 'object' && 'version' in metadata;
25
+ } catch (err) {
26
+ return false;
27
+ }
28
+ }
38
29
 
39
- // Safety check: if path.dirname returns the same path, we've reached the root
40
- if (parentPath === currentPath) {
41
- break;
42
- }
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
+ }
43
40
 
44
- currentPath = parentPath;
45
- }
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);
46
52
 
47
- // Check the filesystem root as the final attempt
48
- try {
49
- const rootTaskManagerPlans = path.join(filesystemRoot, '.ai', 'task-manager', 'plans');
50
- if (fs.existsSync(rootTaskManagerPlans)) {
51
- const stats = fs.lstatSync(rootTaskManagerPlans);
52
- if (stats.isDirectory()) {
53
- const taskManagerRoot = path.join(filesystemRoot, '.ai', 'task-manager');
54
- return taskManagerRoot;
55
- }
56
- }
57
- } catch (err) {
58
- // Silently continue
53
+ if (parentPath === absolutePath) {
54
+ return nextAcc;
59
55
  }
60
56
 
61
- return null;
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;
62
91
  }
63
92
 
64
93
  /**
@@ -101,34 +130,34 @@ function extractIdFromFrontmatter(content, filePath = 'unknown') {
101
130
  /^\s*id\s*:\s*[|>]\s*([+-]?\d+)\s*$/mi
102
131
  ];
103
132
 
104
- // Try each pattern in order
105
- for (const regex of patterns) {
106
- const match = frontmatterText.match(regex);
107
- if (match) {
108
- const rawId = match[1];
109
- const id = parseInt(rawId, 10);
110
-
111
- // Validate the parsed ID
112
- if (isNaN(id)) {
113
- console.error(`[ERROR] Invalid ID value "${rawId}" in ${filePath} - not a valid number`);
114
- continue;
115
- }
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);
116
137
 
117
- if (id < 0) {
118
- console.error(`[ERROR] Invalid ID value ${id} in ${filePath} - ID must be non-negative`);
119
- continue;
120
- }
138
+ if (!foundPattern) return null;
121
139
 
122
- if (id > Number.MAX_SAFE_INTEGER) {
123
- console.error(`[ERROR] Invalid ID value ${id} in ${filePath} - ID exceeds maximum safe integer`);
124
- continue;
125
- }
140
+ const { match, regex } = foundPattern;
141
+ const rawId = match[1];
142
+ const id = parseInt(rawId, 10);
126
143
 
127
- return id;
128
- }
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;
129
158
  }
130
159
 
131
- return null;
160
+ return id;
132
161
  }
133
162
 
134
163
  /**
@@ -139,33 +168,251 @@ function extractIdFromFrontmatter(content, filePath = 'unknown') {
139
168
  */
140
169
  function parseFrontmatter(content) {
141
170
  const lines = content.split('\n');
142
- let inFrontmatter = false;
143
- let frontmatterEnd = false;
144
- let delimiterCount = 0;
145
- const frontmatterLines = [];
146
171
 
147
- for (const line of lines) {
172
+ const result = lines.reduce((acc, line) => {
173
+ if (acc.done) return acc;
174
+
148
175
  if (line.trim() === '---') {
149
- delimiterCount++;
150
- if (delimiterCount === 1) {
151
- inFrontmatter = true;
152
- continue;
153
- } else if (delimiterCount === 2) {
154
- frontmatterEnd = true;
155
- break;
176
+ const nextDelimiterCount = acc.delimiterCount + 1;
177
+ if (nextDelimiterCount === 2) {
178
+ return { ...acc, delimiterCount: nextDelimiterCount, done: true };
156
179
  }
180
+ return { ...acc, delimiterCount: nextDelimiterCount };
157
181
  }
158
182
 
159
- if (inFrontmatter && !frontmatterEnd) {
160
- frontmatterLines.push(line);
183
+ if (acc.delimiterCount === 1) {
184
+ return { ...acc, frontmatterLines: [...acc.frontmatterLines, line] };
161
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;
162
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
+ };
163
400
 
164
- return frontmatterLines.join('\n');
401
+ return findInAncestry(startPath);
165
402
  }
166
403
 
167
404
  module.exports = {
168
405
  findTaskManagerRoot,
406
+ isValidTaskManagerRoot,
407
+ getTaskManagerAt,
408
+ checkStandardRootShortcut,
409
+ validatePlanFile,
169
410
  extractIdFromFrontmatter,
170
- parseFrontmatter
411
+ parseFrontmatter,
412
+ findPlanById,
413
+ countTasks,
414
+ checkBlueprintExists,
415
+ getAllPlans,
416
+ _getParentPaths,
417
+ resolvePlan
171
418
  };