agileflow 2.95.2 → 2.96.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 (81) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +6 -6
  3. package/lib/api-routes.js +605 -0
  4. package/lib/api-server.js +260 -0
  5. package/lib/claude-cli-bridge.js +221 -0
  6. package/lib/dashboard-protocol.js +541 -0
  7. package/lib/dashboard-server.js +1601 -0
  8. package/lib/drivers/claude-driver.ts +310 -0
  9. package/lib/drivers/codex-driver.ts +454 -0
  10. package/lib/drivers/driver-manager.ts +158 -0
  11. package/lib/drivers/gemini-driver.ts +485 -0
  12. package/lib/drivers/index.ts +17 -0
  13. package/lib/flag-detection.js +350 -0
  14. package/lib/git-operations.js +267 -0
  15. package/lib/lock-file.js +144 -0
  16. package/lib/merge-operations.js +959 -0
  17. package/lib/protocol/driver.ts +360 -0
  18. package/lib/protocol/index.ts +12 -0
  19. package/lib/protocol/ir.ts +271 -0
  20. package/lib/session-display.js +330 -0
  21. package/lib/worktree-operations.js +221 -0
  22. package/package.json +2 -2
  23. package/scripts/agileflow-welcome.js +272 -24
  24. package/scripts/api-server-runner.js +177 -0
  25. package/scripts/archive-completed-stories.sh +22 -0
  26. package/scripts/automation-run-due.js +126 -0
  27. package/scripts/backfill-ideation-status.js +124 -0
  28. package/scripts/claude-tmux.sh +62 -1
  29. package/scripts/context-loader.js +292 -0
  30. package/scripts/dashboard-serve.js +323 -0
  31. package/scripts/lib/automation-registry.js +544 -0
  32. package/scripts/lib/automation-runner.js +476 -0
  33. package/scripts/lib/concurrency-limiter.js +513 -0
  34. package/scripts/lib/configure-features.js +46 -0
  35. package/scripts/lib/context-formatter.js +61 -0
  36. package/scripts/lib/damage-control-utils.js +29 -4
  37. package/scripts/lib/hook-metrics.js +324 -0
  38. package/scripts/lib/ideation-index.js +1196 -0
  39. package/scripts/lib/process-cleanup.js +359 -0
  40. package/scripts/lib/quality-gates.js +574 -0
  41. package/scripts/lib/status-task-bridge.js +522 -0
  42. package/scripts/lib/sync-ideation-status.js +292 -0
  43. package/scripts/lib/task-registry-cache.js +490 -0
  44. package/scripts/lib/task-registry.js +1181 -0
  45. package/scripts/migrate-ideation-index.js +515 -0
  46. package/scripts/precompact-context.sh +104 -0
  47. package/scripts/ralph-loop.js +2 -2
  48. package/scripts/session-manager.js +363 -2770
  49. package/scripts/spawn-parallel.js +45 -9
  50. package/src/core/agents/api-validator.md +180 -0
  51. package/src/core/agents/api.md +2 -0
  52. package/src/core/agents/code-reviewer.md +289 -0
  53. package/src/core/agents/configuration/damage-control.md +17 -0
  54. package/src/core/agents/database.md +2 -0
  55. package/src/core/agents/error-analyzer.md +203 -0
  56. package/src/core/agents/logic-analyzer-edge.md +171 -0
  57. package/src/core/agents/logic-analyzer-flow.md +254 -0
  58. package/src/core/agents/logic-analyzer-invariant.md +207 -0
  59. package/src/core/agents/logic-analyzer-race.md +267 -0
  60. package/src/core/agents/logic-analyzer-type.md +218 -0
  61. package/src/core/agents/logic-consensus.md +256 -0
  62. package/src/core/agents/orchestrator.md +89 -1
  63. package/src/core/agents/schema-validator.md +451 -0
  64. package/src/core/agents/team-coordinator.md +328 -0
  65. package/src/core/agents/ui-validator.md +328 -0
  66. package/src/core/agents/ui.md +2 -0
  67. package/src/core/commands/api.md +267 -0
  68. package/src/core/commands/automate.md +415 -0
  69. package/src/core/commands/babysit.md +290 -9
  70. package/src/core/commands/ideate/history.md +403 -0
  71. package/src/core/commands/{ideate.md → ideate/new.md} +244 -34
  72. package/src/core/commands/logic/audit.md +368 -0
  73. package/src/core/commands/roadmap/analyze.md +1 -1
  74. package/src/core/experts/documentation/expertise.yaml +29 -2
  75. package/src/core/templates/CONTEXT.md.example +49 -0
  76. package/src/core/templates/claude-settings.advanced.example.json +4 -0
  77. package/tools/cli/commands/serve.js +456 -0
  78. package/tools/cli/installers/core/installer.js +7 -2
  79. package/tools/cli/installers/ide/claude-code.js +85 -0
  80. package/tools/cli/lib/content-injector.js +27 -1
  81. package/tools/cli/lib/ui.js +26 -57
@@ -0,0 +1,1181 @@
1
+ /**
2
+ * task-registry.js - Multi-Agent Task Orchestration System
3
+ *
4
+ * Provides CRUD operations for tasks with:
5
+ * - State machine with valid transitions (queued→running→completed/failed/blocked)
6
+ * - DAG validation to prevent circular dependencies
7
+ * - Atomic file writes (temp + rename) for crash recovery
8
+ * - Lock file for concurrent access
9
+ * - Event emissions for observability
10
+ *
11
+ * Task States:
12
+ * - queued: Task is waiting to be executed
13
+ * - running: Task is currently being executed by an agent
14
+ * - completed: Task finished successfully
15
+ * - failed: Task failed with an error
16
+ * - blocked: Task cannot proceed due to unmet dependencies
17
+ *
18
+ * Valid Transitions:
19
+ * - queued → running, blocked, cancelled
20
+ * - running → completed, failed, blocked
21
+ * - blocked → queued, running, cancelled
22
+ * - completed → (terminal)
23
+ * - failed → queued (retry), cancelled
24
+ * - cancelled → (terminal)
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ const EventEmitter = require('events');
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const crypto = require('crypto');
33
+ const os = require('os');
34
+
35
+ // ============================================================================
36
+ // Constants
37
+ // ============================================================================
38
+
39
+ // Valid task states - SINGLE SOURCE OF TRUTH
40
+ const TASK_STATES = ['queued', 'running', 'completed', 'failed', 'blocked', 'cancelled'];
41
+
42
+ // Terminal states (no transitions out)
43
+ const TERMINAL_STATES = ['completed', 'cancelled'];
44
+
45
+ // States indicating work is done (for metrics)
46
+ const DONE_STATES = ['completed', 'failed', 'cancelled'];
47
+
48
+ // Valid state transitions
49
+ // Key = from state, Value = array of valid "to" states
50
+ const TRANSITIONS = {
51
+ queued: ['running', 'blocked', 'cancelled'],
52
+ running: ['completed', 'failed', 'blocked'],
53
+ blocked: ['queued', 'running', 'cancelled'],
54
+ completed: [], // Terminal
55
+ failed: ['queued', 'cancelled'], // Can retry
56
+ cancelled: [], // Terminal
57
+ };
58
+
59
+ // Join strategies for parallel task groups
60
+ const JOIN_STRATEGIES = ['all', 'first', 'any', 'any-N', 'majority'];
61
+
62
+ // Failure policies
63
+ const FAILURE_POLICIES = ['fail-fast', 'continue', 'ignore'];
64
+
65
+ // Default configuration
66
+ const DEFAULT_STATE_PATH = '.agileflow/state/task-dependencies.json';
67
+ const LOCK_FILE_NAME = 'task-registry.lock';
68
+ const LOCK_TIMEOUT_MS = 30000; // 30 seconds
69
+ const LOCK_STALE_MS = 60000; // 1 minute - consider lock stale if older
70
+
71
+ // ============================================================================
72
+ // Utility Functions
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Generate a unique task ID
77
+ * @returns {string} Unique task ID (e.g., "task-abc12345")
78
+ */
79
+ function generateTaskId() {
80
+ const timestamp = Date.now().toString(36);
81
+ const random = crypto.randomBytes(4).toString('hex');
82
+ return `task-${timestamp}-${random}`;
83
+ }
84
+
85
+ /**
86
+ * Find project root by looking for .agileflow directory
87
+ * @returns {string} Project root path or current working directory
88
+ */
89
+ function findProjectRoot() {
90
+ let dir = process.cwd();
91
+ while (dir !== '/' && dir !== path.parse(dir).root) {
92
+ if (fs.existsSync(path.join(dir, '.agileflow'))) {
93
+ return dir;
94
+ }
95
+ dir = path.dirname(dir);
96
+ }
97
+ return process.cwd();
98
+ }
99
+
100
+ /**
101
+ * Atomic file write using temp file + rename
102
+ * @param {string} filePath - Target file path
103
+ * @param {string} content - Content to write
104
+ */
105
+ function atomicWrite(filePath, content) {
106
+ const dir = path.dirname(filePath);
107
+ if (!fs.existsSync(dir)) {
108
+ fs.mkdirSync(dir, { recursive: true });
109
+ }
110
+
111
+ // Write to temp file first
112
+ const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
113
+ fs.writeFileSync(tempPath, content, 'utf8');
114
+
115
+ // Atomic rename
116
+ fs.renameSync(tempPath, filePath);
117
+ }
118
+
119
+ /**
120
+ * Safe JSON parse with default
121
+ * @param {string} content - JSON string
122
+ * @param {*} defaultValue - Default value if parse fails
123
+ * @returns {*} Parsed value or default
124
+ */
125
+ function safeJSONParse(content, defaultValue = null) {
126
+ try {
127
+ return JSON.parse(content);
128
+ } catch (e) {
129
+ return defaultValue;
130
+ }
131
+ }
132
+
133
+ // ============================================================================
134
+ // DAG Validation
135
+ // ============================================================================
136
+
137
+ /**
138
+ * Detect circular dependencies using DFS
139
+ * @param {Object} tasks - Map of task ID to task object
140
+ * @param {string} startId - Task ID to start from
141
+ * @param {Set} [visited] - Already visited nodes
142
+ * @param {Set} [recursionStack] - Current recursion path
143
+ * @returns {{ hasCycle: boolean, cycle: string[] | null }}
144
+ */
145
+ function detectCycle(tasks, startId, visited = new Set(), recursionStack = new Set()) {
146
+ visited.add(startId);
147
+ recursionStack.add(startId);
148
+
149
+ const task = tasks[startId];
150
+ if (!task) {
151
+ return { hasCycle: false, cycle: null };
152
+ }
153
+
154
+ // Check all dependencies (blockedBy)
155
+ const deps = task.blockedBy || [];
156
+ for (const depId of deps) {
157
+ if (!visited.has(depId)) {
158
+ const result = detectCycle(tasks, depId, visited, recursionStack);
159
+ if (result.hasCycle) {
160
+ return result;
161
+ }
162
+ } else if (recursionStack.has(depId)) {
163
+ // Found cycle - reconstruct it
164
+ const cycleNodes = [depId, startId];
165
+ return { hasCycle: true, cycle: cycleNodes };
166
+ }
167
+ }
168
+
169
+ recursionStack.delete(startId);
170
+ return { hasCycle: false, cycle: null };
171
+ }
172
+
173
+ /**
174
+ * Validate entire task graph for cycles
175
+ * @param {Object} tasks - Map of task ID to task object
176
+ * @returns {{ valid: boolean, cycles: string[][] }}
177
+ */
178
+ function validateDAG(tasks) {
179
+ const visited = new Set();
180
+ const cycles = [];
181
+
182
+ for (const taskId of Object.keys(tasks)) {
183
+ if (!visited.has(taskId)) {
184
+ const result = detectCycle(tasks, taskId, visited, new Set());
185
+ if (result.hasCycle && result.cycle) {
186
+ cycles.push(result.cycle);
187
+ }
188
+ }
189
+ }
190
+
191
+ return {
192
+ valid: cycles.length === 0,
193
+ cycles,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Get topological sort of tasks (dependency order)
199
+ * @param {Object} tasks - Map of task ID to task object
200
+ * @returns {{ sorted: string[], valid: boolean }}
201
+ */
202
+ function topologicalSort(tasks) {
203
+ const inDegree = {};
204
+ const adjList = {};
205
+
206
+ // Initialize
207
+ for (const taskId of Object.keys(tasks)) {
208
+ inDegree[taskId] = 0;
209
+ adjList[taskId] = [];
210
+ }
211
+
212
+ // Build adjacency list and in-degrees
213
+ for (const [taskId, task] of Object.entries(tasks)) {
214
+ const deps = task.blockedBy || [];
215
+ for (const depId of deps) {
216
+ if (adjList[depId]) {
217
+ adjList[depId].push(taskId);
218
+ inDegree[taskId]++;
219
+ }
220
+ }
221
+ }
222
+
223
+ // Kahn's algorithm
224
+ const queue = [];
225
+ for (const taskId of Object.keys(tasks)) {
226
+ if (inDegree[taskId] === 0) {
227
+ queue.push(taskId);
228
+ }
229
+ }
230
+
231
+ const sorted = [];
232
+ while (queue.length > 0) {
233
+ const node = queue.shift();
234
+ sorted.push(node);
235
+
236
+ for (const neighbor of adjList[node] || []) {
237
+ inDegree[neighbor]--;
238
+ if (inDegree[neighbor] === 0) {
239
+ queue.push(neighbor);
240
+ }
241
+ }
242
+ }
243
+
244
+ // If sorted doesn't include all tasks, there's a cycle
245
+ const valid = sorted.length === Object.keys(tasks).length;
246
+
247
+ return { sorted, valid };
248
+ }
249
+
250
+ // ============================================================================
251
+ // State Machine
252
+ // ============================================================================
253
+
254
+ /**
255
+ * Check if a state is valid
256
+ * @param {string} state - State to check
257
+ * @returns {boolean}
258
+ */
259
+ function isValidState(state) {
260
+ return TASK_STATES.includes(state);
261
+ }
262
+
263
+ /**
264
+ * Check if a transition is valid
265
+ * @param {string} fromState - Current state
266
+ * @param {string} toState - Target state
267
+ * @returns {boolean}
268
+ */
269
+ function isValidTransition(fromState, toState) {
270
+ if (fromState === toState) return true; // No-op
271
+ if (!TRANSITIONS[fromState]) return false;
272
+ return TRANSITIONS[fromState].includes(toState);
273
+ }
274
+
275
+ /**
276
+ * Get valid transitions from a state
277
+ * @param {string} fromState - Current state
278
+ * @returns {string[]}
279
+ */
280
+ function getValidTransitions(fromState) {
281
+ return TRANSITIONS[fromState] || [];
282
+ }
283
+
284
+ /**
285
+ * Check if state is terminal
286
+ * @param {string} state - State to check
287
+ * @returns {boolean}
288
+ */
289
+ function isTerminalState(state) {
290
+ return TERMINAL_STATES.includes(state);
291
+ }
292
+
293
+ // ============================================================================
294
+ // Lock Management
295
+ // ============================================================================
296
+
297
+ /**
298
+ * Simple file-based lock for concurrent access
299
+ */
300
+ class FileLock {
301
+ constructor(lockPath, options = {}) {
302
+ this.lockPath = lockPath;
303
+ this.timeout = options.timeout || LOCK_TIMEOUT_MS;
304
+ this.staleMs = options.staleMs || LOCK_STALE_MS;
305
+ this.held = false;
306
+ }
307
+
308
+ /**
309
+ * Acquire lock with timeout
310
+ * @returns {boolean} True if lock acquired
311
+ */
312
+ acquire() {
313
+ const startTime = Date.now();
314
+
315
+ while (Date.now() - startTime < this.timeout) {
316
+ // Check if existing lock is stale
317
+ if (fs.existsSync(this.lockPath)) {
318
+ try {
319
+ const stat = fs.statSync(this.lockPath);
320
+ const age = Date.now() - stat.mtimeMs;
321
+ if (age > this.staleMs) {
322
+ // Stale lock - remove it
323
+ fs.unlinkSync(this.lockPath);
324
+ } else {
325
+ // Lock held by another process - wait
326
+ this._sleep(50);
327
+ continue;
328
+ }
329
+ } catch (e) {
330
+ // Lock file gone, continue to try acquiring
331
+ }
332
+ }
333
+
334
+ // Try to acquire
335
+ try {
336
+ const dir = path.dirname(this.lockPath);
337
+ if (!fs.existsSync(dir)) {
338
+ fs.mkdirSync(dir, { recursive: true });
339
+ }
340
+
341
+ // Use exclusive flag to prevent race
342
+ const fd = fs.openSync(this.lockPath, 'wx');
343
+ const lockInfo = {
344
+ pid: process.pid,
345
+ hostname: os.hostname(),
346
+ acquired: new Date().toISOString(),
347
+ };
348
+ fs.writeSync(fd, JSON.stringify(lockInfo));
349
+ fs.closeSync(fd);
350
+ this.held = true;
351
+ return true;
352
+ } catch (e) {
353
+ if (e.code === 'EEXIST') {
354
+ // Another process got the lock
355
+ this._sleep(50);
356
+ continue;
357
+ }
358
+ throw e;
359
+ }
360
+ }
361
+
362
+ return false;
363
+ }
364
+
365
+ /**
366
+ * Release lock
367
+ */
368
+ release() {
369
+ if (this.held && fs.existsSync(this.lockPath)) {
370
+ try {
371
+ fs.unlinkSync(this.lockPath);
372
+ } catch (e) {
373
+ // Ignore - lock may already be removed
374
+ }
375
+ this.held = false;
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Synchronous sleep
381
+ * @param {number} ms - Milliseconds to sleep
382
+ */
383
+ _sleep(ms) {
384
+ const end = Date.now() + ms;
385
+ while (Date.now() < end) {
386
+ // Busy wait - not ideal but works for short durations
387
+ }
388
+ }
389
+ }
390
+
391
+ // ============================================================================
392
+ // Task Registry
393
+ // ============================================================================
394
+
395
+ /**
396
+ * TaskRegistry - Event-driven task management with state machine
397
+ */
398
+ class TaskRegistry extends EventEmitter {
399
+ constructor(options = {}) {
400
+ super();
401
+
402
+ this.rootDir = options.rootDir || findProjectRoot();
403
+ this.statePath = path.join(this.rootDir, options.statePath || DEFAULT_STATE_PATH);
404
+ this.lockPath = path.join(path.dirname(this.statePath), LOCK_FILE_NAME);
405
+
406
+ // Cache
407
+ this._cache = null;
408
+ this._dirty = false;
409
+ }
410
+
411
+ // --------------------------------------------------------------------------
412
+ // State Persistence
413
+ // --------------------------------------------------------------------------
414
+
415
+ /**
416
+ * Load task state from disk
417
+ * @returns {Object} Task state
418
+ */
419
+ load() {
420
+ if (this._cache && !this._dirty) {
421
+ return this._cache;
422
+ }
423
+
424
+ const defaultState = this._createDefaultState();
425
+
426
+ if (!fs.existsSync(this.statePath)) {
427
+ this._cache = defaultState;
428
+ this.save();
429
+ return this._cache;
430
+ }
431
+
432
+ try {
433
+ const content = fs.readFileSync(this.statePath, 'utf8');
434
+ this._cache = safeJSONParse(content, defaultState);
435
+ this._dirty = false;
436
+ this.emit('loaded', { state: this._cache });
437
+ return this._cache;
438
+ } catch (e) {
439
+ this.emit('error', { type: 'load', error: e.message });
440
+ this._cache = defaultState;
441
+ return this._cache;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Save task state to disk (atomic)
447
+ */
448
+ save() {
449
+ if (!this._cache) return;
450
+
451
+ this._cache.updated_at = new Date().toISOString();
452
+ const content = JSON.stringify(this._cache, null, 2) + '\n';
453
+
454
+ try {
455
+ atomicWrite(this.statePath, content);
456
+ this._dirty = false;
457
+ this.emit('saved', { state: this._cache });
458
+ } catch (e) {
459
+ this.emit('error', { type: 'save', error: e.message });
460
+ throw e;
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Create default state structure
466
+ * @returns {Object}
467
+ */
468
+ _createDefaultState() {
469
+ return {
470
+ schema_version: '1.0.0',
471
+ created_at: new Date().toISOString(),
472
+ updated_at: new Date().toISOString(),
473
+ tasks: {},
474
+ task_groups: {},
475
+ audit_trail: [],
476
+ };
477
+ }
478
+
479
+ // --------------------------------------------------------------------------
480
+ // CRUD Operations
481
+ // --------------------------------------------------------------------------
482
+
483
+ /**
484
+ * Create a new task
485
+ * @param {Object} taskData - Task data
486
+ * @returns {{ success: boolean, task?: Object, error?: string }}
487
+ */
488
+ create(taskData) {
489
+ const lock = new FileLock(this.lockPath);
490
+ if (!lock.acquire()) {
491
+ return { success: false, error: 'Could not acquire lock' };
492
+ }
493
+
494
+ try {
495
+ const state = this.load();
496
+
497
+ // Generate ID if not provided
498
+ const taskId = taskData.id || generateTaskId();
499
+
500
+ // Check for duplicate ID
501
+ if (state.tasks[taskId]) {
502
+ return { success: false, error: `Task ${taskId} already exists` };
503
+ }
504
+
505
+ // Validate dependencies won't create cycle
506
+ if (taskData.blockedBy && taskData.blockedBy.length > 0) {
507
+ // Temporarily add task to check for cycles
508
+ const testTasks = {
509
+ ...state.tasks,
510
+ [taskId]: { ...taskData, blockedBy: taskData.blockedBy },
511
+ };
512
+ const dagResult = validateDAG(testTasks);
513
+ if (!dagResult.valid) {
514
+ return {
515
+ success: false,
516
+ error: `Adding this task would create circular dependency: ${dagResult.cycles[0].join(' → ')}`,
517
+ };
518
+ }
519
+ }
520
+
521
+ // Create task object
522
+ const task = {
523
+ id: taskId,
524
+ description: taskData.description || '',
525
+ prompt: taskData.prompt || '',
526
+ subagent_type: taskData.subagent_type || null,
527
+ state: 'queued',
528
+ created_at: new Date().toISOString(),
529
+ updated_at: new Date().toISOString(),
530
+ // Dependencies
531
+ blockedBy: taskData.blockedBy || [],
532
+ blocks: taskData.blocks || [],
533
+ // Execution config
534
+ join_strategy: taskData.join_strategy || 'all',
535
+ on_failure: taskData.on_failure || 'fail-fast',
536
+ run_in_background: taskData.run_in_background || false,
537
+ // Link to story (optional)
538
+ story_id: taskData.story_id || null,
539
+ // Results (populated on completion)
540
+ result: null,
541
+ error: null,
542
+ // Metadata
543
+ metadata: taskData.metadata || {},
544
+ };
545
+
546
+ // Automatically set to blocked if has unmet dependencies
547
+ if (task.blockedBy.length > 0) {
548
+ const unmetDeps = task.blockedBy.filter(depId => {
549
+ const dep = state.tasks[depId];
550
+ return !dep || dep.state !== 'completed';
551
+ });
552
+ if (unmetDeps.length > 0) {
553
+ task.state = 'blocked';
554
+ }
555
+ }
556
+
557
+ // Update reverse dependencies (blocks)
558
+ for (const blockerId of task.blockedBy) {
559
+ if (state.tasks[blockerId]) {
560
+ if (!state.tasks[blockerId].blocks) {
561
+ state.tasks[blockerId].blocks = [];
562
+ }
563
+ if (!state.tasks[blockerId].blocks.includes(taskId)) {
564
+ state.tasks[blockerId].blocks.push(taskId);
565
+ }
566
+ }
567
+ }
568
+
569
+ // Store task
570
+ state.tasks[taskId] = task;
571
+ this._dirty = true;
572
+ this.save();
573
+
574
+ // Emit event
575
+ this.emit('created', { task });
576
+
577
+ return { success: true, task };
578
+ } finally {
579
+ lock.release();
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Get a task by ID
585
+ * @param {string} taskId - Task ID
586
+ * @returns {Object|null}
587
+ */
588
+ get(taskId) {
589
+ const state = this.load();
590
+ return state.tasks[taskId] || null;
591
+ }
592
+
593
+ /**
594
+ * Get all tasks
595
+ * @param {Object} [filter] - Optional filter
596
+ * @returns {Object[]}
597
+ */
598
+ getAll(filter = {}) {
599
+ const state = this.load();
600
+ let tasks = Object.values(state.tasks);
601
+
602
+ // Apply filters
603
+ if (filter.state) {
604
+ tasks = tasks.filter(t => t.state === filter.state);
605
+ }
606
+ if (filter.story_id) {
607
+ tasks = tasks.filter(t => t.story_id === filter.story_id);
608
+ }
609
+ if (filter.subagent_type) {
610
+ tasks = tasks.filter(t => t.subagent_type === filter.subagent_type);
611
+ }
612
+
613
+ return tasks;
614
+ }
615
+
616
+ /**
617
+ * Update a task
618
+ * @param {string} taskId - Task ID
619
+ * @param {Object} updates - Fields to update
620
+ * @returns {{ success: boolean, task?: Object, error?: string }}
621
+ */
622
+ update(taskId, updates) {
623
+ const lock = new FileLock(this.lockPath);
624
+ if (!lock.acquire()) {
625
+ return { success: false, error: 'Could not acquire lock' };
626
+ }
627
+
628
+ try {
629
+ const state = this.load();
630
+ const task = state.tasks[taskId];
631
+
632
+ if (!task) {
633
+ return { success: false, error: `Task ${taskId} not found` };
634
+ }
635
+
636
+ // Handle state transition
637
+ if (updates.state && updates.state !== task.state) {
638
+ if (!isValidTransition(task.state, updates.state)) {
639
+ return {
640
+ success: false,
641
+ error: `Invalid transition: ${task.state} → ${updates.state}. Valid: ${getValidTransitions(task.state).join(', ') || 'none'}`,
642
+ };
643
+ }
644
+
645
+ // Log to audit trail
646
+ state.audit_trail.push({
647
+ task_id: taskId,
648
+ from_state: task.state,
649
+ to_state: updates.state,
650
+ at: new Date().toISOString(),
651
+ reason: updates.reason || null,
652
+ });
653
+ }
654
+
655
+ // Handle dependency updates
656
+ if (updates.blockedBy) {
657
+ // Validate no cycles
658
+ const testTask = { ...task, blockedBy: updates.blockedBy };
659
+ const testTasks = { ...state.tasks, [taskId]: testTask };
660
+ const dagResult = validateDAG(testTasks);
661
+ if (!dagResult.valid) {
662
+ return {
663
+ success: false,
664
+ error: `Update would create circular dependency: ${dagResult.cycles[0].join(' → ')}`,
665
+ };
666
+ }
667
+ }
668
+
669
+ // Apply updates
670
+ const changedFields = [];
671
+ for (const [key, value] of Object.entries(updates)) {
672
+ if (key === 'reason') continue; // reason is for audit only
673
+ if (JSON.stringify(task[key]) !== JSON.stringify(value)) {
674
+ task[key] = value;
675
+ changedFields.push(key);
676
+ }
677
+ }
678
+
679
+ if (changedFields.length > 0) {
680
+ task.updated_at = new Date().toISOString();
681
+
682
+ // If transitioning to completed, unblock dependent tasks AFTER state is applied
683
+ if (changedFields.includes('state') && task.state === 'completed') {
684
+ this._unblockDependents(state, taskId);
685
+ }
686
+
687
+ this._dirty = true;
688
+ this.save();
689
+
690
+ this.emit('updated', { task, changes: changedFields });
691
+ }
692
+
693
+ return { success: true, task };
694
+ } finally {
695
+ lock.release();
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Delete a task
701
+ * @param {string} taskId - Task ID
702
+ * @returns {{ success: boolean, error?: string }}
703
+ */
704
+ delete(taskId) {
705
+ const lock = new FileLock(this.lockPath);
706
+ if (!lock.acquire()) {
707
+ return { success: false, error: 'Could not acquire lock' };
708
+ }
709
+
710
+ try {
711
+ const state = this.load();
712
+ const task = state.tasks[taskId];
713
+
714
+ if (!task) {
715
+ return { success: false, error: `Task ${taskId} not found` };
716
+ }
717
+
718
+ // Remove from blockedBy lists of other tasks
719
+ for (const otherTask of Object.values(state.tasks)) {
720
+ if (otherTask.blockedBy) {
721
+ otherTask.blockedBy = otherTask.blockedBy.filter(id => id !== taskId);
722
+ }
723
+ if (otherTask.blocks) {
724
+ otherTask.blocks = otherTask.blocks.filter(id => id !== taskId);
725
+ }
726
+ }
727
+
728
+ delete state.tasks[taskId];
729
+ this._dirty = true;
730
+ this.save();
731
+
732
+ this.emit('deleted', { taskId });
733
+
734
+ return { success: true };
735
+ } finally {
736
+ lock.release();
737
+ }
738
+ }
739
+
740
+ // --------------------------------------------------------------------------
741
+ // State Transitions
742
+ // --------------------------------------------------------------------------
743
+
744
+ /**
745
+ * Transition a task to a new state
746
+ * @param {string} taskId - Task ID
747
+ * @param {string} toState - Target state
748
+ * @param {Object} [options] - Options
749
+ * @returns {{ success: boolean, task?: Object, error?: string }}
750
+ */
751
+ transition(taskId, toState, options = {}) {
752
+ const { reason = null, result = null, error = null } = options;
753
+
754
+ const updates = { state: toState, reason };
755
+ if (result !== null) updates.result = result;
756
+ if (error !== null) updates.error = error;
757
+
758
+ return this.update(taskId, updates);
759
+ }
760
+
761
+ /**
762
+ * Mark task as running
763
+ * @param {string} taskId - Task ID
764
+ * @returns {{ success: boolean, task?: Object, error?: string }}
765
+ */
766
+ start(taskId) {
767
+ return this.transition(taskId, 'running');
768
+ }
769
+
770
+ /**
771
+ * Mark task as completed
772
+ * @param {string} taskId - Task ID
773
+ * @param {*} result - Task result
774
+ * @returns {{ success: boolean, task?: Object, error?: string }}
775
+ */
776
+ complete(taskId, result = null) {
777
+ return this.transition(taskId, 'completed', { result });
778
+ }
779
+
780
+ /**
781
+ * Mark task as failed
782
+ * @param {string} taskId - Task ID
783
+ * @param {string} error - Error message
784
+ * @returns {{ success: boolean, task?: Object, error?: string }}
785
+ */
786
+ fail(taskId, error) {
787
+ return this.transition(taskId, 'failed', { error });
788
+ }
789
+
790
+ /**
791
+ * Mark task as blocked
792
+ * @param {string} taskId - Task ID
793
+ * @param {string} reason - Reason for blocking
794
+ * @returns {{ success: boolean, task?: Object, error?: string }}
795
+ */
796
+ block(taskId, reason) {
797
+ return this.transition(taskId, 'blocked', { reason });
798
+ }
799
+
800
+ /**
801
+ * Cancel a task
802
+ * @param {string} taskId - Task ID
803
+ * @param {string} reason - Reason for cancellation
804
+ * @returns {{ success: boolean, task?: Object, error?: string }}
805
+ */
806
+ cancel(taskId, reason) {
807
+ return this.transition(taskId, 'cancelled', { reason });
808
+ }
809
+
810
+ /**
811
+ * Retry a failed task
812
+ * @param {string} taskId - Task ID
813
+ * @returns {{ success: boolean, task?: Object, error?: string }}
814
+ */
815
+ retry(taskId) {
816
+ const task = this.get(taskId);
817
+ if (!task) {
818
+ return { success: false, error: `Task ${taskId} not found` };
819
+ }
820
+ if (task.state !== 'failed') {
821
+ return { success: false, error: `Can only retry failed tasks, current state: ${task.state}` };
822
+ }
823
+ return this.transition(taskId, 'queued', { reason: 'retry' });
824
+ }
825
+
826
+ // --------------------------------------------------------------------------
827
+ // Dependency Management
828
+ // --------------------------------------------------------------------------
829
+
830
+ /**
831
+ * Add a dependency (blockedBy)
832
+ * @param {string} taskId - Task that will be blocked
833
+ * @param {string} blockerId - Task that blocks
834
+ * @returns {{ success: boolean, error?: string }}
835
+ */
836
+ addDependency(taskId, blockerId) {
837
+ const task = this.get(taskId);
838
+ if (!task) {
839
+ return { success: false, error: `Task ${taskId} not found` };
840
+ }
841
+
842
+ const blockedBy = [...(task.blockedBy || [])];
843
+ if (!blockedBy.includes(blockerId)) {
844
+ blockedBy.push(blockerId);
845
+ }
846
+
847
+ return this.update(taskId, { blockedBy });
848
+ }
849
+
850
+ /**
851
+ * Remove a dependency
852
+ * @param {string} taskId - Task ID
853
+ * @param {string} blockerId - Blocker to remove
854
+ * @returns {{ success: boolean, error?: string }}
855
+ */
856
+ removeDependency(taskId, blockerId) {
857
+ const task = this.get(taskId);
858
+ if (!task) {
859
+ return { success: false, error: `Task ${taskId} not found` };
860
+ }
861
+
862
+ const blockedBy = (task.blockedBy || []).filter(id => id !== blockerId);
863
+ return this.update(taskId, { blockedBy });
864
+ }
865
+
866
+ /**
867
+ * Get tasks that are ready to run (queued with no unmet dependencies)
868
+ * @returns {Object[]}
869
+ */
870
+ getReadyTasks() {
871
+ const state = this.load();
872
+ const ready = [];
873
+
874
+ for (const task of Object.values(state.tasks)) {
875
+ if (task.state !== 'queued') continue;
876
+
877
+ // Check all dependencies are complete
878
+ const unmetDeps = (task.blockedBy || []).filter(depId => {
879
+ const dep = state.tasks[depId];
880
+ return !dep || dep.state !== 'completed';
881
+ });
882
+
883
+ if (unmetDeps.length === 0) {
884
+ ready.push(task);
885
+ }
886
+ }
887
+
888
+ return ready;
889
+ }
890
+
891
+ /**
892
+ * Get the dependency graph for visualization
893
+ * @returns {{ nodes: Object[], edges: Object[] }}
894
+ */
895
+ getDependencyGraph() {
896
+ const state = this.load();
897
+ const nodes = [];
898
+ const edges = [];
899
+
900
+ for (const task of Object.values(state.tasks)) {
901
+ nodes.push({
902
+ id: task.id,
903
+ label: task.description || task.id,
904
+ state: task.state,
905
+ subagent_type: task.subagent_type,
906
+ });
907
+
908
+ for (const blockerId of task.blockedBy || []) {
909
+ edges.push({
910
+ from: blockerId,
911
+ to: task.id,
912
+ });
913
+ }
914
+ }
915
+
916
+ return { nodes, edges };
917
+ }
918
+
919
+ /**
920
+ * Unblock dependent tasks when a task completes
921
+ * @param {Object} state - Current state
922
+ * @param {string} completedTaskId - ID of completed task
923
+ */
924
+ _unblockDependents(state, completedTaskId) {
925
+ const completedTask = state.tasks[completedTaskId];
926
+ if (!completedTask) return;
927
+
928
+ // Find tasks that were blocked by this one
929
+ for (const dependentId of completedTask.blocks || []) {
930
+ const dependent = state.tasks[dependentId];
931
+ if (!dependent || dependent.state !== 'blocked') continue;
932
+
933
+ // Check if all blockers are now complete
934
+ const unmetDeps = (dependent.blockedBy || []).filter(depId => {
935
+ const dep = state.tasks[depId];
936
+ return !dep || dep.state !== 'completed';
937
+ });
938
+
939
+ if (unmetDeps.length === 0) {
940
+ dependent.state = 'queued';
941
+ dependent.updated_at = new Date().toISOString();
942
+ state.audit_trail.push({
943
+ task_id: dependentId,
944
+ from_state: 'blocked',
945
+ to_state: 'queued',
946
+ at: new Date().toISOString(),
947
+ reason: `Unblocked: ${completedTaskId} completed`,
948
+ });
949
+ this.emit('unblocked', { task: dependent, unblockedBy: completedTaskId });
950
+ }
951
+ }
952
+ }
953
+
954
+ // --------------------------------------------------------------------------
955
+ // Task Groups
956
+ // --------------------------------------------------------------------------
957
+
958
+ /**
959
+ * Create a task group for parallel execution
960
+ * @param {Object} groupData - Group configuration
961
+ * @returns {{ success: boolean, group?: Object, error?: string }}
962
+ */
963
+ createGroup(groupData) {
964
+ const lock = new FileLock(this.lockPath);
965
+ if (!lock.acquire()) {
966
+ return { success: false, error: 'Could not acquire lock' };
967
+ }
968
+
969
+ try {
970
+ const state = this.load();
971
+
972
+ const groupId = groupData.id || `group-${Date.now().toString(36)}`;
973
+
974
+ if (state.task_groups[groupId]) {
975
+ return { success: false, error: `Group ${groupId} already exists` };
976
+ }
977
+
978
+ const group = {
979
+ id: groupId,
980
+ name: groupData.name || groupId,
981
+ task_ids: groupData.task_ids || [],
982
+ join_strategy: groupData.join_strategy || 'all',
983
+ on_failure: groupData.on_failure || 'fail-fast',
984
+ created_at: new Date().toISOString(),
985
+ state: 'pending', // pending, running, completed, failed
986
+ };
987
+
988
+ state.task_groups[groupId] = group;
989
+ this._dirty = true;
990
+ this.save();
991
+
992
+ this.emit('group_created', { group });
993
+
994
+ return { success: true, group };
995
+ } finally {
996
+ lock.release();
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * Get group status
1002
+ * @param {string} groupId - Group ID
1003
+ * @returns {Object|null}
1004
+ */
1005
+ getGroupStatus(groupId) {
1006
+ const state = this.load();
1007
+ const group = state.task_groups[groupId];
1008
+ if (!group) return null;
1009
+
1010
+ const tasks = group.task_ids.map(id => state.tasks[id]).filter(Boolean);
1011
+ const completed = tasks.filter(t => t.state === 'completed').length;
1012
+ const failed = tasks.filter(t => t.state === 'failed').length;
1013
+ const running = tasks.filter(t => t.state === 'running').length;
1014
+
1015
+ return {
1016
+ ...group,
1017
+ total: tasks.length,
1018
+ completed,
1019
+ failed,
1020
+ running,
1021
+ pending: tasks.length - completed - failed - running,
1022
+ };
1023
+ }
1024
+
1025
+ // --------------------------------------------------------------------------
1026
+ // Metrics & Queries
1027
+ // --------------------------------------------------------------------------
1028
+
1029
+ /**
1030
+ * Get task statistics
1031
+ * @returns {Object}
1032
+ */
1033
+ getStats() {
1034
+ const state = this.load();
1035
+ const tasks = Object.values(state.tasks);
1036
+
1037
+ const byState = {};
1038
+ for (const s of TASK_STATES) {
1039
+ byState[s] = tasks.filter(t => t.state === s).length;
1040
+ }
1041
+
1042
+ const byAgent = {};
1043
+ for (const task of tasks) {
1044
+ const agent = task.subagent_type || 'unknown';
1045
+ byAgent[agent] = (byAgent[agent] || 0) + 1;
1046
+ }
1047
+
1048
+ return {
1049
+ total: tasks.length,
1050
+ by_state: byState,
1051
+ by_agent: byAgent,
1052
+ groups: Object.keys(state.task_groups).length,
1053
+ };
1054
+ }
1055
+
1056
+ /**
1057
+ * Get tasks for a story
1058
+ * @param {string} storyId - Story ID
1059
+ * @returns {Object[]}
1060
+ */
1061
+ getTasksForStory(storyId) {
1062
+ return this.getAll({ story_id: storyId });
1063
+ }
1064
+
1065
+ /**
1066
+ * Get audit trail
1067
+ * @param {Object} [filter] - Optional filter
1068
+ * @returns {Object[]}
1069
+ */
1070
+ getAuditTrail(filter = {}) {
1071
+ const state = this.load();
1072
+ let trail = [...state.audit_trail];
1073
+
1074
+ if (filter.task_id) {
1075
+ trail = trail.filter(e => e.task_id === filter.task_id);
1076
+ }
1077
+
1078
+ return trail;
1079
+ }
1080
+
1081
+ /**
1082
+ * Clear completed/cancelled tasks older than threshold
1083
+ * @param {number} [maxAgeMs] - Max age in milliseconds (default: 7 days)
1084
+ * @returns {{ cleared: number }}
1085
+ */
1086
+ cleanup(maxAgeMs = 7 * 24 * 60 * 60 * 1000) {
1087
+ const lock = new FileLock(this.lockPath);
1088
+ if (!lock.acquire()) {
1089
+ return { cleared: 0 };
1090
+ }
1091
+
1092
+ try {
1093
+ const state = this.load();
1094
+ const now = Date.now();
1095
+ let cleared = 0;
1096
+
1097
+ for (const [taskId, task] of Object.entries(state.tasks)) {
1098
+ if (!DONE_STATES.includes(task.state)) continue;
1099
+
1100
+ const updatedAt = new Date(task.updated_at).getTime();
1101
+ if (now - updatedAt > maxAgeMs) {
1102
+ delete state.tasks[taskId];
1103
+ cleared++;
1104
+ }
1105
+ }
1106
+
1107
+ if (cleared > 0) {
1108
+ this._dirty = true;
1109
+ this.save();
1110
+ this.emit('cleanup', { cleared });
1111
+ }
1112
+
1113
+ return { cleared };
1114
+ } finally {
1115
+ lock.release();
1116
+ }
1117
+ }
1118
+ }
1119
+
1120
+ // ============================================================================
1121
+ // Singleton & Factory
1122
+ // ============================================================================
1123
+
1124
+ let _instance = null;
1125
+
1126
+ /**
1127
+ * Get singleton registry instance
1128
+ * @param {Object} [options] - Options
1129
+ * @returns {TaskRegistry}
1130
+ */
1131
+ function getTaskRegistry(options = {}) {
1132
+ if (!_instance || options.forceNew) {
1133
+ _instance = new TaskRegistry(options);
1134
+ }
1135
+ return _instance;
1136
+ }
1137
+
1138
+ /**
1139
+ * Reset singleton (for testing)
1140
+ */
1141
+ function resetTaskRegistry() {
1142
+ _instance = null;
1143
+ }
1144
+
1145
+ // ============================================================================
1146
+ // Exports
1147
+ // ============================================================================
1148
+
1149
+ module.exports = {
1150
+ // Constants
1151
+ TASK_STATES,
1152
+ TERMINAL_STATES,
1153
+ DONE_STATES,
1154
+ TRANSITIONS,
1155
+ JOIN_STRATEGIES,
1156
+ FAILURE_POLICIES,
1157
+
1158
+ // State machine
1159
+ isValidState,
1160
+ isValidTransition,
1161
+ getValidTransitions,
1162
+ isTerminalState,
1163
+
1164
+ // DAG validation
1165
+ detectCycle,
1166
+ validateDAG,
1167
+ topologicalSort,
1168
+
1169
+ // Utilities
1170
+ generateTaskId,
1171
+ findProjectRoot,
1172
+ atomicWrite,
1173
+
1174
+ // Classes
1175
+ TaskRegistry,
1176
+ FileLock,
1177
+
1178
+ // Factory
1179
+ getTaskRegistry,
1180
+ resetTaskRegistry,
1181
+ };