@stoneforge/smithy 1.0.3 → 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 (155) hide show
  1. package/README.md +100 -18
  2. package/dist/cli/commands/agent.js +10 -10
  3. package/dist/cli/commands/agent.js.map +1 -1
  4. package/dist/cli/commands/pool.d.ts.map +1 -1
  5. package/dist/cli/commands/pool.js +33 -16
  6. package/dist/cli/commands/pool.js.map +1 -1
  7. package/dist/git/merge.js +1 -1
  8. package/dist/git/merge.js.map +1 -1
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/prompts/director.md +2 -2
  14. package/dist/prompts/index.d.ts.map +1 -1
  15. package/dist/prompts/index.js +6 -3
  16. package/dist/prompts/index.js.map +1 -1
  17. package/dist/prompts/persistent-worker.md +1 -1
  18. package/dist/prompts/steward-base.md +4 -4
  19. package/dist/prompts/steward-recovery.md +113 -0
  20. package/dist/prompts/worker.md +12 -1
  21. package/dist/runtime/spawner.d.ts.map +1 -1
  22. package/dist/runtime/spawner.js +10 -0
  23. package/dist/runtime/spawner.js.map +1 -1
  24. package/dist/server/config.d.ts.map +1 -1
  25. package/dist/server/config.js +3 -1
  26. package/dist/server/config.js.map +1 -1
  27. package/dist/server/daemon-state.d.ts.map +1 -1
  28. package/dist/server/daemon-state.js +5 -3
  29. package/dist/server/daemon-state.js.map +1 -1
  30. package/dist/server/events-websocket.d.ts.map +1 -1
  31. package/dist/server/events-websocket.js +7 -5
  32. package/dist/server/events-websocket.js.map +1 -1
  33. package/dist/server/formatters.d.ts +16 -2
  34. package/dist/server/formatters.d.ts.map +1 -1
  35. package/dist/server/formatters.js +23 -2
  36. package/dist/server/formatters.js.map +1 -1
  37. package/dist/server/index.d.ts.map +1 -1
  38. package/dist/server/index.js +14 -11
  39. package/dist/server/index.js.map +1 -1
  40. package/dist/server/lsp-websocket.d.ts.map +1 -1
  41. package/dist/server/lsp-websocket.js +10 -8
  42. package/dist/server/lsp-websocket.js.map +1 -1
  43. package/dist/server/routes/agents.d.ts.map +1 -1
  44. package/dist/server/routes/agents.js +69 -15
  45. package/dist/server/routes/agents.js.map +1 -1
  46. package/dist/server/routes/daemon.d.ts.map +1 -1
  47. package/dist/server/routes/daemon.js +6 -4
  48. package/dist/server/routes/daemon.js.map +1 -1
  49. package/dist/server/routes/events.d.ts.map +1 -1
  50. package/dist/server/routes/events.js +4 -2
  51. package/dist/server/routes/events.js.map +1 -1
  52. package/dist/server/routes/extensions.d.ts.map +1 -1
  53. package/dist/server/routes/extensions.js +9 -7
  54. package/dist/server/routes/extensions.js.map +1 -1
  55. package/dist/server/routes/plugins.d.ts.map +1 -1
  56. package/dist/server/routes/plugins.js +6 -4
  57. package/dist/server/routes/plugins.js.map +1 -1
  58. package/dist/server/routes/pools.d.ts.map +1 -1
  59. package/dist/server/routes/pools.js +26 -7
  60. package/dist/server/routes/pools.js.map +1 -1
  61. package/dist/server/routes/scheduler.d.ts.map +1 -1
  62. package/dist/server/routes/scheduler.js +9 -7
  63. package/dist/server/routes/scheduler.js.map +1 -1
  64. package/dist/server/routes/sessions.d.ts.map +1 -1
  65. package/dist/server/routes/sessions.js +17 -15
  66. package/dist/server/routes/sessions.js.map +1 -1
  67. package/dist/server/routes/tasks.d.ts.map +1 -1
  68. package/dist/server/routes/tasks.js +54 -31
  69. package/dist/server/routes/tasks.js.map +1 -1
  70. package/dist/server/routes/upload.d.ts.map +1 -1
  71. package/dist/server/routes/upload.js +3 -1
  72. package/dist/server/routes/upload.js.map +1 -1
  73. package/dist/server/routes/workflows.d.ts.map +1 -1
  74. package/dist/server/routes/workflows.js +17 -15
  75. package/dist/server/routes/workflows.js.map +1 -1
  76. package/dist/server/routes/workspace-files.d.ts.map +1 -1
  77. package/dist/server/routes/workspace-files.js +11 -9
  78. package/dist/server/routes/workspace-files.js.map +1 -1
  79. package/dist/server/routes/worktrees.d.ts.map +1 -1
  80. package/dist/server/routes/worktrees.js +6 -4
  81. package/dist/server/routes/worktrees.js.map +1 -1
  82. package/dist/server/server.d.ts.map +1 -1
  83. package/dist/server/server.js +10 -8
  84. package/dist/server/server.js.map +1 -1
  85. package/dist/server/services/lsp-manager.d.ts.map +1 -1
  86. package/dist/server/services/lsp-manager.js +15 -13
  87. package/dist/server/services/lsp-manager.js.map +1 -1
  88. package/dist/server/services.d.ts +1 -2
  89. package/dist/server/services.d.ts.map +1 -1
  90. package/dist/server/services.js +37 -12
  91. package/dist/server/services.js.map +1 -1
  92. package/dist/server/static.d.ts.map +1 -1
  93. package/dist/server/static.js +3 -1
  94. package/dist/server/static.js.map +1 -1
  95. package/dist/server/websocket.d.ts.map +1 -1
  96. package/dist/server/websocket.js +6 -4
  97. package/dist/server/websocket.js.map +1 -1
  98. package/dist/services/agent-pool-service.d.ts.map +1 -1
  99. package/dist/services/agent-pool-service.js +7 -8
  100. package/dist/services/agent-pool-service.js.map +1 -1
  101. package/dist/services/agent-registry.d.ts +1 -1
  102. package/dist/services/agent-registry.d.ts.map +1 -1
  103. package/dist/services/agent-registry.js +2 -0
  104. package/dist/services/agent-registry.js.map +1 -1
  105. package/dist/services/dispatch-daemon.d.ts +64 -2
  106. package/dist/services/dispatch-daemon.d.ts.map +1 -1
  107. package/dist/services/dispatch-daemon.js +387 -59
  108. package/dist/services/dispatch-daemon.js.map +1 -1
  109. package/dist/services/index.d.ts +0 -2
  110. package/dist/services/index.d.ts.map +1 -1
  111. package/dist/services/index.js +0 -11
  112. package/dist/services/index.js.map +1 -1
  113. package/dist/services/merge-steward-service.d.ts.map +1 -1
  114. package/dist/services/merge-steward-service.js +6 -4
  115. package/dist/services/merge-steward-service.js.map +1 -1
  116. package/dist/services/steward-scheduler.d.ts +37 -5
  117. package/dist/services/steward-scheduler.d.ts.map +1 -1
  118. package/dist/services/steward-scheduler.js +224 -41
  119. package/dist/services/steward-scheduler.js.map +1 -1
  120. package/dist/services/task-assignment-service.d.ts.map +1 -1
  121. package/dist/services/task-assignment-service.js +3 -0
  122. package/dist/services/task-assignment-service.js.map +1 -1
  123. package/dist/testing/test-context.d.ts +1 -1
  124. package/dist/testing/test-context.d.ts.map +1 -1
  125. package/dist/types/agent-pool.d.ts +4 -0
  126. package/dist/types/agent-pool.d.ts.map +1 -1
  127. package/dist/types/agent-pool.js +8 -1
  128. package/dist/types/agent-pool.js.map +1 -1
  129. package/dist/types/agent.d.ts +29 -7
  130. package/dist/types/agent.d.ts.map +1 -1
  131. package/dist/types/agent.js +2 -2
  132. package/dist/types/agent.js.map +1 -1
  133. package/dist/types/role-definition.d.ts +1 -1
  134. package/dist/types/role-definition.js +1 -1
  135. package/dist/types/role-definition.js.map +1 -1
  136. package/dist/types/task-meta.d.ts +6 -0
  137. package/dist/types/task-meta.d.ts.map +1 -1
  138. package/dist/types/task-meta.js.map +1 -1
  139. package/dist/utils/logger.d.ts +66 -0
  140. package/dist/utils/logger.d.ts.map +1 -0
  141. package/dist/utils/logger.js +133 -0
  142. package/dist/utils/logger.js.map +1 -0
  143. package/package.json +7 -7
  144. package/web/assets/index-CNcjZKzg.css +32 -0
  145. package/web/assets/{index-R1cylSgw.js → index-CtkfxijF.js} +679 -435
  146. package/web/assets/{utils-vendor-DaJ2Dubl.js → utils-vendor-B7jOGaxP.js} +1 -1
  147. package/web/index.html +3 -3
  148. package/dist/prompts/steward-health.md +0 -39
  149. package/dist/prompts/steward-ops.md +0 -28
  150. package/dist/prompts/steward-reminder.md +0 -26
  151. package/dist/services/health-steward-service.d.ts +0 -446
  152. package/dist/services/health-steward-service.d.ts.map +0 -1
  153. package/dist/services/health-steward-service.js +0 -866
  154. package/dist/services/health-steward-service.js.map +0 -1
  155. package/web/assets/index-DqP-_E4F.css +0 -32
@@ -18,10 +18,12 @@
18
18
  * @module
19
19
  */
20
20
  import { EventEmitter } from 'node:events';
21
- import { InboxStatus, createTimestamp, TaskStatus, asEntityId, asElementId } from '@stoneforge/core';
21
+ import { InboxStatus, createTimestamp, TaskStatus, asEntityId, asElementId, PlanStatus, canAutoComplete } from '@stoneforge/core';
22
22
  import { loadTriagePrompt, loadRolePrompt } from '../prompts/index.js';
23
+ import { createLogger } from '../utils/logger.js';
23
24
  import { getAgentMetadata } from './agent-registry.js';
24
25
  import { getOrchestratorTaskMeta, updateOrchestratorTaskMeta, appendTaskSessionHistory, } from '../types/task-meta.js';
26
+ const logger = createLogger('dispatch-daemon');
25
27
  // ============================================================================
26
28
  // Constants
27
29
  // ============================================================================
@@ -95,32 +97,32 @@ export class DispatchDaemonImpl {
95
97
  try {
96
98
  const result = await this.sessionManager.reconcileOnStartup();
97
99
  if (result.reconciled > 0) {
98
- console.log(`[dispatch-daemon] Reconciled ${result.reconciled} stale session(s)`);
100
+ logger.info(`Reconciled ${result.reconciled} stale session(s)`);
99
101
  }
100
102
  if (result.errors.length > 0) {
101
- console.warn('[dispatch-daemon] Reconciliation errors:', result.errors);
103
+ logger.warn('Reconciliation errors:', result.errors);
102
104
  }
103
105
  }
104
106
  catch (error) {
105
- console.error('[dispatch-daemon] Failed to reconcile on startup:', error);
107
+ logger.error('Failed to reconcile on startup:', error);
106
108
  }
107
109
  // Recover orphaned task assignments (workers with tasks but no session after restart)
108
110
  if (this.config.orphanRecoveryEnabled) {
109
111
  try {
110
112
  const result = await this.recoverOrphanedAssignments();
111
113
  if (result.processed > 0) {
112
- console.log(`[dispatch-daemon] Startup: recovered ${result.processed} orphaned task assignment(s)`);
114
+ logger.info(`Startup: recovered ${result.processed} orphaned task assignment(s)`);
113
115
  }
114
116
  }
115
117
  catch (error) {
116
- console.error('[dispatch-daemon] Failed to recover orphaned assignments on startup:', error);
118
+ logger.error('Failed to recover orphaned assignments on startup:', error);
117
119
  }
118
120
  }
119
121
  // Start the main poll loop
120
122
  this.pollIntervalHandle = this.createPollInterval();
121
123
  // Run an initial poll cycle immediately
122
124
  this.currentPollCycle = this.runPollCycle().catch((error) => {
123
- console.error('[dispatch-daemon] Initial poll cycle error:', error);
125
+ logger.error('Initial poll cycle error:', error);
124
126
  });
125
127
  }
126
128
  async stop() {
@@ -184,7 +186,7 @@ export class DispatchDaemonImpl {
184
186
  taskStatus: [TaskStatus.OPEN, TaskStatus.IN_PROGRESS, TaskStatus.REVIEW],
185
187
  });
186
188
  if (workerTasks.length > 0) {
187
- console.log(`[dispatch-daemon] Worker ${worker.name} already has ${workerTasks.length} assigned task(s), skipping`);
189
+ logger.debug(`Worker ${worker.name} already has ${workerTasks.length} assigned task(s), skipping`);
188
190
  continue;
189
191
  }
190
192
  availableWorkers.push(worker);
@@ -201,7 +203,7 @@ export class DispatchDaemonImpl {
201
203
  errors++;
202
204
  const errorMessage = error instanceof Error ? error.message : String(error);
203
205
  errorMessages.push(`Worker ${worker.name}: ${errorMessage}`);
204
- console.error(`[dispatch-daemon] Error assigning task to worker ${worker.name}:`, error);
206
+ logger.error(`Error assigning task to worker ${worker.name}:`, error);
205
207
  }
206
208
  }
207
209
  }
@@ -209,7 +211,7 @@ export class DispatchDaemonImpl {
209
211
  errors++;
210
212
  const errorMessage = error instanceof Error ? error.message : String(error);
211
213
  errorMessages.push(errorMessage);
212
- console.error('[dispatch-daemon] Error in pollWorkerAvailability:', error);
214
+ logger.error('Error in pollWorkerAvailability:', error);
213
215
  }
214
216
  const result = {
215
217
  pollType: 'worker-availability',
@@ -294,7 +296,7 @@ export class DispatchDaemonImpl {
294
296
  errors++;
295
297
  const errorMessage = error instanceof Error ? error.message : String(error);
296
298
  errorMessages.push(errorMessage);
297
- console.error('[dispatch-daemon] Error in pollInboxes:', error);
299
+ logger.error('Error in pollInboxes:', error);
298
300
  }
299
301
  const result = {
300
302
  pollType: 'inbox',
@@ -322,6 +324,8 @@ export class DispatchDaemonImpl {
322
324
  if (!this.stewardScheduler.isRunning()) {
323
325
  // Start the scheduler if it's not running
324
326
  await this.stewardScheduler.start();
327
+ const registered = await this.stewardScheduler.registerAllStewards();
328
+ logger.info(`Steward scheduler started, registered ${registered} steward(s)`);
325
329
  processed++;
326
330
  }
327
331
  // Get stats to report on activity
@@ -332,7 +336,7 @@ export class DispatchDaemonImpl {
332
336
  errors++;
333
337
  const errorMessage = error instanceof Error ? error.message : String(error);
334
338
  errorMessages.push(errorMessage);
335
- console.error('[dispatch-daemon] Error in pollStewardTriggers:', error);
339
+ logger.error('Error in pollStewardTriggers:', error);
336
340
  }
337
341
  const result = {
338
342
  pollType: 'steward-trigger',
@@ -441,7 +445,7 @@ export class DispatchDaemonImpl {
441
445
  errors++;
442
446
  const errorMessage = error instanceof Error ? error.message : String(error);
443
447
  errorMessages.push(errorMessage);
444
- console.error('[dispatch-daemon] Error in pollWorkflowTasks:', error);
448
+ logger.error('Error in pollWorkflowTasks:', error);
445
449
  }
446
450
  const result = {
447
451
  pollType: 'workflow-task',
@@ -479,17 +483,41 @@ export class DispatchDaemonImpl {
479
483
  });
480
484
  if (workerTasks.length === 0)
481
485
  continue;
482
- // 4. Recover the first orphaned task
486
+ // 4. Check if the task is stuck in a resume loop
483
487
  const taskAssignment = workerTasks[0];
484
- try {
485
- await this.recoverOrphanedTask(worker, taskAssignment.task, taskAssignment.orchestratorMeta);
486
- processed++;
488
+ const resumeCount = taskAssignment.orchestratorMeta?.resumeCount ?? 0;
489
+ const maxResumes = this.config.maxResumeAttemptsBeforeRecovery;
490
+ if (maxResumes > 0 && resumeCount >= maxResumes) {
491
+ // Task has been resumed too many times without status change —
492
+ // spawn a recovery steward instead of resuming the worker again
493
+ try {
494
+ await this.spawnRecoveryStewardForTask(worker, taskAssignment.task, taskAssignment.orchestratorMeta);
495
+ processed++;
496
+ logger.info(`[dispatch-daemon] Spawning recovery steward for stuck task ${taskAssignment.task.id} after ${resumeCount} resume attempts`);
497
+ }
498
+ catch (error) {
499
+ errors++;
500
+ const errorMessage = error instanceof Error ? error.message : String(error);
501
+ errorMessages.push(`Recovery steward for ${worker.name}: ${errorMessage}`);
502
+ logger.error(`Error spawning recovery steward for worker ${worker.name}:`, error);
503
+ }
487
504
  }
488
- catch (error) {
489
- errors++;
490
- const errorMessage = error instanceof Error ? error.message : String(error);
491
- errorMessages.push(`Worker ${worker.name}: ${errorMessage}`);
492
- console.error(`[dispatch-daemon] Error recovering orphaned task for worker ${worker.name}:`, error);
505
+ else {
506
+ // Normal recovery: increment resume count and re-spawn the worker
507
+ try {
508
+ // Increment resumeCount before recovering so the counter is persisted
509
+ await this.api.update(taskAssignment.task.id, {
510
+ metadata: updateOrchestratorTaskMeta(taskAssignment.task.metadata, { resumeCount: resumeCount + 1 }),
511
+ });
512
+ await this.recoverOrphanedTask(worker, taskAssignment.task, taskAssignment.orchestratorMeta);
513
+ processed++;
514
+ }
515
+ catch (error) {
516
+ errors++;
517
+ const errorMessage = error instanceof Error ? error.message : String(error);
518
+ errorMessages.push(`Worker ${worker.name}: ${errorMessage}`);
519
+ logger.error(`Error recovering orphaned task for worker ${worker.name}:`, error);
520
+ }
493
521
  }
494
522
  }
495
523
  // --- Phase 2: Recover orphaned merge steward assignments ---
@@ -522,18 +550,18 @@ export class DispatchDaemonImpl {
522
550
  errors++;
523
551
  const errorMessage = error instanceof Error ? error.message : String(error);
524
552
  errorMessages.push(`Merge steward ${steward.name}: ${errorMessage}`);
525
- console.error(`[dispatch-daemon] Error recovering orphaned steward task for ${steward.name}:`, error);
553
+ logger.error(`Error recovering orphaned steward task for ${steward.name}:`, error);
526
554
  }
527
555
  }
528
556
  if (processed > 0) {
529
- console.log(`[dispatch-daemon] Recovered ${processed} orphaned task assignment(s)`);
557
+ logger.info(`Recovered ${processed} orphaned task assignment(s)`);
530
558
  }
531
559
  }
532
560
  catch (error) {
533
561
  errors++;
534
562
  const errorMessage = error instanceof Error ? error.message : String(error);
535
563
  errorMessages.push(errorMessage);
536
- console.error('[dispatch-daemon] Error in recoverOrphanedAssignments:', error);
564
+ logger.error('Error in recoverOrphanedAssignments:', error);
537
565
  }
538
566
  const result = {
539
567
  pollType: 'orphan-recovery',
@@ -578,7 +606,7 @@ export class DispatchDaemonImpl {
578
606
  // Safety valve: skip if already reconciled 3+ times (prevents infinite loops)
579
607
  const currentCount = orchestratorMeta.reconciliationCount ?? 0;
580
608
  if (currentCount >= 3) {
581
- console.warn(`[dispatch-daemon] Task ${task.id} has been reconciled ${currentCount} times, skipping (safety valve)`);
609
+ logger.warn(`Task ${task.id} has been reconciled ${currentCount} times, skipping (safety valve)`);
582
610
  continue;
583
611
  }
584
612
  // Move back to REVIEW with incremented reconciliation count.
@@ -595,24 +623,24 @@ export class DispatchDaemonImpl {
595
623
  }),
596
624
  });
597
625
  processed++;
598
- console.log(`[dispatch-daemon] Reconciled closed-but-unmerged task ${task.id} (mergeStatus=${orchestratorMeta.mergeStatus}, attempt=${currentCount + 1})`);
626
+ logger.info(`Reconciled closed-but-unmerged task ${task.id} (mergeStatus=${orchestratorMeta.mergeStatus}, attempt=${currentCount + 1})`);
599
627
  }
600
628
  catch (error) {
601
629
  errors++;
602
630
  const errorMessage = error instanceof Error ? error.message : String(error);
603
631
  errorMessages.push(`Task ${assignment.taskId}: ${errorMessage}`);
604
- console.error(`[dispatch-daemon] Error reconciling task ${assignment.taskId}:`, error);
632
+ logger.error(`Error reconciling task ${assignment.taskId}:`, error);
605
633
  }
606
634
  }
607
635
  if (processed > 0) {
608
- console.log(`[dispatch-daemon] Reconciled ${processed} closed-but-unmerged task(s)`);
636
+ logger.info(`Reconciled ${processed} closed-but-unmerged task(s)`);
609
637
  }
610
638
  }
611
639
  catch (error) {
612
640
  errors++;
613
641
  const errorMessage = error instanceof Error ? error.message : String(error);
614
642
  errorMessages.push(errorMessage);
615
- console.error('[dispatch-daemon] Error in reconcileClosedUnmergedTasks:', error);
643
+ logger.error('Error in reconcileClosedUnmergedTasks:', error);
616
644
  }
617
645
  const result = {
618
646
  pollType: 'closed-unmerged-reconciliation',
@@ -665,7 +693,7 @@ export class DispatchDaemonImpl {
665
693
  // Safety valve: skip if already recovered 3+ times
666
694
  const currentCount = orchestratorMeta.stuckMergeRecoveryCount ?? 0;
667
695
  if (currentCount >= 3) {
668
- console.warn(`[dispatch-daemon] Task ${task.id} has been recovered from stuck merge ${currentCount} times, skipping (safety valve)`);
696
+ logger.warn(`Task ${task.id} has been recovered from stuck merge ${currentCount} times, skipping (safety valve)`);
669
697
  continue;
670
698
  }
671
699
  // Reset mergeStatus to 'pending' for fresh steward pickup
@@ -689,24 +717,24 @@ export class DispatchDaemonImpl {
689
717
  // Ignore worktree cleanup errors
690
718
  }
691
719
  processed++;
692
- console.log(`[dispatch-daemon] Recovered stuck merge task ${task.id} (mergeStatus=${orchestratorMeta.mergeStatus}, attempt=${currentCount + 1})`);
720
+ logger.info(`Recovered stuck merge task ${task.id} (mergeStatus=${orchestratorMeta.mergeStatus}, attempt=${currentCount + 1})`);
693
721
  }
694
722
  catch (error) {
695
723
  errors++;
696
724
  const errorMessage = error instanceof Error ? error.message : String(error);
697
725
  errorMessages.push(`Task ${assignment.taskId}: ${errorMessage}`);
698
- console.error(`[dispatch-daemon] Error recovering stuck merge task ${assignment.taskId}:`, error);
726
+ logger.error(`Error recovering stuck merge task ${assignment.taskId}:`, error);
699
727
  }
700
728
  }
701
729
  if (processed > 0) {
702
- console.log(`[dispatch-daemon] Recovered ${processed} stuck merge task(s)`);
730
+ logger.info(`Recovered ${processed} stuck merge task(s)`);
703
731
  }
704
732
  }
705
733
  catch (error) {
706
734
  errors++;
707
735
  const errorMessage = error instanceof Error ? error.message : String(error);
708
736
  errorMessages.push(errorMessage);
709
- console.error('[dispatch-daemon] Error in recoverStuckMergeTasks:', error);
737
+ logger.error('Error in recoverStuckMergeTasks:', error);
710
738
  }
711
739
  const stuckResult = {
712
740
  pollType: 'stuck-merge-recovery',
@@ -719,6 +747,79 @@ export class DispatchDaemonImpl {
719
747
  this.emitter.emit('poll:complete', stuckResult);
720
748
  return stuckResult;
721
749
  }
750
+ /**
751
+ * Detects active plans where all non-tombstone tasks are closed
752
+ * and marks them as completed.
753
+ */
754
+ async pollPlanAutoComplete() {
755
+ const startedAt = new Date().toISOString();
756
+ const startTime = Date.now();
757
+ let processed = 0;
758
+ let errors = 0;
759
+ const errorMessages = [];
760
+ this.emitter.emit('poll:start', 'plan-auto-complete');
761
+ try {
762
+ // 1. List all active plans
763
+ const allPlans = await this.api.list({ type: 'plan' });
764
+ const activePlans = allPlans.filter((p) => p.status === PlanStatus.ACTIVE);
765
+ // 2. Check each active plan for auto-completion eligibility
766
+ for (const plan of activePlans) {
767
+ try {
768
+ // Get tasks in this plan (excluding deleted/tombstone)
769
+ const tasks = await this.api.getTasksInPlan(plan.id, { includeDeleted: false });
770
+ // Build status counts
771
+ const statusCounts = {
772
+ [TaskStatus.OPEN]: 0,
773
+ [TaskStatus.IN_PROGRESS]: 0,
774
+ [TaskStatus.BLOCKED]: 0,
775
+ [TaskStatus.CLOSED]: 0,
776
+ [TaskStatus.DEFERRED]: 0,
777
+ [TaskStatus.TOMBSTONE]: 0,
778
+ };
779
+ for (const task of tasks) {
780
+ if (task.status in statusCounts) {
781
+ statusCounts[task.status]++;
782
+ }
783
+ }
784
+ // 3. Check if plan can be auto-completed (all non-tombstone tasks are CLOSED)
785
+ if (canAutoComplete(statusCounts)) {
786
+ const now = new Date().toISOString();
787
+ await this.api.update(plan.id, {
788
+ status: PlanStatus.COMPLETED,
789
+ completedAt: now,
790
+ });
791
+ processed++;
792
+ logger.info(`Auto-completed plan ${plan.id} ("${plan.title}")`);
793
+ }
794
+ }
795
+ catch (error) {
796
+ errors++;
797
+ const errorMessage = error instanceof Error ? error.message : String(error);
798
+ errorMessages.push(`Plan ${plan.id}: ${errorMessage}`);
799
+ logger.error(`Error checking plan ${plan.id} for auto-completion:`, error);
800
+ }
801
+ }
802
+ if (processed > 0) {
803
+ logger.info(`Auto-completed ${processed} plan(s)`);
804
+ }
805
+ }
806
+ catch (error) {
807
+ errors++;
808
+ const errorMessage = error instanceof Error ? error.message : String(error);
809
+ errorMessages.push(errorMessage);
810
+ logger.error('Error in pollPlanAutoComplete:', error);
811
+ }
812
+ const result = {
813
+ pollType: 'plan-auto-complete',
814
+ startedAt,
815
+ durationMs: Date.now() - startTime,
816
+ processed,
817
+ errors,
818
+ errorMessages: errorMessages.length > 0 ? errorMessages : undefined,
819
+ };
820
+ this.emitter.emit('poll:complete', result);
821
+ return result;
822
+ }
722
823
  // ----------------------------------------
723
824
  // Configuration
724
825
  // ----------------------------------------
@@ -754,7 +855,7 @@ export class DispatchDaemonImpl {
754
855
  await this.currentPollCycle;
755
856
  }
756
857
  catch (error) {
757
- console.error('[dispatch-daemon] Poll cycle error:', error);
858
+ logger.error('Poll cycle error:', error);
758
859
  }
759
860
  }, this.config.pollIntervalMs);
760
861
  }
@@ -768,11 +869,14 @@ export class DispatchDaemonImpl {
768
869
  stewardTriggerPollEnabled: config?.stewardTriggerPollEnabled ?? true,
769
870
  workflowTaskPollEnabled: config?.workflowTaskPollEnabled ?? true,
770
871
  orphanRecoveryEnabled: config?.orphanRecoveryEnabled ?? true,
872
+ planAutoCompleteEnabled: config?.planAutoCompleteEnabled ?? true,
771
873
  closedUnmergedReconciliationEnabled: config?.closedUnmergedReconciliationEnabled ?? true,
772
874
  closedUnmergedGracePeriodMs: config?.closedUnmergedGracePeriodMs ?? 120_000,
773
875
  stuckMergeRecoveryEnabled: config?.stuckMergeRecoveryEnabled ?? true,
774
876
  stuckMergeRecoveryGracePeriodMs: config?.stuckMergeRecoveryGracePeriodMs ?? 600_000,
877
+ maxResumeAttemptsBeforeRecovery: config?.maxResumeAttemptsBeforeRecovery ?? 3,
775
878
  maxSessionDurationMs: config?.maxSessionDurationMs ?? 0,
879
+ maxStewardSessionDurationMs: config?.maxStewardSessionDurationMs ?? 30 * 60 * 1000,
776
880
  onSessionStarted: config?.onSessionStarted,
777
881
  projectRoot: config?.projectRoot ?? process.cwd(),
778
882
  directorInboxForwardingEnabled: config?.directorInboxForwardingEnabled ?? true,
@@ -820,6 +924,10 @@ export class DispatchDaemonImpl {
820
924
  if (this.config.stuckMergeRecoveryEnabled) {
821
925
  await this.recoverStuckMergeTasks();
822
926
  }
927
+ // Auto-complete plans where all tasks are closed
928
+ if (this.config.planAutoCompleteEnabled) {
929
+ await this.pollPlanAutoComplete();
930
+ }
823
931
  }
824
932
  finally {
825
933
  this.polling = false;
@@ -828,9 +936,16 @@ export class DispatchDaemonImpl {
828
936
  /**
829
937
  * Terminates sessions that have exceeded the configured max duration.
830
938
  * Prevents stuck workers from blocking their slot indefinitely.
939
+ *
940
+ * Applies two thresholds:
941
+ * - `maxSessionDurationMs` — general limit for all sessions (0 = disabled)
942
+ * - `maxStewardSessionDurationMs` — stricter limit for steward sessions
943
+ * (default 30 minutes), which are expected to be short-lived
831
944
  */
832
945
  async reapStaleSessions() {
833
- if (this.config.maxSessionDurationMs <= 0)
946
+ const hasGeneralLimit = this.config.maxSessionDurationMs > 0;
947
+ const hasStewardLimit = this.config.maxStewardSessionDurationMs > 0;
948
+ if (!hasGeneralLimit && !hasStewardLimit)
834
949
  return;
835
950
  const running = this.sessionManager.listSessions({ status: 'running' });
836
951
  const now = Date.now();
@@ -839,7 +954,24 @@ export class DispatchDaemonImpl {
839
954
  ? session.createdAt
840
955
  : new Date(session.createdAt).getTime();
841
956
  const age = now - createdAt;
842
- if (age > this.config.maxSessionDurationMs) {
957
+ // Apply steward-specific limit
958
+ if (hasStewardLimit && session.agentRole === 'steward' && age > this.config.maxStewardSessionDurationMs) {
959
+ try {
960
+ await this.sessionManager.stopSession(session.id, {
961
+ graceful: false,
962
+ reason: `Steward session exceeded max duration (${Math.round(age / 1000)}s, limit: ${Math.round(this.config.maxStewardSessionDurationMs / 1000)}s)`,
963
+ });
964
+ }
965
+ catch (error) {
966
+ const message = error instanceof Error ? error.message : String(error);
967
+ if (!message.includes('not found')) {
968
+ logger.warn(`Failed to reap steward session ${session.id}:`, error);
969
+ }
970
+ }
971
+ continue;
972
+ }
973
+ // Apply general limit
974
+ if (hasGeneralLimit && age > this.config.maxSessionDurationMs) {
843
975
  try {
844
976
  await this.sessionManager.stopSession(session.id, {
845
977
  graceful: false,
@@ -849,7 +981,7 @@ export class DispatchDaemonImpl {
849
981
  catch (error) {
850
982
  const message = error instanceof Error ? error.message : String(error);
851
983
  if (!message.includes('not found')) {
852
- console.warn(`[dispatch-daemon] Failed to reap session ${session.id}:`, error);
984
+ logger.warn(`Failed to reap session ${session.id}:`, error);
853
985
  }
854
986
  }
855
987
  }
@@ -918,11 +1050,11 @@ export class DispatchDaemonImpl {
918
1050
  this.config.onSessionStarted(session, events, workerId, `[resumed session for task ${task.id}]`);
919
1051
  }
920
1052
  this.emitter.emit('agent:spawned', workerId, worktreePath);
921
- console.log(`[dispatch-daemon] Resumed session for orphaned task ${task.id} on worker ${worker.name}`);
1053
+ logger.info(`Resumed session for orphaned task ${task.id} on worker ${worker.name}`);
922
1054
  return;
923
1055
  }
924
1056
  catch (error) {
925
- console.warn(`[dispatch-daemon] Failed to resume session ${previousSessionId} for worker ${worker.name}, falling back to fresh spawn:`, error);
1057
+ logger.warn(`Failed to resume session ${previousSessionId} for worker ${worker.name}, falling back to fresh spawn:`, error);
926
1058
  }
927
1059
  }
928
1060
  // 3. Fall back to fresh spawn
@@ -950,7 +1082,7 @@ export class DispatchDaemonImpl {
950
1082
  this.config.onSessionStarted(session, events, workerId, initialPrompt);
951
1083
  }
952
1084
  this.emitter.emit('agent:spawned', workerId, worktreePath);
953
- console.log(`[dispatch-daemon] Spawned fresh session for orphaned task ${task.id} on worker ${worker.name}`);
1085
+ logger.info(`Spawned fresh session for orphaned task ${task.id} on worker ${worker.name}`);
954
1086
  }
955
1087
  /**
956
1088
  * Recovers a single orphaned merge steward task by resuming or re-spawning.
@@ -964,7 +1096,7 @@ export class DispatchDaemonImpl {
964
1096
  if (worktreePath) {
965
1097
  const exists = await this.worktreeManager.worktreeExists(worktreePath);
966
1098
  if (!exists) {
967
- console.warn(`[dispatch-daemon] Worktree ${worktreePath} no longer exists for steward task ${task.id}, using project root`);
1099
+ logger.warn(`Worktree ${worktreePath} no longer exists for steward task ${task.id}, using project root`);
968
1100
  worktreePath = undefined;
969
1101
  }
970
1102
  }
@@ -1002,16 +1134,16 @@ export class DispatchDaemonImpl {
1002
1134
  this.config.onSessionStarted(session, events, stewardId, `[resumed steward session for task ${task.id}]`);
1003
1135
  }
1004
1136
  this.emitter.emit('agent:spawned', stewardId, worktreePath);
1005
- console.log(`[dispatch-daemon] Resumed steward session for orphaned task ${task.id} on ${steward.name}`);
1137
+ logger.info(`Resumed steward session for orphaned task ${task.id} on ${steward.name}`);
1006
1138
  return;
1007
1139
  }
1008
1140
  catch (error) {
1009
- console.warn(`[dispatch-daemon] Failed to resume steward session ${previousSessionId} for ${steward.name}, falling back to fresh spawn:`, error);
1141
+ logger.warn(`Failed to resume steward session ${previousSessionId} for ${steward.name}, falling back to fresh spawn:`, error);
1010
1142
  }
1011
1143
  }
1012
1144
  // 3. Fall back to fresh spawn (spawnMergeStewardForTask handles metadata update AND session history)
1013
1145
  await this.spawnMergeStewardForTask(steward, task);
1014
- console.log(`[dispatch-daemon] Spawned fresh steward session for orphaned task ${task.id} on ${steward.name}`);
1146
+ logger.info(`Spawned fresh steward session for orphaned task ${task.id} on ${steward.name}`);
1015
1147
  }
1016
1148
  /**
1017
1149
  * Assigns the highest priority unassigned task to a worker.
@@ -1041,7 +1173,7 @@ export class DispatchDaemonImpl {
1041
1173
  };
1042
1174
  const poolCheck = await this.poolService.canSpawn(spawnRequest);
1043
1175
  if (!poolCheck.canSpawn) {
1044
- console.log(`[dispatch-daemon] Pool capacity reached for worker ${worker.name}: ${poolCheck.reason}`);
1176
+ logger.debug(`Pool capacity reached for worker ${worker.name}: ${poolCheck.reason}`);
1045
1177
  return false;
1046
1178
  }
1047
1179
  }
@@ -1193,7 +1325,7 @@ export class DispatchDaemonImpl {
1193
1325
  *
1194
1326
  * @param task - The task being reviewed
1195
1327
  * @param stewardId - The steward's entity ID
1196
- * @param stewardFocus - The steward's focus area (merge or health)
1328
+ * @param stewardFocus - The steward's focus area (merge, docs, or custom)
1197
1329
  * @param syncResult - Optional result from pre-spawn branch sync
1198
1330
  */
1199
1331
  async buildStewardPrompt(task, stewardId, stewardFocus = 'merge', syncResult) {
@@ -1285,7 +1417,7 @@ export class DispatchDaemonImpl {
1285
1417
  };
1286
1418
  const poolCheck = await this.poolService.canSpawn(spawnRequest);
1287
1419
  if (!poolCheck.canSpawn) {
1288
- console.log(`[dispatch-daemon] Pool capacity reached for steward ${steward.name}: ${poolCheck.reason}`);
1420
+ logger.debug(`Pool capacity reached for steward ${steward.name}: ${poolCheck.reason}`);
1289
1421
  return;
1290
1422
  }
1291
1423
  }
@@ -1297,7 +1429,7 @@ export class DispatchDaemonImpl {
1297
1429
  if (worktreePath) {
1298
1430
  const exists = await this.worktreeManager.worktreeExists(worktreePath);
1299
1431
  if (!exists) {
1300
- console.warn(`[dispatch-daemon] Worktree ${worktreePath} no longer exists for task ${task.id}, creating fresh worktree`);
1432
+ logger.warn(`Worktree ${worktreePath} no longer exists for task ${task.id}, creating fresh worktree`);
1301
1433
  const sourceBranch = orchestratorMeta?.branch;
1302
1434
  if (sourceBranch) {
1303
1435
  try {
@@ -1308,7 +1440,7 @@ export class DispatchDaemonImpl {
1308
1440
  worktreePath = result.path;
1309
1441
  }
1310
1442
  catch (e) {
1311
- console.error(`[dispatch-daemon] Failed to create steward worktree: ${e}`);
1443
+ logger.error(`Failed to create steward worktree: ${e}`);
1312
1444
  worktreePath = undefined;
1313
1445
  }
1314
1446
  }
@@ -1330,7 +1462,7 @@ export class DispatchDaemonImpl {
1330
1462
  // This ensures `git diff origin/master..HEAD` shows only the task's changes
1331
1463
  let syncResult;
1332
1464
  if (worktreePath) {
1333
- console.log(`[dispatch-daemon] Syncing task ${task.id} branch before steward spawn...`);
1465
+ logger.debug(`Syncing task ${task.id} branch before steward spawn...`);
1334
1466
  syncResult = await this.syncTaskBranch(task);
1335
1467
  // Store sync result in task metadata for audit trail
1336
1468
  await this.api.update(task.id, {
@@ -1388,7 +1520,203 @@ export class DispatchDaemonImpl {
1388
1520
  await this.poolService.onAgentSpawned(stewardId);
1389
1521
  }
1390
1522
  this.emitter.emit('agent:spawned', stewardId, worktreePath);
1391
- console.log(`[dispatch-daemon] Spawned merge steward ${steward.name} for task ${task.id}`);
1523
+ logger.info(`Spawned merge steward ${steward.name} for task ${task.id}`);
1524
+ }
1525
+ /**
1526
+ * Spawns a recovery steward for a task stuck in a resume loop.
1527
+ *
1528
+ * When a worker session exits without calling `sf task complete` or
1529
+ * `sf task handoff`, the orphan recovery loop resumes it. After
1530
+ * maxResumeAttemptsBeforeRecovery consecutive resumes without a status
1531
+ * change, this method is called instead to:
1532
+ * 1. Unassign the worker from the task
1533
+ * 2. Find an available recovery steward (or any available steward)
1534
+ * 3. Spawn a recovery steward session with full task context
1535
+ *
1536
+ * @param worker - The worker that was assigned to the stuck task
1537
+ * @param task - The stuck task
1538
+ * @param taskMeta - The task's orchestrator metadata
1539
+ */
1540
+ async spawnRecoveryStewardForTask(worker, task, taskMeta) {
1541
+ const workerId = asEntityId(worker.id);
1542
+ // 1. Find an available recovery steward
1543
+ const stewards = await this.agentRegistry.listAgents({
1544
+ role: 'steward',
1545
+ stewardFocus: 'recovery',
1546
+ });
1547
+ // Find a steward without an active session
1548
+ let recoverySteward;
1549
+ for (const steward of stewards) {
1550
+ const session = this.sessionManager.getActiveSession(asEntityId(steward.id));
1551
+ if (!session) {
1552
+ recoverySteward = steward;
1553
+ break;
1554
+ }
1555
+ }
1556
+ if (!recoverySteward) {
1557
+ // No recovery steward available — emit a notification and leave the task as-is
1558
+ this.emitter.emit('daemon:notification', {
1559
+ type: 'warning',
1560
+ title: 'Recovery steward unavailable',
1561
+ message: `Task ${task.id} is stuck after ${taskMeta?.resumeCount ?? 0} resume attempts, but no recovery steward is available. The task will not be resumed again until a recovery steward is available.`,
1562
+ });
1563
+ logger.warn(`No available recovery steward for stuck task ${task.id}. ` +
1564
+ `Task has been resumed ${taskMeta?.resumeCount ?? 0} times without status change.`);
1565
+ return;
1566
+ }
1567
+ const stewardId = asEntityId(recoverySteward.id);
1568
+ // 2. Check pool capacity before spawning
1569
+ const stewardMeta = getAgentMetadata(recoverySteward);
1570
+ if (this.poolService && stewardMeta) {
1571
+ const spawnRequest = {
1572
+ role: 'steward',
1573
+ stewardFocus: stewardMeta.stewardFocus,
1574
+ agentId: stewardId,
1575
+ };
1576
+ const poolCheck = await this.poolService.canSpawn(spawnRequest);
1577
+ if (!poolCheck.canSpawn) {
1578
+ logger.debug(`Pool capacity reached for recovery steward ${recoverySteward.name}: ${poolCheck.reason}`);
1579
+ return;
1580
+ }
1581
+ }
1582
+ // 3. Resolve worktree — reuse the worker's existing worktree
1583
+ let worktreePath = taskMeta?.worktree ?? taskMeta?.handoffWorktree;
1584
+ const branch = taskMeta?.branch ?? taskMeta?.handoffBranch;
1585
+ if (worktreePath) {
1586
+ const exists = await this.worktreeManager.worktreeExists(worktreePath);
1587
+ if (!exists) {
1588
+ logger.warn(`Worktree ${worktreePath} no longer exists for stuck task ${task.id}`);
1589
+ // Try to create a read-only worktree for the steward
1590
+ if (branch) {
1591
+ try {
1592
+ const result = await this.worktreeManager.createReadOnlyWorktree({
1593
+ agentName: stewardId,
1594
+ purpose: `recovery-${task.id}`,
1595
+ });
1596
+ worktreePath = result.path;
1597
+ }
1598
+ catch (e) {
1599
+ logger.error(`Failed to create recovery steward worktree: ${e}`);
1600
+ worktreePath = undefined;
1601
+ }
1602
+ }
1603
+ else {
1604
+ worktreePath = undefined;
1605
+ }
1606
+ }
1607
+ }
1608
+ // Guard: never spawn a steward without a worktree
1609
+ if (!worktreePath) {
1610
+ this.emitter.emit('daemon:notification', {
1611
+ type: 'warning',
1612
+ title: 'Recovery steward skipped',
1613
+ message: `Cannot spawn recovery steward for task ${task.id}: worktree missing and no branch info available to create a new one.`,
1614
+ });
1615
+ return;
1616
+ }
1617
+ // 4. Unassign the worker from the task
1618
+ await this.api.update(task.id, {
1619
+ assignee: undefined,
1620
+ metadata: updateOrchestratorTaskMeta(task.metadata, {
1621
+ assignedAgent: stewardId,
1622
+ }),
1623
+ });
1624
+ // 5. Build the recovery steward prompt
1625
+ const initialPrompt = await this.buildRecoveryStewardPrompt(task, stewardId, taskMeta);
1626
+ // 6. Start the recovery steward session
1627
+ const { session, events } = await this.sessionManager.startSession(stewardId, {
1628
+ workingDirectory: worktreePath,
1629
+ worktree: worktreePath,
1630
+ initialPrompt,
1631
+ interactive: false, // Stewards use headless mode
1632
+ });
1633
+ // 7. Record steward assignment and session history on the task
1634
+ const taskAfterUpdate = await this.api.get(task.id);
1635
+ const sessionHistoryEntry = {
1636
+ sessionId: session.id,
1637
+ providerSessionId: session.providerSessionId,
1638
+ agentId: stewardId,
1639
+ agentName: recoverySteward.name,
1640
+ agentRole: 'steward',
1641
+ startedAt: createTimestamp(),
1642
+ };
1643
+ const metadataWithHistory = appendTaskSessionHistory(taskAfterUpdate?.metadata, sessionHistoryEntry);
1644
+ const finalMetadata = updateOrchestratorTaskMeta(metadataWithHistory, {
1645
+ assignedAgent: stewardId,
1646
+ sessionId: session.providerSessionId ?? session.id,
1647
+ });
1648
+ await this.api.update(task.id, {
1649
+ assignee: stewardId,
1650
+ metadata: finalMetadata,
1651
+ });
1652
+ // 8. Callbacks and notifications
1653
+ if (this.config.onSessionStarted) {
1654
+ this.config.onSessionStarted(session, events, stewardId, initialPrompt);
1655
+ }
1656
+ if (this.poolService) {
1657
+ await this.poolService.onAgentSpawned(stewardId);
1658
+ }
1659
+ this.emitter.emit('agent:spawned', stewardId, worktreePath);
1660
+ }
1661
+ /**
1662
+ * Builds the initial prompt for a recovery steward session.
1663
+ * Includes the steward-recovery role prompt followed by task context,
1664
+ * session history, and resume count information.
1665
+ *
1666
+ * @param task - The stuck task
1667
+ * @param stewardId - The recovery steward's entity ID
1668
+ * @param taskMeta - The task's orchestrator metadata
1669
+ */
1670
+ async buildRecoveryStewardPrompt(task, stewardId, taskMeta) {
1671
+ const parts = [];
1672
+ // Load the recovery steward role prompt
1673
+ const roleResult = loadRolePrompt('steward', 'recovery', { projectRoot: this.config.projectRoot });
1674
+ if (roleResult) {
1675
+ parts.push('Please read and internalize the following operating instructions. These define your role and how you should behave:', '', roleResult.prompt, '', '---', '');
1676
+ }
1677
+ // Get the director ID for context
1678
+ const director = await this.agentRegistry.getDirector();
1679
+ const directorId = director?.id ?? 'unknown';
1680
+ const branch = taskMeta?.branch ?? taskMeta?.handoffBranch;
1681
+ const worktree = taskMeta?.worktree ?? taskMeta?.handoffWorktree;
1682
+ const resumeCount = taskMeta?.resumeCount ?? 0;
1683
+ parts.push('## Recovery Assignment', '', `**Steward ID:** ${stewardId}`, `**Director ID:** ${directorId}`, `**Task ID:** ${task.id}`, `**Title:** ${task.title}`, `**Status:** ${task.status}`);
1684
+ if (branch) {
1685
+ parts.push(`**Branch:** ${branch}`);
1686
+ }
1687
+ if (worktree) {
1688
+ parts.push(`**Worktree:** ${worktree}`);
1689
+ }
1690
+ if (task.priority !== undefined) {
1691
+ parts.push(`**Priority:** ${task.priority}`);
1692
+ }
1693
+ parts.push('', '## Recovery Context', '', `This task has been resumed **${resumeCount} times** without a status change.`, 'The previous worker session exited without calling `sf task complete` or `sf task handoff`.', 'This indicates the worker may have crashed, lost context, or encountered an unrecoverable error.', '');
1694
+ // Include session history if available
1695
+ if (taskMeta?.sessionHistory && taskMeta.sessionHistory.length > 0) {
1696
+ parts.push('### Session History', '');
1697
+ for (const entry of taskMeta.sessionHistory) {
1698
+ const ended = entry.endedAt ? ` → ${entry.endedAt}` : ' (may still be running)';
1699
+ parts.push(`- **${entry.agentRole}** ${entry.agentName} (${entry.sessionId}): ${entry.startedAt}${ended}`);
1700
+ }
1701
+ parts.push('');
1702
+ }
1703
+ // Fetch and include the description content
1704
+ if (task.descriptionRef) {
1705
+ try {
1706
+ const doc = await this.api.get(asElementId(task.descriptionRef));
1707
+ if (doc?.content) {
1708
+ parts.push('### Task Description', doc.content);
1709
+ }
1710
+ }
1711
+ catch {
1712
+ parts.push(`**Description Document:** ${task.descriptionRef}`);
1713
+ }
1714
+ }
1715
+ // Include acceptance criteria if any
1716
+ if (task.acceptanceCriteria) {
1717
+ parts.push('', '### Acceptance Criteria', task.acceptanceCriteria);
1718
+ }
1719
+ return parts.join('\n');
1392
1720
  }
1393
1721
  /**
1394
1722
  * Processes an inbox item for an agent.
@@ -1540,7 +1868,7 @@ export class DispatchDaemonImpl {
1540
1868
  processed += channelItems.length;
1541
1869
  }
1542
1870
  catch (error) {
1543
- console.error(`[dispatch-daemon] Failed to spawn triage session for agent ${agent.name}:`, error);
1871
+ logger.error(`Failed to spawn triage session for agent ${agent.name}:`, error);
1544
1872
  }
1545
1873
  // Only one triage session per poll cycle per agent — remaining channels
1546
1874
  // will be picked up in subsequent cycles
@@ -1629,7 +1957,7 @@ export class DispatchDaemonImpl {
1629
1957
  this.inboxService.markAsReadBatch(items.map((item) => item.id));
1630
1958
  }
1631
1959
  catch (error) {
1632
- console.warn('[dispatch-daemon] Failed to mark triage items as read:', error);
1960
+ logger.warn('Failed to mark triage items as read:', error);
1633
1961
  }
1634
1962
  try {
1635
1963
  await this.worktreeManager.removeWorktree(worktreeResult.path);
@@ -1665,7 +1993,7 @@ export class DispatchDaemonImpl {
1665
1993
  }
1666
1994
  }
1667
1995
  catch (error) {
1668
- console.warn(`[dispatch-daemon] Failed to fetch content for message ${message.id}:`, error);
1996
+ logger.warn(`Failed to fetch content for message ${message.id}:`, error);
1669
1997
  }
1670
1998
  }
1671
1999
  formattedMessages.push(`--- Inbox Item ID: ${inboxItemId} | Message ID: ${message.id} | From: ${senderId} | At: ${timestamp} ---`, content, '');
@@ -1692,7 +2020,7 @@ export class DispatchDaemonImpl {
1692
2020
  }
1693
2021
  }
1694
2022
  catch (error) {
1695
- console.warn(`[dispatch-daemon] Failed to fetch content for forwarded message ${message.id}:`, error);
2023
+ logger.warn(`Failed to fetch content for forwarded message ${message.id}:`, error);
1696
2024
  }
1697
2025
  }
1698
2026
  return content; // No prefix — messageSession() handles the [Message from ...] prefix
@@ -1768,7 +2096,7 @@ export class DispatchDaemonImpl {
1768
2096
  timeout: 120_000,
1769
2097
  });
1770
2098
  // Merge succeeded
1771
- console.log(`[dispatch-daemon] Synced task ${task.id} branch with ${remoteBranch}`);
2099
+ logger.debug(`Synced task ${task.id} branch with ${remoteBranch}`);
1772
2100
  return {
1773
2101
  success: true,
1774
2102
  message: `Branch synced with ${remoteBranch}`,
@@ -1791,7 +2119,7 @@ export class DispatchDaemonImpl {
1791
2119
  conflicts.push(match[2]);
1792
2120
  }
1793
2121
  if (conflicts.length > 0) {
1794
- console.log(`[dispatch-daemon] Merge conflicts detected for task ${task.id}: ${conflicts.join(', ')}`);
2122
+ logger.debug(`Merge conflicts detected for task ${task.id}: ${conflicts.join(', ')}`);
1795
2123
  return {
1796
2124
  success: false,
1797
2125
  conflicts,