bosun 0.41.2 → 0.41.3

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 (71) hide show
  1. package/.env.example +1 -1
  2. package/agent/agent-prompt-catalog.mjs +971 -0
  3. package/agent/agent-prompts.mjs +2 -970
  4. package/agent/agent-supervisor.mjs +6 -3
  5. package/agent/autofix-git.mjs +33 -0
  6. package/agent/autofix-prompts.mjs +151 -0
  7. package/agent/autofix.mjs +11 -175
  8. package/agent/bosun-skills.mjs +3 -2
  9. package/bosun.config.example.json +17 -0
  10. package/bosun.schema.json +87 -188
  11. package/cli.mjs +34 -1
  12. package/config/config-doctor.mjs +5 -250
  13. package/config/config-file-names.mjs +5 -0
  14. package/config/config.mjs +89 -493
  15. package/config/executor-config.mjs +493 -0
  16. package/config/repo-root.mjs +1 -2
  17. package/config/workspace-health.mjs +242 -0
  18. package/git/git-safety.mjs +15 -0
  19. package/github/github-oauth-portal.mjs +46 -0
  20. package/infra/library-manager-utils.mjs +22 -0
  21. package/infra/library-manager-well-known-sources.mjs +578 -0
  22. package/infra/library-manager.mjs +512 -1030
  23. package/infra/monitor.mjs +28 -9
  24. package/infra/session-tracker.mjs +10 -7
  25. package/kanban/kanban-adapter.mjs +17 -1
  26. package/lib/codebase-audit-manifests.mjs +117 -0
  27. package/lib/codebase-audit.mjs +18 -115
  28. package/package.json +18 -3
  29. package/server/ui-server.mjs +1194 -79
  30. package/shell/codex-config-file.mjs +178 -0
  31. package/shell/codex-config.mjs +538 -575
  32. package/task/task-cli.mjs +54 -3
  33. package/task/task-executor.mjs +143 -13
  34. package/task/task-store.mjs +409 -1
  35. package/telegram/telegram-bot.mjs +127 -0
  36. package/tools/apply-pr-suggestions.mjs +401 -0
  37. package/tools/syntax-check.mjs +21 -9
  38. package/ui/app.js +3 -14
  39. package/ui/components/kanban-board.js +227 -4
  40. package/ui/components/session-list.js +85 -5
  41. package/ui/demo-defaults.js +334 -80
  42. package/ui/demo.html +155 -0
  43. package/ui/modules/session-api.js +96 -0
  44. package/ui/modules/settings-schema.js +1 -2
  45. package/ui/modules/state.js +21 -3
  46. package/ui/setup.html +4 -5
  47. package/ui/styles/components.css +58 -4
  48. package/ui/tabs/agents.js +12 -15
  49. package/ui/tabs/control.js +1 -0
  50. package/ui/tabs/library.js +484 -22
  51. package/ui/tabs/manual-flows.js +105 -29
  52. package/ui/tabs/tasks.js +785 -140
  53. package/ui/tabs/telemetry.js +129 -11
  54. package/ui/tabs/workflow-canvas-utils.mjs +130 -0
  55. package/ui/tabs/workflows.js +293 -23
  56. package/voice/voice-tool-definitions.mjs +757 -0
  57. package/voice/voice-tools.mjs +34 -778
  58. package/workflow/manual-flow-audit.mjs +165 -0
  59. package/workflow/manual-flows.mjs +164 -259
  60. package/workflow/workflow-engine.mjs +147 -58
  61. package/workflow/workflow-nodes/definitions.mjs +1207 -0
  62. package/workflow/workflow-nodes/transforms.mjs +612 -0
  63. package/workflow/workflow-nodes.mjs +304 -52
  64. package/workflow/workflow-templates.mjs +313 -191
  65. package/workflow-templates/_helpers.mjs +154 -0
  66. package/workflow-templates/agents.mjs +61 -4
  67. package/workflow-templates/code-quality.mjs +7 -7
  68. package/workflow-templates/github.mjs +20 -10
  69. package/workflow-templates/task-batch.mjs +20 -9
  70. package/workflow-templates/task-lifecycle.mjs +31 -6
  71. package/workspace/worktree-manager.mjs +277 -3
@@ -10,6 +10,24 @@
10
10
  let _nextX = 100;
11
11
  let _nextY = 100;
12
12
 
13
+ const TEMPLATE_LAYOUT_DEFAULTS = Object.freeze({
14
+ centerX: 480,
15
+ topY: 80,
16
+ columnGap: 280,
17
+ rowGap: 180,
18
+ });
19
+
20
+ function toFiniteNumber(value, fallback = 0) {
21
+ return Number.isFinite(value) ? value : fallback;
22
+ }
23
+
24
+ function getNodeOriginalPosition(node, fallbackX = 0) {
25
+ return {
26
+ x: toFiniteNumber(node?.position?.x, fallbackX),
27
+ y: toFiniteNumber(node?.position?.y, 0),
28
+ };
29
+ }
30
+
13
31
  /**
14
32
  * Create a workflow node definition with automatic positioning.
15
33
  * @param {string} id - Unique node identifier within the template
@@ -63,3 +81,139 @@ export function resetLayout() {
63
81
  _nextX = 100;
64
82
  _nextY = 100;
65
83
  }
84
+
85
+ export function normalizeTemplateLayoutInPlace(template, opts = {}) {
86
+ const nodes = Array.isArray(template?.nodes) ? template.nodes : [];
87
+ const edges = Array.isArray(template?.edges) ? template.edges : [];
88
+ if (nodes.length <= 1) return template;
89
+
90
+ const centerX = toFiniteNumber(opts.centerX, TEMPLATE_LAYOUT_DEFAULTS.centerX);
91
+ const topY = toFiniteNumber(opts.topY, TEMPLATE_LAYOUT_DEFAULTS.topY);
92
+ const columnGap = Math.max(180, toFiniteNumber(opts.columnGap, TEMPLATE_LAYOUT_DEFAULTS.columnGap));
93
+ const rowGap = Math.max(120, toFiniteNumber(opts.rowGap, TEMPLATE_LAYOUT_DEFAULTS.rowGap));
94
+
95
+ const nodeIds = new Set(nodes.map((node) => node.id));
96
+ const originalIndex = new Map(nodes.map((node, index) => [node.id, index]));
97
+ const originalPosition = new Map(
98
+ nodes.map((node, index) => [node.id, getNodeOriginalPosition(node, index * columnGap)]),
99
+ );
100
+
101
+ const incoming = new Map(nodes.map((node) => [node.id, []]));
102
+ const outgoing = new Map(nodes.map((node) => [node.id, []]));
103
+ for (const edgeDef of edges) {
104
+ if (!edgeDef || edgeDef.backEdge === true) continue;
105
+ const source = String(edgeDef.source || "").trim();
106
+ const target = String(edgeDef.target || "").trim();
107
+ if (!nodeIds.has(source) || !nodeIds.has(target)) continue;
108
+ outgoing.get(source).push(target);
109
+ incoming.get(target).push(source);
110
+ }
111
+
112
+ const sortedNodeIds = [...nodeIds].sort((left, right) => {
113
+ const leftPos = originalPosition.get(left) || { x: 0, y: 0 };
114
+ const rightPos = originalPosition.get(right) || { x: 0, y: 0 };
115
+ if (leftPos.y !== rightPos.y) return leftPos.y - rightPos.y;
116
+ if (leftPos.x !== rightPos.x) return leftPos.x - rightPos.x;
117
+ return (originalIndex.get(left) || 0) - (originalIndex.get(right) || 0);
118
+ });
119
+
120
+ const rootIds = sortedNodeIds.filter((nodeId) => {
121
+ const node = nodes[originalIndex.get(nodeId) || 0];
122
+ return (incoming.get(nodeId)?.length || 0) === 0 || String(node?.type || "").startsWith("trigger.");
123
+ });
124
+ const queue = rootIds.length > 0 ? [...rootIds] : [sortedNodeIds[0]];
125
+ const indegree = new Map([...incoming.entries()].map(([nodeId, parents]) => [nodeId, parents.length]));
126
+ const depthById = new Map(queue.map((nodeId) => [nodeId, 0]));
127
+ const queued = new Set(queue);
128
+
129
+ for (const nodeId of sortedNodeIds) {
130
+ if ((indegree.get(nodeId) || 0) === 0 && !queued.has(nodeId)) {
131
+ queue.push(nodeId);
132
+ queued.add(nodeId);
133
+ depthById.set(nodeId, 0);
134
+ }
135
+ }
136
+
137
+ while (queue.length > 0) {
138
+ const nodeId = queue.shift();
139
+ const currentDepth = depthById.get(nodeId) || 0;
140
+ for (const targetId of outgoing.get(nodeId) || []) {
141
+ const nextDepth = currentDepth + 1;
142
+ if (nextDepth > (depthById.get(targetId) || 0)) {
143
+ depthById.set(targetId, nextDepth);
144
+ }
145
+ indegree.set(targetId, (indegree.get(targetId) || 0) - 1);
146
+ if ((indegree.get(targetId) || 0) <= 0 && !queued.has(targetId)) {
147
+ queue.push(targetId);
148
+ queued.add(targetId);
149
+ }
150
+ }
151
+ }
152
+
153
+ const unresolved = sortedNodeIds.filter((nodeId) => !depthById.has(nodeId));
154
+ while (unresolved.length > 0) {
155
+ let progressed = false;
156
+ for (let index = unresolved.length - 1; index >= 0; index -= 1) {
157
+ const nodeId = unresolved[index];
158
+ const parents = incoming.get(nodeId) || [];
159
+ if (parents.every((parentId) => depthById.has(parentId))) {
160
+ const nextDepth = parents.length > 0
161
+ ? Math.max(...parents.map((parentId) => depthById.get(parentId) || 0)) + 1
162
+ : 0;
163
+ depthById.set(nodeId, nextDepth);
164
+ unresolved.splice(index, 1);
165
+ progressed = true;
166
+ }
167
+ }
168
+ if (progressed) continue;
169
+ const nodeId = unresolved.shift();
170
+ const parents = incoming.get(nodeId) || [];
171
+ const knownParentDepths = parents
172
+ .map((parentId) => depthById.get(parentId))
173
+ .filter((depth) => Number.isFinite(depth));
174
+ const fallbackDepth = knownParentDepths.length > 0
175
+ ? Math.max(...knownParentDepths) + 1
176
+ : 0;
177
+ depthById.set(nodeId, fallbackDepth);
178
+ }
179
+
180
+ const layerMap = new Map();
181
+ for (const nodeId of sortedNodeIds) {
182
+ const depth = depthById.get(nodeId) || 0;
183
+ if (!layerMap.has(depth)) layerMap.set(depth, []);
184
+ layerMap.get(depth).push(nodeId);
185
+ }
186
+
187
+ const assignedX = new Map();
188
+ const sortedLayers = [...layerMap.keys()].sort((left, right) => left - right);
189
+ for (const depth of sortedLayers) {
190
+ const layerNodeIds = layerMap.get(depth) || [];
191
+ layerNodeIds.sort((left, right) => {
192
+ const leftParents = (incoming.get(left) || []).filter((parentId) => assignedX.has(parentId));
193
+ const rightParents = (incoming.get(right) || []).filter((parentId) => assignedX.has(parentId));
194
+ const leftBarycenter = leftParents.length > 0
195
+ ? leftParents.reduce((sum, parentId) => sum + assignedX.get(parentId), 0) / leftParents.length
196
+ : (originalPosition.get(left)?.x || 0);
197
+ const rightBarycenter = rightParents.length > 0
198
+ ? rightParents.reduce((sum, parentId) => sum + assignedX.get(parentId), 0) / rightParents.length
199
+ : (originalPosition.get(right)?.x || 0);
200
+ if (leftBarycenter !== rightBarycenter) return leftBarycenter - rightBarycenter;
201
+ const leftPos = originalPosition.get(left) || { x: 0, y: 0 };
202
+ const rightPos = originalPosition.get(right) || { x: 0, y: 0 };
203
+ if (leftPos.x !== rightPos.x) return leftPos.x - rightPos.x;
204
+ if (leftPos.y !== rightPos.y) return leftPos.y - rightPos.y;
205
+ return (originalIndex.get(left) || 0) - (originalIndex.get(right) || 0);
206
+ });
207
+
208
+ const startX = centerX - ((layerNodeIds.length - 1) * columnGap) / 2;
209
+ layerNodeIds.forEach((nodeId, index) => {
210
+ const node = nodes[originalIndex.get(nodeId) || 0];
211
+ const x = Math.round(startX + index * columnGap);
212
+ const y = topY + depth * rowGap;
213
+ node.position = { x, y };
214
+ assignedX.set(nodeId, x);
215
+ });
216
+ }
217
+
218
+ return template;
219
+ }
@@ -13,6 +13,63 @@
13
13
 
14
14
  import { node, edge, resetLayout } from "./_helpers.mjs";
15
15
 
16
+ const AGENT_SESSION_MONITOR_COMMAND = [
17
+ 'node -e "',
18
+ "const fs = require('node:fs');",
19
+ "const path = require('node:path');",
20
+ "const { pathToFileURL } = require('node:url');",
21
+ "const cwd = process.cwd();",
22
+ "const mirrorMarker = `${path.sep}.bosun${path.sep}workspaces${path.sep}`.toLowerCase();",
23
+ "let repoRoot = cwd;",
24
+ "if (cwd.toLowerCase().includes(mirrorMarker)) {",
25
+ "const sourceRepoRoot = path.resolve(cwd, '..', '..', '..', '..');",
26
+ "if (fs.existsSync(path.join(sourceRepoRoot, 'infra', 'session-tracker.mjs'))) repoRoot = sourceRepoRoot;",
27
+ "}",
28
+ "const trackerModuleUrl = pathToFileURL(path.join(repoRoot, 'infra', 'session-tracker.mjs')).href;",
29
+ "import(trackerModuleUrl).then(({ getSessionTracker }) => {",
30
+ "const tracker = getSessionTracker();",
31
+ "const sessions = tracker.getActiveSessions().map((session) => {",
32
+ "const progress = tracker.getProgressStatus(session.taskId);",
33
+ "return {",
34
+ "id: session.taskId,",
35
+ "taskId: session.taskId,",
36
+ "taskTitle: session.taskTitle || null,",
37
+ "status: progress.status,",
38
+ "idleMs: progress.idleMs,",
39
+ "totalEvents: progress.totalEvents,",
40
+ "elapsedMs: progress.elapsedMs,",
41
+ "lastEventType: progress.lastEventType,",
42
+ "recommendation: progress.recommendation,",
43
+ "tokenPercent: null",
44
+ "};",
45
+ "});",
46
+ "console.log(JSON.stringify(sessions));",
47
+ "}).catch((err) => { console.error(err?.stack || String(err)); process.exit(1); });",
48
+ '"',
49
+ ].join("");
50
+
51
+ const buildAgentSessionMonitorParseExpression = (rawExpression) => [
52
+ "(() => {",
53
+ `const raw = String(${rawExpression} || '[]');`,
54
+ "const lines = raw.split(/\\r?\\n/).map((line) => line.trim()).filter(Boolean);",
55
+ "const candidate = lines.length ? lines[lines.length - 1] : '[]';",
56
+ "try {",
57
+ "const parsed = JSON.parse(candidate);",
58
+ "return Array.isArray(parsed) ? parsed : [];",
59
+ "} catch {",
60
+ "return [];",
61
+ "}",
62
+ "})()",
63
+ ].join("");
64
+
65
+ const AGENT_SESSION_MONITOR_LIST_PARSE_EXPRESSION = buildAgentSessionMonitorParseExpression(
66
+ "$ctx.getNodeOutput('list-sessions')?.output",
67
+ );
68
+
69
+ const AGENT_SESSION_MONITOR_HEALTH_PARSE_EXPRESSION = buildAgentSessionMonitorParseExpression(
70
+ "$ctx.getNodeOutput('check-health')?.output",
71
+ );
72
+
16
73
  // ═══════════════════════════════════════════════════════════════════════════
17
74
  // Frontend Agent with Screenshot Validation
18
75
  // ═══════════════════════════════════════════════════════════════════════════
@@ -368,21 +425,21 @@ export const AGENT_SESSION_MONITOR_TEMPLATE = {
368
425
  }, { x: 400, y: 50 }),
369
426
 
370
427
  node("list-sessions", "action.run_command", "List Active Sessions", {
371
- command: "bosun agent list --json --active",
428
+ command: AGENT_SESSION_MONITOR_COMMAND,
372
429
  continueOnError: true,
373
430
  }, { x: 400, y: 200 }),
374
431
 
375
432
  node("has-active", "condition.expression", "Any Active?", {
376
- expression: "($ctx.getNodeOutput('list-sessions')?.output || '[]') !== '[]' && ($ctx.getNodeOutput('list-sessions')?.output || '').length > 5",
433
+ expression: `${AGENT_SESSION_MONITOR_LIST_PARSE_EXPRESSION}.length > 0`,
377
434
  }, { x: 400, y: 340 }),
378
435
 
379
436
  node("check-health", "action.run_command", "Check Session Health", {
380
- command: "bosun agent health --json",
437
+ command: AGENT_SESSION_MONITOR_COMMAND,
381
438
  continueOnError: true,
382
439
  }, { x: 200, y: 490 }),
383
440
 
384
441
  node("has-issues", "condition.expression", "Any Unhealthy?", {
385
- expression: "(() => { const out = String($ctx.getNodeOutput('check-health')?.output || ''); const maxIdleMs = Number($data?.maxIdleMs || 600000); const maxTokenPercent = Number($data?.maxTokenPercent || 85); const idleMatch = out.match(/idle(?:_ms)?\\s*[:=]\\s*(\\d+)/i); const tokenMatch = out.match(/token(?:_usage|_percent)?\\s*[:=]\\s*(\\d+(?:\\.\\d+)?)/i); const idleExceeded = idleMatch ? Number(idleMatch[1]) > maxIdleMs : false; const tokenExceeded = tokenMatch ? Number(tokenMatch[1]) >= maxTokenPercent : false; return out.includes('stalled') || out.includes('timeout') || idleExceeded || tokenExceeded; })()",
442
+ expression: `(() => { const sessions = ${AGENT_SESSION_MONITOR_HEALTH_PARSE_EXPRESSION}; const maxIdleMs = Number($data?.maxIdleMs || 600000); const maxTokenPercent = Number($data?.maxTokenPercent || 85); return sessions.some((item) => { const status = String(item?.status || '').toLowerCase(); const idleMs = Number(item?.idleMs || 0); const tokenPercent = Number(item?.tokenPercent); return status === 'idle' || status === 'stalled' || status === 'timeout' || idleMs > maxIdleMs || (Number.isFinite(tokenPercent) && tokenPercent >= maxTokenPercent); }); })()`,
386
443
  }, { x: 200, y: 640 }),
387
444
 
388
445
  node("auto-continue", "action.continue_session", "Auto-Continue Stalled", {
@@ -34,7 +34,7 @@ export const CODE_QUALITY_STRIKER_TEMPLATE = {
34
34
  trigger: "trigger.schedule",
35
35
  variables: {
36
36
  sessionTimeoutMs: 5400000, // 90 minutes hard cap
37
- branch: "chore/code-quality-striker-{{_runId}}",
37
+ branch: "chore/code-quality-striker",
38
38
  baseBranch: "main",
39
39
  sessionLogPath: ".bosun-monitor/code-quality-striker.md",
40
40
  maxFilesPerSession: 6, // keep PRs reviewable; prevents mega-diffs
@@ -184,7 +184,7 @@ If tests fail, **revert your change** (\`git checkout -- <file>\`) and either:
184
184
 
185
185
  ### Step 5 — Commit, push, and open the PR
186
186
 
187
- Branch name: \`chore/code-quality-striker-{{_runId}}\`
187
+ Branch name: \`{{branch}}\`
188
188
  Base branch: \`{{baseBranch}}\`
189
189
 
190
190
  Commit message format:
@@ -203,7 +203,7 @@ PR body template:
203
203
  \`\`\`markdown
204
204
  ## Code Quality Pass
205
205
 
206
- **Session**: code-quality-striker {{_runId}}
206
+ **Session**: {{branch}}
207
207
  **Scope**: structural refactor only — zero functional changes
208
208
 
209
209
  ### Changes
@@ -266,7 +266,7 @@ A small, clean, tested PR is always better than nothing.`,
266
266
 
267
267
  // ── 7a. Create PR ──────────────────────────────────────────────────────
268
268
  node("create-pr", "action.create_pr", "Open Quality PR", {
269
- title: "refactor: code quality pass {{_runId}}",
269
+ title: "refactor: code quality pass",
270
270
  body: "Automated code-quality session. Structural refactor only — zero functional changes. See `.bosun-monitor/code-quality-striker.md` for session details.",
271
271
  branch: "{{branch}}",
272
272
  baseBranch: "{{baseBranch}}",
@@ -274,17 +274,17 @@ A small, clean, tested PR is always better than nothing.`,
274
274
  }, { x: 200, y: 890 }),
275
275
 
276
276
  node("notify-success", "notify.telegram", "Notify PR Opened", {
277
- message: ":check: Code quality striker session complete.\nPR opened: **{{branch}}**\nRun ID: `{{_runId}}`",
277
+ message: ":check: Code quality striker session complete.\nPR opened: **{{branch}}**",
278
278
  silent: true,
279
279
  }, { x: 200, y: 1030 }),
280
280
 
281
281
  // ── 7b. Validation failed — notify and abort ───────────────────────────
282
282
  node("notify-failure", "notify.telegram", "Notify — Validation Failed", {
283
- message: ":alert: Code quality striker **validation failed** for run `{{_runId}}`.\n\nThe agent produced changes that broke tests or build. No PR was created.\nCheck `.bosun-monitor/code-quality-striker.md` for details.",
283
+ message: ":alert: Code quality striker **validation failed**.\n\nThe agent produced changes that broke tests or build. No PR was created.\nCheck `.bosun-monitor/code-quality-striker.md` for details.",
284
284
  }, { x: 600, y: 890 }),
285
285
 
286
286
  node("log-failure", "notify.log", "Log Failure", {
287
- message: "Code quality striker run {{_runId}} failed validation — no PR created.",
287
+ message: "Code quality striker validation failed — no PR created.",
288
288
  level: "warn",
289
289
  }, { x: 600, y: 1030 }),
290
290
  ],
@@ -102,7 +102,7 @@ Respond with JSON: { "action": "<choice>", "reason": "<why>", "message": "<optio
102
102
  }, { x: 400, y: 520, outputs: ["merge", "prompt-agent", "close", "retry", "escalate", "wait-for-ci", "default"] }),
103
103
 
104
104
  node("do-merge", "action.run_command", "Auto-Merge PR", {
105
- command: "gh pr merge {{prNumber}} --auto --squash",
105
+ command: "gh pr merge {{prNumber}} --auto --merge",
106
106
  failOnError: true,
107
107
  maxRetries: "{{maxRetries}}",
108
108
  retryDelayMs: 30000,
@@ -698,7 +698,7 @@ export const BOSUN_PR_PROGRESSOR_TEMPLATE = {
698
698
  recommended: true,
699
699
  trigger: "trigger.workflow_call",
700
700
  variables: {
701
- mergeMethod: "squash",
701
+ mergeMethod: "merge",
702
702
  labelNeedsFix: "bosun-needs-fix",
703
703
  labelNeedsReview: "bosun-needs-human-review",
704
704
  suspiciousDeletionRatio: 3,
@@ -889,7 +889,7 @@ export const BOSUN_PR_PROGRESSOR_TEMPLATE = {
889
889
  "const ratio=Number('{{suspiciousDeletionRatio}}')||3;",
890
890
  "const minDel=Number('{{minDestructiveDeletions}}')||500;",
891
891
  "const labelReview=String('{{labelNeedsReview}}'||'bosun-needs-human-review');",
892
- "const method=String('{{mergeMethod}}'||'squash').toLowerCase();",
892
+ "const method=String('{{mergeMethod}}'||'merge').toLowerCase();",
893
893
  "function gh(args){return execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();}",
894
894
  "if(!repo||!n){console.log(JSON.stringify({mergedCount:0,heldCount:0,skippedCount:1,skipped:[{repo,number:n,reason:'missing_repo_or_pr'}]}));process.exit(0);}",
895
895
  "try{",
@@ -1015,7 +1015,7 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
1015
1015
  recommended: true,
1016
1016
  trigger: "trigger.schedule",
1017
1017
  variables: {
1018
- mergeMethod: "squash", // squash | merge | rebase
1018
+ mergeMethod: "merge", // merge | squash | rebase
1019
1019
  labelNeedsFix: "bosun-needs-fix", // applied to CI failures and conflicts
1020
1020
  labelNeedsReview: "bosun-needs-human-review", // applied when review agent flags a suspicious diff
1021
1021
  // auto: active workspace repos from bosun.config.json (fallback current repo)
@@ -1027,6 +1027,7 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
1027
1027
  // If net deletions > additions × ratio AND deletions > minDestructiveDeletions → HOLD
1028
1028
  suspiciousDeletionRatio: 3, // e.g. deletes 3× more lines than it adds
1029
1029
  minDestructiveDeletions: 500, // absolute floor — small PRs are fine even if net negative
1030
+ autoApplySuggestions: true, // auto-commit review suggestions before merge
1030
1031
  },
1031
1032
  nodes: [
1032
1033
  node("trigger", "trigger.schedule", "Poll Every 90s", {
@@ -1429,7 +1430,7 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
1429
1430
  "const ratio=Number('{{suspiciousDeletionRatio}}')||3;",
1430
1431
  "const minDel=Number('{{minDestructiveDeletions}}')||500;",
1431
1432
  "const labelReview=String('{{labelNeedsReview}}'||'bosun-needs-human-review');",
1432
- "const method=String('{{mergeMethod}}'||'squash').toLowerCase();",
1433
+ "const method=String('{{mergeMethod}}'||'merge').toLowerCase();",
1433
1434
  "const merged=[]; const held=[]; const skipped=[];",
1434
1435
  "function gh(args){return execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();}",
1435
1436
  "for(const c of candidates){",
@@ -1464,6 +1465,17 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
1464
1465
  " });",
1465
1466
  " if(hasFailure){skipped.push({repo,number:n,reason:'ci_failed'});continue;}",
1466
1467
  " if(hasPending){skipped.push({repo,number:n,reason:'ci_pending'});continue;}",
1468
+ " const doApplySuggestions=String('{{autoApplySuggestions}}'||'true')==='true'&&process.env.BOSUN_AUTO_APPLY_SUGGESTIONS!=='false';",
1469
+ " if(doApplySuggestions){",
1470
+ " try{",
1471
+ " const toolPath=require('path').resolve(process.cwd(),'tools','apply-pr-suggestions.mjs');",
1472
+ " if(require('fs').existsSync(toolPath)){",
1473
+ " const sugOut=execFileSync('node',[toolPath,'--owner',repo.split('/')[0],'--repo',repo.split('/')[1],n,'--json'],{encoding:'utf8',timeout:60000,stdio:['pipe','pipe','pipe']});",
1474
+ " const sugRes=(()=>{try{return JSON.parse(sugOut)}catch{return null}})();",
1475
+ " if(sugRes?.commitSha) console.error('[watchdog] auto-applied '+sugRes.applied+' suggestion(s) on PR #'+n+' → '+sugRes.commitSha.slice(0,8));",
1476
+ " }",
1477
+ " }catch(sugErr){console.error('[watchdog] suggestion auto-apply skipped for PR #'+n+': '+String(sugErr?.message||sugErr).slice(0,120));}",
1478
+ " }",
1467
1479
  " const mergeArgs=['pr','merge',n,'--repo',repo,'--delete-branch'];",
1468
1480
  " if(method==='rebase') mergeArgs.push('--rebase');",
1469
1481
  " else if(method==='merge') mergeArgs.push('--merge');",
@@ -1488,11 +1500,10 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
1488
1500
  },
1489
1501
  }, { x: 600, y: 700 }),
1490
1502
 
1491
- node("notify", "notify.telegram", "Watchdog Report", {
1503
+ node("notify", "notify.log", "Watchdog Report", {
1492
1504
  message:
1493
- ":bug: Bosun PR Watchdog cycle complete — " +
1494
- "fix-dispatched: {{fixNeeded}} | candidates-reviewed: {{readyCandidates}}",
1495
- silent: true,
1505
+ "Bosun PR Watchdog cycle complete — see live digest/status board for streaming updates",
1506
+ level: "info",
1496
1507
  }, { x: 400, y: 900 }),
1497
1508
 
1498
1509
  node("no-prs", "notify.log", "No Bosun PRs Open", {
@@ -1992,4 +2003,3 @@ export const SDK_CONFLICT_RESOLVER_TEMPLATE = {
1992
2003
  },
1993
2004
  };
1994
2005
 
1995
-
@@ -16,7 +16,9 @@
16
16
  * → loop.for_each (fan-out, maxConcurrent tasks at a time)
17
17
  * → sub-workflow: template-task-lifecycle per task
18
18
  * → action.set_variable (record batch results)
19
- * → notify.log (summary)
19
+ * → condition.expression (any failures?)
20
+ * → notify.telegram (failure alert)
21
+ * → notify.log (summary)
20
22
  */
21
23
 
22
24
  import { node, edge, resetLayout } from "./_helpers.mjs";
@@ -110,11 +112,19 @@ export const TASK_BATCH_PROCESSOR_TEMPLATE = {
110
112
  value: "{{dispatch-tasks}}",
111
113
  }, { x: 400, y: 570 }),
112
114
 
113
- // ── Notify on completion ─────────────────────────────────────────────
114
- node("notify-complete", "notify.telegram", "Batch Summary", {
115
- channel: "{{notifyChannel}}",
116
- message: "Task batch completed: {{dispatch-tasks.successCount}}/{{dispatch-tasks.totalItems}} succeeded ({{dispatch-tasks.failCount}} failed)",
115
+ node("has-batch-failures", "condition.expression", "Any Batch Failures?", {
116
+ expression: "Number($data?.batchResult?.failCount || 0) > 0",
117
117
  }, { x: 400, y: 700 }),
118
+
119
+ node("notify-failures", "notify.telegram", "Batch Failure Alert", {
120
+ channel: "{{notifyChannel}}",
121
+ message: "Task batch needs attention: {{batchResult.failCount}} failed out of {{batchResult.totalItems}} ({{batchResult.successCount}} succeeded)",
122
+ }, { x: 220, y: 830 }),
123
+
124
+ node("log-summary", "notify.log", "Batch Summary", {
125
+ message: "Task batch completed: {{batchResult.successCount}}/{{batchResult.totalItems}} succeeded ({{batchResult.failCount}} failed)",
126
+ level: "info",
127
+ }, { x: 580, y: 830 }),
118
128
  ],
119
129
  edges: [
120
130
  edge("trigger", "check-coordinator"),
@@ -122,7 +132,9 @@ export const TASK_BATCH_PROCESSOR_TEMPLATE = {
122
132
  edge("query-tasks", "dispatch-tasks"),
123
133
  edge("dispatch-tasks", "join-dispatch"),
124
134
  edge("join-dispatch", "record-results"),
125
- edge("record-results", "notify-complete"),
135
+ edge("record-results", "has-batch-failures"),
136
+ edge("has-batch-failures", "notify-failures", { condition: "$output?.result === true" }),
137
+ edge("has-batch-failures", "log-summary", { condition: "$output?.result !== true" }),
126
138
  ],
127
139
  metadata: {
128
140
  author: "bosun",
@@ -156,7 +168,6 @@ export const TASK_BATCH_PR_TEMPLATE = {
156
168
  maxBatchSize: 5,
157
169
  defaultBaseBranch: "main",
158
170
  draftPR: true,
159
- notifyChannel: "telegram",
160
171
  },
161
172
  nodes: [
162
173
  // ── Trigger ──────────────────────────────────────────────────────────
@@ -262,9 +273,9 @@ export const TASK_BATCH_PR_TEMPLATE = {
262
273
  }, { x: 400, y: 1230 }),
263
274
 
264
275
  // ── Batch complete notification ──────────────────────────────────────
265
- node("notify", "notify.telegram", "Batch Complete", {
266
- channel: "{{notifyChannel}}",
276
+ node("notify", "notify.log", "Batch Complete", {
267
277
  message: "Task batch PR pipeline complete",
278
+ level: "info",
268
279
  }, { x: 400, y: 1220 }),
269
280
  ],
270
281
  edges: [
@@ -431,19 +431,41 @@ export const TASK_LIFECYCLE_TEMPLATE = {
431
431
  taskId: "{{taskId}}",
432
432
  }, { x: 600, y: 1090 }),
433
433
 
434
+ node("wt-failure-blocking", "condition.expression", "Non-Retryable WT Failure?", {
435
+ expression: "$ctx.getNodeOutput('acquire-worktree')?.retryable === false",
436
+ }, { x: 600, y: 1220, outputs: ["yes", "no"] }),
437
+
438
+ node("set-blocked-wt-failed", "action.update_task_status", "Set Blocked (WT Fail)", {
439
+ taskId: "{{taskId}}",
440
+ status: "blocked",
441
+ taskTitle: "{{taskTitle}}",
442
+ }, { x: 470, y: 1350 }),
443
+
444
+ node("annotate-blocked-wt-failed", "action.bosun_function", "Annotate Blocked (WT Fail)", {
445
+ function: "tasks.update",
446
+ args: {
447
+ taskId: "{{taskId}}",
448
+ fields: {
449
+ cooldownUntil: "{{acquire-worktree.retryAt}}",
450
+ blockedReason: "{{acquire-worktree.blockedReason}}",
451
+ meta: "{{(() => { const current = ($data.taskMeta && typeof $data.taskMeta === 'object') ? $data.taskMeta : {}; const output = $ctx.getNodeOutput('acquire-worktree') || {}; return { ...current, autoRecovery: { active: true, reason: 'worktree_failure', failureKind: output.failureKind || 'branch_refresh_conflict', retryAt: output.retryAt || null, recoveryDelayMs: output.autoRecoverDelayMs || null, error: output.error || '', recordedAt: output.recordedAt || null }, worktreeFailure: { failureKind: output.failureKind || 'branch_refresh_conflict', retryable: output.retryable !== false, retryAt: output.retryAt || null, blockedReason: output.blockedReason || '', error: output.error || '', recordedAt: output.recordedAt || null } }; })()}}",
452
+ },
453
+ },
454
+ }, { x: 470, y: 1480 }),
455
+
434
456
  node("set-todo-wt-failed", "action.update_task_status", "Set Todo (WT Fail)", {
435
457
  taskId: "{{taskId}}",
436
458
  status: "todo",
437
459
  taskTitle: "{{taskTitle}}",
438
- }, { x: 600, y: 1220 }),
460
+ }, { x: 730, y: 1350 }),
439
461
 
440
462
  node("release-slot-wt-failed", "action.release_slot", "Release Slot (WT Fail)", {
441
463
  taskId: "{{taskId}}",
442
- }, { x: 600, y: 1350 }),
464
+ }, { x: 600, y: 1480 }),
443
465
 
444
466
  node("notify-wt-failed", "notify.telegram", "Notify WT Failed", {
445
- message: "⚠️ Worktree failed for \"{{taskTitle}}\" ({{taskId}})",
446
- }, { x: 600, y: 1480 }),
467
+ message: "⚠️ Worktree failed for \"{{taskTitle}}\" ({{taskId}}){{acquire-worktree.recoveryNote}}",
468
+ }, { x: 600, y: 1740 }),
447
469
  ],
448
470
  edges: [
449
471
  // Main flow
@@ -515,7 +537,11 @@ export const TASK_LIFECYCLE_TEMPLATE = {
515
537
 
516
538
  // Worktree failed path
517
539
  edge("worktree-ok", "release-claim-wt-failed", { condition: "$output?.result !== true", port: "no" }),
518
- edge("release-claim-wt-failed", "set-todo-wt-failed"),
540
+ edge("release-claim-wt-failed", "wt-failure-blocking"),
541
+ edge("wt-failure-blocking", "set-blocked-wt-failed", { condition: "$output?.result === true", port: "yes" }),
542
+ edge("wt-failure-blocking", "set-todo-wt-failed", { condition: "$output?.result !== true", port: "no" }),
543
+ edge("set-blocked-wt-failed", "annotate-blocked-wt-failed"),
544
+ edge("annotate-blocked-wt-failed", "release-slot-wt-failed"),
519
545
  edge("set-todo-wt-failed", "release-slot-wt-failed"),
520
546
  edge("release-slot-wt-failed", "notify-wt-failed"),
521
547
  ],
@@ -801,4 +827,3 @@ export const VE_ORCHESTRATOR_LITE_TEMPLATE = {
801
827
  },
802
828
  };
803
829
 
804
-