@magnusekdahl/parallix 1.1.0 → 1.2.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.
Files changed (68) hide show
  1. package/config/integration-pipelines.json +9 -0
  2. package/config/workflow.config.schema.json +12 -0
  3. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
  4. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
  5. package/docs/adr/0041-integration-pipeline-gates.md +1 -1
  6. package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
  7. package/docs/adr/index.md +1 -0
  8. package/docs/use-cases.md +44 -0
  9. package/lib/agents/agents.js +149 -100
  10. package/lib/agents/claude-telemetry.js +14 -10
  11. package/lib/agents/claude.js +32 -11
  12. package/lib/agents/codex-telemetry.js +32 -19
  13. package/lib/agents/codex.js +36 -11
  14. package/lib/agents/limit-hit.js +42 -25
  15. package/lib/agents/mistral-telemetry.js +1 -1
  16. package/lib/agents/mistral.js +13 -3
  17. package/lib/agents/opencode-export.js +11 -5
  18. package/lib/agents/opencode-telemetry.js +75 -90
  19. package/lib/agents/opencode.js +86 -29
  20. package/lib/agents/stage-telemetry.js +3 -7
  21. package/lib/commands/active.js +139 -94
  22. package/lib/commands/checkpoint.js +3 -1
  23. package/lib/commands/config.js +5 -3
  24. package/lib/commands/coverage-gate.js +17 -10
  25. package/lib/commands/diff.js +11 -5
  26. package/lib/commands/draft.js +95 -55
  27. package/lib/commands/handoff.js +253 -65
  28. package/lib/commands/integrate.js +288 -188
  29. package/lib/commands/mission-start.js +39 -34
  30. package/lib/commands/rebase.js +32 -21
  31. package/lib/commands/repair-handoff.js +21 -18
  32. package/lib/commands/resolve-conflict.js +5 -2
  33. package/lib/commands/review.js +1 -0
  34. package/lib/commands/setup-review.js +1 -0
  35. package/lib/commands/stats-backfill.js +50 -41
  36. package/lib/commands/stats.js +653 -204
  37. package/lib/commands/status.js +33 -28
  38. package/lib/core/fmt.js +58 -15
  39. package/lib/core/git.js +7 -5
  40. package/lib/core/gitignore.js +6 -4
  41. package/lib/core/mission-utils.js +161 -88
  42. package/lib/core/nels.js +199 -0
  43. package/lib/core/persistent-data-migration.js +55 -23
  44. package/lib/core/product-config.js +44 -23
  45. package/lib/core/runtime-matrix.js +15 -2
  46. package/lib/core/spawn-tee.js +40 -13
  47. package/lib/core/state-map.js +32 -13
  48. package/lib/core/storage.js +19 -5
  49. package/lib/core/subagent-limit.js +28 -0
  50. package/lib/core/verification.js +29 -12
  51. package/lib/review/rebase.js +11 -3
  52. package/lib/review/review-adapter.js +93 -6
  53. package/lib/review/review-artifacts.js +89 -23
  54. package/lib/review/review-commands.js +139 -45
  55. package/lib/review/review-events.js +103 -52
  56. package/lib/review/review-loop.js +117 -87
  57. package/lib/review/review-polling.js +25 -5
  58. package/lib/review/review-prompts.js +36 -5
  59. package/lib/review/review-state.js +39 -15
  60. package/lib/tools/backlog.js +162 -71
  61. package/lib/tools/forgejo.js +290 -129
  62. package/lib/tools/gatekeeper.js +15 -0
  63. package/lib/tools/redgreen.js +220 -0
  64. package/lib/tools/sessions.js +12 -5
  65. package/lib/tools/setup-review.js +170 -70
  66. package/package.json +11 -3
  67. package/prompts/draft.md +6 -0
  68. package/templates/mission-scaffold.md +1 -1
@@ -5,48 +5,63 @@ const { WORKFLOW_AGENT_NAMES } = require('../agents/agents');
5
5
  const fmt = require('../core/fmt');
6
6
  const { resolveTaskStorage } = require('../core/product-config');
7
7
 
8
+ /** @returns {readonly string[]} */
8
9
  function getSupportedAgents() {
9
10
  return WORKFLOW_AGENT_NAMES;
10
11
  }
11
12
 
13
+ /** @param {string} [rootDir] @returns {{tasksDir: string, completedDir: string, archiveTasksDir: string}} */
12
14
  function getTaskStorage(rootDir = process.cwd()) {
13
15
  return resolveTaskStorage(rootDir);
14
16
  }
15
17
 
18
+ /**
19
+ * @param {string} slug
20
+ * @param {string} [rootDir]
21
+ * @returns {string[]}
22
+ */
16
23
  function findTaskFiles(slug, rootDir = process.cwd()) {
17
24
  const { tasksDir, completedDir, archiveTasksDir: archivedDir } = getTaskStorage(rootDir);
18
- const sortByBacklogState = (filePath) => {
19
- if (filePath.startsWith(tasksDir + path.sep)) return 0;
20
- if (filePath.startsWith(completedDir + path.sep)) return 1;
21
- if (filePath.startsWith(archivedDir + path.sep)) return 2;
25
+ const sortByBacklogState = /** @param {string} filePath */ (filePath) => {
26
+ if (filePath.startsWith(tasksDir + path.sep)) {return 0;}
27
+ if (filePath.startsWith(completedDir + path.sep)) {return 1;}
28
+ if (filePath.startsWith(archivedDir + path.sep)) {return 2;}
22
29
  return 3;
23
30
  };
24
31
 
32
+ /** @param {string} dir */
25
33
  const scan = (dir) => {
26
- if (!fs.existsSync(dir)) return [];
34
+ if (!fs.existsSync(dir)) {return [];}
27
35
  const files = fs.readdirSync(dir);
28
36
  const normalizedSlug = slug.toLowerCase();
29
37
  return files
30
- .filter(f => f.toLowerCase().startsWith(normalizedSlug))
31
- .map(f => path.join(dir, f));
38
+ .filter(/** @param {string} f */ f => f.toLowerCase().startsWith(normalizedSlug))
39
+ .map(/** @param {string} f */ f => path.join(dir, f));
32
40
  };
33
41
 
34
42
  const matches = [...scan(tasksDir), ...scan(completedDir), ...scan(archivedDir)];
35
43
  return matches.sort((a, b) => sortByBacklogState(a) - sortByBacklogState(b) || a.localeCompare(b));
36
44
  }
37
45
 
46
+ /**
47
+ * @param {string} slug
48
+ * @param {string} [rootDir]
49
+ * @returns {{ok: boolean, taskFile?: string, matches: string[], reason?: string}}
50
+ */
38
51
  function resolveTaskFile(slug, rootDir = process.cwd()) {
39
52
  let matches = findTaskFiles(slug, rootDir);
40
53
  const normalizedId = slug.toUpperCase();
41
54
  const { tasksDir, completedDir, archiveTasksDir: archivedDir } = getTaskStorage(rootDir);
55
+ /** @param {string[]} candidateMatches */
42
56
  const preferSameTaskInHigherPriorityDir = (candidateMatches) => {
43
- if (candidateMatches.length < 2) return null;
57
+ if (candidateMatches.length < 2) {return null;}
44
58
  const [preferred, ...rest] = candidateMatches;
45
- return rest.every(match => path.basename(match) === path.basename(preferred)) ? preferred : null;
59
+ return rest.every(/** @param {string} match */ match => path.basename(match) === path.basename(preferred)) ? preferred : null;
46
60
  };
47
61
 
62
+ /** @param {string[]} candidateFiles @param {string} targetId */
48
63
  const findById = (candidateFiles, targetId) => {
49
- return candidateFiles.filter(f => {
64
+ return candidateFiles.filter(/** @param {string} f */ f => {
50
65
  try {
51
66
  const content = fs.readFileSync(f, 'utf8');
52
67
  const idMatch = content.match(/^id:\s*([^\r\n]+)/m);
@@ -115,13 +130,23 @@ function resolveTaskFile(slug, rootDir = process.cwd()) {
115
130
  return { ok: false, reason: 'ambiguous', matches };
116
131
  }
117
132
 
133
+ /**
134
+ * @param {string} slug
135
+ * @param {string} [rootDir]
136
+ * @returns {string|undefined|null}
137
+ */
118
138
  function findTaskFile(slug, rootDir = process.cwd()) {
119
139
  const result = resolveTaskFile(slug, rootDir);
120
140
  return result.ok ? result.taskFile : null;
121
141
  }
122
142
 
143
+ /**
144
+ * @param {{ok: boolean, taskFile?: string, matches: string[], reason?: string}} result
145
+ * @param {string} slug
146
+ * @param {Function} [log]
147
+ */
123
148
  function reportTaskResolution(result, slug, log = fmt.log.plain) {
124
- if (result.ok) return;
149
+ if (result.ok) {return;}
125
150
  const { tasksDir, completedDir } = getTaskStorage(process.cwd());
126
151
  const taskHint = path.relative(process.cwd(), tasksDir).split(path.sep).join('/');
127
152
  const completedHint = path.relative(process.cwd(), completedDir).split(path.sep).join('/');
@@ -139,13 +164,19 @@ function reportTaskResolution(result, slug, log = fmt.log.plain) {
139
164
  }
140
165
  }
141
166
 
167
+ /** @param {string} file @returns {string|null} */
142
168
  function taskIdFromFilename(file) {
143
169
  // Extract ID from filename prefix (e.g., task-093 or task-093.01)
144
170
  const filenameMatch = file.match(/^(task-\d+(?:\.\d+)?)/i);
145
171
  return filenameMatch ? filenameMatch[1].toUpperCase() : null;
146
172
  }
147
173
 
148
- function checkBacklogIntegrity(rootDir = process.cwd(), slug = null) {
174
+ /**
175
+ * @param {string} [rootDir]
176
+ * @param {string} [slug]
177
+ * @returns {{file: string, type: string, taskId?: string, canonicalFile?: string}[]}
178
+ */
179
+ function checkBacklogIntegrity(rootDir = process.cwd(), /** @type {string|null} */ slug = null) {
149
180
  const { tasksDir, completedDir, archiveTasksDir: archivedDir } = getTaskStorage(rootDir);
150
181
  const issues = [];
151
182
  const normalizedSlug = slug ? slug.toLowerCase() : null;
@@ -157,20 +188,21 @@ function checkBacklogIntegrity(rootDir = process.cwd(), slug = null) {
157
188
  const tasksLocations = new Map(); // id -> rel path in tasks/
158
189
  const canonicalLocations = new Map(); // id -> rel path in completed|archive
159
190
 
191
+ /** @param {string} dir @param {{canonical?: boolean}} [opts] */
160
192
  const scan = (dir, { canonical = false } = {}) => {
161
- if (!fs.existsSync(dir)) return;
162
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
193
+ if (!fs.existsSync(dir)) {return;}
194
+ const files = fs.readdirSync(dir).filter(/** @param {string} f */ f => f.endsWith('.md'));
163
195
  for (const file of files) {
164
196
  if (normalizedSlug && !file.toLowerCase().startsWith(normalizedSlug)) {
165
197
  continue;
166
198
  }
167
199
  const filePath = path.join(dir, file);
168
200
  const filenameId = taskIdFromFilename(file);
169
- if (!filenameId) continue;
201
+ if (!filenameId) {continue;}
170
202
  const relPath = path.relative(rootDir, filePath);
171
203
 
172
204
  if (canonical) {
173
- if (!canonicalLocations.has(filenameId)) canonicalLocations.set(filenameId, relPath);
205
+ if (!canonicalLocations.has(filenameId)) {canonicalLocations.set(filenameId, relPath);}
174
206
  } else if (!tasksLocations.has(filenameId)) {
175
207
  tasksLocations.set(filenameId, relPath);
176
208
  }
@@ -243,8 +275,9 @@ function pruneStaleBacklogDuplicates(rootDir = process.cwd()) {
243
275
  return removed;
244
276
  }
245
277
 
278
+ /** @param {string} taskFilePath @returns {string|null} */
246
279
  function getTaskStatus(taskFilePath) {
247
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
280
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
248
281
  const content = fs.readFileSync(taskFilePath, 'utf8');
249
282
 
250
283
  // Try YAML first
@@ -262,8 +295,9 @@ function getTaskStatus(taskFilePath) {
262
295
  return null;
263
296
  }
264
297
 
298
+ /** @param {string} taskFilePath @param {string} newStatus @returns {boolean} */
265
299
  function setTaskStatus(taskFilePath, newStatus) {
266
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
300
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
267
301
  let content = fs.readFileSync(taskFilePath, 'utf8');
268
302
 
269
303
  // Update YAML
@@ -287,11 +321,16 @@ function setTaskStatus(taskFilePath, newStatus) {
287
321
  return true;
288
322
  }
289
323
 
324
+ /**
325
+ * @param {string} slug
326
+ * @param {string} [rootDir]
327
+ * @returns {boolean}
328
+ */
290
329
  function completeTask(slug, rootDir = process.cwd()) {
291
330
  const resolution = resolveTaskFile(slug, rootDir);
292
- if (!resolution.ok) return false;
331
+ if (!resolution.ok) {return false;}
293
332
 
294
- const taskFilePath = resolution.taskFile;
333
+ const taskFilePath = /** @type {string} */ (resolution.taskFile);
295
334
  const fileName = path.basename(taskFilePath);
296
335
  const { tasksDir, completedDir } = getTaskStorage(rootDir);
297
336
 
@@ -318,8 +357,12 @@ function completeTask(slug, rootDir = process.cwd()) {
318
357
  * Internal helper to parse assignee families from YAML frontmatter content.
319
358
  * Supports inline array, simple inline, and block formats.
320
359
  */
360
+ /**
361
+ * @param {string} content
362
+ * @returns {{matched: boolean, families: string[]}}
363
+ */
321
364
  function parseAssigneeFamilies(content) {
322
- let families = [];
365
+ let families = /** @type {string[]} */ ([]);
323
366
  let matched = false;
324
367
 
325
368
  const lineMatch = content.match(/^assignee:[ \t]*(.*)$/m);
@@ -333,8 +376,8 @@ function parseAssigneeFamilies(content) {
333
376
  : rest;
334
377
 
335
378
  families = rawValues.split(',')
336
- .map(s => s.trim().replace(/^['"]|['"]$/g, '').replace(/^@/, ''))
337
- .filter(s => s.length > 0);
379
+ .map(/** @param {string} s */ s => s.trim().replace(/^['"]|['"]$/g, '').replace(/^@/, ''))
380
+ .filter(/** @param {string} s */ s => s.length > 0);
338
381
  }
339
382
  }
340
383
 
@@ -344,18 +387,24 @@ function parseAssigneeFamilies(content) {
344
387
  if (blockMatch) {
345
388
  matched = true;
346
389
  families = blockMatch[1].split(/[\r\n]+/)
347
- .map(line => line.trim())
348
- .filter(line => line.startsWith('-'))
349
- .map(line => line.substring(1).trim().replace(/^['"]|['"]$/g, '').replace(/^@/, ''))
350
- .filter(s => s.length > 0);
390
+ .map(/** @param {string} line */ line => line.trim())
391
+ .filter(/** @param {string} line */ line => line.startsWith('-'))
392
+ .map(/** @param {string} line */ line => line.substring(1).trim().replace(/^['"]|['"]$/g, '').replace(/^@/, ''))
393
+ .filter(/** @param {string} s */ s => s.length > 0);
351
394
  }
352
395
  }
353
396
 
354
397
  return { matched, families };
355
398
  }
356
399
 
400
+ /**
401
+ * @param {string} taskFilePath
402
+ * @param {string} message
403
+ * @param {string} [rootDir]
404
+ * @returns {boolean|null|void}
405
+ */
357
406
  function commitTaskFileUpdate(taskFilePath, message, rootDir = process.cwd()) {
358
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return;
407
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return;}
359
408
  // Run `git add/commit` against the worktree the caller intended (rootDir),
360
409
  // not whichever branch process.cwd() happens to have checked out. All
361
410
  // worktrees share one git object store, so the only thing that pins the
@@ -380,23 +429,24 @@ function commitTaskFileUpdate(taskFilePath, message, rootDir = process.cwd()) {
380
429
  return false;
381
430
  }
382
431
  return true;
383
- } catch (e) {
384
- fmt.log.fail(`Git error during task update: ${e.message}`);
432
+ } catch (/** @type {unknown} */ e) {
433
+ fmt.log.fail(`Git error during task update: ${(/** @type {Error} */ (e)).message}`);
385
434
  return false;
386
435
  }
387
436
  }
388
437
 
438
+ /** @param {string} taskFilePath @returns {boolean} */
389
439
  function clearTaskAgentAssignee(taskFilePath) {
390
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
440
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
391
441
  let content = fs.readFileSync(taskFilePath, 'utf8');
392
- if (!content.match(/^assignee:/m)) return false;
442
+ if (!content.match(/^assignee:/m)) {return false;}
393
443
 
394
444
  const { families } = parseAssigneeFamilies(content);
395
445
  const supportedAgents = getSupportedAgents();
396
446
 
397
447
  // Separate agent families from human assignees
398
- const agentFamilies = families.filter(f => supportedAgents.includes(f.toLowerCase()));
399
- const humanFamilies = families.filter(f => !supportedAgents.includes(f.toLowerCase()));
448
+ const agentFamilies = families.filter(/** @param {string} f */ f => supportedAgents.includes(f.toLowerCase()));
449
+ const humanFamilies = families.filter(/** @param {string} f */ f => !supportedAgents.includes(f.toLowerCase()));
400
450
 
401
451
  // If there are no agent families to clear, nothing to do — preserve human assignees
402
452
  if (agentFamilies.length === 0) {
@@ -410,7 +460,7 @@ function clearTaskAgentAssignee(taskFilePath) {
410
460
  // Remove agent families and write back human-only
411
461
  let newAssigneeLine;
412
462
  if (hasBlockForm) {
413
- newAssigneeLine = 'assignee:\n' + humanFamilies.map(f => ` - ${f}`).join('\n') + '\n';
463
+ newAssigneeLine = 'assignee:\n' + humanFamilies.map(/** @param {string} f */ f => ` - ${f}`).join('\n') + '\n';
414
464
  } else {
415
465
  newAssigneeLine = `assignee: [${humanFamilies.join(', ')}]`;
416
466
  }
@@ -442,7 +492,7 @@ function clearTaskAgentAssignee(taskFilePath) {
442
492
  if (blockLines) {
443
493
  const keptLines = blockLines[1].split('\n').filter(line => {
444
494
  const m = line.match(/^\s+-\s+(.+)/);
445
- if (!m) return line.trim() === '';
495
+ if (!m) {return line.trim() === '';}
446
496
  const family = m[1].trim().replace(/^['"]|['"]$/g, '');
447
497
  return !supportedAgents.includes(family.toLowerCase());
448
498
  }).join('\n');
@@ -465,6 +515,12 @@ function clearTaskAgentAssignee(taskFilePath) {
465
515
  * restoring a prior assignee, or clearing agent assignees,
466
516
  * and commit the change to the mission branch.
467
517
  */
518
+ /**
519
+ * @param {string} slug
520
+ * @param {string} newStatus
521
+ * @param {{implementer?: string|null, clearAssignee?: boolean, rootDir?: string, log?: Function}} [opts]
522
+ * @returns {boolean}
523
+ */
468
524
  function transitionTask(slug, newStatus, { implementer = null, clearAssignee = false, rootDir = process.cwd(), log = fmt.log.plain } = {}) {
469
525
  const resolution = resolveTaskFile(slug, rootDir);
470
526
  if (!resolution.ok) {
@@ -472,7 +528,7 @@ function transitionTask(slug, newStatus, { implementer = null, clearAssignee = f
472
528
  return false;
473
529
  }
474
530
 
475
- const taskFile = resolution.taskFile;
531
+ const taskFile = /** @type {string} */ (resolution.taskFile);
476
532
 
477
533
  // Guard: reject suffixed slugs (e.g. "task-1048-regress") to prevent
478
534
  // resolveTaskFile's base-ID fallback from silently committing to the
@@ -507,7 +563,7 @@ function transitionTask(slug, newStatus, { implementer = null, clearAssignee = f
507
563
 
508
564
  if (changed) {
509
565
  let msg = `backlog(${slug}): transition to ${newStatus}`;
510
- if (implementer) msg += ` and implementer=${implementer}`;
566
+ if (implementer) {msg += ` and implementer=${implementer}`;}
511
567
 
512
568
  if (commitTaskFileUpdate(taskFile, msg, rootDir)) {
513
569
  log(fmt.status('PASS', `Task ${fmt.slug(slug)} transitioned to ${newStatus}${implementer ? ' (assignee=' + fmt.agent(implementer) + ')' : ''} and committed.`));
@@ -519,77 +575,107 @@ function transitionTask(slug, newStatus, { implementer = null, clearAssignee = f
519
575
  return true; // Already in the desired state
520
576
  }
521
577
 
578
+ /** @param {string} taskFilePath @returns {string|null} */
522
579
  function getTaskAssignee(taskFilePath) {
523
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
580
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
524
581
  const content = fs.readFileSync(taskFilePath, 'utf8');
525
582
  const { families } = parseAssigneeFamilies(content);
526
583
  return families.length > 0 ? families[0] : null;
527
584
  }
528
585
 
586
+ /** @param {string} taskFilePath @returns {string|null} */
529
587
  function getTaskImplementer(taskFilePath) {
530
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
588
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
531
589
  const content = fs.readFileSync(taskFilePath, 'utf8');
532
590
  const { families } = parseAssigneeFamilies(content);
533
591
 
534
- const normalizedFamilies = families.map(f => f.toLowerCase());
592
+ const normalizedFamilies = families.map(/** @param {string} f */ f => f.toLowerCase());
535
593
  const supportedAgents = getSupportedAgents();
536
- return normalizedFamilies.find(f => supportedAgents.includes(f)) || null;
594
+ return normalizedFamilies.find(/** @param {string} f */ f => supportedAgents.includes(f)) || null;
537
595
  }
538
596
 
597
+ /** @param {string} taskFilePath @param {string} field @returns {string|null} */
539
598
  function getTaskFrontmatterValue(taskFilePath, field) {
540
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
599
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
541
600
  const content = fs.readFileSync(taskFilePath, 'utf8');
542
601
  const pattern = new RegExp(`^${field}:\\s*([^\\r\\n]+)`, 'mi');
543
602
  const match = content.match(pattern);
544
- if (!match) return null;
603
+ if (!match) {return null;}
545
604
  const value = match[1].trim().replace(/^['"]|['"]$/g, '');
546
605
  return value || null;
547
606
  }
548
607
 
549
- function getTaskClassification(taskFilePath) {
550
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
608
+ const CLASSIFICATION_LABELS = new Set(['ai_sdlc', 'user_value', 'unknown']);
609
+
610
+ /**
611
+ * Parse all labels from a task file's frontmatter, supporting both block
612
+ * and inline YAML formats. Returns a lowercased array of label strings.
613
+ * When both formats are present, block labels take precedence (inline is
614
+ * treated as a fallback when no block labels are found).
615
+ */
616
+ /** @param {string} taskFilePath */
617
+ function getTaskLabels(taskFilePath) {
618
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return [];}
551
619
  const content = fs.readFileSync(taskFilePath, 'utf8');
552
- const matches = new Set();
553
620
 
554
621
  const blockMatch = content.match(/^labels:[ \t]*[\r\n]+((?:\s+-\s+.+[\r\n]*)+)/m);
555
622
  if (blockMatch) {
556
- const labels = blockMatch[1].split(/[\r\n]+/)
623
+ return blockMatch[1].split(/[\r\n]+/)
557
624
  .map(line => line.trim())
558
625
  .filter(line => line.startsWith('-'))
559
626
  .map(line => line.substring(1).trim().replace(/^['"]|['"]$/g, ''))
560
- .map(s => s.toLowerCase());
561
- if (labels.includes('ai_sdlc')) matches.add('ai_sdlc');
562
- if (labels.includes('user_value')) matches.add('user_value');
563
- if (labels.includes('unknown')) matches.add('unknown');
627
+ .map(s => s.toLowerCase())
628
+ .filter(s => s.length > 0);
564
629
  }
565
630
 
566
631
  const inlineMatch = content.match(/^labels:[ \t]*\[(.*?)\]/m);
567
632
  if (inlineMatch) {
568
- const labels = inlineMatch[1].split(',')
633
+ return inlineMatch[1].split(',')
569
634
  .map(s => s.trim().replace(/^['"]|['"]$/g, ''))
570
- .map(s => s.toLowerCase());
571
- if (labels.includes('ai_sdlc')) matches.add('ai_sdlc');
572
- if (labels.includes('user_value')) matches.add('user_value');
573
- if (labels.includes('unknown')) matches.add('unknown');
635
+ .map(s => s.toLowerCase())
636
+ .filter(s => s.length > 0);
574
637
  }
575
638
 
639
+ return [];
640
+ }
641
+
642
+ /** @param {string} taskFilePath */
643
+ function getTaskClassification(taskFilePath) {
644
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
645
+ const labels = getTaskLabels(taskFilePath);
646
+ const matches = new Set();
647
+ for (const label of labels) {
648
+ if (CLASSIFICATION_LABELS.has(label)) {matches.add(label);}
649
+ }
576
650
  return matches.size === 1 ? [...matches][0] : null;
577
651
  }
578
652
 
653
+ /** @param {string} taskFilePath */
654
+ function hasBugLabel(taskFilePath) {
655
+ const labels = getTaskLabels(taskFilePath);
656
+ return labels.includes('bug');
657
+ }
658
+
579
659
 
660
+ /**
661
+ * @param {string} taskFilePath
662
+ * @param {string} agentFamily
663
+ * @param {{promote?: boolean}} [opts]
664
+ * @returns {boolean}
665
+ */
580
666
  function setTaskAssignee(taskFilePath, agentFamily, { promote = true } = {}) {
581
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
667
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
582
668
  let content = fs.readFileSync(taskFilePath, 'utf8');
583
669
 
584
670
  const { matched, families } = parseAssigneeFamilies(content);
585
671
 
586
672
  if (matched) {
587
673
  const lowerAgentFamily = agentFamily.toLowerCase();
588
- const existingIndex = families.findIndex(f => f.toLowerCase() === lowerAgentFamily);
674
+ const existingIndex = families.findIndex(/** @param {string} f */ f => f.toLowerCase() === lowerAgentFamily);
589
675
 
590
676
  if (existingIndex !== 0) {
591
677
  if (existingIndex !== -1) {
592
- if (!promote) return false; // Already in the list, and we don't want to move it
678
+ if (!promote) {return false;} // Already in the list, and we don't want to move it
593
679
  // Remove existing to promote to first
594
680
  families.splice(existingIndex, 1);
595
681
  }
@@ -620,7 +706,7 @@ function setTaskAssignee(taskFilePath, agentFamily, { promote = true } = {}) {
620
706
 
621
707
  // No assignee line exists — insert one after the id frontmatter line
622
708
  const insertMatch = content.match(/^(id:.*)/m);
623
- if (insertMatch) {
709
+ if (insertMatch && insertMatch.index !== undefined) {
624
710
  const insertPos = insertMatch.index + insertMatch[0].length;
625
711
  const newLine = '\nassignee: [' + agentFamily + ']';
626
712
  content = content.slice(0, insertPos) + newLine + content.slice(insertPos);
@@ -631,8 +717,9 @@ function setTaskAssignee(taskFilePath, agentFamily, { promote = true } = {}) {
631
717
  return false;
632
718
  }
633
719
 
720
+ /** @param {string} taskFilePath @param {string} agentFamily @returns {boolean} */
634
721
  function setTaskImplementer(taskFilePath, agentFamily) {
635
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
722
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
636
723
  let content = fs.readFileSync(taskFilePath, 'utf8');
637
724
 
638
725
  const { matched, families } = parseAssigneeFamilies(content);
@@ -644,13 +731,13 @@ function setTaskImplementer(taskFilePath, agentFamily) {
644
731
  const nextFamilies = [agentFamily, ...preservedFamilies];
645
732
 
646
733
  if (matched) {
647
- const normalizedCurrent = families.map(f => f.toLowerCase());
648
- const normalizedNext = nextFamilies.map(f => f.toLowerCase());
734
+ const normalizedCurrent = families.map(/** @param {string} f */ f => f.toLowerCase());
735
+ const normalizedNext = nextFamilies.map(/** @param {string} f */ f => f.toLowerCase());
649
736
  // Check if the first agent is already the one we want to set
650
737
  if (normalizedCurrent.length > 0 && normalizedCurrent[0] === agentFamily.toLowerCase()) {
651
738
  // If the rest of the list is also the same, it's a no-op
652
739
  if (normalizedCurrent.length === normalizedNext.length &&
653
- normalizedCurrent.every((f, i) => f === normalizedNext[i])) {
740
+ normalizedCurrent.every((f, /** @type {number} */ i) => f === normalizedNext[i])) {
654
741
  return false;
655
742
  }
656
743
  }
@@ -670,7 +757,7 @@ function setTaskImplementer(taskFilePath, agentFamily) {
670
757
  }
671
758
 
672
759
  const insertMatch = content.match(/^(id:.*)/m);
673
- if (insertMatch) {
760
+ if (insertMatch && insertMatch.index !== undefined) {
674
761
  const insertPos = insertMatch.index + insertMatch[0].length;
675
762
  const newLine = '\nassignee: [' + agentFamily + ']';
676
763
  content = content.slice(0, insertPos) + newLine + content.slice(insertPos);
@@ -681,12 +768,13 @@ function setTaskImplementer(taskFilePath, agentFamily) {
681
768
  return false;
682
769
  }
683
770
 
771
+ /** @param {string} taskFilePath @param {string} agentFamily @returns {boolean} */
684
772
  function enforceTaskAssignee(taskFilePath, agentFamily) {
685
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
773
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
686
774
  let content = fs.readFileSync(taskFilePath, 'utf8');
687
775
 
688
776
  const { families } = parseAssigneeFamilies(content);
689
- if (families.length === 1 && families[0] === agentFamily) return true;
777
+ if (families.length === 1 && families[0] === agentFamily) {return true;}
690
778
 
691
779
  const newAssignee = `assignee: [${agentFamily}]`;
692
780
 
@@ -696,7 +784,7 @@ function enforceTaskAssignee(taskFilePath, agentFamily) {
696
784
  content = content.replace(/^assignee:[ \t]*.*$/m, newAssignee);
697
785
  } else {
698
786
  const insertMatch = content.match(/^(id:.*)/m);
699
- if (!insertMatch) return false;
787
+ if (!insertMatch || insertMatch.index === undefined) {return false;}
700
788
  const insertPos = insertMatch.index + insertMatch[0].length;
701
789
  content = content.slice(0, insertPos) + '\n' + newAssignee + content.slice(insertPos);
702
790
  }
@@ -705,11 +793,12 @@ function enforceTaskAssignee(taskFilePath, agentFamily) {
705
793
  return true;
706
794
  }
707
795
 
796
+ /** @param {string} taskFilePath @returns {string[]} */
708
797
  function getAcceptanceCriteria(taskFilePath) {
709
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return [];
798
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return [];}
710
799
  const content = fs.readFileSync(taskFilePath, 'utf8');
711
800
  const sectionMatch = content.match(/## Acceptance Criteria\s*\n([\s\S]*?)(?:\n## |\nDefinition of Done:|\n---\n|$)/);
712
- if (!sectionMatch) return [];
801
+ if (!sectionMatch) {return [];}
713
802
 
714
803
  return sectionMatch[1]
715
804
  .split('\n')
@@ -734,6 +823,8 @@ module.exports = {
734
823
  getTaskImplementer,
735
824
  getTaskFrontmatterValue,
736
825
  getTaskClassification,
826
+ getTaskLabels,
827
+ hasBugLabel,
737
828
  setTaskAssignee,
738
829
  setTaskImplementer,
739
830
  enforceTaskAssignee,