bosun 0.40.21 → 0.41.1

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 (80) hide show
  1. package/.env.example +8 -0
  2. package/README.md +20 -0
  3. package/agent/agent-custom-tools.mjs +23 -5
  4. package/agent/agent-event-bus.mjs +248 -6
  5. package/agent/agent-pool.mjs +131 -30
  6. package/agent/agent-work-analyzer.mjs +8 -16
  7. package/agent/primary-agent.mjs +81 -7
  8. package/agent/retry-queue.mjs +164 -0
  9. package/bench/swebench/bosun-swebench.mjs +5 -0
  10. package/bosun.config.example.json +25 -0
  11. package/bosun.schema.json +825 -183
  12. package/cli.mjs +267 -8
  13. package/config/config-doctor.mjs +51 -2
  14. package/config/config.mjs +232 -5
  15. package/github/github-auth-manager.mjs +70 -19
  16. package/infra/library-manager.mjs +894 -60
  17. package/infra/monitor.mjs +701 -69
  18. package/infra/runtime-accumulator.mjs +376 -84
  19. package/infra/session-tracker.mjs +95 -28
  20. package/infra/test-runtime.mjs +267 -0
  21. package/lib/codebase-audit.mjs +133 -18
  22. package/package.json +30 -8
  23. package/server/setup-web-server.mjs +29 -1
  24. package/server/ui-server.mjs +1571 -49
  25. package/setup.mjs +27 -24
  26. package/shell/codex-shell.mjs +34 -3
  27. package/shell/copilot-shell.mjs +50 -8
  28. package/task/msg-hub.mjs +193 -0
  29. package/task/pipeline.mjs +544 -0
  30. package/task/task-claims.mjs +6 -10
  31. package/task/task-cli.mjs +38 -2
  32. package/task/task-executor-pipeline.mjs +143 -0
  33. package/task/task-executor.mjs +36 -27
  34. package/telegram/get-telegram-chat-id.mjs +57 -47
  35. package/ui/components/chat-view.js +18 -1
  36. package/ui/components/workspace-switcher.js +321 -9
  37. package/ui/demo-defaults.js +17830 -10433
  38. package/ui/demo.html +9 -1
  39. package/ui/modules/router.js +1 -1
  40. package/ui/modules/settings-schema.js +2 -0
  41. package/ui/modules/state.js +54 -57
  42. package/ui/modules/voice-client-sdk.js +376 -37
  43. package/ui/modules/voice-client.js +173 -33
  44. package/ui/setup.html +68 -2
  45. package/ui/styles/components.css +571 -1
  46. package/ui/styles.css +201 -1
  47. package/ui/tabs/dashboard.js +74 -0
  48. package/ui/tabs/library.js +410 -55
  49. package/ui/tabs/logs.js +10 -0
  50. package/ui/tabs/settings.js +178 -99
  51. package/ui/tabs/tasks.js +1083 -507
  52. package/ui/tabs/telemetry.js +34 -0
  53. package/ui/tabs/workflow-canvas-utils.mjs +38 -1
  54. package/ui/tabs/workflows.js +1275 -402
  55. package/voice/voice-agents-sdk.mjs +2 -2
  56. package/voice/voice-relay.mjs +28 -20
  57. package/workflow/declarative-workflows.mjs +145 -0
  58. package/workflow/msg-hub.mjs +237 -0
  59. package/workflow/pipeline-workflows.mjs +287 -0
  60. package/workflow/pipeline.mjs +828 -315
  61. package/workflow/project-detection.mjs +559 -0
  62. package/workflow/workflow-cli.mjs +128 -0
  63. package/workflow/workflow-contract.mjs +433 -232
  64. package/workflow/workflow-engine.mjs +510 -47
  65. package/workflow/workflow-nodes/custom-loader.mjs +251 -0
  66. package/workflow/workflow-nodes.mjs +2024 -184
  67. package/workflow/workflow-templates.mjs +118 -24
  68. package/workflow-templates/agents.mjs +20 -20
  69. package/workflow-templates/bosun-native.mjs +212 -2
  70. package/workflow-templates/code-quality.mjs +20 -14
  71. package/workflow-templates/continuation-loop.mjs +339 -0
  72. package/workflow-templates/github.mjs +516 -40
  73. package/workflow-templates/planning.mjs +446 -17
  74. package/workflow-templates/reliability.mjs +65 -12
  75. package/workflow-templates/task-batch.mjs +27 -10
  76. package/workflow-templates/task-execution.mjs +752 -0
  77. package/workflow-templates/task-lifecycle.mjs +117 -14
  78. package/workspace/context-cache.mjs +66 -18
  79. package/workspace/workspace-manager.mjs +153 -1
  80. package/workflow-templates/issue-continuation.mjs +0 -243
@@ -31,6 +31,7 @@ export const TASK_PLANNER_TEMPLATE = {
31
31
  minTodoCount: 3,
32
32
  taskCount: 5,
33
33
  dedupHours: 24,
34
+ failureCooldownMinutes: 30,
34
35
  prompt: "",
35
36
  plannerContext:
36
37
  "Focus on high-value implementation work. Avoid duplicating existing tasks.",
@@ -42,7 +43,15 @@ export const TASK_PLANNER_TEMPLATE = {
42
43
  }, { x: 400, y: 50 }),
43
44
 
44
45
  node("check-dedup", "condition.expression", "Dedup Window", {
45
- expression: "(Date.now() - ($data?._lastPlannerRun || 0)) > (($data?.dedupHours || 24) * 3600000)",
46
+ expression:
47
+ "(() => {" +
48
+ " const now = Date.now();" +
49
+ " const lastSuccessAt = Number($data?._lastPlannerRun || 0);" +
50
+ " const lastFailureAt = Number($data?._lastPlannerFailureAt || 0);" +
51
+ " const successWindowMs = Number($data?.dedupHours || 24) * 3600000;" +
52
+ " const failureWindowMs = Number($data?.failureCooldownMinutes || 30) * 60000;" +
53
+ " return (now - lastSuccessAt) > successWindowMs && (now - lastFailureAt) > failureWindowMs;" +
54
+ "})()",
46
55
  }, { x: 400, y: 180 }),
47
56
 
48
57
  node("log-start", "notify.log", "Log Planner Start", {
@@ -94,6 +103,14 @@ export const TASK_PLANNER_TEMPLATE = {
94
103
  message: "Task planner failed to materialize tasks from planner output",
95
104
  level: "warn",
96
105
  }, { x: 600, y: 830 }),
106
+
107
+ // Cooldown on failure: stamp _lastPlannerRun so the dedup window
108
+ // prevents immediate retry without blocking normal planning for a full day.
109
+ node("set-timestamp-fail", "action.set_variable", "Cooldown After Failure", {
110
+ key: "_lastPlannerFailureAt",
111
+ value: "Date.now()",
112
+ isExpression: true,
113
+ }, { x: 600, y: 960 }),
97
114
  ],
98
115
  edges: [
99
116
  edge("trigger", "check-dedup"),
@@ -105,6 +122,7 @@ export const TASK_PLANNER_TEMPLATE = {
105
122
  edge("check-result", "set-timestamp", { condition: "$output?.result === true" }),
106
123
  edge("check-result", "notify-fail", { condition: "$output?.result !== true" }),
107
124
  edge("set-timestamp", "notify-done"),
125
+ edge("notify-fail", "set-timestamp-fail"),
108
126
  ],
109
127
  metadata: {
110
128
  author: "bosun",
@@ -477,12 +495,14 @@ export const WEEKLY_FITNESS_SUMMARY_TEMPLATE = {
477
495
  }, { x: 420, y: 40 }),
478
496
 
479
497
  node("task-metrics", "action.bosun_cli", "Collect Task Metrics", {
480
- command: "task list --format json --since {{lookbackDays}}d",
498
+ subcommand: "task list",
499
+ args: "--json",
500
+ parseJson: true,
481
501
  continueOnError: true,
482
502
  }, { x: 150, y: 180 }),
483
503
 
484
504
  node("pr-metrics", "action.run_command", "Collect PR Metrics", {
485
- command: "gh pr list --state all --json number,state,mergedAt,closedAt,title --limit 200",
505
+ command: "gh pr list --state all --json number,state,mergedAt,closedAt,createdAt,updatedAt,title,body --limit 200",
486
506
  continueOnError: true,
487
507
  }, { x: 420, y: 180 }),
488
508
 
@@ -491,21 +511,426 @@ export const WEEKLY_FITNESS_SUMMARY_TEMPLATE = {
491
511
  continueOnError: true,
492
512
  }, { x: 690, y: 180 }),
493
513
 
514
+ node("read-previous-summary", "action.read_file", "Read Prior Summary", {
515
+ path: ".bosun/workflow-runs/weekly-fitness-summary.latest.json",
516
+ }, { x: 960, y: 180 }),
517
+
518
+ node("summarize-fitness-metrics", "action.set_variable", "Summarize Fitness Metrics", {
519
+ key: "fitnessSummary",
520
+ value:
521
+ "(() => {" +
522
+ " try {" +
523
+ " const now = Date.now();" +
524
+ " const lookbackDays = Math.max(1, Number($data?.lookbackDays || 7));" +
525
+ " const windowMs = lookbackDays * 24 * 60 * 60 * 1000;" +
526
+ " const currentStart = now - windowMs;" +
527
+ " const previousStart = currentStart - windowMs;" +
528
+ " const previousEnd = currentStart;" +
529
+ " const toNumber = (v, fallback = 0) => { const n = Number(v); return Number.isFinite(n) ? n : fallback; };" +
530
+ " const toIso = (ms) => new Date(ms).toISOString();" +
531
+ " const parseJsonSafe = (raw) => { try { return JSON.parse(String(raw)); } catch { return null; } };" +
532
+ " const extractCanonicalItems = (value) => {" +
533
+ " if (!value || typeof value !== 'object') return null;" +
534
+ " const keys = ['items', 'tasks', 'entries', 'records', 'results', 'data'];" +
535
+ " for (const key of keys) {" +
536
+ " if (Array.isArray(value[key])) return value[key].filter(Boolean);" +
537
+ " }" +
538
+ " return null;" +
539
+ " };" +
540
+ " const parseSource = (raw, depth = 0) => {" +
541
+ " if (depth > 3) return { items: [], degraded: true, parsedAny: false, partial: false };" +
542
+ " if (Array.isArray(raw)) return { items: raw.filter(Boolean), degraded: false, parsedAny: raw.length > 0, partial: false };" +
543
+ " if (raw && typeof raw === 'object') {" +
544
+ " const canonical = extractCanonicalItems(raw) ?? extractCanonicalItems(raw.output) ?? extractCanonicalItems(raw.result) ?? extractCanonicalItems(raw.payload);" +
545
+ " if (canonical) return { items: canonical, degraded: false, parsedAny: canonical.length > 0, partial: false };" +
546
+ " const wrappedCandidates = [raw.output, raw.result, raw.payload, raw.data, raw.stdout, raw.content, raw.text, raw.json];" +
547
+ " for (const candidate of wrappedCandidates) {" +
548
+ " if (candidate == null) continue;" +
549
+ " const parsedCandidate = parseSource(candidate, depth + 1);" +
550
+ " if (parsedCandidate.items.length > 0 || parsedCandidate.parsedAny || parsedCandidate.degraded === false) return parsedCandidate;" +
551
+ " }" +
552
+ " return { items: [], degraded: Object.keys(raw).length > 0, parsedAny: false, partial: false };" +
553
+ " }" +
554
+ " if (typeof raw !== 'string') return { items: [], degraded: true, parsedAny: false, partial: false };" +
555
+ " const trimmed = raw.trim();" +
556
+ " if (!trimmed) return { items: [], degraded: false, parsedAny: false, partial: false };" +
557
+ " const parsed = parseJsonSafe(trimmed);" +
558
+ " if (Array.isArray(parsed)) return { items: parsed.filter(Boolean), degraded: false, parsedAny: true, partial: false };" +
559
+ " if (parsed && typeof parsed === 'object') {" +
560
+ " const canonical = extractCanonicalItems(parsed) ?? extractCanonicalItems(parsed.output) ?? extractCanonicalItems(parsed.result) ?? extractCanonicalItems(parsed.payload);" +
561
+ " if (canonical) return { items: canonical, degraded: false, parsedAny: true, partial: false };" +
562
+ " }" +
563
+ " const lines = trimmed.split(/\\r?\\n/).filter((line) => line.trim() !== '');" +
564
+ " const parsedLines = [];" +
565
+ " let failedLines = 0;" +
566
+ " for (const line of lines) {" +
567
+ " const parsedLine = parseJsonSafe(line);" +
568
+ " if (parsedLine == null) { failedLines += 1; continue; }" +
569
+ " if (Array.isArray(parsedLine)) parsedLines.push(...parsedLine.filter(Boolean));" +
570
+ " else parsedLines.push(parsedLine);" +
571
+ " }" +
572
+ " if (parsedLines.length > 0) return { items: parsedLines, degraded: failedLines > 0, parsedAny: true, partial: failedLines > 0 };" +
573
+ " return { items: [], degraded: true, parsedAny: false, partial: false };" +
574
+ " };" +
575
+ " const getTs = (item) => {" +
576
+ " if (!item || typeof item !== 'object') return null;" +
577
+ " const fields = ['completedAt', 'closedAt', 'mergedAt', 'resolvedAt', 'updatedAt', 'createdAt', 'timestamp', 'ts', 'date', 'completed_at', 'closed_at', 'merged_at', 'resolved_at', 'updated_at', 'created_at'];" +
578
+ " for (const key of fields) {" +
579
+ " const value = item[key];" +
580
+ " if (!value) continue;" +
581
+ " const ms = Date.parse(String(value));" +
582
+ " if (Number.isFinite(ms)) return ms;" +
583
+ " if (typeof value === 'number' && Number.isFinite(value)) return value > 1e12 ? value : value * 1000;" +
584
+ " }" +
585
+ " return null;" +
586
+ " };" +
587
+ " const normalizeBucket = (items) => {" +
588
+ " const stamped = [];" +
589
+ " const unstamped = [];" +
590
+ " for (const item of items) {" +
591
+ " const ts = getTs(item);" +
592
+ " if (ts == null) unstamped.push(item); else stamped.push({ item, ts });" +
593
+ " }" +
594
+ " return { stamped, unstamped };" +
595
+ " };" +
596
+ " const splitWindows = (items) => {" +
597
+ " const { stamped, unstamped } = normalizeBucket(items);" +
598
+ " const current = stamped.filter((entry) => entry.ts >= currentStart && entry.ts <= now).map((entry) => entry.item);" +
599
+ " const previous = stamped.filter((entry) => entry.ts >= previousStart && entry.ts < previousEnd).map((entry) => entry.item);" +
600
+ " const usedFallbackWindow = stamped.length === 0 && unstamped.length > 0;" +
601
+ " if (usedFallbackWindow) return { current: unstamped, previous: [], usedFallbackWindow };" +
602
+ " return { current, previous, usedFallbackWindow };" +
603
+ " };" +
604
+ " const metric = (name, value, previous, direction, unit, confidence, status, notes = []) => {" +
605
+ " const hasCurrent = typeof value === 'number' && Number.isFinite(value);" +
606
+ " const hasPrevious = typeof previous === 'number' && Number.isFinite(previous);" +
607
+ " return {" +
608
+ " name," +
609
+ " value: hasCurrent ? value : null," +
610
+ " previous: hasPrevious ? previous : null," +
611
+ " delta: hasCurrent && hasPrevious ? Number((value - previous).toFixed(2)) : null," +
612
+ " direction," +
613
+ " unit," +
614
+ " confidence," +
615
+ " status," +
616
+ " notes: notes.filter(Boolean)," +
617
+ " };" +
618
+ " };" +
619
+ " const sourceStatus = (nodeOut, parsedList, parsedMeta = {}) => {" +
620
+ " const output = nodeOut?.output;" +
621
+ " const hasPayload = (() => {" +
622
+ " if (output == null) return false;" +
623
+ " if (Array.isArray(output)) return true;" +
624
+ " if (typeof output === 'string') return output.trim() !== '';" +
625
+ " if (typeof output === 'object') {" +
626
+ " const wrapped = [output.stdout, output.content, output.text, output.json, output.output, output.result, output.payload, output.data];" +
627
+ " if (wrapped.some((v) => (typeof v === 'string' ? v.trim() !== '' : v != null))) return true;" +
628
+ " return Object.keys(output).length > 0;" +
629
+ " }" +
630
+ " return true;" +
631
+ " })();" +
632
+ " const success = nodeOut?.success !== false;" +
633
+ " if (!hasPayload) return { status: 'missing', confidence: 'low' };" +
634
+ " if (!Array.isArray(parsedList)) return { status: 'degraded', confidence: 'low' };" +
635
+ " if (parsedMeta?.degraded || parsedMeta?.partial) return { status: 'degraded', confidence: parsedList.length > 0 ? 'medium' : 'low' };" +
636
+ " if (!success) return { status: 'degraded', confidence: parsedList.length > 0 ? 'medium' : 'low' };" +
637
+ " return { status: 'ok', confidence: parsedList.length > 0 ? 'high' : 'medium' };" +
638
+ " };" +
639
+ " const taskNode = $ctx.getNodeOutput('task-metrics') || {};" +
640
+ " const prNode = $ctx.getNodeOutput('pr-metrics') || {};" +
641
+ " const debtNode = $ctx.getNodeOutput('debt-metrics') || {};" +
642
+ " const prevNode = $ctx.getNodeOutput('read-previous-summary') || {};" +
643
+ " const taskParsed = parseSource(taskNode.output);" +
644
+ " const prParsed = parseSource(prNode.output);" +
645
+ " const debtParsed = parseSource(debtNode.output);" +
646
+ " const tasks = taskParsed.items;" +
647
+ " const prs = prParsed.items;" +
648
+ " const debt = debtParsed.items;" +
649
+ " const taskHealth = sourceStatus(taskNode, tasks, taskParsed);" +
650
+ " const prHealth = sourceStatus(prNode, prs, prParsed);" +
651
+ " const debtHealth = sourceStatus(debtNode, debt, debtParsed);" +
652
+ " const taskSplit = splitWindows(tasks);" +
653
+ " const prSplit = splitWindows(prs);" +
654
+ " const debtSplit = splitWindows(debt);" +
655
+ " const doneStatuses = new Set(['done', 'closed', 'completed', 'merged', 'resolved']);" +
656
+ " const isDone = (item) => doneStatuses.has(String(item?.status ?? item?.state ?? '').toLowerCase());" +
657
+ " const taskTelemetryUnavailable = taskHealth.status === 'missing' || (taskHealth.status === 'degraded' && tasks.length === 0);" +
658
+ " const throughputCurrent = taskTelemetryUnavailable ? null : taskSplit.current.filter(isDone).length;" +
659
+ " const throughputPrevious = taskTelemetryUnavailable ? null : taskSplit.previous.filter(isDone).length;" +
660
+ " const reopenedCount = (items) => items.filter((item) => {" +
661
+ " if (!item || typeof item !== 'object') return false;" +
662
+ " const reopenCount = toNumber(item.reopenCount ?? item.reopenedCount ?? item.reopen_count ?? item.reopened_count, 0);" +
663
+ " if (reopenCount > 0) return true;" +
664
+ " if (item.reopened === true) return true;" +
665
+ " const status = String(item.status ?? item.state ?? '').toLowerCase();" +
666
+ " return status.includes('reopen');" +
667
+ " }).length;" +
668
+ " const reopenedCurrent = taskTelemetryUnavailable ? null : reopenedCount(taskSplit.current);" +
669
+ " const reopenedPrevious = taskTelemetryUnavailable ? null : reopenedCount(taskSplit.previous);" +
670
+ " const classifyRegression = (pr) => /revert|regression|rollback|hotfix/i.test(String(pr?.title || '') + ' ' + String(pr?.body || ''));" +
671
+ " const regressionCurrentCount = prSplit.current.filter(classifyRegression).length;" +
672
+ " const regressionPreviousCount = prSplit.previous.filter(classifyRegression).length;" +
673
+ " const regressionCurrentRate = prSplit.current.length > 0 ? Number(((regressionCurrentCount / prSplit.current.length) * 100).toFixed(2)) : null;" +
674
+ " const regressionPreviousRate = prSplit.previous.length > 0 ? Number(((regressionPreviousCount / prSplit.previous.length) * 100).toFixed(2)) : null;" +
675
+ " const mergedCount = (items) => items.filter((pr) => String(pr?.state || '').toLowerCase() === 'merged' || Boolean(pr?.mergedAt) || Boolean(pr?.merged_at) || pr?.merged === true).length;" +
676
+ " const closedCount = (items) => items.filter((pr) => {" +
677
+ " const state = String(pr?.state || '').toLowerCase();" +
678
+ " return state === 'closed' || state === 'merged' || Boolean(pr?.closedAt) || Boolean(pr?.closed_at) || Boolean(pr?.mergedAt) || Boolean(pr?.merged_at);" +
679
+ " }).length;" +
680
+ " const mergeClosedCurrent = closedCount(prSplit.current);" +
681
+ " const mergeClosedPrevious = closedCount(prSplit.previous);" +
682
+ " const mergeSuccessCurrent = mergeClosedCurrent > 0 ? Number(((mergedCount(prSplit.current) / mergeClosedCurrent) * 100).toFixed(2)) : null;" +
683
+ " const mergeSuccessPrevious = mergeClosedPrevious > 0 ? Number(((mergedCount(prSplit.previous) / mergeClosedPrevious) * 100).toFixed(2)) : null;" +
684
+ " const debtDelta = (entries) => {" +
685
+ " let total = 0;" +
686
+ " for (const entry of entries) {" +
687
+ " if (entry == null) continue;" +
688
+ " if (typeof entry === 'number') { total += entry; continue; }" +
689
+ " if (typeof entry !== 'object') continue;" +
690
+ " if (Number.isFinite(Number(entry.debtDelta))) { total += Number(entry.debtDelta); continue; }" +
691
+ " if (Number.isFinite(Number(entry.delta))) { total += Number(entry.delta); continue; }" +
692
+ " if (Number.isFinite(Number(entry.netChange))) { total += Number(entry.netChange); continue; }" +
693
+ " const amt = Number.isFinite(Number(entry.amount)) ? Number(entry.amount) : 1;" +
694
+ " const kind = String(entry.type || entry.event || entry.action || '').toLowerCase();" +
695
+ " if (/resolved|burn|paydown|decrease|closed/.test(kind)) total -= amt;" +
696
+ " else if (/created|added|increase|opened|new/.test(kind)) total += amt;" +
697
+ " }" +
698
+ " return Number(total.toFixed(2));" +
699
+ " };" +
700
+ " const debtCurrent = debtSplit.current.length > 0 ? debtDelta(debtSplit.current) : null;" +
701
+ " const debtPrevious = debtSplit.previous.length > 0 ? debtDelta(debtSplit.previous) : null;" +
702
+ " const priorRaw = prevNode?.success === true ? (parseSource(prevNode.content).items?.[0] ?? parseJsonSafe(prevNode.content)) : null;" +
703
+ " const priorParsed = priorRaw?.fitnessSummary && typeof priorRaw.fitnessSummary === 'object' ? priorRaw.fitnessSummary : (priorRaw && typeof priorRaw === 'object' ? priorRaw : null);" +
704
+ " const metricConfidence = (primaryHealth, hasValue, usedFallbackWindow) => {" +
705
+ " if (!hasValue) return 'low';" +
706
+ " if (primaryHealth.status === 'missing') return 'low';" +
707
+ " if (primaryHealth.status === 'degraded') return 'low';" +
708
+ " if (usedFallbackWindow) return 'medium';" +
709
+ " return primaryHealth.confidence || 'medium';" +
710
+ " };" +
711
+ " const throughputMetric = metric('throughput', throughputCurrent, throughputPrevious, 'up_is_good', 'tasks', metricConfidence(taskHealth, throughputCurrent != null, taskSplit.usedFallbackWindow), taskHealth.status, [throughputCurrent == null ? 'Task telemetry unavailable for this window.' : '', taskSplit.usedFallbackWindow ? 'No task timestamps detected; treated all records as current week.' : '']);" +
712
+ " const regressionMetric = metric('regression_rate', regressionCurrentRate, regressionPreviousRate, 'down_is_good', 'percent', metricConfidence(prHealth, regressionCurrentRate != null, prSplit.usedFallbackWindow), prHealth.status, [regressionCurrentRate == null ? 'Insufficient PR sample to compute regression rate.' : '', prSplit.usedFallbackWindow ? 'No PR timestamps detected; treated all records as current week.' : '']);" +
713
+ " const mergeMetric = metric('merge_success', mergeSuccessCurrent, mergeSuccessPrevious, 'up_is_good', 'percent', metricConfidence(prHealth, mergeSuccessCurrent != null, prSplit.usedFallbackWindow), prHealth.status, [mergeSuccessCurrent == null ? 'No closed or merged PRs in scope.' : '', prSplit.usedFallbackWindow ? 'No PR timestamps detected; treated all records as current week.' : '']);" +
714
+ " const reopenedMetric = metric('reopened_tasks', reopenedCurrent, reopenedPrevious, 'down_is_good', 'tasks', metricConfidence(taskHealth, reopenedCurrent != null, taskSplit.usedFallbackWindow), taskHealth.status, [reopenedCurrent == null ? 'Task telemetry unavailable for this window.' : '', taskSplit.usedFallbackWindow ? 'No task timestamps detected; treated all records as current week.' : '']);" +
715
+ " const debtMetric = metric('debt_growth', debtCurrent, debtPrevious, 'down_is_good', 'points', metricConfidence(debtHealth, debtCurrent != null, debtSplit.usedFallbackWindow), debtHealth.status, [debtCurrent == null ? 'No debt ledger events in scope.' : '', debtSplit.usedFallbackWindow ? 'No debt timestamps detected; treated all records as current week.' : '']);" +
716
+ " const metrics = { throughput: throughputMetric, regression_rate: regressionMetric, merge_success: mergeMetric, reopened_tasks: reopenedMetric, debt_growth: debtMetric };" +
717
+ " const metricKeys = ['throughput', 'regression_rate', 'merge_success', 'reopened_tasks', 'debt_growth'];" +
718
+ " const trendDeltas = metricKeys.reduce((acc, key) => { const d = metrics?.[key]?.delta; acc[key] = Number.isFinite(d) ? d : null; return acc; }, {});" +
719
+ " const normalizePriorTrendDelta = (metricName) => {" +
720
+ " const direct = priorParsed?.priorWeekTrendDeltas?.[metricName];" +
721
+ " if (Number.isFinite(Number(direct))) return Number(Number(direct).toFixed(2));" +
722
+ " const trend = priorParsed?.trendDeltas?.[metricName];" +
723
+ " if (Number.isFinite(Number(trend))) return Number(Number(trend).toFixed(2));" +
724
+ " const metricDelta = priorParsed?.metrics?.[metricName]?.delta;" +
725
+ " if (Number.isFinite(Number(metricDelta))) return Number(Number(metricDelta).toFixed(2));" +
726
+ " return null;" +
727
+ " };" +
728
+ " const priorWeekTrendDeltas = metricKeys.reduce((acc, key) => { acc[key] = normalizePriorTrendDelta(key); return acc; }, {});" +
729
+ " const priorWeekDeltas = priorWeekTrendDeltas;" +
730
+ " const priorWeekMetrics = priorParsed?.metrics && typeof priorParsed.metrics === 'object' ? priorParsed.metrics : null;" +
731
+ " const alertThresholds = { throughput: 1, regression_rate: 2.5, merge_success: 2.5, reopened_tasks: 1, debt_growth: 1 };" +
732
+ " const metricTrendAlerts = Object.entries(metrics).flatMap(([metricName, m]) => {" +
733
+ " if (m == null || m.delta == null) return [];" +
734
+ " if (String(m.confidence || '').toLowerCase() === 'low') return [];" +
735
+ " const delta = Number(m.delta);" +
736
+ " const isRegression = (m.direction === 'up_is_good' && delta < 0) || (m.direction === 'down_is_good' && delta > 0);" +
737
+ " if (!isRegression) return [];" +
738
+ " const absDelta = Math.abs(delta);" +
739
+ " const threshold = alertThresholds[metricName] ?? 1;" +
740
+ " const severity = absDelta >= threshold * 2 ? 'high' : absDelta >= threshold ? 'medium' : 'low';" +
741
+ " return [{ metric: metricName, severity, delta, reason: `${metricName} moved in a negative direction by ${delta} ${m.unit}.` }];" +
742
+ " });" +
743
+ " const sourceHealth = {" +
744
+ " tasks: { ...taskHealth, count: tasks.length }," +
745
+ " prs: { ...prHealth, count: prs.length }," +
746
+ " debt: { ...debtHealth, count: debt.length }," +
747
+ " };" +
748
+ " const sourceTelemetryAlerts = Object.entries(sourceHealth).flatMap(([sourceName, health]) => {" +
749
+ " if (health?.status === 'ok') return [];" +
750
+ " const severity = health?.status === 'missing' ? 'high' : 'medium';" +
751
+ " const reason = health?.status === 'missing' ? `${sourceName} telemetry missing; metric interpretation may be limited.` : `${sourceName} telemetry partially parsed; confidence reduced.`;" +
752
+ " return [{ metric: `telemetry:${sourceName}`, severity, delta: null, reason }];" +
753
+ " });" +
754
+ " const trendAlerts = [...metricTrendAlerts, ...sourceTelemetryAlerts];" +
755
+ " const confidenceValues = Object.values(metrics).map((m) => m?.confidence || 'low');" +
756
+ " const overallConfidence = confidenceValues.every((c) => c === 'high') ? 'high' : confidenceValues.some((c) => c === 'low') ? 'low' : 'medium';" +
757
+ " const plannerSignals = {" +
758
+ " schemaVersion: '1.0'," +
759
+ " overallConfidence," +
760
+ " trendAlertCount: trendAlerts.length," +
761
+ " highSeverityAlertCount: trendAlerts.filter((a) => a?.severity === 'high').length," +
762
+ " sourceStatus: Object.fromEntries(Object.entries(sourceHealth).map(([k, v]) => [k, v?.status || 'missing']))," +
763
+ " metricStatus: Object.fromEntries(metricKeys.map((k) => [k, metrics?.[k]?.status || 'missing']))," +
764
+ " metricConfidence: Object.fromEntries(metricKeys.map((k) => [k, metrics?.[k]?.confidence || 'low']))," +
765
+ " metricValues: Object.fromEntries(metricKeys.map((k) => [k, Number.isFinite(metrics?.[k]?.value) ? Number(metrics[k].value) : null]))," +
766
+ " trendDeltas," +
767
+ " priorWeekTrendDeltas," +
768
+ " };" +
769
+ " const plannerArtifact = {" +
770
+ " schemaVersion: '1.0'," +
771
+ " generatedAt: toIso(now)," +
772
+ " lookbackDays," +
773
+ " sourceStatus: plannerSignals.sourceStatus," +
774
+ " metricConfidence: plannerSignals.metricConfidence," +
775
+ " metricValues: plannerSignals.metricValues," +
776
+ " trendDeltas," +
777
+ " priorWeekTrendDeltas," +
778
+ " trendAlertCount: plannerSignals.trendAlertCount," +
779
+ " highSeverityAlertCount: plannerSignals.highSeverityAlertCount," +
780
+ " trendAlerts," +
781
+ " };" +
782
+ " return {" +
783
+ " schemaVersion: '1.0'," +
784
+ " generatedAt: toIso(now)," +
785
+ " lookbackDays," +
786
+ " window: { currentStart: toIso(currentStart), currentEnd: toIso(now), previousStart: toIso(previousStart), previousEnd: toIso(previousEnd) }," +
787
+ " sourceHealth," +
788
+ " metrics," +
789
+ " trendDeltas," +
790
+ " trendAlerts," +
791
+ " priorWeekTrendDeltas," +
792
+ " priorWeekDeltas," +
793
+ " priorWeekMetrics," +
794
+ " plannerSignals," +
795
+ " plannerArtifact," +
796
+ " dataQuality: {" +
797
+ " overallConfidence," +
798
+ " missingSources: Object.entries(sourceHealth).filter(([, v]) => v.status === 'missing').map(([k]) => k)," +
799
+ " degradedSources: Object.entries(sourceHealth).filter(([, v]) => v.status === 'degraded').map(([k]) => k)," +
800
+ " }," +
801
+ " };" +
802
+ " } catch (error) {" +
803
+ " return {" +
804
+ " schemaVersion: '1.0'," +
805
+ " generatedAt: new Date().toISOString()," +
806
+ " lookbackDays: Number($data?.lookbackDays || 7)," +
807
+ " sourceHealth: {" +
808
+ " tasks: { status: 'missing', confidence: 'low', count: 0 }," +
809
+ " prs: { status: 'missing', confidence: 'low', count: 0 }," +
810
+ " debt: { status: 'missing', confidence: 'low', count: 0 }," +
811
+ " }," +
812
+ " metrics: {" +
813
+ " throughput: { value: null, previous: null, delta: null, confidence: 'low', status: 'missing' }," +
814
+ " regression_rate: { value: null, previous: null, delta: null, confidence: 'low', status: 'missing' }," +
815
+ " merge_success: { value: null, previous: null, delta: null, confidence: 'low', status: 'missing' }," +
816
+ " reopened_tasks: { value: null, previous: null, delta: null, confidence: 'low', status: 'missing' }," +
817
+ " debt_growth: { value: null, previous: null, delta: null, confidence: 'low', status: 'missing' }," +
818
+ " }," +
819
+ " trendDeltas: { throughput: null, regression_rate: null, merge_success: null, reopened_tasks: null, debt_growth: null }," +
820
+ " trendAlerts: [{ metric: 'summary', severity: 'high', delta: null, reason: `Fitness summary fallback engaged: ${error?.message || 'unknown error'}` }]," +
821
+ " priorWeekTrendDeltas: { throughput: null, regression_rate: null, merge_success: null, reopened_tasks: null, debt_growth: null }," +
822
+ " priorWeekDeltas: { throughput: null, regression_rate: null, merge_success: null, reopened_tasks: null, debt_growth: null }," +
823
+ " priorWeekMetrics: null," +
824
+ " plannerSignals: {" +
825
+ " schemaVersion: '1.0'," +
826
+ " overallConfidence: 'low'," +
827
+ " trendAlertCount: 1," +
828
+ " highSeverityAlertCount: 1," +
829
+ " sourceStatus: { tasks: 'missing', prs: 'missing', debt: 'missing' }," +
830
+ " metricStatus: { throughput: 'missing', regression_rate: 'missing', merge_success: 'missing', reopened_tasks: 'missing', debt_growth: 'missing' }," +
831
+ " metricConfidence: { throughput: 'low', regression_rate: 'low', merge_success: 'low', reopened_tasks: 'low', debt_growth: 'low' }," +
832
+ " metricValues: { throughput: null, regression_rate: null, merge_success: null, reopened_tasks: null, debt_growth: null }," +
833
+ " trendDeltas: { throughput: null, regression_rate: null, merge_success: null, reopened_tasks: null, debt_growth: null }," +
834
+ " priorWeekTrendDeltas: { throughput: null, regression_rate: null, merge_success: null, reopened_tasks: null, debt_growth: null }," +
835
+ " }," +
836
+ " plannerArtifact: {" +
837
+ " schemaVersion: '1.0'," +
838
+ " generatedAt: new Date().toISOString()," +
839
+ " lookbackDays: Number($data?.lookbackDays || 7)," +
840
+ " sourceStatus: { tasks: 'missing', prs: 'missing', debt: 'missing' }," +
841
+ " metricConfidence: { throughput: 'low', regression_rate: 'low', merge_success: 'low', reopened_tasks: 'low', debt_growth: 'low' }," +
842
+ " metricValues: { throughput: null, regression_rate: null, merge_success: null, reopened_tasks: null, debt_growth: null }," +
843
+ " trendDeltas: { throughput: null, regression_rate: null, merge_success: null, reopened_tasks: null, debt_growth: null }," +
844
+ " priorWeekTrendDeltas: { throughput: null, regression_rate: null, merge_success: null, reopened_tasks: null, debt_growth: null }," +
845
+ " trendAlertCount: 1," +
846
+ " highSeverityAlertCount: 1," +
847
+ " trendAlerts: [{ metric: 'summary', severity: 'high', delta: null, reason: `Fitness summary fallback engaged: ${error?.message || 'unknown error'}` }]," +
848
+ " }," +
849
+ " dataQuality: { overallConfidence: 'low', missingSources: ['tasks', 'prs', 'debt'], degradedSources: [] }," +
850
+ " };" +
851
+ " }" +
852
+ "})()",
853
+ isExpression: true,
854
+ }, { x: 420, y: 360 }),
855
+
856
+ node("serialize-fitness-summary", "action.set_variable", "Serialize Fitness Summary", {
857
+ key: "fitnessSummaryJson",
858
+ value: "(() => JSON.stringify($data?.fitnessSummary || {}, null, 2))()",
859
+ isExpression: true,
860
+ }, { x: 420, y: 500 }),
861
+
862
+ node("render-trend-alerts", "action.set_variable", "Render Trend Alerts", {
863
+ key: "fitnessTrendAlertsText",
864
+ value: "(() => { const alerts = Array.isArray($data?.fitnessSummary?.trendAlerts) ? $data.fitnessSummary.trendAlerts : []; if (!alerts.length) return 'No negative trend alerts this week.'; return alerts.map((a, idx) => `${idx + 1}. ${a.metric} (${a.severity}) - ${a.reason}`).join('\\n'); })()",
865
+ isExpression: true,
866
+ }, { x: 420, y: 640 }),
867
+
868
+ node("persist-fitness-summary", "action.write_file", "Persist Fitness Summary Artifact", {
869
+ path: ".bosun/workflow-runs/weekly-fitness-summary.latest.json",
870
+ content: "{{fitnessSummaryJson}}",
871
+ mkdir: true,
872
+ }, { x: 420, y: 780 }),
873
+
494
874
  node("evaluate-fitness", "action.run_agent", "Evaluate Fitness", {
495
- prompt: `# Weekly Delivery Fitness Evaluation\n\nEvaluate the last {{lookbackDays}} days using these metrics:\n- Throughput\n- Regression rate\n- Merge success\n- Reopened tasks\n- Debt growth\n\n## Task Data\n{{taskMetrics}}\n\n## PR Data\n{{prMetrics}}\n\n## Debt Ledger Data\n{{debtMetrics}}\n\nFocus directive: {{evaluatorFocus}}\n\nReturn sections:\n1) Scorecard (0-100) with one line per metric\n2) Root-cause analysis of the largest drag\n3) Countermeasures ranked by impact/cost\n4) FOLLOW_UP_ACTION lines using format:\nFOLLOW_UP_ACTION: [title] | [description] | [repo_area] | [risk] | [effort]\n\nOnly include FOLLOW_UP_ACTION lines for changes that are worth implementing this week.`,
875
+ prompt: `# Weekly Delivery Fitness Evaluation
876
+
877
+ Evaluate the last {{lookbackDays}} days using this machine-readable summary:
878
+
879
+ Metrics to evaluate:
880
+ - Throughput
881
+ - Regression rate
882
+ - Merge success
883
+ - Reopened tasks
884
+ - Debt growth
885
+
886
+ ## Weekly Fitness JSON
887
+ {{fitnessSummaryJson}}
888
+
889
+ ## Negative Trend Alerts
890
+ {{fitnessTrendAlertsText}}
891
+
892
+ Focus directive: {{evaluatorFocus}}
893
+
894
+ Requirements:
895
+ - Respect confidence and status on each metric.
896
+ - If a metric has low confidence or missing telemetry, call that out explicitly and avoid overconfident recommendations.
897
+ - Use prior-week deltas when available.
898
+ - If one telemetry source is unavailable, still provide a stable scorecard and best-effort recommendations.
899
+
900
+ Return sections:
901
+ 1) Scorecard (0-100) with one line per metric and confidence
902
+ 2) Root-cause analysis of the largest drag
903
+ 3) Countermeasures ranked by impact/cost
904
+ 4) FOLLOW_UP_ACTION lines using format:
905
+ FOLLOW_UP_ACTION: [title] | [description] | [repo_area] | [risk] | [effort]
906
+
907
+ Only include FOLLOW_UP_ACTION lines for changes that are worth implementing this week.`,
496
908
  sdk: "auto",
497
909
  timeoutMs: 600000,
498
- }, { x: 420, y: 360 }),
910
+ }, { x: 420, y: 930 }),
499
911
 
500
912
  node("has-followups", "condition.expression", "Follow-ups Enabled + Present", {
501
913
  expression: "($data?.createFollowupTasks === true) && (($ctx.getNodeOutput('evaluate-fitness')?.output || '').includes('FOLLOW_UP_ACTION:'))",
502
- }, { x: 420, y: 520 }),
914
+ }, { x: 420, y: 1090 }),
503
915
 
504
916
  node("build-followup-json", "action.run_agent", "Build Follow-up Tasks JSON", {
505
- prompt: `Convert FOLLOW_UP_ACTION lines below into a single JSON object with shape { \"tasks\": [...] }.\n\nSource:\n{{evaluateFitness}}\n\nRules:\n- Generate at most {{maxFollowupTasks}} tasks\n- Include fields: title, description, implementation_steps, acceptance_criteria, verification, priority, tags, base_branch, impact, confidence, risk, estimated_effort, repo_areas, why_now, kill_criteria\n- Keep tasks implementation-ready and avoid duplicates\n- Return only JSON`,
917
+ prompt: `Convert FOLLOW_UP_ACTION lines below into a single JSON object with shape { "tasks": [...] }.
918
+
919
+ Source:
920
+ {{evaluate-fitness.output}}
921
+
922
+ Structured context:
923
+ {{fitnessSummaryJson}}
924
+
925
+ Rules:
926
+ - Generate at most {{maxFollowupTasks}} tasks
927
+ - Include fields: title, description, implementation_steps, acceptance_criteria, verification, priority, tags, base_branch, impact, confidence, risk, estimated_effort, repo_areas, why_now, kill_criteria
928
+ - Use trend deltas from the summary artifact to justify urgency and avoid parse errors
929
+ - Keep tasks implementation-ready and avoid duplicates
930
+ - Return only JSON`,
506
931
  sdk: "auto",
507
932
  timeoutMs: 300000,
508
- }, { x: 220, y: 690 }),
933
+ }, { x: 220, y: 1260 }),
509
934
 
510
935
  node("materialize-followups", "action.materialize_planner_tasks", "Materialize Follow-up Tasks", {
511
936
  plannerNodeId: "build-followup-json",
@@ -514,25 +939,31 @@ export const WEEKLY_FITNESS_SUMMARY_TEMPLATE = {
514
939
  dedup: true,
515
940
  failOnZero: false,
516
941
  minCreated: 0,
517
- }, { x: 220, y: 850 }),
942
+ }, { x: 220, y: 1420 }),
518
943
 
519
944
  node("notify-summary", "notify.telegram", "Send Weekly Fitness Summary", {
520
- message: ":chart: Weekly fitness evaluation complete. Follow-up tasks created: {{materialize-followups.createdCount}}\n\n{{evaluateFitness}}",
945
+ message: ":chart: Weekly fitness evaluation complete. Follow-up tasks created: {{materialize-followups.createdCount}}\\n\\nTrend alerts:\\n{{fitnessTrendAlertsText}}\\n\\n{{evaluate-fitness.output}}",
521
946
  silent: true,
522
- }, { x: 420, y: 1010 }),
947
+ }, { x: 420, y: 1580 }),
523
948
 
524
949
  node("log-no-followups", "notify.log", "No Follow-up Tasks", {
525
950
  message: "Weekly fitness evaluation completed with no follow-up task creation.",
526
951
  level: "info",
527
- }, { x: 620, y: 690 }),
952
+ }, { x: 620, y: 1260 }),
528
953
  ],
529
954
  edges: [
530
955
  edge("trigger", "task-metrics"),
531
956
  edge("trigger", "pr-metrics"),
532
957
  edge("trigger", "debt-metrics"),
533
- edge("task-metrics", "evaluate-fitness"),
534
- edge("pr-metrics", "evaluate-fitness"),
535
- edge("debt-metrics", "evaluate-fitness"),
958
+ edge("trigger", "read-previous-summary"),
959
+ edge("task-metrics", "summarize-fitness-metrics"),
960
+ edge("pr-metrics", "summarize-fitness-metrics"),
961
+ edge("debt-metrics", "summarize-fitness-metrics"),
962
+ edge("read-previous-summary", "summarize-fitness-metrics"),
963
+ edge("summarize-fitness-metrics", "serialize-fitness-summary"),
964
+ edge("serialize-fitness-summary", "render-trend-alerts"),
965
+ edge("render-trend-alerts", "persist-fitness-summary"),
966
+ edge("persist-fitness-summary", "evaluate-fitness"),
536
967
  edge("evaluate-fitness", "has-followups"),
537
968
  edge("has-followups", "build-followup-json", { condition: "$output?.result === true" }),
538
969
  edge("has-followups", "log-no-followups", { condition: "$output?.result !== true" }),
@@ -556,5 +987,3 @@ export const WEEKLY_FITNESS_SUMMARY_TEMPLATE = {
556
987
  },
557
988
  },
558
989
  };
559
-
560
-