bosun 0.41.0 → 0.41.2

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 (64) hide show
  1. package/.env.example +8 -0
  2. package/README.md +20 -0
  3. package/agent/agent-event-bus.mjs +248 -6
  4. package/agent/agent-pool.mjs +125 -28
  5. package/agent/agent-work-analyzer.mjs +8 -16
  6. package/agent/retry-queue.mjs +164 -0
  7. package/bosun.config.example.json +25 -0
  8. package/bosun.schema.json +825 -183
  9. package/cli.mjs +59 -5
  10. package/config/config.mjs +130 -3
  11. package/infra/monitor.mjs +693 -67
  12. package/infra/runtime-accumulator.mjs +376 -84
  13. package/infra/session-tracker.mjs +82 -25
  14. package/lib/codebase-audit.mjs +133 -18
  15. package/package.json +23 -4
  16. package/server/setup-web-server.mjs +25 -0
  17. package/server/ui-server.mjs +248 -29
  18. package/setup.mjs +27 -24
  19. package/shell/codex-shell.mjs +34 -3
  20. package/shell/copilot-shell.mjs +50 -8
  21. package/task/msg-hub.mjs +193 -0
  22. package/task/pipeline.mjs +544 -0
  23. package/task/task-cli.mjs +38 -2
  24. package/task/task-executor-pipeline.mjs +143 -0
  25. package/task/task-executor.mjs +36 -27
  26. package/telegram/get-telegram-chat-id.mjs +57 -47
  27. package/ui/components/workspace-switcher.js +7 -7
  28. package/ui/demo-defaults.js +15694 -10573
  29. package/ui/modules/settings-schema.js +2 -0
  30. package/ui/modules/state.js +54 -57
  31. package/ui/modules/voice-client-sdk.js +375 -36
  32. package/ui/modules/voice-client.js +140 -31
  33. package/ui/setup.html +68 -2
  34. package/ui/styles/components.css +57 -0
  35. package/ui/styles.css +201 -1
  36. package/ui/tabs/dashboard.js +74 -0
  37. package/ui/tabs/logs.js +10 -0
  38. package/ui/tabs/settings.js +178 -99
  39. package/ui/tabs/tasks.js +31 -1
  40. package/ui/tabs/telemetry.js +34 -0
  41. package/ui/tabs/workflow-canvas-utils.mjs +8 -1
  42. package/ui/tabs/workflows.js +532 -275
  43. package/voice/voice-agents-sdk.mjs +1 -1
  44. package/voice/voice-relay.mjs +6 -6
  45. package/workflow/declarative-workflows.mjs +145 -0
  46. package/workflow/msg-hub.mjs +237 -0
  47. package/workflow/pipeline-workflows.mjs +287 -0
  48. package/workflow/pipeline.mjs +828 -315
  49. package/workflow/workflow-cli.mjs +128 -0
  50. package/workflow/workflow-engine.mjs +329 -17
  51. package/workflow/workflow-nodes/custom-loader.mjs +250 -0
  52. package/workflow/workflow-nodes.mjs +1955 -223
  53. package/workflow/workflow-templates.mjs +26 -8
  54. package/workflow-templates/agents.mjs +0 -1
  55. package/workflow-templates/bosun-native.mjs +212 -2
  56. package/workflow-templates/continuation-loop.mjs +339 -0
  57. package/workflow-templates/github.mjs +516 -40
  58. package/workflow-templates/planning.mjs +446 -17
  59. package/workflow-templates/reliability.mjs +65 -12
  60. package/workflow-templates/task-batch.mjs +24 -8
  61. package/workflow-templates/task-lifecycle.mjs +83 -6
  62. package/workspace/context-cache.mjs +66 -18
  63. package/workspace/workspace-manager.mjs +2 -1
  64. package/workflow-templates/issue-continuation.mjs +0 -243
@@ -46,12 +46,30 @@ export const ERROR_RECOVERY_TEMPLATE = {
46
46
  }, { x: 400, y: 180 }),
47
47
 
48
48
  node("analyze-error", "action.run_agent", "Analyze Failure", {
49
- prompt: "Analyze the following error and suggest a fix:\n\n{{lastError}}\n\nTask: {{taskTitle}}",
49
+ prompt:
50
+ "Analyze the following task failure and suggest the most likely minimal fix.\n\n" +
51
+ "Task: {{taskTitle}} ({{taskId}})\n" +
52
+ "Retry attempt: {{$data?.retryCount || 0}}/{{$data?.maxRetries || 3}}\n" +
53
+ "Branch: {{branch}}\n" +
54
+ "Base branch: {{baseBranch}}\n" +
55
+ "Worktree: {{worktreePath}}\n\n" +
56
+ "Last error:\n{{lastError}}",
50
57
  timeoutMs: 300000,
51
58
  }, { x: 200, y: 330 }),
52
59
 
53
60
  node("retry-task", "action.run_agent", "Retry Task", {
54
- prompt: "{{taskExecutorRetryPrompt}}",
61
+ prompt:
62
+ "{{taskExecutorRetryPrompt}}\n\n" +
63
+ "Failure context:\n" +
64
+ "- taskId: {{taskId}}\n" +
65
+ "- taskTitle: {{taskTitle}}\n" +
66
+ "- branch: {{branch}}\n" +
67
+ "- baseBranch: {{baseBranch}}\n" +
68
+ "- worktreePath: {{worktreePath}}\n" +
69
+ "- retryCount: {{$data?.retryCount || 0}}/{{$data?.maxRetries || 3}}\n" +
70
+ "- lastError: {{lastError}}\n" +
71
+ "- recoveryAnalysis: {{$ctx.getNodeOutput('analyze-error')?.output || ''}}\n\n" +
72
+ "Use the analysis to choose a different approach if the previous attempt failed.",
55
73
  timeoutMs: 3600000,
56
74
  failOnError: true,
57
75
  maxRetries: "{{maxRetries}}",
@@ -69,13 +87,17 @@ export const ERROR_RECOVERY_TEMPLATE = {
69
87
  }, { x: 90, y: 760 }),
70
88
 
71
89
  node("escalate", "notify.telegram", "Escalate to Human", {
72
- message: ":alert: Task **{{taskTitle}}** failed after {{maxRetries}} attempts. Manual intervention needed.\n\nLast error: {{lastError}}",
90
+ message:
91
+ ":alert: Task **{{taskTitle}}** failed after {{maxRetries}} attempts. Manual intervention needed.\n\n" +
92
+ "Last error: {{lastError}}\n\n" +
93
+ "Recovery analysis: {{$ctx.getNodeOutput('analyze-error')?.output || ''}}",
73
94
  }, { x: 600, y: 620 }),
74
95
 
75
96
  node("chain-repair", "action.execute_workflow", "Trigger Repair Workflow", {
76
97
  workflowId: "template-task-repair-worktree",
77
98
  mode: "dispatch",
78
- input: "({taskId: $data?.taskId, taskTitle: $data?.taskTitle, worktreePath: $data?.worktreePath, branch: $data?.branch, baseBranch: $data?.baseBranch, error: $data?.lastError})",
99
+ input:
100
+ "(() => { const analysisRaw = String($ctx.getNodeOutput('analyze-error')?.output || '').trim(); const retryOutputRaw = String($ctx.getNodeOutput('retry-task')?.output || '').trim(); const retryErrorRaw = String($ctx.getNodeOutput('retry-task')?.error || '').trim(); const truncate = (value, limit = 2000) => value.length > limit ? `${value.slice(0, limit)}...` : value; const diagnostics = [String($data?.lastError || '').trim(), analysisRaw ? `Recovery analysis:\n${truncate(analysisRaw)}` : '', retryOutputRaw ? `Retry output:\n${truncate(retryOutputRaw)}` : '', retryErrorRaw ? `Retry error:\n${truncate(retryErrorRaw)}` : ''].filter(Boolean).join('\n\n'); return { taskId: $data?.taskId, taskTitle: $data?.taskTitle, worktreePath: $data?.worktreePath, branch: $data?.branch, baseBranch: $data?.baseBranch, error: diagnostics || String($data?.lastError || ''), recoveryAnalysis: truncate(analysisRaw), retryResult: { success: $ctx.getNodeOutput('retry-task')?.success === true, output: truncate(retryOutputRaw), error: truncate(retryErrorRaw) } }; })()",
79
101
  }, { x: 400, y: 760 }),
80
102
  ],
81
103
  edges: [
@@ -92,7 +114,7 @@ export const ERROR_RECOVERY_TEMPLATE = {
92
114
  author: "bosun",
93
115
  version: 1,
94
116
  createdAt: "2025-02-24T00:00:00Z",
95
- templateVersion: "1.0.1",
117
+ templateVersion: "1.1.0",
96
118
  tags: ["error", "recovery", "autofix"],
97
119
  requiredTemplates: ["template-task-repair-worktree"],
98
120
  replaces: {
@@ -458,6 +480,20 @@ export const TASK_FINALIZATION_GUARD_TEMPLATE = {
458
480
  },
459
481
  }, { x: 240, y: 900 }),
460
482
 
483
+ node("handoff-pr-progressor", "action.execute_workflow", "Dispatch PR Progressor", {
484
+ workflowId: "template-bosun-pr-progressor",
485
+ mode: "dispatch",
486
+ input: {
487
+ taskId: "{{taskId}}",
488
+ taskTitle: "{{taskTitle}}",
489
+ branch: "{{branch}}",
490
+ baseBranch: "{{baseBranch}}",
491
+ prNumber: "{{$data?.prNumber ?? $ctx.getNodeOutput('create-pr')?.prNumber ?? null}}",
492
+ prUrl: "{{$data?.prUrl || $ctx.getNodeOutput('create-pr')?.prUrl || ''}}",
493
+ repo: "{{$data?.repo || $data?.repoSlug || $data?.repository || $ctx.getNodeOutput('create-pr')?.repoSlug || ''}}",
494
+ },
495
+ }, { x: 240, y: 1040 }),
496
+
461
497
  node("mark-todo-failed", "action.update_task_status", "Mark Todo (Checks Failed)", {
462
498
  taskId: "{{taskId}}",
463
499
  status: "todo",
@@ -497,13 +533,13 @@ export const TASK_FINALIZATION_GUARD_TEMPLATE = {
497
533
  node("notify-pass", "notify.log", "Log Finalization Success", {
498
534
  message: "Task {{taskId}} finalization passed — moved to inreview",
499
535
  level: "info",
500
- }, { x: 240, y: 1040 }),
536
+ }, { x: 240, y: 1180 }),
501
537
 
502
538
  node("chain-archiver", "flow.universal", "Queue Archival", {
503
539
  workflowId: "template-task-archiver",
504
540
  mode: "dispatch",
505
541
  input: "({taskId: $data?.taskId, taskTitle: $data?.taskTitle, completedAt: new Date().toISOString(), taskJson: JSON.stringify($data?.task || {id: $data?.taskId, title: $data?.taskTitle})})",
506
- }, { x: 240, y: 1180 }),
542
+ }, { x: 240, y: 1320 }),
507
543
 
508
544
  node("end-success", "flow.end", "End Success", {
509
545
  status: "completed",
@@ -513,7 +549,7 @@ export const TASK_FINALIZATION_GUARD_TEMPLATE = {
513
549
  taskId: "{{taskId}}",
514
550
  taskTitle: "{{taskTitle}}",
515
551
  },
516
- }, { x: 240, y: 1310 }),
552
+ }, { x: 240, y: 1450 }),
517
553
 
518
554
  node("notify-fail", "notify.telegram", "Notify Finalization Failure", {
519
555
  message: ":alert: Task finalization failed for **{{taskTitle}}** ({{taskId}}). Repair workflow handoff triggered.",
@@ -543,7 +579,8 @@ export const TASK_FINALIZATION_GUARD_TEMPLATE = {
543
579
  edge("create-pr", "create-pr-success"),
544
580
  edge("create-pr-success", "mark-inreview", { condition: "$output?.result === true", port: "yes" }),
545
581
  edge("create-pr-success", "mark-todo-failed", { condition: "$output?.result !== true", port: "no" }),
546
- edge("mark-inreview", "notify-pass"),
582
+ edge("mark-inreview", "handoff-pr-progressor"),
583
+ edge("handoff-pr-progressor", "notify-pass"),
547
584
  edge("notify-skip-missing-context", "end-success"),
548
585
  edge("notify-pass", "chain-archiver"),
549
586
  edge("chain-archiver", "end-success"),
@@ -557,7 +594,7 @@ export const TASK_FINALIZATION_GUARD_TEMPLATE = {
557
594
  createdAt: "2026-02-26T00:00:00Z",
558
595
  templateVersion: "1.0.1",
559
596
  tags: ["finalization", "quality-gate", "prepush", "handoff", "reliability"],
560
- requiredTemplates: ["template-task-archiver"],
597
+ requiredTemplates: ["template-task-archiver", "template-bosun-pr-progressor"],
561
598
  replaces: {
562
599
  module: "task-executor.mjs",
563
600
  functions: ["_handleTaskResult finalization gate"],
@@ -659,6 +696,20 @@ export const TASK_REPAIR_WORKTREE_TEMPLATE = {
659
696
  },
660
697
  }, { x: 250, y: 1020 }),
661
698
 
699
+ node("handoff-pr-progressor", "action.execute_workflow", "Dispatch PR Progressor", {
700
+ workflowId: "template-bosun-pr-progressor",
701
+ mode: "dispatch",
702
+ input: {
703
+ taskId: "{{taskId}}",
704
+ taskTitle: "{{taskTitle}}",
705
+ branch: "{{branch}}",
706
+ baseBranch: "{{baseBranch}}",
707
+ prNumber: "{{$data?.prNumber ?? $ctx.getNodeOutput('create-pr')?.prNumber ?? null}}",
708
+ prUrl: "{{$data?.prUrl || $ctx.getNodeOutput('create-pr')?.prUrl || ''}}",
709
+ repo: "{{$data?.repo || $data?.repoSlug || $data?.repository || $ctx.getNodeOutput('create-pr')?.repoSlug || ''}}",
710
+ },
711
+ }, { x: 250, y: 1160 }),
712
+
662
713
  node("mark-todo", "action.update_task_status", "Mark Todo (Repair Failed)", {
663
714
  taskId: "{{taskId}}",
664
715
  status: "todo",
@@ -676,7 +727,7 @@ export const TASK_REPAIR_WORKTREE_TEMPLATE = {
676
727
  node("notify-success", "notify.telegram", "Notify Repair Success", {
677
728
  message: ":check: Repair workflow recovered **{{taskTitle}}** ({{taskId}}) and moved it to inreview.",
678
729
  silent: true,
679
- }, { x: 250, y: 1160 }),
730
+ }, { x: 250, y: 1300 }),
680
731
 
681
732
  node("notify-escalate", "notify.telegram", "Escalate Repair Failure", {
682
733
  message: ":alert: Repair workflow could not recover **{{taskTitle}}** ({{taskId}}). Manual intervention required.",
@@ -700,7 +751,8 @@ export const TASK_REPAIR_WORKTREE_TEMPLATE = {
700
751
  edge("create-pr", "create-pr-success"),
701
752
  edge("create-pr-success", "mark-inreview", { condition: "$output?.result === true", port: "yes" }),
702
753
  edge("create-pr-success", "mark-todo", { condition: "$output?.result !== true", port: "no" }),
703
- edge("mark-inreview", "notify-success"),
754
+ edge("mark-inreview", "handoff-pr-progressor"),
755
+ edge("handoff-pr-progressor", "notify-success"),
704
756
  edge("mark-todo", "notify-escalate"),
705
757
  edge("no-worktree", "notify-escalate"),
706
758
  ],
@@ -710,6 +762,7 @@ export const TASK_REPAIR_WORKTREE_TEMPLATE = {
710
762
  createdAt: "2026-02-26T00:00:00Z",
711
763
  templateVersion: "1.0.1",
712
764
  tags: ["repair", "recovery", "worktree", "resilience", "automation"],
765
+ requiredTemplates: ["template-bosun-pr-progressor"],
713
766
  replaces: {
714
767
  module: "task-executor.mjs",
715
768
  functions: ["retry/escalation recovery path"],
@@ -51,7 +51,7 @@ export const TASK_BATCH_PROCESSOR_TEMPLATE = {
51
51
  // ── Trigger: Tasks available for processing ──────────────────────────
52
52
  node("trigger", "trigger.task_available", "Tasks Available?", {
53
53
  maxParallel: "{{maxConcurrent}}",
54
- pollIntervalMs: 60000,
54
+ pollIntervalMs: 15000,
55
55
  status: "{{pollStatus}}",
56
56
  }, { x: 400, y: 50 }),
57
57
 
@@ -91,7 +91,7 @@ export const TASK_BATCH_PROCESSOR_TEMPLATE = {
91
91
 
92
92
  // ── Fan-out: dispatch each task to the lifecycle workflow ─────────────
93
93
  node("dispatch-tasks", "loop.for_each", "Dispatch Tasks", {
94
- items: "{{queryResult}}",
94
+ items: "{{query-tasks.output}}",
95
95
  itemVariable: "currentTask",
96
96
  indexVariable: "taskIndex",
97
97
  maxConcurrent: "{{maxConcurrent}}",
@@ -113,7 +113,7 @@ export const TASK_BATCH_PROCESSOR_TEMPLATE = {
113
113
  // ── Notify on completion ─────────────────────────────────────────────
114
114
  node("notify-complete", "notify.telegram", "Batch Summary", {
115
115
  channel: "{{notifyChannel}}",
116
- message: "Task batch completed: {{batchResult.successCount}}/{{batchResult.totalItems}} succeeded",
116
+ message: "Task batch completed: {{dispatch-tasks.successCount}}/{{dispatch-tasks.totalItems}} succeeded ({{dispatch-tasks.failCount}} failed)",
117
117
  }, { x: 400, y: 700 }),
118
118
  ],
119
119
  edges: [
@@ -162,7 +162,7 @@ export const TASK_BATCH_PR_TEMPLATE = {
162
162
  // ── Trigger ──────────────────────────────────────────────────────────
163
163
  node("trigger", "trigger.task_available", "Tasks Available?", {
164
164
  maxParallel: "{{maxConcurrent}}",
165
- pollIntervalMs: 60000,
165
+ pollIntervalMs: 15000,
166
166
  status: "{{pollStatus}}",
167
167
  }, { x: 400, y: 50 }),
168
168
 
@@ -195,7 +195,7 @@ export const TASK_BATCH_PR_TEMPLATE = {
195
195
 
196
196
  // ── Fan-out: per-task agent + PR ─────────────────────────────────────
197
197
  node("for-each-task", "loop.for_each", "Process Each Task", {
198
- items: "{{queryResult}}",
198
+ items: "{{query-tasks.output}}",
199
199
  itemVariable: "task",
200
200
  indexVariable: "idx",
201
201
  maxConcurrent: "{{maxConcurrent}}",
@@ -241,11 +241,25 @@ export const TASK_BATCH_PR_TEMPLATE = {
241
241
  status: "inreview",
242
242
  }, { x: 400, y: 1090 }),
243
243
 
244
+ node("handoff-pr-progressor", "action.execute_workflow", "Dispatch PR Progressor", {
245
+ workflowId: "template-bosun-pr-progressor",
246
+ mode: "dispatch",
247
+ input: {
248
+ taskId: "{{task.taskId}}",
249
+ taskTitle: "{{task.taskTitle}}",
250
+ branch: "{{task.branch}}",
251
+ baseBranch: "{{defaultBaseBranch}}",
252
+ prNumber: "{{$ctx.getNodeOutput('create-pr')?.prNumber ?? null}}",
253
+ prUrl: "{{$ctx.getNodeOutput('create-pr')?.prUrl || ''}}",
254
+ repo: "{{$ctx.getNodeOutput('create-pr')?.repoSlug || $data?.repo || $data?.repoSlug || $data?.repository || ''}}",
255
+ },
256
+ }, { x: 400, y: 1160 }),
257
+
244
258
  node("join-batch-outcomes", "flow.join", "Join Batch Outcomes", {
245
259
  mode: "all",
246
- sourceNodeIds: ["detect-commits", "set-inreview"],
260
+ sourceNodeIds: ["detect-commits", "handoff-pr-progressor"],
247
261
  includeSkipped: true,
248
- }, { x: 400, y: 1160 }),
262
+ }, { x: 400, y: 1230 }),
249
263
 
250
264
  // ── Batch complete notification ──────────────────────────────────────
251
265
  node("notify", "notify.telegram", "Batch Complete", {
@@ -263,7 +277,8 @@ export const TASK_BATCH_PR_TEMPLATE = {
263
277
  edge("push-branch", "create-pr"),
264
278
  edge("create-pr", "set-inreview"),
265
279
  edge("detect-commits", "join-batch-outcomes", { condition: "result.hasNewCommits !== true" }),
266
- edge("set-inreview", "join-batch-outcomes"),
280
+ edge("set-inreview", "handoff-pr-progressor"),
281
+ edge("handoff-pr-progressor", "join-batch-outcomes"),
267
282
  edge("join-batch-outcomes", "notify"),
268
283
  ],
269
284
  metadata: {
@@ -272,5 +287,6 @@ export const TASK_BATCH_PR_TEMPLATE = {
272
287
  createdAt: "2026-03-15T00:00:00Z",
273
288
  templateVersion: "1.0.0",
274
289
  tags: ["task", "batch", "pr", "agent", "autonomous"],
290
+ requiredTemplates: ["template-bosun-pr-progressor"],
275
291
  },
276
292
  };
@@ -65,6 +65,10 @@ export const TASK_LIFECYCLE_TEMPLATE = {
65
65
  defaultSdk: "auto",
66
66
  defaultTargetBranch: "origin/main",
67
67
  taskTimeoutMs: 21600000, // 6 hours
68
+ prePrValidationEnabled: true,
69
+ prePrValidationCommand: "npm run prepush:check",
70
+ autoMergeOnCreate: false,
71
+ autoMergeMethod: "squash",
68
72
  maxRetries: 2,
69
73
  maxContinues: 3,
70
74
  protectedBranches: ["main", "master", "develop", "production"],
@@ -228,12 +232,43 @@ export const TASK_LIFECYCLE_TEMPLATE = {
228
232
  expression: "$ctx.getNodeOutput('detect-commits')?.hasCommits === true",
229
233
  }, { x: 120, y: 1870, outputs: ["yes", "no"] }),
230
234
 
235
+ // ── SUCCESS PATH: Local quality gate before push/PR ──────────────────
236
+ node("pre-pr-validation", "action.run_command", "Pre-PR Validation", {
237
+ command: "{{prePrValidationCommand}}",
238
+ cwd: "{{worktreePath}}",
239
+ failOnError: false,
240
+ }, { x: -120, y: 1940 }),
241
+
242
+ node("pre-pr-validation-ok", "condition.expression", "Validation Passed?", {
243
+ expression:
244
+ "(() => {" +
245
+ "const enabled = $data?.prePrValidationEnabled !== false;" +
246
+ "if (!enabled) return true;" +
247
+ "const out = $ctx.getNodeOutput('pre-pr-validation');" +
248
+ "if (!out) return false;" +
249
+ "if (out.success === true) return true;" +
250
+ "const code = Number(out.exitCode);" +
251
+ "return Number.isFinite(code) && code === 0;" +
252
+ "})()",
253
+ }, { x: -120, y: 2060, outputs: ["yes", "no"] }),
254
+
255
+ node("log-validation-failed", "notify.log", "Log Validation Failed", {
256
+ message: "Task \"{{taskTitle}}\" ({{taskId}}) — pre-PR validation failed, returning to todo",
257
+ level: "warn",
258
+ }, { x: 300, y: 2000 }),
259
+
260
+ node("set-todo-validation-failed", "action.update_task_status", "Set Todo (Validation Fail)", {
261
+ taskId: "{{taskId}}",
262
+ status: "todo",
263
+ taskTitle: "{{taskTitle}}",
264
+ }, { x: 300, y: 2130 }),
231
265
  // ── SUCCESS PATH: Push branch (with rebase + empty-diff guard) ───────
232
266
  node("push-branch", "action.push_branch", "Push Branch", {
233
267
  worktreePath: "{{worktreePath}}",
234
268
  branch: "{{branch}}",
235
269
  baseBranch: "{{baseBranch}}",
236
270
  rebaseBeforePush: true,
271
+ skipHooks: true,
237
272
  emptyDiffGuard: true,
238
273
  protectedBranches: "{{protectedBranches}}",
239
274
  }, { x: 0, y: 2000 }),
@@ -250,6 +285,8 @@ export const TASK_LIFECYCLE_TEMPLATE = {
250
285
  base: "{{baseBranch}}",
251
286
  branch: "{{branch}}",
252
287
  cwd: "{{worktreePath}}",
288
+ enableAutoMerge: "{{autoMergeOnCreate}}",
289
+ autoMergeMethod: "{{autoMergeMethod}}",
253
290
  }, { x: 0, y: 2260 }),
254
291
 
255
292
  node("pr-created", "condition.expression", "PR Linked?", {
@@ -263,11 +300,25 @@ export const TASK_LIFECYCLE_TEMPLATE = {
263
300
  taskTitle: "{{taskTitle}}",
264
301
  }, { x: 0, y: 2390 }),
265
302
 
303
+ node("handoff-pr-progressor", "action.execute_workflow", "Handoff PR Progressor", {
304
+ workflowId: "template-bosun-pr-progressor",
305
+ mode: "dispatch",
306
+ input: {
307
+ taskId: "{{taskId}}",
308
+ taskTitle: "{{taskTitle}}",
309
+ branch: "{{branch}}",
310
+ baseBranch: "{{baseBranch}}",
311
+ prNumber: "{{$ctx.getNodeOutput('create-pr')?.prNumber ?? $data?.prNumber ?? null}}",
312
+ prUrl: "{{$ctx.getNodeOutput('create-pr')?.prUrl || $data?.prUrl || ''}}",
313
+ repo: "{{$ctx.getNodeOutput('create-pr')?.repoSlug || $data?.repo || $data?.repoSlug || $data?.repository || ''}}",
314
+ },
315
+ }, { x: -120, y: 2520 }),
316
+
266
317
  // ── SUCCESS PATH: Log success ────────────────────────────────────────
267
318
  node("log-success", "notify.log", "Log Success", {
268
319
  message: "Task \"{{taskTitle}}\" ({{taskId}}) completed — PR created",
269
320
  level: "info",
270
- }, { x: 0, y: 2520 }),
321
+ }, { x: -120, y: 2650 }),
271
322
 
272
323
  // ── NO COMMITS PATH: Log no-commit ───────────────────────────────────
273
324
  node("log-no-commits", "notify.log", "Log No Commits", {
@@ -297,6 +348,8 @@ export const TASK_LIFECYCLE_TEMPLATE = {
297
348
  branch: "{{branch}}",
298
349
  cwd: "{{worktreePath}}",
299
350
  failOnError: false,
351
+ enableAutoMerge: "{{autoMergeOnCreate}}",
352
+ autoMergeMethod: "{{autoMergeMethod}}",
300
353
  }, { x: 400, y: 1740 }),
301
354
 
302
355
  node("pr-created-stolen", "condition.expression", "PR Linked After Claim Loss?", {
@@ -309,10 +362,24 @@ export const TASK_LIFECYCLE_TEMPLATE = {
309
362
  taskTitle: "{{taskTitle}}",
310
363
  }, { x: 250, y: 2000 }),
311
364
 
365
+ node("handoff-pr-progressor-stolen", "action.execute_workflow", "Handoff PR Progressor (Recovered)", {
366
+ workflowId: "template-bosun-pr-progressor",
367
+ mode: "dispatch",
368
+ input: {
369
+ taskId: "{{taskId}}",
370
+ taskTitle: "{{taskTitle}}",
371
+ branch: "{{branch}}",
372
+ baseBranch: "{{baseBranch}}",
373
+ prNumber: "{{$ctx.getNodeOutput('create-pr-retry')?.prNumber ?? $data?.prNumber ?? null}}",
374
+ prUrl: "{{$ctx.getNodeOutput('create-pr-retry')?.prUrl || $data?.prUrl || ''}}",
375
+ repo: "{{$ctx.getNodeOutput('create-pr-retry')?.repoSlug || $data?.repo || $data?.repoSlug || $data?.repository || ''}}",
376
+ },
377
+ }, { x: 120, y: 2130 }),
378
+
312
379
  node("log-claim-stolen-recovered", "notify.log", "Log Claim Loss Recovery", {
313
380
  message: "Task \"{{taskTitle}}\" ({{taskId}}) — claim lost after PR link recovery, keeping inreview",
314
381
  level: "warn",
315
- }, { x: 250, y: 2130 }),
382
+ }, { x: 120, y: 2260 }),
316
383
 
317
384
  node("log-claim-stolen", "notify.log", "Log Claim Stolen", {
318
385
  message: "Task \"{{taskTitle}}\" ({{taskId}}) — claim was stolen, aborting",
@@ -328,7 +395,7 @@ export const TASK_LIFECYCLE_TEMPLATE = {
328
395
 
329
396
  node("join-outcomes", "flow.join", "Join Outcome Paths", {
330
397
  mode: "all",
331
- sourceNodeIds: ["log-success", "set-todo-push-failed", "set-todo-cooldown", "set-todo-stolen", "log-claim-stolen-recovered"],
398
+ sourceNodeIds: ["log-success", "set-todo-push-failed", "set-todo-cooldown", "set-todo-validation-failed", "set-todo-stolen", "log-claim-stolen-recovered"],
332
399
  includeSkipped: true,
333
400
  }, { x: 200, y: 2560 }),
334
401
 
@@ -402,13 +469,19 @@ export const TASK_LIFECYCLE_TEMPLATE = {
402
469
  edge("detect-commits", "has-commits"),
403
470
 
404
471
  // Success path (has commits)
405
- edge("has-commits", "push-branch", { condition: "$output?.result === true", port: "yes" }),
472
+ edge("has-commits", "pre-pr-validation", { condition: "$output?.result === true", port: "yes" }),
473
+ edge("pre-pr-validation", "pre-pr-validation-ok"),
474
+ edge("pre-pr-validation-ok", "push-branch", { condition: "$output?.result === true", port: "yes" }),
475
+ edge("pre-pr-validation-ok", "log-validation-failed", { condition: "$output?.result !== true", port: "no" }),
476
+ edge("log-validation-failed", "set-todo-validation-failed"),
477
+ edge("set-todo-validation-failed", "join-outcomes"),
406
478
  edge("push-branch", "push-ok"),
407
479
  edge("push-ok", "create-pr", { condition: "$output?.result === true", port: "yes" }),
408
480
  edge("create-pr", "pr-created"),
409
481
  edge("pr-created", "set-inreview", { condition: "$output?.result === true", port: "yes" }),
410
482
  edge("pr-created", "set-todo-push-failed", { condition: "$output?.result !== true", port: "no" }),
411
- edge("set-inreview", "log-success"),
483
+ edge("set-inreview", "handoff-pr-progressor"),
484
+ edge("handoff-pr-progressor", "log-success"),
412
485
  edge("log-success", "join-outcomes"),
413
486
 
414
487
  // Push failed path
@@ -424,7 +497,8 @@ export const TASK_LIFECYCLE_TEMPLATE = {
424
497
  edge("claim-stolen", "create-pr-retry", { condition: "$output?.result === true", port: "yes" }),
425
498
  edge("create-pr-retry", "pr-created-stolen"),
426
499
  edge("pr-created-stolen", "set-inreview-stolen", { condition: "$output?.result === true", port: "yes" }),
427
- edge("set-inreview-stolen", "log-claim-stolen-recovered"),
500
+ edge("set-inreview-stolen", "handoff-pr-progressor-stolen"),
501
+ edge("handoff-pr-progressor-stolen", "log-claim-stolen-recovered"),
428
502
  edge("log-claim-stolen-recovered", "join-outcomes"),
429
503
  edge("pr-created-stolen", "log-claim-stolen", { condition: "$output?.result !== true", port: "no" }),
430
504
  edge("log-claim-stolen", "set-todo-stolen"),
@@ -451,6 +525,7 @@ export const TASK_LIFECYCLE_TEMPLATE = {
451
525
  createdAt: "2026-03-01T00:00:00Z",
452
526
  templateVersion: "2.0.0",
453
527
  tags: ["task", "lifecycle", "executor", "workflow-first", "core"],
528
+ requiredTemplates: ["template-bosun-pr-progressor"],
454
529
  replaces: {
455
530
  module: "task-executor.mjs",
456
531
  functions: [
@@ -725,3 +800,5 @@ export const VE_ORCHESTRATOR_LITE_TEMPLATE = {
725
800
  },
726
801
  },
727
802
  };
803
+
804
+
@@ -856,16 +856,57 @@ function extractCommandText(item) {
856
856
  return "";
857
857
  }
858
858
 
859
- function normalizeGitDescriptor(item) {
860
- const toolName = extractToolName(item);
861
- const commandText = extractCommandText(item);
862
- return `${toolName} ${commandText}`
859
+ function normalizeGitTokenSource(value) {
860
+ return String(value || "")
863
861
  .toLowerCase()
864
- .replaceAll(/_+/g, " ")
862
+ .replaceAll(/[^a-z0-9]+/g, " ")
865
863
  .replaceAll(/\s+/g, " ")
866
864
  .trim();
867
865
  }
868
866
 
867
+ function inferGitCommandSignals(toolName, commandText) {
868
+ const normalizedToolName = normalizeGitTokenSource(toolName);
869
+ const normalizedCommand = normalizeGitTokenSource(commandText);
870
+ const merged = `${normalizedCommand} ${normalizedToolName}`.trim();
871
+
872
+ const commandLooksGit = /\bgit\b/.test(normalizedCommand);
873
+ const toolLooksGit = normalizedToolName.includes("git");
874
+ if (!toolLooksGit && !commandLooksGit) {
875
+ return {
876
+ eligible: false,
877
+ boundedDiff: false,
878
+ hasLog: false,
879
+ hasShortlog: false,
880
+ hasReflog: false,
881
+ hasDiff: false,
882
+ hasShow: false,
883
+ hasStatus: false,
884
+ };
885
+ }
886
+
887
+ let commandBody = normalizedCommand.startsWith("git ")
888
+ ? normalizedCommand.slice(4).trim()
889
+ : normalizedCommand;
890
+
891
+ if (!commandBody && toolLooksGit) {
892
+ commandBody = normalizedToolName
893
+ .replaceAll(/\bgit\b/g, " ")
894
+ .replaceAll(/\s+/g, " ")
895
+ .trim();
896
+ }
897
+
898
+ const source = `${commandBody} ${normalizedToolName}`.trim();
899
+ const hasShortlog = /\bshortlog\b/.test(source);
900
+ const hasReflog = /\breflog\b/.test(source);
901
+ const hasLog = !hasShortlog && !hasReflog && /\blog\b/.test(source);
902
+ const hasDiff = /\bdiff\b/.test(source);
903
+ const hasShow = /\bshow\b/.test(source);
904
+ const hasStatus = /\bstatus\b/.test(source);
905
+ const boundedDiff = /(?:^|\s)--(?:stat|shortstat|numstat|name-only|name-status|summary)\b/.test(commandBody)
906
+ || /\bdiff(?:\s|-)*(?:stat|shortstat|numstat|name only|name status|summary)\b/.test(merged);
907
+ return { eligible: true, boundedDiff, hasLog, hasShortlog, hasReflog, hasDiff, hasShow, hasStatus };
908
+ }
909
+
869
910
  function classifyImmediateGitOutput(item, opts) {
870
911
  const maxChars = opts?.gitOutputMaxChars ?? DEFAULT_GIT_OUTPUT_MAX_CHARS;
871
912
  if (!Number.isFinite(maxChars) || maxChars <= 0) return null;
@@ -873,17 +914,25 @@ function classifyImmediateGitOutput(item, opts) {
873
914
  const text = getItemText(item);
874
915
  if (typeof text !== "string" || text.length <= maxChars) return null;
875
916
 
876
- const descriptor = normalizeGitDescriptor(item);
877
- if (!descriptor.includes("git")) return null;
878
-
879
- if (/\bgit\s+log\b/.test(descriptor)) return { kind: "log", text };
880
- if (/\bgit\s+shortlog\b/.test(descriptor)) return { kind: "shortlog", text };
881
- if (/\bgit\s+reflog\b/.test(descriptor)) return { kind: "reflog", text };
882
-
883
- if (/\bgit\s+diff\b/.test(descriptor)) {
884
- const boundedDiff = /(?:^|\s)--(?:stat|shortstat|numstat|name-only|name-status|summary)\b/.test(descriptor);
885
- if (!boundedDiff) return { kind: "diff", text };
886
- }
917
+ const toolName = extractToolName(item);
918
+ const commandText = extractCommandText(item);
919
+ const {
920
+ eligible,
921
+ boundedDiff,
922
+ hasLog,
923
+ hasShortlog,
924
+ hasReflog,
925
+ hasDiff,
926
+ hasShow,
927
+ hasStatus,
928
+ } = inferGitCommandSignals(toolName, commandText);
929
+ if (!eligible) return null;
930
+
931
+ if (hasStatus || hasShow) return null;
932
+ if (hasShortlog) return { kind: "shortlog", text };
933
+ if (hasReflog) return { kind: "reflog", text };
934
+ if (hasLog) return { kind: "log", text };
935
+ if (hasDiff && !boundedDiff) return { kind: "diff", text };
887
936
 
888
937
  return null;
889
938
  }
@@ -899,7 +948,7 @@ function compressImmediateGitText(text, logId, opts) {
899
948
  const tail = tailChars > 0 ? text.slice(-tailChars) : "";
900
949
  const omitted = text.length - headChars - (tailChars > 0 ? tailChars : 0);
901
950
  const lineCount = text.length === 0 ? 0 : (text.match(/\n/g) || []).length + 1;
902
- const note = `\n\n[…git capped: ${lineCount} lines, ${omitted} chars hidden. Full: bosun --tool-log ${logId}]\n\n`;
951
+ const note = `\n\n[…git capped: ${lineCount} lines, ${omitted} chars suppressed. Full: bosun --tool-log ${logId}]\n\n`;
903
952
  return head + note + tail;
904
953
  }
905
954
 
@@ -2413,4 +2462,3 @@ export async function maybeCompressSessionItems(
2413
2462
  return compressedItems;
2414
2463
  }
2415
2464
 
2416
-
@@ -357,12 +357,13 @@ function buildGitPullFailureDetails(err, repoPath, childProcess) {
357
357
  const stderr = normalizeSingleLine(err?.stderr || "");
358
358
  const stdout = normalizeSingleLine(err?.stdout || "");
359
359
  const message = normalizeSingleLine(err?.message || err || "");
360
+ const preferred = normalizeSingleLine(err?.stderr || err?.stdout || err?.message || "");
360
361
  const parts = [];
361
362
  if (Number.isFinite(err?.status)) parts.push(`status=${err.status}`);
362
363
  if (err?.signal) parts.push(`signal=${err.signal}`);
363
364
  if (err?.code) parts.push(`code=${err.code}`);
364
365
 
365
- let details = stderr || stdout || message || "git pull --rebase failed";
366
+ let details = preferred || stderr || stdout || message || "git pull --rebase failed";
366
367
  if (!details || isGenericGitPullFailure(details)) {
367
368
  const fallback = message && !isGenericGitPullFailure(message)
368
369
  ? message