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
@@ -17,7 +17,13 @@
17
17
  * schema → object — JSON Schema for node config
18
18
  */
19
19
 
20
- import { registerNodeType } from "./workflow-engine.mjs";
20
+ import {
21
+ getNodeType,
22
+ listNodeTypes,
23
+ NodeStatus,
24
+ registerNodeType,
25
+ unregisterNodeType,
26
+ } from "./workflow-engine.mjs";
21
27
  import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
22
28
  import { resolve, dirname } from "node:path";
23
29
  import { execSync, execFileSync, spawn, spawnSync } from "node:child_process";
@@ -35,8 +41,18 @@ import {
35
41
  import { fixGitConfigCorruption } from "../workspace/worktree-manager.mjs";
36
42
  import { clearBlockedWorktreeIdentity } from "../git/git-safety.mjs";
37
43
  import { getGitHubToken, invalidateTokenType } from "../github/github-auth-manager.mjs";
44
+ import {
45
+ CUSTOM_NODE_DIR_NAME,
46
+ ensureCustomWorkflowNodesLoaded,
47
+ getCustomNodeDir,
48
+ scaffoldCustomNodeFile,
49
+ startCustomNodeDiscovery,
50
+ stopCustomNodeDiscovery,
51
+ } from "./workflow-nodes/custom-loader.mjs";
38
52
 
39
53
  const TAG = "[workflow-nodes]";
54
+ let customLoadPromise = null;
55
+ let customDiscoveryStarted = false;
40
56
  const PORTABLE_WORKTREE_COUNT_COMMAND = "node -e \"const cp=require('node:child_process');const wt=cp.execSync('git worktree list --porcelain',{encoding:'utf8'});const count=(wt.match(/^worktree /gm)||[]).length;process.stdout.write(String(count)+'\\\\n');\"";
41
57
  const PORTABLE_PRUNE_AND_COUNT_WORKTREES_COMMAND = "node -e \"const cp=require('node:child_process');cp.execSync('git worktree prune',{stdio:'ignore'});const wt=cp.execSync('git worktree list --porcelain',{encoding:'utf8'});const count=(wt.match(/^worktree /gm)||[]).length;process.stdout.write(String(count)+'\\\\n');\"";
42
58
  const WORKFLOW_AGENT_HEARTBEAT_MS = (() => {
@@ -51,6 +67,394 @@ const WORKFLOW_AGENT_EVENT_PREVIEW_LIMIT = (() => {
51
67
  })();
52
68
  const BOSUN_ATTACHED_PR_LABEL = "bosun-attached";
53
69
 
70
+ const HTML_TEXT_BREAK_TAGS = new Set([
71
+ "address",
72
+ "article",
73
+ "aside",
74
+ "blockquote",
75
+ "br",
76
+ "dd",
77
+ "div",
78
+ "dl",
79
+ "dt",
80
+ "figcaption",
81
+ "figure",
82
+ "footer",
83
+ "form",
84
+ "h1",
85
+ "h2",
86
+ "h3",
87
+ "h4",
88
+ "h5",
89
+ "h6",
90
+ "header",
91
+ "hr",
92
+ "li",
93
+ "main",
94
+ "nav",
95
+ "ol",
96
+ "p",
97
+ "pre",
98
+ "section",
99
+ "table",
100
+ "tbody",
101
+ "td",
102
+ "tfoot",
103
+ "th",
104
+ "thead",
105
+ "tr",
106
+ "ul",
107
+ ]);
108
+
109
+ function decodeHtmlEntities(value = "") {
110
+ return String(value).replace(/&(?:nbsp|amp|lt|gt|quot|apos|#39|#\d+|#x[0-9a-f]+);/gi, (entity) => {
111
+ const normalized = entity.toLowerCase();
112
+ switch (normalized) {
113
+ case " ":
114
+ return " ";
115
+ case "&":
116
+ return "&";
117
+ case "<":
118
+ return "<";
119
+ case "&gt;":
120
+ return ">";
121
+ case "&quot;":
122
+ return '"';
123
+ case "&apos;":
124
+ case "&#39;":
125
+ return "'";
126
+ default:
127
+ if (normalized.startsWith("&#x")) {
128
+ return String.fromCodePoint(Number.parseInt(normalized.slice(3, -1), 16));
129
+ }
130
+ if (normalized.startsWith("&#")) {
131
+ return String.fromCodePoint(Number.parseInt(normalized.slice(2, -1), 10));
132
+ }
133
+ return entity;
134
+ }
135
+ });
136
+ }
137
+
138
+ function stripHtmlToText(html = "") {
139
+ const input = String(html ?? "");
140
+ let plain = "";
141
+ let index = 0;
142
+ let skippedTagName = null;
143
+
144
+ while (index < input.length) {
145
+ const tagStart = input.indexOf("<", index);
146
+ if (tagStart === -1) {
147
+ if (!skippedTagName) plain += input.slice(index);
148
+ break;
149
+ }
150
+
151
+ if (!skippedTagName && tagStart > index) {
152
+ plain += input.slice(index, tagStart);
153
+ }
154
+
155
+ const tagEnd = input.indexOf(">", tagStart + 1);
156
+ if (tagEnd === -1) {
157
+ if (!skippedTagName) plain += input.slice(tagStart).replace(/</g, " ");
158
+ break;
159
+ }
160
+
161
+ const rawTag = input.slice(tagStart + 1, tagEnd).trim();
162
+ const loweredTag = rawTag.toLowerCase();
163
+ const isClosingTag = loweredTag.startsWith("/");
164
+ const normalizedTag = isClosingTag ? loweredTag.slice(1).trimStart() : loweredTag;
165
+ const tagName = normalizedTag.match(/^[a-z0-9]+/i)?.[0] ?? "";
166
+
167
+ if (skippedTagName) {
168
+ if (isClosingTag && tagName === skippedTagName) {
169
+ skippedTagName = null;
170
+ plain += " ";
171
+ }
172
+ index = tagEnd + 1;
173
+ continue;
174
+ }
175
+
176
+ if (tagName === "script" || tagName === "style") {
177
+ if (!isClosingTag && !normalizedTag.endsWith("/")) {
178
+ skippedTagName = tagName;
179
+ }
180
+ index = tagEnd + 1;
181
+ continue;
182
+ }
183
+
184
+ if (HTML_TEXT_BREAK_TAGS.has(tagName)) {
185
+ plain += " ";
186
+ }
187
+
188
+ index = tagEnd + 1;
189
+ }
190
+
191
+ return decodeHtmlEntities(plain);
192
+ }
193
+
194
+
195
+ const PORT_TYPE_DESCRIPTIONS = Object.freeze({
196
+ Any: "Wildcard payload",
197
+ TaskDef: "Task definition/context payload",
198
+ TriggerEvent: "Event payload emitted by trigger nodes",
199
+ AgentResult: "Agent execution output",
200
+ String: "Text payload",
201
+ Boolean: "Boolean flag",
202
+ Number: "Numeric payload",
203
+ JSON: "Structured JSON payload",
204
+ GitRef: "Git branch/hash/ref payload",
205
+ PRUrl: "Pull request URL payload",
206
+ LogStream: "Log output or command transcript",
207
+ SessionRef: "Session identifier payload",
208
+ CommandResult: "Command execution result",
209
+ });
210
+
211
+ const PORT_TYPE_COLORS = Object.freeze({
212
+ Any: "#9ca3af",
213
+ TaskDef: "#10b981",
214
+ TriggerEvent: "#22c55e",
215
+ AgentResult: "#8b5cf6",
216
+ String: "#3b82f6",
217
+ Boolean: "#14b8a6",
218
+ Number: "#0ea5e9",
219
+ JSON: "#06b6d4",
220
+ GitRef: "#f97316",
221
+ PRUrl: "#f43f5e",
222
+ LogStream: "#eab308",
223
+ SessionRef: "#a855f7",
224
+ CommandResult: "#f59e0b",
225
+ });
226
+
227
+ function clonePortSpec(port, fallbackName = "default") {
228
+ if (!port || typeof port !== "object") {
229
+ const type = "Any";
230
+ return {
231
+ name: fallbackName,
232
+ label: fallbackName,
233
+ type,
234
+ description: PORT_TYPE_DESCRIPTIONS[type],
235
+ color: PORT_TYPE_COLORS[type] || null,
236
+ accepts: [],
237
+ };
238
+ }
239
+ const type = String(port.type || "Any").trim() || "Any";
240
+ return {
241
+ ...port,
242
+ name: String(port.name || fallbackName).trim() || fallbackName,
243
+ label: String(port.label || port.name || fallbackName).trim() || fallbackName,
244
+ type,
245
+ description: String(port.description || PORT_TYPE_DESCRIPTIONS[type] || "").trim(),
246
+ color: String(port.color || PORT_TYPE_COLORS[type] || "").trim() || null,
247
+ accepts: Array.isArray(port.accepts)
248
+ ? Array.from(new Set(port.accepts.map((value) => String(value || "").trim()).filter(Boolean)))
249
+ : [],
250
+ };
251
+ }
252
+
253
+ function makePort(name, type, description = "", extra = {}) {
254
+ return clonePortSpec({
255
+ name,
256
+ label: name,
257
+ type,
258
+ description: description || PORT_TYPE_DESCRIPTIONS[type] || "",
259
+ color: PORT_TYPE_COLORS[type] || null,
260
+ ...extra,
261
+ }, name || "default");
262
+ }
263
+
264
+ const CATEGORY_PORT_DEFAULTS = Object.freeze({
265
+ trigger: Object.freeze({
266
+ inputs: [],
267
+ outputs: [makePort("default", "TriggerEvent")],
268
+ }),
269
+ condition: Object.freeze({
270
+ inputs: [makePort("default", "JSON", "", { accepts: ["TriggerEvent", "TaskDef", "AgentResult", "String", "Any"] })],
271
+ outputs: [makePort("default", "Boolean")],
272
+ }),
273
+ action: Object.freeze({
274
+ inputs: [makePort("default", "TaskDef", "", { accepts: ["TriggerEvent", "JSON", "String", "Boolean", "Any"] })],
275
+ outputs: [makePort("default", "JSON")],
276
+ }),
277
+ validation: Object.freeze({
278
+ inputs: [makePort("default", "JSON", "", { accepts: ["TaskDef", "Any"] })],
279
+ outputs: [makePort("default", "Boolean")],
280
+ }),
281
+ transform: Object.freeze({
282
+ inputs: [makePort("default", "JSON", "", { accepts: ["Any", "String"] })],
283
+ outputs: [makePort("default", "JSON")],
284
+ }),
285
+ notify: Object.freeze({
286
+ inputs: [makePort("default", "String", "", { accepts: ["Any", "JSON", "AgentResult", "LogStream"] })],
287
+ outputs: [makePort("default", "Any")],
288
+ }),
289
+ flow: Object.freeze({
290
+ inputs: [makePort("default", "Any")],
291
+ outputs: [makePort("default", "Any")],
292
+ }),
293
+ loop: Object.freeze({
294
+ inputs: [makePort("default", "Any")],
295
+ outputs: [makePort("default", "Any")],
296
+ }),
297
+ meeting: Object.freeze({
298
+ inputs: [makePort("default", "SessionRef", "", { accepts: ["TriggerEvent", "Any"] })],
299
+ outputs: [makePort("default", "JSON")],
300
+ }),
301
+ agent: Object.freeze({
302
+ inputs: [makePort("default", "TaskDef", "", { accepts: ["TriggerEvent", "JSON", "String", "Any"] })],
303
+ outputs: [makePort("default", "AgentResult")],
304
+ }),
305
+ });
306
+
307
+ const NODE_PORT_OVERRIDES = Object.freeze({
308
+ "trigger.manual": {
309
+ outputs: [makePort("default", "TaskDef", "Manual dispatch payload")],
310
+ },
311
+ "trigger.event": {
312
+ outputs: [makePort("default", "TriggerEvent", "Event payload")],
313
+ },
314
+ "action.run_agent": {
315
+ inputs: [makePort("default", "TaskDef", "", { accepts: ["TriggerEvent", "String", "JSON", "Boolean", "Any"] })],
316
+ outputs: [makePort("default", "AgentResult", "Agent response payload")],
317
+ },
318
+ "action.run_command": {
319
+ inputs: [makePort("default", "String", "", { accepts: ["TaskDef", "JSON", "TriggerEvent", "Boolean", "Any"] })],
320
+ outputs: [makePort("default", "CommandResult", "Command execution output", { accepts: ["LogStream"] })],
321
+ },
322
+ "action.git_operations": {
323
+ inputs: [makePort("default", "GitRef", "", { accepts: ["TaskDef", "JSON", "TriggerEvent", "Boolean", "String", "Any"] })],
324
+ outputs: [makePort("default", "GitRef", "Git operation result/ref")],
325
+ },
326
+ "action.push_branch": {
327
+ inputs: [makePort("default", "GitRef", "", { accepts: ["TaskDef", "JSON", "TriggerEvent", "Boolean", "String", "Any"] })],
328
+ outputs: [makePort("default", "GitRef")],
329
+ },
330
+ "action.detect_new_commits": {
331
+ inputs: [makePort("default", "GitRef", "", { accepts: ["TaskDef", "JSON", "TriggerEvent", "Boolean", "String", "Any"] })],
332
+ outputs: [makePort("default", "GitRef", "Commit detection summary")],
333
+ },
334
+ "action.create_pr": {
335
+ inputs: [makePort("default", "GitRef", "", { accepts: ["TaskDef", "JSON", "TriggerEvent", "Boolean", "String", "Any"] })],
336
+ outputs: [makePort("default", "PRUrl", "Pull request link payload")],
337
+ },
338
+ "transform.json_parse": {
339
+ inputs: [makePort("default", "String", "", { accepts: ["JSON", "Any"] })],
340
+ outputs: [makePort("default", "JSON")],
341
+ },
342
+ "condition.expression": {
343
+ inputs: [makePort("default", "JSON", "", { accepts: ["TaskDef", "AgentResult", "Any"] })],
344
+ outputs: [makePort("default", "Boolean")],
345
+ },
346
+ "notify.log": {
347
+ inputs: [makePort("default", "LogStream", "", { accepts: ["String", "Any", "JSON"] })],
348
+ outputs: [makePort("default", "LogStream")],
349
+ },
350
+ "action.continue_session": {
351
+ inputs: [makePort("default", "SessionRef", "", { accepts: ["TaskDef", "Any"] })],
352
+ outputs: [makePort("default", "AgentResult")],
353
+ },
354
+ "action.restart_agent": {
355
+ inputs: [makePort("default", "SessionRef", "", { accepts: ["TaskDef", "Any"] })],
356
+ outputs: [makePort("default", "AgentResult")],
357
+ },
358
+ });
359
+
360
+ const NODE_PRIMARY_FIELD_OVERRIDES = Object.freeze({
361
+ "action.run_agent": ["model", "prompt", "stream"],
362
+ "condition.expression": ["expression"],
363
+ "trigger.event": ["eventType", "filter"],
364
+ "trigger.schedule": ["intervalMs", "cron"],
365
+ "trigger.scheduled_once": ["runAt", "timezone"],
366
+ "action.git_operations": ["operation", "branch", "targetBranch"],
367
+ "action.create_pr": ["title", "baseBranch", "headBranch"],
368
+ "action.run_command": ["command", "cwd"],
369
+ "notify.telegram": ["chatId", "message"],
370
+ });
371
+
372
+ function inferPrimaryFields(schemaProps = {}) {
373
+ const keys = Object.keys(schemaProps || {});
374
+ if (keys.length === 0) return [];
375
+ const priority = [
376
+ "model",
377
+ "expression",
378
+ "enabled",
379
+ "branch",
380
+ "branchName",
381
+ "baseBranch",
382
+ "headBranch",
383
+ "eventType",
384
+ "command",
385
+ "message",
386
+ "prompt",
387
+ "query",
388
+ "operation",
389
+ "timeout",
390
+ ];
391
+ const selected = [];
392
+ for (const key of priority) {
393
+ if (keys.includes(key) && !selected.includes(key)) selected.push(key);
394
+ if (selected.length >= 3) return selected;
395
+ }
396
+ for (const key of keys) {
397
+ const field = schemaProps[key] || {};
398
+ const type = String(field.type || "string");
399
+ const isShortString = type === "string" && !field.format && !String(key).toLowerCase().includes("path");
400
+ const isBoolean = type === "boolean";
401
+ if (field.enum || isShortString || isBoolean) {
402
+ if (!selected.includes(key)) selected.push(key);
403
+ }
404
+ if (selected.length >= 3) break;
405
+ }
406
+ return selected.slice(0, 3);
407
+ }
408
+
409
+ function buildNodePorts(type, handler) {
410
+ const explicitPorts = handler?.ports || {};
411
+ const explicitInputs = Array.isArray(handler?.inputs) ? handler.inputs : explicitPorts.inputs;
412
+ const explicitOutputs = Array.isArray(handler?.outputs) ? handler.outputs : explicitPorts.outputs;
413
+ if (Array.isArray(explicitInputs) || Array.isArray(explicitOutputs)) {
414
+ return {
415
+ inputs: (explicitInputs || []).map((port, index) => clonePortSpec(port, index === 0 ? "default" : `input-${index + 1}`)),
416
+ outputs: (explicitOutputs || []).map((port, index) => clonePortSpec(port, index === 0 ? "default" : `output-${index + 1}`)),
417
+ };
418
+ }
419
+
420
+ const override = NODE_PORT_OVERRIDES[type];
421
+ if (override) {
422
+ return {
423
+ inputs: (override.inputs || []).map((port, index) => clonePortSpec(port, index === 0 ? "default" : `input-${index + 1}`)),
424
+ outputs: (override.outputs || []).map((port, index) => clonePortSpec(port, index === 0 ? "default" : `output-${index + 1}`)),
425
+ };
426
+ }
427
+
428
+ const [category] = String(type || "").split(".");
429
+ const fallback = CATEGORY_PORT_DEFAULTS[category] || CATEGORY_PORT_DEFAULTS.flow;
430
+ return {
431
+ inputs: (fallback.inputs || []).map((port, index) => clonePortSpec(port, index === 0 ? "default" : `input-${index + 1}`)),
432
+ outputs: (fallback.outputs || []).map((port, index) => clonePortSpec(port, index === 0 ? "default" : `output-${index + 1}`)),
433
+ };
434
+ }
435
+
436
+ function buildNodeUi(type, handler) {
437
+ const schemaProps = handler?.schema?.properties || {};
438
+ const explicitPrimaryFields = Array.isArray(handler?.ui?.primaryFields)
439
+ ? handler.ui.primaryFields
440
+ : null;
441
+ const inferred = NODE_PRIMARY_FIELD_OVERRIDES[type] || inferPrimaryFields(schemaProps);
442
+ return {
443
+ ...(handler?.ui || {}),
444
+ primaryFields: (explicitPrimaryFields || inferred)
445
+ .map((value) => String(value || "").trim())
446
+ .filter(Boolean),
447
+ };
448
+ }
449
+
450
+ function registerBuiltinNodeType(type, handler) {
451
+ const ports = buildNodePorts(type, handler);
452
+ const ui = buildNodeUi(type, handler);
453
+ handler.ports = ports;
454
+ handler.ui = ui;
455
+ registerNodeType(type, handler);
456
+ }
457
+
54
458
  function shouldBypassGhPrCreationForTests() {
55
459
  return Boolean(process.env.VITEST) && process.env.BOSUN_TEST_ALLOW_GH !== "true";
56
460
  }
@@ -885,7 +1289,25 @@ function normalizeLegacyWorkflowCommand(command) {
885
1289
  }
886
1290
 
887
1291
  function resolveWorkflowNodeValue(value, ctx) {
888
- if (typeof value === "string") return ctx.resolve(value);
1292
+ if (typeof value === "string") {
1293
+ const resolved = ctx.resolve(value);
1294
+ if (resolved !== value) return resolved;
1295
+
1296
+ const exactExpr = value.match(/^\{\{([\s\S]+)\}\}$/);
1297
+ if (exactExpr) {
1298
+ const expr = String(exactExpr[1] || "").trim();
1299
+ if (expr.includes("$ctx") || expr.includes("$data") || expr.includes("$output")) {
1300
+ try {
1301
+ const fn = new Function("$data", "$ctx", "$output", `return (${expr});`);
1302
+ const evalResult = fn(ctx.data || {}, ctx, null);
1303
+ if (evalResult !== undefined) return evalResult;
1304
+ } catch {
1305
+ // Fall through to unresolved template string when expression is invalid.
1306
+ }
1307
+ }
1308
+ }
1309
+ return resolved;
1310
+ }
889
1311
  if (Array.isArray(value)) {
890
1312
  return value.map((item) => resolveWorkflowNodeValue(item, ctx));
891
1313
  }
@@ -1152,7 +1574,7 @@ function buildWorkflowAgentToolContract(rootDir, agentProfileId = "") {
1152
1574
  // TRIGGERS — Events that initiate a workflow
1153
1575
  // ═══════════════════════════════════════════════════════════════════════════
1154
1576
 
1155
- registerNodeType("trigger.manual", {
1577
+ registerBuiltinNodeType("trigger.manual", {
1156
1578
  describe: () => "Manual trigger — workflow starts on user request",
1157
1579
  schema: {
1158
1580
  type: "object",
@@ -1164,7 +1586,7 @@ registerNodeType("trigger.manual", {
1164
1586
  },
1165
1587
  });
1166
1588
 
1167
- registerNodeType("trigger.task_low", {
1589
+ registerBuiltinNodeType("trigger.task_low", {
1168
1590
  describe: () =>
1169
1591
  "Fires when backlog task count drops below threshold. Self-queries kanban " +
1170
1592
  "when todoCount is not pre-populated in context data. Workspace-aware: " +
@@ -1230,7 +1652,7 @@ registerNodeType("trigger.task_low", {
1230
1652
  },
1231
1653
  });
1232
1654
 
1233
- registerNodeType("trigger.schedule", {
1655
+ registerBuiltinNodeType("trigger.schedule", {
1234
1656
  describe: () => "Fires on a cron-like schedule (checked by supervisor loop)",
1235
1657
  schema: {
1236
1658
  type: "object",
@@ -1249,7 +1671,7 @@ registerNodeType("trigger.schedule", {
1249
1671
  },
1250
1672
  });
1251
1673
 
1252
- registerNodeType("trigger.event", {
1674
+ registerBuiltinNodeType("trigger.event", {
1253
1675
  describe: () => "Fires on a specific bosun event (task.complete, pr.merged, etc.)",
1254
1676
  schema: {
1255
1677
  type: "object",
@@ -1274,7 +1696,7 @@ registerNodeType("trigger.event", {
1274
1696
  },
1275
1697
  });
1276
1698
 
1277
- registerNodeType("trigger.meeting.wake_phrase", {
1699
+ registerBuiltinNodeType("trigger.meeting.wake_phrase", {
1278
1700
  describe: () => "Fires when a transcript/event payload contains the configured wake phrase",
1279
1701
  schema: {
1280
1702
  type: "object",
@@ -1433,7 +1855,7 @@ registerNodeType("trigger.meeting.wake_phrase", {
1433
1855
  },
1434
1856
  });
1435
1857
 
1436
- registerNodeType("trigger.webhook", {
1858
+ registerBuiltinNodeType("trigger.webhook", {
1437
1859
  describe: () => "Fires when a webhook is received at the workflow's endpoint",
1438
1860
  schema: {
1439
1861
  type: "object",
@@ -1447,7 +1869,7 @@ registerNodeType("trigger.webhook", {
1447
1869
  },
1448
1870
  });
1449
1871
 
1450
- registerNodeType("trigger.pr_event", {
1872
+ registerBuiltinNodeType("trigger.pr_event", {
1451
1873
  describe: () => "Fires on PR events (opened, merged, review requested, etc.)",
1452
1874
  schema: {
1453
1875
  type: "object",
@@ -1484,7 +1906,7 @@ registerNodeType("trigger.pr_event", {
1484
1906
  },
1485
1907
  });
1486
1908
 
1487
- registerNodeType("trigger.task_assigned", {
1909
+ registerBuiltinNodeType("trigger.task_assigned", {
1488
1910
  describe: () => "Fires when a task is assigned to an agent",
1489
1911
  schema: {
1490
1912
  type: "object",
@@ -1500,7 +1922,7 @@ registerNodeType("trigger.task_assigned", {
1500
1922
  },
1501
1923
  });
1502
1924
 
1503
- registerNodeType("trigger.anomaly", {
1925
+ registerBuiltinNodeType("trigger.anomaly", {
1504
1926
  describe: () => "Fires when the anomaly detector reports an anomaly matching the configured criteria",
1505
1927
  schema: {
1506
1928
  type: "object",
@@ -1540,7 +1962,7 @@ registerNodeType("trigger.anomaly", {
1540
1962
  },
1541
1963
  });
1542
1964
 
1543
- registerNodeType("trigger.scheduled_once", {
1965
+ registerBuiltinNodeType("trigger.scheduled_once", {
1544
1966
  describe: () => "Fires once at or after a specific scheduled time (persistent — survives restarts)",
1545
1967
  schema: {
1546
1968
  type: "object",
@@ -1577,7 +1999,7 @@ registerNodeType("trigger.scheduled_once", {
1577
1999
  },
1578
2000
  });
1579
2001
 
1580
- registerNodeType("trigger.workflow_call", {
2002
+ registerBuiltinNodeType("trigger.workflow_call", {
1581
2003
  describe: () =>
1582
2004
  "Fires when this workflow is invoked by another workflow via action.execute_workflow. " +
1583
2005
  "Defines expected input parameters that callers should provide.",
@@ -1642,7 +2064,7 @@ registerNodeType("trigger.workflow_call", {
1642
2064
  // CONDITIONS — Branching / routing logic
1643
2065
  // ═══════════════════════════════════════════════════════════════════════════
1644
2066
 
1645
- registerNodeType("condition.expression", {
2067
+ registerBuiltinNodeType("condition.expression", {
1646
2068
  describe: () => "Evaluate a JS expression to branch workflow execution",
1647
2069
  schema: {
1648
2070
  type: "object",
@@ -1667,7 +2089,7 @@ registerNodeType("condition.expression", {
1667
2089
  },
1668
2090
  });
1669
2091
 
1670
- registerNodeType("condition.task_has_tag", {
2092
+ registerBuiltinNodeType("condition.task_has_tag", {
1671
2093
  describe: () => "Check if current task has a specific tag or label",
1672
2094
  schema: {
1673
2095
  type: "object",
@@ -1689,7 +2111,7 @@ registerNodeType("condition.task_has_tag", {
1689
2111
  },
1690
2112
  });
1691
2113
 
1692
- registerNodeType("condition.file_exists", {
2114
+ registerBuiltinNodeType("condition.file_exists", {
1693
2115
  describe: () => "Check if a file or directory exists in the workspace",
1694
2116
  schema: {
1695
2117
  type: "object",
@@ -1706,7 +2128,7 @@ registerNodeType("condition.file_exists", {
1706
2128
  },
1707
2129
  });
1708
2130
 
1709
- registerNodeType("condition.switch", {
2131
+ registerBuiltinNodeType("condition.switch", {
1710
2132
  describe: () => "Multi-way branch based on a value matching cases",
1711
2133
  schema: {
1712
2134
  type: "object",
@@ -1759,12 +2181,13 @@ registerNodeType("condition.switch", {
1759
2181
  // ACTIONS — Side-effect operations
1760
2182
  // ═══════════════════════════════════════════════════════════════════════════
1761
2183
 
1762
- registerNodeType("action.run_agent", {
2184
+ registerBuiltinNodeType("action.run_agent", {
1763
2185
  describe: () => "Run a bosun agent with a prompt to perform work",
1764
2186
  schema: {
1765
2187
  type: "object",
1766
2188
  properties: {
1767
2189
  prompt: { type: "string", description: "Agent prompt (supports {{variables}})" },
2190
+ systemPrompt: { type: "string", description: "Optional stable system prompt for cache anchoring" },
1768
2191
  sdk: { type: "string", enum: ["codex", "copilot", "claude", "auto"], default: "auto" },
1769
2192
  model: { type: "string", description: "Optional model override for the selected SDK" },
1770
2193
  taskId: { type: "string", description: "Optional task ID used for task metadata lookup" },
@@ -1822,8 +2245,16 @@ registerNodeType("action.run_agent", {
1822
2245
  ? Math.max(1000, Math.trunc(Number(resolvedTimeoutMs)))
1823
2246
  : 3600000;
1824
2247
  const includeTaskContext = node.config?.includeTaskContext !== false;
2248
+ const configuredSystemPrompt =
2249
+ ctx.resolve(node.config?.systemPrompt || "") ||
2250
+ ctx.data?._taskSystemPrompt ||
2251
+ "";
1825
2252
  const toolContract = buildWorkflowAgentToolContract(cwd, agentProfileId);
1826
- let finalPrompt = `${toolContract}\n\n${prompt}`;
2253
+ const effectiveSystemPrompt = [configuredSystemPrompt, toolContract]
2254
+ .map((value) => String(value || "").trim())
2255
+ .filter(Boolean)
2256
+ .join("\n\n");
2257
+ let finalPrompt = prompt;
1827
2258
  if (includeTaskContext) {
1828
2259
  const explicitContext =
1829
2260
  ctx.data?.taskContext ||
@@ -1831,7 +2262,7 @@ registerNodeType("action.run_agent", {
1831
2262
  null;
1832
2263
  const task = ctx.data?.task || ctx.data?.taskDetail || ctx.data?.taskInfo || null;
1833
2264
  const contextBlock = explicitContext || buildTaskContextBlock(task);
1834
- if (contextBlock) finalPrompt = `${prompt}\n\n${contextBlock}`;
2265
+ if (contextBlock) finalPrompt = `${finalPrompt}\n\n${contextBlock}`;
1835
2266
  }
1836
2267
 
1837
2268
  ctx.log(node.id, `Running agent (${sdk}) in ${cwd}`);
@@ -2167,6 +2598,7 @@ registerNodeType("action.run_agent", {
2167
2598
  sdk: sdkOverride,
2168
2599
  model: modelOverride,
2169
2600
  onEvent: launchExtra.onEvent,
2601
+ systemPrompt: effectiveSystemPrompt,
2170
2602
  });
2171
2603
  }
2172
2604
 
@@ -2178,10 +2610,12 @@ registerNodeType("action.run_agent", {
2178
2610
  sdk: sdkOverride,
2179
2611
  model: modelOverride,
2180
2612
  onEvent: launchExtra.onEvent,
2613
+ systemPrompt: effectiveSystemPrompt,
2181
2614
  });
2182
2615
  }
2183
2616
 
2184
2617
  if (!result) {
2618
+ launchExtra.systemPrompt = effectiveSystemPrompt;
2185
2619
  result = await agentPool.launchEphemeralThread(passPrompt, cwd, timeoutMs, launchExtra);
2186
2620
  }
2187
2621
  success = result?.success === true;
@@ -2477,13 +2911,14 @@ registerNodeType("action.run_agent", {
2477
2911
  },
2478
2912
  });
2479
2913
 
2480
- registerNodeType("action.run_command", {
2914
+ registerBuiltinNodeType("action.run_command", {
2481
2915
  describe: () => "Execute a shell command in the workspace",
2482
2916
  schema: {
2483
2917
  type: "object",
2484
2918
  properties: {
2485
2919
  command: { type: "string", description: "Shell command to run" },
2486
2920
  cwd: { type: "string", description: "Working directory" },
2921
+ env: { type: "object", description: "Environment variables passed to the command (supports templates)", additionalProperties: true },
2487
2922
  timeoutMs: { type: "number", default: 300000 },
2488
2923
  shell: { type: "string", default: "auto", enum: ["auto", "bash", "pwsh", "cmd"] },
2489
2924
  captureOutput: { type: "boolean", default: true },
@@ -2495,6 +2930,20 @@ registerNodeType("action.run_command", {
2495
2930
  const resolvedCommand = ctx.resolve(node.config?.command || "");
2496
2931
  const command = normalizeLegacyWorkflowCommand(resolvedCommand);
2497
2932
  const cwd = ctx.resolve(node.config?.cwd || ctx.data?.worktreePath || process.cwd());
2933
+ const resolvedEnvConfig = resolveWorkflowNodeValue(node.config?.env ?? {}, ctx);
2934
+ const commandEnv = { ...process.env };
2935
+ if (resolvedEnvConfig && typeof resolvedEnvConfig === "object" && !Array.isArray(resolvedEnvConfig)) {
2936
+ for (const [key, value] of Object.entries(resolvedEnvConfig)) {
2937
+ const name = String(key || "").trim();
2938
+ if (!name) continue;
2939
+ if (value == null) {
2940
+ delete commandEnv[name];
2941
+ continue;
2942
+ }
2943
+ commandEnv[name] = typeof value === "string" ? value : JSON.stringify(value);
2944
+ }
2945
+ }
2946
+
2498
2947
  const timeout = node.config?.timeoutMs || 300000;
2499
2948
 
2500
2949
  if (command !== resolvedCommand) {
@@ -2508,6 +2957,7 @@ registerNodeType("action.run_command", {
2508
2957
  encoding: "utf8",
2509
2958
  maxBuffer: 10 * 1024 * 1024,
2510
2959
  stdio: node.config?.captureOutput !== false ? "pipe" : "inherit",
2960
+ env: commandEnv,
2511
2961
  });
2512
2962
  ctx.log(node.id, `Command succeeded`);
2513
2963
  return { success: true, output: output?.trim(), exitCode: 0 };
@@ -2530,7 +2980,7 @@ registerNodeType("action.run_command", {
2530
2980
  },
2531
2981
  });
2532
2982
 
2533
- registerNodeType("action.execute_workflow", {
2983
+ registerBuiltinNodeType("action.execute_workflow", {
2534
2984
  describe: () => "Execute another workflow by ID (synchronously or dispatch mode)",
2535
2985
  schema: {
2536
2986
  type: "object",
@@ -2751,7 +3201,313 @@ registerNodeType("action.execute_workflow", {
2751
3201
  },
2752
3202
  });
2753
3203
 
2754
- registerNodeType("meeting.start", {
3204
+ registerBuiltinNodeType("action.inline_workflow", {
3205
+ describe: () =>
3206
+ "Execute an embedded workflow definition inline (sync or dispatch) without saving it. " +
3207
+ "Useful for parent workflows that need a local subgraph with its own run/context boundary.",
3208
+ schema: {
3209
+ type: "object",
3210
+ properties: {
3211
+ workflow: {
3212
+ type: "object",
3213
+ description:
3214
+ "Embedded workflow definition fragment. Supports { name, variables, nodes, edges, trigger, metadata }.",
3215
+ additionalProperties: true,
3216
+ },
3217
+ mode: { type: "string", enum: ["sync", "dispatch"], default: "sync" },
3218
+ input: {
3219
+ type: "object",
3220
+ description: "Input payload passed to the embedded workflow",
3221
+ additionalProperties: true,
3222
+ },
3223
+ inheritContext: {
3224
+ type: "boolean",
3225
+ default: false,
3226
+ description: "Copy parent workflow context data into child input before applying input overrides",
3227
+ },
3228
+ includeKeys: {
3229
+ type: "array",
3230
+ items: { type: "string" },
3231
+ description: "Optional allow-list of parent context keys to inherit when inheritContext=true",
3232
+ },
3233
+ outputVariable: {
3234
+ type: "string",
3235
+ description: "Optional context key to store execution summary output",
3236
+ },
3237
+ failOnChildError: {
3238
+ type: "boolean",
3239
+ default: true,
3240
+ description: "In sync mode, throw when the embedded workflow completes with errors",
3241
+ },
3242
+ forwardFields: {
3243
+ type: "array",
3244
+ items: { type: "string" },
3245
+ description:
3246
+ "Optional allow-list of top-level fields from the embedded workflow's extracted outputs " +
3247
+ "to promote onto this node's output.",
3248
+ },
3249
+ extractFromNodes: {
3250
+ type: "array",
3251
+ items: { type: "string" },
3252
+ description:
3253
+ "Optional child node IDs to extract outputs from. When omitted, the last completed " +
3254
+ "child node output is used.",
3255
+ },
3256
+ allowRecursive: {
3257
+ type: "boolean",
3258
+ default: false,
3259
+ description: "Allow recursive embedded workflow execution when true",
3260
+ },
3261
+ },
3262
+ required: ["workflow"],
3263
+ },
3264
+ async execute(node, ctx, engine) {
3265
+ const workflowDef = resolveWorkflowNodeValue(node.config?.workflow ?? null, ctx);
3266
+ const modeRaw = String(ctx.resolve(node.config?.mode || "sync") || "sync")
3267
+ .trim()
3268
+ .toLowerCase();
3269
+ const mode = modeRaw || "sync";
3270
+ const outputVariable = String(ctx.resolve(node.config?.outputVariable || "") || "").trim();
3271
+ const inheritContext = parseBooleanSetting(
3272
+ resolveWorkflowNodeValue(node.config?.inheritContext ?? false, ctx),
3273
+ false,
3274
+ );
3275
+ const failOnChildError = parseBooleanSetting(
3276
+ resolveWorkflowNodeValue(node.config?.failOnChildError ?? true, ctx),
3277
+ true,
3278
+ );
3279
+ const allowRecursive = parseBooleanSetting(
3280
+ resolveWorkflowNodeValue(node.config?.allowRecursive ?? false, ctx),
3281
+ false,
3282
+ );
3283
+ const includeKeys = Array.isArray(node.config?.includeKeys)
3284
+ ? node.config.includeKeys
3285
+ .map((value) => String(resolveWorkflowNodeValue(value, ctx) || "").trim())
3286
+ .filter(Boolean)
3287
+ : [];
3288
+ const forwardFields = Array.isArray(node.config?.forwardFields)
3289
+ ? node.config.forwardFields
3290
+ .map((value) => String(resolveWorkflowNodeValue(value, ctx) || "").trim())
3291
+ .filter(Boolean)
3292
+ : [];
3293
+ const extractFromNodes = Array.isArray(node.config?.extractFromNodes)
3294
+ ? node.config.extractFromNodes
3295
+ .map((value) => String(resolveWorkflowNodeValue(value, ctx) || "").trim())
3296
+ .filter(Boolean)
3297
+ : [];
3298
+
3299
+ if (!workflowDef || typeof workflowDef !== "object" || Array.isArray(workflowDef)) {
3300
+ throw new Error("action.inline_workflow: 'workflow' must resolve to an object");
3301
+ }
3302
+ if (mode !== "sync" && mode !== "dispatch") {
3303
+ throw new Error(`action.inline_workflow: invalid mode "${mode}". Expected "sync" or "dispatch".`);
3304
+ }
3305
+ if (!engine || typeof engine.executeDefinition !== "function") {
3306
+ throw new Error("action.inline_workflow: workflow engine is not available");
3307
+ }
3308
+
3309
+ const resolvedInputConfig = resolveWorkflowNodeValue(node.config?.input ?? {}, ctx);
3310
+ if (
3311
+ resolvedInputConfig != null &&
3312
+ (typeof resolvedInputConfig !== "object" || Array.isArray(resolvedInputConfig))
3313
+ ) {
3314
+ throw new Error("action.inline_workflow: 'input' must resolve to an object");
3315
+ }
3316
+ const configuredInput =
3317
+ resolvedInputConfig && typeof resolvedInputConfig === "object"
3318
+ ? resolvedInputConfig
3319
+ : {};
3320
+
3321
+ const sourceData =
3322
+ ctx.data && typeof ctx.data === "object"
3323
+ ? ctx.data
3324
+ : {};
3325
+ const inheritedInput = {};
3326
+ if (inheritContext) {
3327
+ if (includeKeys.length > 0) {
3328
+ for (const key of includeKeys) {
3329
+ if (Object.prototype.hasOwnProperty.call(sourceData, key)) {
3330
+ inheritedInput[key] = sourceData[key];
3331
+ }
3332
+ }
3333
+ } else {
3334
+ Object.assign(inheritedInput, sourceData);
3335
+ }
3336
+ }
3337
+
3338
+ const parentWorkflowId = String(ctx.data?._workflowId || "").trim() || "inline-parent";
3339
+ const workflowStack = normalizeWorkflowStack(ctx.data?._workflowStack);
3340
+ if (parentWorkflowId && workflowStack[workflowStack.length - 1] !== parentWorkflowId) {
3341
+ workflowStack.push(parentWorkflowId);
3342
+ }
3343
+ const inlineWorkflowId = String(workflowDef.id || `inline:${parentWorkflowId}:${node.id}`).trim();
3344
+ if (!allowRecursive && workflowStack.includes(inlineWorkflowId)) {
3345
+ const cyclePath = [...workflowStack, inlineWorkflowId].join(" -> ");
3346
+ throw new Error(
3347
+ `action.inline_workflow: recursive inline workflow call blocked (${cyclePath}). ` +
3348
+ "Set allowRecursive=true to override.",
3349
+ );
3350
+ }
3351
+
3352
+ const childInput = {
3353
+ ...inheritedInput,
3354
+ ...configuredInput,
3355
+ _workflowStack: [...workflowStack, inlineWorkflowId],
3356
+ _parentWorkflowId: parentWorkflowId,
3357
+ };
3358
+
3359
+ const inlineName = String(workflowDef.name || node.label || `Inline ${node.id}`).trim() || inlineWorkflowId;
3360
+ const executeInline = () => engine.executeDefinition({
3361
+ trigger: workflowDef.trigger || "trigger.workflow_call",
3362
+ ...workflowDef,
3363
+ id: inlineWorkflowId,
3364
+ name: inlineName,
3365
+ metadata: {
3366
+ ...(workflowDef.metadata || {}),
3367
+ inline: true,
3368
+ sourceNodeId: node.id,
3369
+ parentWorkflowId,
3370
+ },
3371
+ }, childInput, {
3372
+ force: true,
3373
+ sourceNodeId: node.id,
3374
+ inlineWorkflowId,
3375
+ inlineWorkflowName: inlineName,
3376
+ });
3377
+
3378
+ const extractChildOutputs = (childCtx) => {
3379
+ const childOutputs = childCtx?.nodeOutputs instanceof Map
3380
+ ? Object.fromEntries(childCtx.nodeOutputs)
3381
+ : childCtx?.nodeOutputs && typeof childCtx.nodeOutputs === "object"
3382
+ ? { ...childCtx.nodeOutputs }
3383
+ : {};
3384
+
3385
+ let extracted = {};
3386
+ if (extractFromNodes.length > 0) {
3387
+ for (const childNodeId of extractFromNodes) {
3388
+ if (Object.prototype.hasOwnProperty.call(childOutputs, childNodeId)) {
3389
+ extracted[childNodeId] = childOutputs[childNodeId];
3390
+ }
3391
+ }
3392
+ if (extractFromNodes.length === 1) {
3393
+ const single = extracted[extractFromNodes[0]];
3394
+ if (
3395
+ single &&
3396
+ typeof single === "object" &&
3397
+ single._workflowEnd === true &&
3398
+ single.output &&
3399
+ typeof single.output === "object" &&
3400
+ !Array.isArray(single.output)
3401
+ ) {
3402
+ extracted = { ...single.output };
3403
+ }
3404
+ }
3405
+ } else {
3406
+ const completedNodeIds = Array.from(childCtx?.nodeStatuses?.entries?.() || [])
3407
+ .filter(([, status]) => status === NodeStatus.COMPLETED)
3408
+ .map(([childNodeId]) => childNodeId);
3409
+ const lastCompletedNodeId = completedNodeIds[completedNodeIds.length - 1];
3410
+ if (lastCompletedNodeId && Object.prototype.hasOwnProperty.call(childOutputs, lastCompletedNodeId)) {
3411
+ const candidate = childOutputs[lastCompletedNodeId];
3412
+ if (
3413
+ candidate &&
3414
+ typeof candidate === "object" &&
3415
+ candidate._workflowEnd === true &&
3416
+ candidate.output &&
3417
+ typeof candidate.output === "object" &&
3418
+ !Array.isArray(candidate.output)
3419
+ ) {
3420
+ extracted = { ...candidate.output };
3421
+ } else if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
3422
+ extracted = { ...candidate };
3423
+ } else {
3424
+ extracted = { result: candidate };
3425
+ }
3426
+ }
3427
+ }
3428
+
3429
+ if (forwardFields.length > 0 && extracted && typeof extracted === "object") {
3430
+ return Object.fromEntries(
3431
+ forwardFields
3432
+ .filter((field) => Object.prototype.hasOwnProperty.call(extracted, field))
3433
+ .map((field) => [field, extracted[field]]),
3434
+ );
3435
+ }
3436
+ return extracted;
3437
+ };
3438
+
3439
+ if (mode === "dispatch") {
3440
+ ctx.log(node.id, `Dispatching inline workflow "${inlineWorkflowId}"`);
3441
+ let dispatched;
3442
+ try {
3443
+ dispatched = Promise.resolve(executeInline());
3444
+ } catch (err) {
3445
+ dispatched = Promise.reject(err);
3446
+ }
3447
+ dispatched
3448
+ .then((childCtx) => {
3449
+ const status = childCtx?.errors?.length ? "failed" : "completed";
3450
+ ctx.log(node.id, `Dispatched inline workflow "${inlineWorkflowId}" finished with status=${status}`);
3451
+ })
3452
+ .catch((err) => {
3453
+ ctx.log(node.id, `Dispatched inline workflow "${inlineWorkflowId}" failed: ${err.message}`, "error");
3454
+ });
3455
+
3456
+ const output = {
3457
+ success: true,
3458
+ dispatched: true,
3459
+ mode: "dispatch",
3460
+ workflowId: inlineWorkflowId,
3461
+ matchedPort: "default",
3462
+ port: "default",
3463
+ };
3464
+ if (outputVariable) {
3465
+ ctx.data[outputVariable] = output;
3466
+ }
3467
+ return output;
3468
+ }
3469
+
3470
+ ctx.log(node.id, `Executing inline workflow "${inlineWorkflowId}" (sync)`);
3471
+ const childCtx = await executeInline();
3472
+ const childErrors = Array.isArray(childCtx?.errors)
3473
+ ? childCtx.errors.map((entry) => ({
3474
+ nodeId: entry?.nodeId || null,
3475
+ error: String(entry?.error || "unknown child workflow error"),
3476
+ }))
3477
+ : [];
3478
+ const status = childErrors.length > 0 ? "failed" : "completed";
3479
+ const extracted = extractChildOutputs(childCtx);
3480
+ const output = {
3481
+ success: status === "completed",
3482
+ dispatched: false,
3483
+ mode: "sync",
3484
+ workflowId: inlineWorkflowId,
3485
+ runId: childCtx?.id || null,
3486
+ status,
3487
+ errorCount: childErrors.length,
3488
+ errors: childErrors,
3489
+ matchedPort: status === "completed" ? "default" : "error",
3490
+ port: status === "completed" ? "default" : "error",
3491
+ childOutputs: extracted,
3492
+ ...(extracted && typeof extracted === "object" ? extracted : {}),
3493
+ };
3494
+
3495
+ if (outputVariable) {
3496
+ ctx.data[outputVariable] = output;
3497
+ }
3498
+
3499
+ if (status === "failed" && failOnChildError) {
3500
+ const reason = childErrors[0]?.error || "inline workflow failed";
3501
+ const err = new Error(`action.inline_workflow: child inline workflow "${inlineWorkflowId}" failed: ${reason}`);
3502
+ err.childWorkflow = output;
3503
+ throw err;
3504
+ }
3505
+
3506
+ return output;
3507
+ },
3508
+ });
3509
+
3510
+ registerBuiltinNodeType("meeting.start", {
2755
3511
  describe: () => "Create or reuse a meeting session for workflow-driven voice/video orchestration",
2756
3512
  schema: {
2757
3513
  type: "object",
@@ -2831,7 +3587,7 @@ registerNodeType("meeting.start", {
2831
3587
  },
2832
3588
  });
2833
3589
 
2834
- registerNodeType("meeting.send", {
3590
+ registerBuiltinNodeType("meeting.send", {
2835
3591
  describe: () => "Send a meeting message through the meeting session dispatcher",
2836
3592
  schema: {
2837
3593
  type: "object",
@@ -2908,7 +3664,7 @@ registerNodeType("meeting.send", {
2908
3664
  },
2909
3665
  });
2910
3666
 
2911
- registerNodeType("meeting.transcript", {
3667
+ registerBuiltinNodeType("meeting.transcript", {
2912
3668
  describe: () => "Fetch meeting transcript pages and optionally project as plain text",
2913
3669
  schema: {
2914
3670
  type: "object",
@@ -2979,7 +3735,7 @@ registerNodeType("meeting.transcript", {
2979
3735
  },
2980
3736
  });
2981
3737
 
2982
- registerNodeType("meeting.vision", {
3738
+ registerBuiltinNodeType("meeting.vision", {
2983
3739
  describe: () => "Analyze a meeting video frame and persist a vision summary",
2984
3740
  schema: {
2985
3741
  type: "object",
@@ -3076,7 +3832,7 @@ registerNodeType("meeting.vision", {
3076
3832
  },
3077
3833
  });
3078
3834
 
3079
- registerNodeType("meeting.finalize", {
3835
+ registerBuiltinNodeType("meeting.finalize", {
3080
3836
  describe: () => "Finalize a meeting session with status and optional note",
3081
3837
  schema: {
3082
3838
  type: "object",
@@ -3128,7 +3884,7 @@ registerNodeType("meeting.finalize", {
3128
3884
  },
3129
3885
  });
3130
3886
 
3131
- registerNodeType("action.create_task", {
3887
+ registerBuiltinNodeType("action.create_task", {
3132
3888
  describe: () => "Create a new task in the kanban board",
3133
3889
  schema: {
3134
3890
  type: "object",
@@ -3174,7 +3930,7 @@ registerNodeType("action.create_task", {
3174
3930
  },
3175
3931
  });
3176
3932
 
3177
- registerNodeType("action.update_task_status", {
3933
+ registerBuiltinNodeType("action.update_task_status", {
3178
3934
  describe: () => "Update the status of an existing task",
3179
3935
  schema: {
3180
3936
  type: "object",
@@ -3323,7 +4079,7 @@ registerNodeType("action.update_task_status", {
3323
4079
  },
3324
4080
  });
3325
4081
 
3326
- registerNodeType("action.git_operations", {
4082
+ registerBuiltinNodeType("action.git_operations", {
3327
4083
  describe: () => "Perform git operations (commit, push, create branch, etc.)",
3328
4084
  schema: {
3329
4085
  type: "object",
@@ -3423,7 +4179,7 @@ registerNodeType("action.git_operations", {
3423
4179
  },
3424
4180
  });
3425
4181
 
3426
- registerNodeType("action.create_pr", {
4182
+ registerBuiltinNodeType("action.create_pr", {
3427
4183
  describe: () =>
3428
4184
  "Create a pull request via GitHub CLI. Falls back to Bosun-managed handoff " +
3429
4185
  "when gh is unavailable or the operation fails with failOnError=false.",
@@ -3445,6 +4201,26 @@ registerNodeType("action.create_pr", {
3445
4201
  oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
3446
4202
  description: "Comma-separated or array of reviewer handles",
3447
4203
  },
4204
+ enableAutoMerge: {
4205
+ type: "boolean",
4206
+ default: false,
4207
+ description: "Enable gh auto-merge immediately after PR creation/linking",
4208
+ },
4209
+ autoMerge: {
4210
+ type: "boolean",
4211
+ description: "Legacy alias for enableAutoMerge",
4212
+ },
4213
+ autoMergeMethod: {
4214
+ type: "string",
4215
+ enum: ["merge", "squash", "rebase"],
4216
+ default: "squash",
4217
+ description: "Merge method used with gh pr merge --auto",
4218
+ },
4219
+ mergeMethod: {
4220
+ type: "string",
4221
+ enum: ["merge", "squash", "rebase"],
4222
+ description: "Legacy alias for autoMergeMethod",
4223
+ },
3448
4224
  cwd: { type: "string" },
3449
4225
  failOnError: { type: "boolean", default: false, description: "If true, throw on gh failure instead of falling back" },
3450
4226
  },
@@ -3460,6 +4236,16 @@ registerNodeType("action.create_pr", {
3460
4236
  ).trim();
3461
4237
  const draft = node.config?.draft === true;
3462
4238
  const failOnError = node.config?.failOnError === true;
4239
+ const enableAutoMerge = parseBooleanSetting(
4240
+ resolveWorkflowNodeValue(node.config?.enableAutoMerge ?? node.config?.autoMerge ?? false, ctx),
4241
+ false,
4242
+ );
4243
+ const autoMergeMethodRaw = String(
4244
+ ctx.resolve(node.config?.autoMergeMethod || node.config?.mergeMethod || "squash"),
4245
+ ).trim().toLowerCase();
4246
+ const autoMergeMethod = ["merge", "squash", "rebase"].includes(autoMergeMethodRaw)
4247
+ ? autoMergeMethodRaw
4248
+ : "squash";
3463
4249
  const cwd = ctx.resolve(node.config?.cwd || ctx.data?.worktreePath || process.cwd());
3464
4250
 
3465
4251
  // Normalize labels/reviewers to arrays
@@ -3502,6 +4288,46 @@ registerNodeType("action.create_pr", {
3502
4288
  stdio: ["pipe", "pipe", "pipe"],
3503
4289
  };
3504
4290
 
4291
+ const maybeEnableAutoMerge = (prNumber) => {
4292
+ if (!enableAutoMerge) {
4293
+ return { enabled: false, attempted: false, success: false };
4294
+ }
4295
+ if (draft) {
4296
+ return { enabled: true, attempted: false, success: false, reason: "draft_pr", method: autoMergeMethod };
4297
+ }
4298
+ const parsedPrNumber = Number.parseInt(String(prNumber || ""), 10);
4299
+ if (!Number.isFinite(parsedPrNumber) || parsedPrNumber <= 0) {
4300
+ return { enabled: true, attempted: false, success: false, reason: "missing_pr_number", method: autoMergeMethod };
4301
+ }
4302
+ if (shouldBypassGhPrCreationForTests()) {
4303
+ return { enabled: true, attempted: false, success: false, reason: "test_runtime_skip", method: autoMergeMethod };
4304
+ }
4305
+ try {
4306
+ const mergeArgs = ["pr", "merge", String(parsedPrNumber), "--auto", `--${autoMergeMethod}`];
4307
+ if (repoSlug) mergeArgs.push("--repo", repoSlug);
4308
+ execFileSync("gh", mergeArgs, execOptions);
4309
+ ctx.log(node.id, `Auto-merge requested for PR #${parsedPrNumber} (${autoMergeMethod})`);
4310
+ return {
4311
+ enabled: true,
4312
+ attempted: true,
4313
+ success: true,
4314
+ method: autoMergeMethod,
4315
+ prNumber: parsedPrNumber,
4316
+ };
4317
+ } catch (err) {
4318
+ const error = err?.stderr?.toString?.()?.trim() || err?.message || String(err);
4319
+ ctx.log(node.id, `Auto-merge request failed for PR #${parsedPrNumber}: ${error}`);
4320
+ return {
4321
+ enabled: true,
4322
+ attempted: true,
4323
+ success: false,
4324
+ method: autoMergeMethod,
4325
+ prNumber: parsedPrNumber,
4326
+ error,
4327
+ };
4328
+ }
4329
+ };
4330
+
3505
4331
  /** Re-resolve token after invalidating the current one (401 retry). */
3506
4332
  const retryWithFallbackToken = async () => {
3507
4333
  if (!resolvedTokenType) return false;
@@ -3557,6 +4383,7 @@ registerNodeType("action.create_pr", {
3557
4383
  } catch {
3558
4384
  }
3559
4385
  }
4386
+ const autoMergeState = maybeEnableAutoMerge(prNumber);
3560
4387
  return {
3561
4388
  success: true,
3562
4389
  existing: true,
@@ -3570,6 +4397,7 @@ registerNodeType("action.create_pr", {
3570
4397
  labels,
3571
4398
  reviewers,
3572
4399
  output: String(existing?.url || `existing-pr-${prNumber}`),
4400
+ autoMerge: autoMergeState,
3573
4401
  };
3574
4402
  } catch {
3575
4403
  return null;
@@ -3610,6 +4438,13 @@ registerNodeType("action.create_pr", {
3610
4438
  cwd,
3611
4439
  repoSlug: repoSlug || null,
3612
4440
  ghError: "skipped_in_test_runtime",
4441
+ autoMerge: {
4442
+ enabled: enableAutoMerge,
4443
+ attempted: false,
4444
+ success: false,
4445
+ reason: "test_runtime_skip",
4446
+ method: autoMergeMethod,
4447
+ },
3613
4448
  };
3614
4449
  }
3615
4450
 
@@ -3620,6 +4455,7 @@ registerNodeType("action.create_pr", {
3620
4455
  const urlMatch = trimmed.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
3621
4456
  const prNumber = urlMatch ? parseInt(urlMatch[1], 10) : null;
3622
4457
  const prUrl = urlMatch ? urlMatch[0] : trimmed;
4458
+ const autoMergeState = maybeEnableAutoMerge(prNumber);
3623
4459
  ctx.log(node.id, `PR created: ${prUrl}`);
3624
4460
  return {
3625
4461
  success: true,
@@ -3633,6 +4469,7 @@ registerNodeType("action.create_pr", {
3633
4469
  labels,
3634
4470
  reviewers,
3635
4471
  output: trimmed,
4472
+ autoMerge: autoMergeState,
3636
4473
  };
3637
4474
  } catch (err) {
3638
4475
  const errorMsg = err?.stderr?.toString?.()?.trim() || err?.message || String(err);
@@ -3649,6 +4486,7 @@ registerNodeType("action.create_pr", {
3649
4486
  const urlMatch = trimmed.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
3650
4487
  const prNumber = urlMatch ? parseInt(urlMatch[1], 10) : null;
3651
4488
  const prUrl = urlMatch ? urlMatch[0] : trimmed;
4489
+ const autoMergeState = maybeEnableAutoMerge(prNumber);
3652
4490
  ctx.log(node.id, `PR created (after auth retry): ${prUrl}`);
3653
4491
  return {
3654
4492
  success: true,
@@ -3662,6 +4500,7 @@ registerNodeType("action.create_pr", {
3662
4500
  labels,
3663
4501
  reviewers,
3664
4502
  output: trimmed,
4503
+ autoMerge: autoMergeState,
3665
4504
  };
3666
4505
  } catch (retryErr) {
3667
4506
  const retryMsg = retryErr?.stderr?.toString?.()?.trim() || retryErr?.message || String(retryErr);
@@ -3701,12 +4540,19 @@ registerNodeType("action.create_pr", {
3701
4540
  reviewers,
3702
4541
  cwd,
3703
4542
  ghError: errorMsg,
4543
+ autoMerge: {
4544
+ enabled: enableAutoMerge,
4545
+ attempted: false,
4546
+ success: false,
4547
+ reason: "pr_creation_failed",
4548
+ method: autoMergeMethod,
4549
+ },
3704
4550
  };
3705
4551
  }
3706
4552
  },
3707
4553
  });
3708
4554
 
3709
- registerNodeType("action.write_file", {
4555
+ registerBuiltinNodeType("action.write_file", {
3710
4556
  describe: () => "Write content to a file in the workspace",
3711
4557
  schema: {
3712
4558
  type: "object",
@@ -3735,7 +4581,7 @@ registerNodeType("action.write_file", {
3735
4581
  },
3736
4582
  });
3737
4583
 
3738
- registerNodeType("action.read_file", {
4584
+ registerBuiltinNodeType("action.read_file", {
3739
4585
  describe: () => "Read content from a file",
3740
4586
  schema: {
3741
4587
  type: "object",
@@ -3754,7 +4600,7 @@ registerNodeType("action.read_file", {
3754
4600
  },
3755
4601
  });
3756
4602
 
3757
- registerNodeType("action.set_variable", {
4603
+ registerBuiltinNodeType("action.set_variable", {
3758
4604
  describe: () => "Set a variable in the workflow context for downstream nodes",
3759
4605
  schema: {
3760
4606
  type: "object",
@@ -3784,7 +4630,7 @@ registerNodeType("action.set_variable", {
3784
4630
  },
3785
4631
  });
3786
4632
 
3787
- registerNodeType("action.delay", {
4633
+ registerBuiltinNodeType("action.delay", {
3788
4634
  describe: () => "Wait for a specified duration before continuing (supports ms, seconds, minutes, hours)",
3789
4635
  schema: {
3790
4636
  type: "object",
@@ -3837,7 +4683,7 @@ registerNodeType("action.delay", {
3837
4683
  // VALIDATION — Verification gates
3838
4684
  // ═══════════════════════════════════════════════════════════════════════════
3839
4685
 
3840
- registerNodeType("validation.screenshot", {
4686
+ registerBuiltinNodeType("validation.screenshot", {
3841
4687
  describe: () => "Take a screenshot for visual verification and store in evidence",
3842
4688
  schema: {
3843
4689
  type: "object",
@@ -3951,7 +4797,7 @@ registerNodeType("validation.screenshot", {
3951
4797
  },
3952
4798
  });
3953
4799
 
3954
- registerNodeType("validation.model_review", {
4800
+ registerBuiltinNodeType("validation.model_review", {
3955
4801
  describe: () => "Send evidence (screenshots, code, logs) to a non-agent model for independent verification",
3956
4802
  schema: {
3957
4803
  type: "object",
@@ -4069,7 +4915,7 @@ Respond with exactly one of:
4069
4915
  },
4070
4916
  });
4071
4917
 
4072
- registerNodeType("validation.tests", {
4918
+ registerBuiltinNodeType("validation.tests", {
4073
4919
  describe: () => "Run test suite and verify results",
4074
4920
  schema: {
4075
4921
  type: "object",
@@ -4098,7 +4944,7 @@ registerNodeType("validation.tests", {
4098
4944
  },
4099
4945
  });
4100
4946
 
4101
- registerNodeType("validation.build", {
4947
+ registerBuiltinNodeType("validation.build", {
4102
4948
  describe: () => "Run build and verify it succeeds with 0 errors",
4103
4949
  schema: {
4104
4950
  type: "object",
@@ -4132,7 +4978,7 @@ registerNodeType("validation.build", {
4132
4978
  },
4133
4979
  });
4134
4980
 
4135
- registerNodeType("validation.lint", {
4981
+ registerBuiltinNodeType("validation.lint", {
4136
4982
  describe: () => "Run linter and verify results",
4137
4983
  schema: {
4138
4984
  type: "object",
@@ -4161,7 +5007,7 @@ registerNodeType("validation.lint", {
4161
5007
  // TRANSFORM — Data manipulation
4162
5008
  // ═══════════════════════════════════════════════════════════════════════════
4163
5009
 
4164
- registerNodeType("transform.json_parse", {
5010
+ registerBuiltinNodeType("transform.json_parse", {
4165
5011
  describe: () => "Parse JSON from a previous node's output",
4166
5012
  schema: {
4167
5013
  type: "object",
@@ -4183,7 +5029,7 @@ registerNodeType("transform.json_parse", {
4183
5029
  },
4184
5030
  });
4185
5031
 
4186
- registerNodeType("transform.template", {
5032
+ registerBuiltinNodeType("transform.template", {
4187
5033
  describe: () => "Render a text template with context variables",
4188
5034
  schema: {
4189
5035
  type: "object",
@@ -4198,7 +5044,7 @@ registerNodeType("transform.template", {
4198
5044
  },
4199
5045
  });
4200
5046
 
4201
- registerNodeType("transform.aggregate", {
5047
+ registerBuiltinNodeType("transform.aggregate", {
4202
5048
  describe: () => "Aggregate outputs from multiple nodes into a single object",
4203
5049
  schema: {
4204
5050
  type: "object",
@@ -4216,7 +5062,7 @@ registerNodeType("transform.aggregate", {
4216
5062
  },
4217
5063
  });
4218
5064
 
4219
- registerNodeType("transform.llm_parse", {
5065
+ registerBuiltinNodeType("transform.llm_parse", {
4220
5066
  describe: () =>
4221
5067
  "Parse unstructured LLM output into structured fields using regex patterns " +
4222
5068
  "or keyword extraction. Essential for routing decisions based on LLM verdicts " +
@@ -4327,7 +5173,7 @@ registerNodeType("transform.llm_parse", {
4327
5173
  // NOTIFY — Notifications
4328
5174
  // ═══════════════════════════════════════════════════════════════════════════
4329
5175
 
4330
- registerNodeType("notify.log", {
5176
+ registerBuiltinNodeType("notify.log", {
4331
5177
  describe: () => "Log a message (to console and workflow run log)",
4332
5178
  schema: {
4333
5179
  type: "object",
@@ -4346,7 +5192,7 @@ registerNodeType("notify.log", {
4346
5192
  },
4347
5193
  });
4348
5194
 
4349
- registerNodeType("notify.telegram", {
5195
+ registerBuiltinNodeType("notify.telegram", {
4350
5196
  describe: () => "Send a message to Telegram chat",
4351
5197
  schema: {
4352
5198
  type: "object",
@@ -4374,42 +5220,154 @@ registerNodeType("notify.telegram", {
4374
5220
  );
4375
5221
  return { sent: true, message };
4376
5222
  }
4377
- ctx.log(node.id, "Telegram service not available", "warn");
4378
- return { sent: false, reason: "no_telegram" };
4379
- },
4380
- });
5223
+ ctx.log(node.id, "Telegram service not available", "warn");
5224
+ return { sent: false, reason: "no_telegram" };
5225
+ },
5226
+ });
5227
+
5228
+ registerBuiltinNodeType("notify.webhook_out", {
5229
+ describe: () => "Send an HTTP webhook notification",
5230
+ schema: {
5231
+ type: "object",
5232
+ properties: {
5233
+ url: { type: "string", description: "Webhook URL" },
5234
+ method: { type: "string", default: "POST" },
5235
+ body: { type: "object", description: "Request body (supports {{variables}} in string values)" },
5236
+ headers: { type: "object" },
5237
+ },
5238
+ required: ["url"],
5239
+ },
5240
+ async execute(node, ctx) {
5241
+ const url = ctx.resolve(node.config?.url || "");
5242
+ const method = node.config?.method || "POST";
5243
+ const body = node.config?.body ? JSON.stringify(node.config.body) : undefined;
5244
+
5245
+ ctx.log(node.id, `Webhook ${method} to ${url}`);
5246
+ try {
5247
+ const resp = await fetch(url, {
5248
+ method,
5249
+ headers: {
5250
+ "Content-Type": "application/json",
5251
+ ...node.config?.headers,
5252
+ },
5253
+ body,
5254
+ });
5255
+ return { success: resp.ok, status: resp.status };
5256
+ } catch (err) {
5257
+ return { success: false, error: err.message };
5258
+ }
5259
+ },
5260
+ });
5261
+
5262
+ registerNodeType("action.emit_event", {
5263
+ describe: () =>
5264
+ "Emit an internal workflow event and optionally dispatch matching trigger.event workflows",
5265
+ schema: {
5266
+ type: "object",
5267
+ properties: {
5268
+ eventType: { type: "string", description: "Event type to emit (for example session-stuck)" },
5269
+ payload: {
5270
+ type: "object",
5271
+ description: "Event payload object forwarded to matching workflows",
5272
+ additionalProperties: true,
5273
+ },
5274
+ dispatch: {
5275
+ type: "boolean",
5276
+ default: true,
5277
+ description: "When true, evaluate and execute matching event-trigger workflows",
5278
+ },
5279
+ includeCurrentWorkflow: {
5280
+ type: "boolean",
5281
+ default: false,
5282
+ description: "Allow dispatching the currently running workflow if it matches",
5283
+ },
5284
+ outputVariable: {
5285
+ type: "string",
5286
+ description: "Optional context key where event output will be stored",
5287
+ },
5288
+ },
5289
+ required: ["eventType"],
5290
+ },
5291
+ async execute(node, ctx, engine) {
5292
+ const eventType = String(ctx.resolve(node.config?.eventType || "") || "").trim();
5293
+ if (!eventType) throw new Error("action.emit_event: 'eventType' is required");
5294
+
5295
+ const payload = resolveWorkflowNodeValue(node.config?.payload ?? {}, ctx);
5296
+ const shouldDispatch = parseBooleanSetting(
5297
+ resolveWorkflowNodeValue(node.config?.dispatch ?? true, ctx),
5298
+ true,
5299
+ );
5300
+ const includeCurrentWorkflow = parseBooleanSetting(
5301
+ resolveWorkflowNodeValue(node.config?.includeCurrentWorkflow ?? false, ctx),
5302
+ false,
5303
+ );
5304
+ const currentWorkflowId = String(ctx.data?._workflowId || "").trim();
5305
+
5306
+ const output = {
5307
+ success: true,
5308
+ eventType,
5309
+ payload,
5310
+ dispatched: false,
5311
+ dispatchCount: 0,
5312
+ matched: [],
5313
+ runs: [],
5314
+ };
5315
+
5316
+ if (shouldDispatch && engine?.evaluateTriggers && engine?.execute) {
5317
+ const matched = await engine.evaluateTriggers(eventType, payload || {});
5318
+ output.matched = matched;
5319
+ for (const trigger of matched) {
5320
+ const workflowId = String(trigger?.workflowId || "").trim();
5321
+ if (!workflowId) continue;
5322
+ if (!includeCurrentWorkflow && currentWorkflowId && workflowId === currentWorkflowId) {
5323
+ continue;
5324
+ }
5325
+ try {
5326
+ const childCtx = await engine.execute(
5327
+ workflowId,
5328
+ {
5329
+ ...(payload && typeof payload === "object" ? payload : {}),
5330
+ eventType,
5331
+ _triggerSource: "workflow.emit_event",
5332
+ _triggeredByWorkflowId: currentWorkflowId || null,
5333
+ _triggeredByRunId: ctx.id,
5334
+ },
5335
+ { force: true },
5336
+ );
5337
+ const childErrors = Array.isArray(childCtx?.errors) ? childCtx.errors : [];
5338
+ output.runs.push({
5339
+ workflowId,
5340
+ runId: childCtx?.id || null,
5341
+ status: childErrors.length > 0 ? "failed" : "completed",
5342
+ });
5343
+ } catch (err) {
5344
+ output.runs.push({
5345
+ workflowId,
5346
+ runId: null,
5347
+ status: "failed",
5348
+ error: err?.message || String(err),
5349
+ });
5350
+ }
5351
+ }
5352
+ output.dispatchCount = output.runs.length;
5353
+ output.dispatched = output.dispatchCount > 0;
5354
+ }
4381
5355
 
4382
- registerNodeType("notify.webhook_out", {
4383
- describe: () => "Send an HTTP webhook notification",
4384
- schema: {
4385
- type: "object",
4386
- properties: {
4387
- url: { type: "string", description: "Webhook URL" },
4388
- method: { type: "string", default: "POST" },
4389
- body: { type: "object", description: "Request body (supports {{variables}} in string values)" },
4390
- headers: { type: "object" },
4391
- },
4392
- required: ["url"],
4393
- },
4394
- async execute(node, ctx) {
4395
- const url = ctx.resolve(node.config?.url || "");
4396
- const method = node.config?.method || "POST";
4397
- const body = node.config?.body ? JSON.stringify(node.config.body) : undefined;
5356
+ if (ctx?.data && typeof ctx.data === "object") {
5357
+ ctx.data.eventType = eventType;
5358
+ ctx.data.eventPayload = payload;
5359
+ }
4398
5360
 
4399
- ctx.log(node.id, `Webhook ${method} to ${url}`);
4400
- try {
4401
- const resp = await fetch(url, {
4402
- method,
4403
- headers: {
4404
- "Content-Type": "application/json",
4405
- ...node.config?.headers,
4406
- },
4407
- body,
4408
- });
4409
- return { success: resp.ok, status: resp.status };
4410
- } catch (err) {
4411
- return { success: false, error: err.message };
5361
+ const outputVariable = String(ctx.resolve(node.config?.outputVariable || "") || "").trim();
5362
+ if (outputVariable) {
5363
+ ctx.data[outputVariable] = output;
4412
5364
  }
5365
+
5366
+ ctx.log(
5367
+ node.id,
5368
+ `Emitted event ${eventType} (dispatch=${output.dispatched}, runs=${output.dispatchCount})`,
5369
+ );
5370
+ return output;
4413
5371
  },
4414
5372
  });
4415
5373
 
@@ -4417,7 +5375,7 @@ registerNodeType("notify.webhook_out", {
4417
5375
  // AGENT-SPECIFIC — Specialized agent operations
4418
5376
  // ═══════════════════════════════════════════════════════════════════════════
4419
5377
 
4420
- registerNodeType("agent.select_profile", {
5378
+ registerBuiltinNodeType("agent.select_profile", {
4421
5379
  describe: () => "Select an agent profile based on task characteristics",
4422
5380
  schema: {
4423
5381
  type: "object",
@@ -4659,8 +5617,50 @@ function normalizePlannerTaskForCreation(task, index) {
4659
5617
  }
4660
5618
  return normalized;
4661
5619
  };
5620
+ const normalizeScore = (value) => {
5621
+ const numeric = Number(value);
5622
+ if (!Number.isFinite(numeric)) return null;
5623
+ return Math.max(0, Math.min(10, Math.round(numeric)));
5624
+ };
5625
+ const normalizeRiskLevel = (value) => {
5626
+ const raw = String(value || "").trim().toLowerCase();
5627
+ if (["low", "medium", "high", "critical"].includes(raw)) return raw;
5628
+ const numeric = Number(value);
5629
+ if (!Number.isFinite(numeric)) return null;
5630
+ if (numeric >= 9) return "critical";
5631
+ if (numeric >= 7) return "high";
5632
+ if (numeric >= 4) return "medium";
5633
+ return "low";
5634
+ };
5635
+ const normalizeArchetype = (value) => {
5636
+ const normalized = String(value || "")
5637
+ .trim()
5638
+ .toLowerCase()
5639
+ .replace(/[^a-z0-9]+/g, "_")
5640
+ .replace(/^_+|_+$/g, "");
5641
+ return normalized || "";
5642
+ };
5643
+ const inferArchetype = () => {
5644
+ const explicit =
5645
+ task.archetype ||
5646
+ task.task_archetype ||
5647
+ task.taskArchetype ||
5648
+ task.pattern ||
5649
+ "";
5650
+ const normalizedExplicit = normalizeArchetype(explicit);
5651
+ if (normalizedExplicit) return normalizedExplicit;
5652
+ const conventional = title
5653
+ .toLowerCase()
5654
+ .match(/^(?:\[[^\]]+\]\s*)?([a-z][a-z0-9_-]*)(?:\([^)]*\))?:/);
5655
+ if (conventional?.[1]) return normalizeArchetype(conventional[1]);
5656
+ if (title.toLowerCase().includes("test")) return "test";
5657
+ if (title.toLowerCase().includes("doc")) return "docs";
5658
+ if (title.toLowerCase().includes("refactor")) return "refactor";
5659
+ return "general";
5660
+ };
4662
5661
  const scoreMode = inferPlannerTaskScoreMode(task);
4663
5662
  const preferTenScaleIntegers = scoreMode === PLANNER_SCORE_MODE_TEN;
5663
+
4664
5664
  const lines = [];
4665
5665
  const description = String(task.description || "").trim();
4666
5666
  if (description) lines.push(description);
@@ -4676,6 +5676,7 @@ function normalizePlannerTaskForCreation(task, index) {
4676
5676
  const estimatedEffort = String(task.estimated_effort || task.estimatedEffort || "").trim().toLowerCase();
4677
5677
  const whyNow = String(task.why_now || task.whyNow || "").trim();
4678
5678
  const killCriteria = normalizeStringList(task.kill_criteria || task.killCriteria);
5679
+ const archetype = inferArchetype();
4679
5680
 
4680
5681
  const appendList = (heading, values) => {
4681
5682
  if (!Array.isArray(values) || values.length === 0) return;
@@ -4727,6 +5728,7 @@ function normalizePlannerTaskForCreation(task, index) {
4727
5728
  impact,
4728
5729
  confidence,
4729
5730
  risk,
5731
+ archetype,
4730
5732
  estimatedEffort: estimatedEffort || null,
4731
5733
  whyNow: whyNow || null,
4732
5734
  killCriteria: killCriteria.length > 0 ? killCriteria : null,
@@ -4830,6 +5832,401 @@ function resolvePlannerFeedbackContext(value) {
4830
5832
  return String(value).trim();
4831
5833
  }
4832
5834
 
5835
+ function resolvePlannerFeedbackObject(value) {
5836
+ if (!value) return null;
5837
+ if (typeof value === "object" && !Array.isArray(value)) return value;
5838
+ if (typeof value !== "string") return null;
5839
+ const trimmed = value.trim();
5840
+ if (!trimmed) return null;
5841
+ try {
5842
+ const parsed = JSON.parse(trimmed);
5843
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
5844
+ } catch {
5845
+ return null;
5846
+ }
5847
+ }
5848
+
5849
+ function normalizePlannerTaskArchetype(task) {
5850
+ const explicitArchetype = String(
5851
+ task?.archetype || task?.taskArchetype || task?.task_archetype || "",
5852
+ )
5853
+ .trim()
5854
+ .toLowerCase()
5855
+ .replace(/[^a-z0-9()_-]+/g, "_")
5856
+ .replace(/^_+|_+$/g, "");
5857
+ if (explicitArchetype) return explicitArchetype;
5858
+ const title = String(task?.title || "").trim().toLowerCase();
5859
+ if (!title) return "general";
5860
+ const withoutPrefix = title.replace(/^\[[^\]]+\]\s*/, "").trim();
5861
+ const scoped = withoutPrefix.match(/^([a-z][a-z0-9_-]*)\(([^)]+)\)\s*:/);
5862
+ if (scoped) return scoped[1];
5863
+ const typed = withoutPrefix.match(/^([a-z][a-z0-9_-]*)\s*:/);
5864
+ if (typed) return typed[1];
5865
+ const fallback = withoutPrefix
5866
+ .replace(/[^a-z0-9]+/g, " ")
5867
+ .trim()
5868
+ .split(/\s+/)
5869
+ .slice(0, 2)
5870
+ .join("_");
5871
+ return fallback || "general";
5872
+ }
5873
+
5874
+ function resolvePlannerPatternKeys(task) {
5875
+ const archetype = normalizePlannerTaskArchetype(task);
5876
+ const areas = resolveTaskRepoAreas(task);
5877
+ const normalizedAreas = areas.length > 0
5878
+ ? areas.map((area) => normalizePlannerAreaKey(area)).filter(Boolean)
5879
+ : ["global"];
5880
+ return normalizedAreas.map((area) => `${area}::${archetype}`);
5881
+ }
5882
+
5883
+ function resolvePlannerDebtTrendSignal(task) {
5884
+ const numericCandidates = [
5885
+ task?.debt_trend,
5886
+ task?.debtTrend,
5887
+ task?.meta?.debt_trend,
5888
+ task?.meta?.debtTrend,
5889
+ task?.meta?.planner?.debt_trend,
5890
+ task?.meta?.planner?.debtTrend,
5891
+ task?.meta?.planner?.debt_growth,
5892
+ task?.meta?.planner?.debtGrowth,
5893
+ ];
5894
+ for (const candidate of numericCandidates) {
5895
+ const numeric = Number(candidate);
5896
+ if (Number.isFinite(numeric)) {
5897
+ return Math.max(0, Math.min(5, Math.abs(numeric)));
5898
+ }
5899
+ }
5900
+
5901
+ const textCandidates = [
5902
+ task?.debt_trend,
5903
+ task?.debtTrend,
5904
+ task?.meta?.debt_trend,
5905
+ task?.meta?.debtTrend,
5906
+ task?.meta?.planner?.debt_trend,
5907
+ task?.meta?.planner?.debtTrend,
5908
+ task?.meta?.planner?.why_now,
5909
+ task?.meta?.planner?.whyNow,
5910
+ task?.description,
5911
+ ]
5912
+ .map((value) => String(value || "").trim().toLowerCase())
5913
+ .filter(Boolean);
5914
+ for (const text of textCandidates) {
5915
+ if (/(worsen|worsening|increase|increasing|growth|growing|upward|regress)/.test(text)) {
5916
+ return 2;
5917
+ }
5918
+ if (/(stable|flat|neutral|steady)/.test(text)) {
5919
+ return 1;
5920
+ }
5921
+ }
5922
+ return 0;
5923
+ }
5924
+
5925
+ function hasTaskCommitEvidence(task) {
5926
+ const commitCandidates = [
5927
+ task?.hasCommits,
5928
+ task?.meta?.hasCommits,
5929
+ task?.meta?.execution?.hasCommits,
5930
+ task?.meta?.execution?.commitCount,
5931
+ task?.meta?.execution?.commits,
5932
+ task?.commitCount,
5933
+ task?.commits,
5934
+ task?.meta?.commits,
5935
+ ];
5936
+ for (const candidate of commitCandidates) {
5937
+ if (typeof candidate === "boolean") return candidate;
5938
+ const numeric = Number(candidate);
5939
+ if (Number.isFinite(numeric) && numeric > 0) return true;
5940
+ if (Array.isArray(candidate) && candidate.length > 0) return true;
5941
+ }
5942
+ return false;
5943
+ }
5944
+
5945
+ function createEmptyPlannerPatternPrior() {
5946
+ return {
5947
+ failureCount: 0,
5948
+ successCount: 0,
5949
+ failureWeight: 0,
5950
+ successWeight: 0,
5951
+ failureCounter: 0,
5952
+ commitlessFailureCount: 0,
5953
+ commitlessSuccessCount: 0,
5954
+ commitlessFailureCounter: 0,
5955
+ signalTotals: {
5956
+ agentAttempts: 0,
5957
+ consecutiveNoCommits: 0,
5958
+ blockedReason: 0,
5959
+ debtTrend: 0,
5960
+ },
5961
+ lastUpdatedAt: null,
5962
+ };
5963
+ }
5964
+
5965
+ function normalizePlannerPatternPrior(entry) {
5966
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
5967
+ return createEmptyPlannerPatternPrior();
5968
+ }
5969
+ const base = createEmptyPlannerPatternPrior();
5970
+ const signalTotals = entry.signalTotals && typeof entry.signalTotals === "object"
5971
+ ? entry.signalTotals
5972
+ : {};
5973
+ return {
5974
+ ...base,
5975
+ ...entry,
5976
+ signalTotals: {
5977
+ agentAttempts: Number(signalTotals.agentAttempts || 0),
5978
+ consecutiveNoCommits: Number(signalTotals.consecutiveNoCommits || 0),
5979
+ blockedReason: Number(signalTotals.blockedReason || 0),
5980
+ debtTrend: Number(signalTotals.debtTrend || 0),
5981
+ },
5982
+ };
5983
+ }
5984
+
5985
+ function resolvePlannerOutcomeSignals(task, weights) {
5986
+ const attempts = Math.max(0, Number(task?.agentAttempts || task?.meta?.agentAttempts || 0));
5987
+ const noCommits = Math.max(
5988
+ 0,
5989
+ Number(task?.consecutiveNoCommits || task?.meta?.consecutiveNoCommits || 0),
5990
+ );
5991
+ const blockedReason = String(task?.blockedReason || task?.meta?.blockedReason || "").trim();
5992
+ const debtTrendSignal = resolvePlannerDebtTrendSignal(task);
5993
+ const commitEvidence = hasTaskCommitEvidence(task);
5994
+ const status = String(task?.status || "").trim().toLowerCase();
5995
+ const completedStatus = ["done", "completed", "closed", "merged"].includes(status);
5996
+ const agentAttemptsPenalty = commitEvidence ? 0 : (attempts * weights.agentAttempts);
5997
+ const consecutiveNoCommitsPenalty = noCommits * weights.consecutiveNoCommits;
5998
+ const blockedPenalty = blockedReason ? weights.blockedReason : 0;
5999
+ const debtTrendPenalty = debtTrendSignal * weights.debtTrend;
6000
+
6001
+ const failureWeight =
6002
+ agentAttemptsPenalty +
6003
+ consecutiveNoCommitsPenalty +
6004
+ blockedPenalty +
6005
+ debtTrendPenalty;
6006
+ const successWeight =
6007
+ (commitEvidence ? weights.commitSuccess : 0) +
6008
+ ((completedStatus && !blockedReason) ? weights.completedSuccess : 0);
6009
+ const commitlessFailureEvent = attempts > 0 && !commitEvidence;
6010
+
6011
+ return {
6012
+ attempts,
6013
+ noCommits,
6014
+ blockedReason,
6015
+ debtTrendSignal,
6016
+ commitEvidence,
6017
+ commitlessFailureEvent,
6018
+ failureWeight,
6019
+ successWeight,
6020
+ failureComponents: {
6021
+ agentAttemptsPenalty,
6022
+ consecutiveNoCommitsPenalty,
6023
+ blockedPenalty,
6024
+ debtTrendPenalty,
6025
+ },
6026
+ };
6027
+ }
6028
+
6029
+ function resolvePlannerPriorStatePath() {
6030
+ const configured = String(process.env.BOSUN_PLANNER_PATTERN_PRIORS_FILE || "").trim();
6031
+ if (configured) return configured;
6032
+ return resolve(process.cwd(), ".bosun", "workflow-runs", "planner-pattern-priors.json");
6033
+ }
6034
+
6035
+ function shouldPersistPlannerPriorState() {
6036
+ if (String(process.env.BOSUN_DISABLE_PLANNER_PATTERN_PRIORS || "").trim().toLowerCase() === "true") {
6037
+ return false;
6038
+ }
6039
+ if (process.env.VITEST && process.env.BOSUN_TEST_ENABLE_PLANNER_PRIOR_PERSISTENCE !== "true") {
6040
+ return false;
6041
+ }
6042
+ return true;
6043
+ }
6044
+
6045
+ function loadPlannerPriorState(statePath) {
6046
+ const base = { version: 1, patterns: {}, outcomes: {} };
6047
+ if (!statePath || !existsSync(statePath)) return base;
6048
+ try {
6049
+ const parsed = JSON.parse(readFileSync(statePath, "utf8"));
6050
+ if (!parsed || typeof parsed !== "object") return base;
6051
+ return {
6052
+ version: 1,
6053
+ patterns:
6054
+ parsed.patterns && typeof parsed.patterns === "object"
6055
+ ? Object.fromEntries(
6056
+ Object.entries(parsed.patterns).map(([key, value]) => [
6057
+ key,
6058
+ normalizePlannerPatternPrior(value),
6059
+ ]),
6060
+ )
6061
+ : {},
6062
+ outcomes: parsed.outcomes && typeof parsed.outcomes === "object" ? parsed.outcomes : {},
6063
+ };
6064
+ } catch {
6065
+ return base;
6066
+ }
6067
+ }
6068
+
6069
+ function savePlannerPriorState(statePath, state) {
6070
+ if (!statePath) return;
6071
+ try {
6072
+ mkdirSync(dirname(statePath), { recursive: true });
6073
+ writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
6074
+ } catch {
6075
+ // Best-effort persistence only.
6076
+ }
6077
+ }
6078
+
6079
+ function replayPlannerOutcomes(existingTasks, priorState, weights) {
6080
+ if (!Array.isArray(existingTasks) || existingTasks.length === 0) return;
6081
+ const nowIso = new Date().toISOString();
6082
+ const maxOutcomes = 5000;
6083
+
6084
+ for (const task of existingTasks) {
6085
+ const taskId = String(task?.id || task?.task_id || "").trim();
6086
+ if (!taskId) continue;
6087
+ const keys = resolvePlannerPatternKeys(task);
6088
+ if (!keys.length) continue;
6089
+ const signals = resolvePlannerOutcomeSignals(task, weights);
6090
+ const signature = JSON.stringify({
6091
+ status: String(task?.status || "").trim().toLowerCase(),
6092
+ attempts: signals.attempts,
6093
+ noCommits: signals.noCommits,
6094
+ blockedReason: signals.blockedReason.toLowerCase(),
6095
+ debtTrendSignal: signals.debtTrendSignal,
6096
+ hasCommits: hasTaskCommitEvidence(task),
6097
+ });
6098
+ if (priorState.outcomes?.[taskId]?.signature === signature) continue;
6099
+ priorState.outcomes[taskId] = { signature, updatedAt: nowIso };
6100
+
6101
+ for (const key of keys) {
6102
+ const current = normalizePlannerPatternPrior(priorState.patterns[key]);
6103
+ const priorCounter = Math.max(0, Number(current.failureCounter || 0));
6104
+ const priorCommitlessCounter = Math.max(0, Number(current.commitlessFailureCounter || 0));
6105
+ if (signals.failureWeight > 0) {
6106
+ current.failureCount = Number(current.failureCount || 0) + 1;
6107
+ current.failureWeight = Number(current.failureWeight || 0) + signals.failureWeight;
6108
+ current.signalTotals.agentAttempts += signals.failureComponents.agentAttemptsPenalty;
6109
+ current.signalTotals.consecutiveNoCommits += signals.failureComponents.consecutiveNoCommitsPenalty;
6110
+ current.signalTotals.blockedReason += signals.failureComponents.blockedPenalty;
6111
+ current.signalTotals.debtTrend += signals.failureComponents.debtTrendPenalty;
6112
+ }
6113
+ if (signals.successWeight > 0) {
6114
+ current.successCount = Number(current.successCount || 0) + 1;
6115
+ current.successWeight = Number(current.successWeight || 0) + signals.successWeight;
6116
+ }
6117
+ if (signals.commitlessFailureEvent) {
6118
+ current.commitlessFailureCount = Number(current.commitlessFailureCount || 0) + 1;
6119
+ }
6120
+ if (signals.commitEvidence) {
6121
+ current.commitlessSuccessCount = Number(current.commitlessSuccessCount || 0) + 1;
6122
+ }
6123
+ current.failureCounter = Number(
6124
+ Math.max(
6125
+ 0,
6126
+ (priorCounter * 0.82) + signals.failureWeight - (signals.successWeight * 0.95),
6127
+ ).toFixed(3),
6128
+ );
6129
+ current.commitlessFailureCounter = Number(
6130
+ Math.max(
6131
+ 0,
6132
+ (priorCommitlessCounter * 0.86) +
6133
+ (signals.commitlessFailureEvent ? 1.25 : 0) -
6134
+ (signals.commitEvidence ? 1.1 : 0),
6135
+ ).toFixed(3),
6136
+ );
6137
+ current.lastUpdatedAt = nowIso;
6138
+ priorState.patterns[key] = current;
6139
+ }
6140
+ }
6141
+
6142
+ const outcomeEntries = Object.entries(priorState.outcomes || {});
6143
+ if (outcomeEntries.length > maxOutcomes) {
6144
+ outcomeEntries
6145
+ .sort((a, b) => String(a[1]?.updatedAt || "").localeCompare(String(b[1]?.updatedAt || "")))
6146
+ .slice(0, outcomeEntries.length - maxOutcomes)
6147
+ .forEach(([id]) => {
6148
+ delete priorState.outcomes[id];
6149
+ });
6150
+ }
6151
+ }
6152
+
6153
+ function rankPlannerTaskCandidates(tasks, priorState, rankingConfig) {
6154
+ const scored = (Array.isArray(tasks) ? tasks : []).map((task) => {
6155
+ const impact = Number.isFinite(task?.impact) ? Number(task.impact) : 5;
6156
+ const confidence = Number.isFinite(task?.confidence) ? Number(task.confidence) : 5;
6157
+ const riskLevel = String(task?.risk || "").trim().toLowerCase();
6158
+ const riskPenalty = ({ low: 0, medium: 0.4, high: 0.9, critical: 1.6 })[riskLevel] || 0;
6159
+ const baseScore = (impact * 1.15) + (confidence * 0.85) - riskPenalty;
6160
+
6161
+ const keys = resolvePlannerPatternKeys(task);
6162
+ const penalties = keys.map((key) => {
6163
+ const prior = priorState?.patterns?.[key];
6164
+ if (!prior || typeof prior !== "object") return { key, signalPenalty: 0, negativePrior: 0 };
6165
+ const failureCount = Number(prior.failureCount || 0);
6166
+ const successCount = Number(prior.successCount || 0);
6167
+ const failureWeight = Number(prior.failureWeight || 0);
6168
+ const successWeight = Number(prior.successWeight || 0);
6169
+ const failureCounter = Number(prior.failureCounter || 0);
6170
+ const commitlessFailureCounter = Number(prior.commitlessFailureCounter || 0);
6171
+ const commitlessFailureCount = Number(prior.commitlessFailureCount || 0);
6172
+ const commitlessSuccessCount = Number(prior.commitlessSuccessCount || 0);
6173
+ const netFailureEvents = Math.max(0, failureCount - successCount);
6174
+ const netFailureWeight = Math.max(0, failureWeight - successWeight);
6175
+ const netCommitlessEvents = Math.max(0, commitlessFailureCount - commitlessSuccessCount);
6176
+ const repeatedFailureSignal = Math.max(
6177
+ netFailureEvents,
6178
+ Math.max(0, failureCounter),
6179
+ netCommitlessEvents,
6180
+ Math.max(0, commitlessFailureCounter),
6181
+ );
6182
+ const signalPenalty = Math.max(
6183
+ netFailureWeight * rankingConfig.signalPenaltyScale,
6184
+ Math.max(0, failureCounter) * rankingConfig.signalPenaltyScale,
6185
+ );
6186
+ const negativePrior =
6187
+ repeatedFailureSignal >= rankingConfig.failureThreshold
6188
+ ? Math.min(
6189
+ rankingConfig.maxNegativePrior,
6190
+ rankingConfig.failurePriorStep * (repeatedFailureSignal - rankingConfig.failureThreshold + 1),
6191
+ )
6192
+ : 0;
6193
+ return {
6194
+ key,
6195
+ signalPenalty,
6196
+ negativePrior,
6197
+ failureCounter: Math.max(0, failureCounter),
6198
+ commitlessFailureCounter: Math.max(0, commitlessFailureCounter),
6199
+ netCommitlessEvents,
6200
+ };
6201
+ });
6202
+ const totalPenalty = penalties.reduce(
6203
+ (sum, item) => sum + item.signalPenalty + item.negativePrior,
6204
+ 0,
6205
+ );
6206
+ const averagePenalty = penalties.length > 0 ? totalPenalty / penalties.length : 0;
6207
+ const rankScore = baseScore - averagePenalty;
6208
+
6209
+ return {
6210
+ ...task,
6211
+ _ranking: {
6212
+ baseScore: Number(baseScore.toFixed(3)),
6213
+ penalty: Number(averagePenalty.toFixed(3)),
6214
+ score: Number(rankScore.toFixed(3)),
6215
+ patternKeys: keys,
6216
+ penalties,
6217
+ },
6218
+ };
6219
+ });
6220
+
6221
+ scored.sort((a, b) => {
6222
+ if ((b?._ranking?.score || 0) !== (a?._ranking?.score || 0)) {
6223
+ return (b?._ranking?.score || 0) - (a?._ranking?.score || 0);
6224
+ }
6225
+ return Number(a?.index || 0) - Number(b?.index || 0);
6226
+ });
6227
+ return scored;
6228
+ }
6229
+
4833
6230
  function buildPlannerSkipReasonHistogram(skipped = []) {
4834
6231
  const histogram = {};
4835
6232
  for (const entry of skipped) {
@@ -4839,7 +6236,7 @@ function buildPlannerSkipReasonHistogram(skipped = []) {
4839
6236
  return histogram;
4840
6237
  }
4841
6238
 
4842
- registerNodeType("action.materialize_planner_tasks", {
6239
+ registerBuiltinNodeType("action.materialize_planner_tasks", {
4843
6240
  describe: () => "Parse planner JSON output and create backlog tasks in Kanban",
4844
6241
  schema: {
4845
6242
  type: "object",
@@ -4854,6 +6251,10 @@ registerNodeType("action.materialize_planner_tasks", {
4854
6251
  minImpactScore: { type: "number", default: CALIBRATED_MIN_IMPACT_SCORE, description: "Minimum planner impact score required for creation; accepts 0-1 or 0-10 scales" },
4855
6252
  maxRiskWithoutHuman: { type: "string", default: CALIBRATED_MAX_RISK_WITHOUT_HUMAN, description: "Maximum planner risk level allowed for auto-creation (low|medium|high|critical)" },
4856
6253
  maxConcurrentRepoAreaTasks: { type: "number", default: 0, description: "Maximum concurrent backlog tasks per repo area (0 disables limit)" },
6254
+ failurePriorThreshold: { type: "number", default: 2, description: "Net repeated failures required before applying negative priors" },
6255
+ failurePriorStep: { type: "number", default: 1.5, description: "Penalty added per repeated failure beyond threshold" },
6256
+ maxFailurePriorPenalty: { type: "number", default: 8, description: "Cap for repeated-failure negative prior penalty" },
6257
+ feedbackSignalScale: { type: "number", default: 0.12, description: "Scale factor applied to weighted feedback signal penalties" },
4857
6258
  },
4858
6259
  },
4859
6260
  async execute(node, ctx, engine) {
@@ -4875,6 +6276,35 @@ registerNodeType("action.materialize_planner_tasks", {
4875
6276
  { preferTenScaleIntegers: true },
4876
6277
  ) || CALIBRATED_MAX_RISK_WITHOUT_HUMAN;
4877
6278
  const maxConcurrentRepoAreaTasks = Number(ctx.resolve(node.config?.maxConcurrentRepoAreaTasks ?? 0));
6279
+ const rankingConfig = {
6280
+ failureThreshold: Math.max(1, Number(ctx.resolve(node.config?.failurePriorThreshold ?? 2)) || 2),
6281
+ failurePriorStep: Math.max(0, Number(ctx.resolve(node.config?.failurePriorStep ?? 1.5)) || 1.5),
6282
+ maxNegativePrior: Math.max(0, Number(ctx.resolve(node.config?.maxFailurePriorPenalty ?? 8)) || 8),
6283
+ signalPenaltyScale: Math.max(0, Number(ctx.resolve(node.config?.feedbackSignalScale ?? 0.12)) || 0.12),
6284
+ };
6285
+ const plannerFeedback = resolvePlannerFeedbackObject(ctx.data?._plannerFeedback);
6286
+ const feedbackWeights = {
6287
+ agentAttempts: Math.max(
6288
+ 0,
6289
+ Number(plannerFeedback?.rankingSignals?.weights?.agentAttempts || 0.6),
6290
+ ),
6291
+ consecutiveNoCommits: Math.max(
6292
+ 0,
6293
+ Number(
6294
+ plannerFeedback?.rankingSignals?.weights?.consecutiveNoCommits || 1.3,
6295
+ ),
6296
+ ),
6297
+ blockedReason: Math.max(
6298
+ 0,
6299
+ Number(plannerFeedback?.rankingSignals?.weights?.blockedReason || 1.8),
6300
+ ),
6301
+ debtTrend: Math.max(
6302
+ 0,
6303
+ Number(plannerFeedback?.rankingSignals?.weights?.debtTrend || 0.7),
6304
+ ),
6305
+ commitSuccess: 2.2,
6306
+ completedSuccess: 0.8,
6307
+ };
4878
6308
  const materializationDefaults = resolvePlannerMaterializationDefaults(ctx);
4879
6309
 
4880
6310
  const parsedTasks = extractPlannerTasksFromWorkflowOutput(outputText, maxTasks);
@@ -4904,14 +6334,19 @@ registerNodeType("action.materialize_planner_tasks", {
4904
6334
 
4905
6335
  const existingTitleSet = new Set();
4906
6336
  const existingBacklogAreaCounts = new Map();
6337
+ let existingRows = [];
4907
6338
  const shouldFetchExistingTasks =
4908
6339
  Boolean(kanban?.listTasks)
4909
- && (dedupEnabled || (Number.isFinite(maxConcurrentRepoAreaTasks) && maxConcurrentRepoAreaTasks > 0));
6340
+ && (
6341
+ dedupEnabled
6342
+ || (Number.isFinite(maxConcurrentRepoAreaTasks) && maxConcurrentRepoAreaTasks > 0)
6343
+ || (Number.isFinite(rankingConfig.failureThreshold) && rankingConfig.failureThreshold > 0)
6344
+ );
4910
6345
  if (shouldFetchExistingTasks) {
4911
6346
  try {
4912
6347
  const existing = await kanban.listTasks(projectId, {});
4913
- const rows = Array.isArray(existing) ? existing : [];
4914
- for (const row of rows) {
6348
+ existingRows = Array.isArray(existing) ? existing : [];
6349
+ for (const row of existingRows) {
4915
6350
  const title = String(row?.title || "").trim().toLowerCase();
4916
6351
  if (dedupEnabled && title) existingTitleSet.add(title);
4917
6352
  const rowStatus = String(row?.status || "").trim().toLowerCase();
@@ -4928,12 +6363,79 @@ registerNodeType("action.materialize_planner_tasks", {
4928
6363
  ctx.log(node.id, `Could not prefetch tasks for dedup: ${err.message}`, "warn");
4929
6364
  }
4930
6365
  }
6366
+ const priorStatePath = shouldPersistPlannerPriorState()
6367
+ ? resolvePlannerPriorStatePath()
6368
+ : "";
6369
+ const priorState = loadPlannerPriorState(priorStatePath);
6370
+ replayPlannerOutcomes(existingRows, priorState, feedbackWeights);
6371
+ const feedbackHotTasks = Array.isArray(plannerFeedback?.taskStore?.hotTasks)
6372
+ ? plannerFeedback.taskStore.hotTasks
6373
+ : [];
6374
+ replayPlannerOutcomes(feedbackHotTasks, priorState, feedbackWeights);
6375
+ const feedbackPatterns = Array.isArray(plannerFeedback?.rankingSignals?.patterns)
6376
+ ? plannerFeedback.rankingSignals.patterns
6377
+ : [];
6378
+ for (const pattern of feedbackPatterns) {
6379
+ if (!pattern || typeof pattern !== "object") continue;
6380
+ const key = String(
6381
+ pattern.key ||
6382
+ buildPlannerPatternKey(
6383
+ pattern.repoArea || pattern.repo_area || "global",
6384
+ pattern.archetype || "general",
6385
+ ),
6386
+ ).trim();
6387
+ if (!key) continue;
6388
+ const entry = normalizePlannerPatternPrior(priorState.patterns[key]);
6389
+ const incomingCounter = Math.max(0, Number(pattern.failureCounter || 0));
6390
+ const incomingFailures = Math.max(0, Number(pattern.failures || 0));
6391
+ const incomingSuccesses = Math.max(0, Number(pattern.successes || 0));
6392
+ const incomingCommitlessCounter = Math.max(
6393
+ 0,
6394
+ Number(pattern.commitlessFailureCounter || pattern.commitless_counter || 0),
6395
+ );
6396
+ const incomingCommitlessFailures = Math.max(
6397
+ 0,
6398
+ Number(pattern.commitlessFailures || pattern.commitless_failures || 0),
6399
+ );
6400
+ const incomingCommitlessSuccesses = Math.max(
6401
+ 0,
6402
+ Number(pattern.commitlessSuccesses || pattern.commitless_successes || 0),
6403
+ );
6404
+ entry.failureCounter = Number(
6405
+ Math.max(entry.failureCounter || 0, incomingCounter).toFixed(3),
6406
+ );
6407
+ entry.failureCount = Math.max(
6408
+ Number(entry.failureCount || 0),
6409
+ incomingFailures,
6410
+ );
6411
+ entry.successCount = Math.max(
6412
+ Number(entry.successCount || 0),
6413
+ incomingSuccesses,
6414
+ );
6415
+ entry.commitlessFailureCounter = Number(
6416
+ Math.max(entry.commitlessFailureCounter || 0, incomingCommitlessCounter).toFixed(3),
6417
+ );
6418
+ entry.commitlessFailureCount = Math.max(
6419
+ Number(entry.commitlessFailureCount || 0),
6420
+ incomingCommitlessFailures,
6421
+ );
6422
+ entry.commitlessSuccessCount = Math.max(
6423
+ Number(entry.commitlessSuccessCount || 0),
6424
+ incomingCommitlessSuccesses,
6425
+ );
6426
+ entry.lastUpdatedAt = new Date().toISOString();
6427
+ priorState.patterns[key] = entry;
6428
+ }
6429
+ if (priorStatePath) {
6430
+ savePlannerPriorState(priorStatePath, priorState);
6431
+ }
6432
+ const rankedTasks = rankPlannerTaskCandidates(parsedTasks, priorState, rankingConfig);
4931
6433
 
4932
6434
  const created = [];
4933
6435
  const skipped = [];
4934
6436
  const materializationOutcomes = [];
4935
6437
  const createdAreaCounts = new Map();
4936
- for (const task of parsedTasks) {
6438
+ for (const task of rankedTasks) {
4937
6439
  const baseOutcome = {
4938
6440
  title: task.title,
4939
6441
  impact: task.impact,
@@ -5038,10 +6540,12 @@ registerNodeType("action.materialize_planner_tasks", {
5038
6540
  existingMeta.planner = {
5039
6541
  nodeId: plannerNodeId,
5040
6542
  index: task.index,
6543
+ archetype: task.archetype || null,
5041
6544
  impact: task.impact,
5042
6545
  confidence: task.confidence,
5043
6546
  risk: task.risk,
5044
6547
  estimated_effort: task.estimatedEffort,
6548
+ archetype: task.archetype,
5045
6549
  repo_areas: task.repoAreas,
5046
6550
  why_now: task.whyNow,
5047
6551
  kill_criteria: task.killCriteria,
@@ -5087,10 +6591,17 @@ registerNodeType("action.materialize_planner_tasks", {
5087
6591
  created,
5088
6592
  skipped,
5089
6593
  tasks: parsedTasks,
6594
+ rankedTasks: rankedTasks.map((task) => ({
6595
+ title: task.title,
6596
+ archetype: task.archetype || null,
6597
+ score: task?._ranking?.score,
6598
+ penalty: task?._ranking?.penalty,
6599
+ patternKeys: task?._ranking?.patternKeys || [],
6600
+ })),
5090
6601
  };
5091
6602
  },
5092
6603
  });
5093
- registerNodeType("agent.run_planner", {
6604
+ registerBuiltinNodeType("agent.run_planner", {
5094
6605
  describe: () => "Run the task planner agent to generate new backlog tasks",
5095
6606
  schema: {
5096
6607
  type: "object",
@@ -5233,7 +6744,7 @@ registerNodeType("agent.run_planner", {
5233
6744
  };
5234
6745
  },
5235
6746
  });
5236
- registerNodeType("agent.evidence_collect", {
6747
+ registerBuiltinNodeType("agent.evidence_collect", {
5237
6748
  describe: () => "Collect all evidence from .bosun/evidence for review",
5238
6749
  schema: {
5239
6750
  type: "object",
@@ -5270,7 +6781,7 @@ registerNodeType("agent.evidence_collect", {
5270
6781
  // FLOW CONTROL — Gates, barriers, and routing
5271
6782
  // ═══════════════════════════════════════════════════════════════════════════
5272
6783
 
5273
- registerNodeType("flow.gate", {
6784
+ registerBuiltinNodeType("flow.gate", {
5274
6785
  describe: () => "Pause workflow execution until a condition is met or manual approval is given",
5275
6786
  schema: {
5276
6787
  type: "object",
@@ -5341,7 +6852,7 @@ registerNodeType("flow.gate", {
5341
6852
  },
5342
6853
  });
5343
6854
 
5344
- registerNodeType("flow.join", {
6855
+ registerBuiltinNodeType("flow.join", {
5345
6856
  describe: () => "Explicitly join multiple branches before continuing",
5346
6857
  schema: {
5347
6858
  type: "object",
@@ -5440,7 +6951,7 @@ registerNodeType("flow.join", {
5440
6951
  },
5441
6952
  });
5442
6953
 
5443
- registerNodeType("flow.end", {
6954
+ registerBuiltinNodeType("flow.end", {
5444
6955
  describe: () => "End the workflow immediately with explicit terminal status",
5445
6956
  schema: {
5446
6957
  type: "object",
@@ -5596,14 +7107,14 @@ const UNIVERSAL_FLOW_NODE = {
5596
7107
  },
5597
7108
  };
5598
7109
 
5599
- registerNodeType("flow.universal", UNIVERSAL_FLOW_NODE);
5600
- registerNodeType("flow.universial", UNIVERSAL_FLOW_NODE);
7110
+ registerBuiltinNodeType("flow.universal", UNIVERSAL_FLOW_NODE);
7111
+ registerBuiltinNodeType("flow.universial", UNIVERSAL_FLOW_NODE);
5601
7112
 
5602
7113
  // ═══════════════════════════════════════════════════════════════════════════
5603
7114
  // LOOP / ITERATION
5604
7115
  // ═══════════════════════════════════════════════════════════════════════════
5605
7116
 
5606
- registerNodeType("loop.for_each", {
7117
+ registerBuiltinNodeType("loop.for_each", {
5607
7118
  describe: () =>
5608
7119
  "Iterate over an array, executing a sub-workflow for each item. " +
5609
7120
  "Supports parallel fan-out via maxConcurrent and provides per-item " +
@@ -5696,7 +7207,7 @@ registerNodeType("loop.for_each", {
5696
7207
  },
5697
7208
  });
5698
7209
 
5699
- registerNodeType("loop.while", {
7210
+ registerBuiltinNodeType("loop.while", {
5700
7211
  describe: () =>
5701
7212
  "Repeat a sub-workflow until a condition evaluates to false or max iterations " +
5702
7213
  "are reached. Enables convergence loops (generate→verify→revise) by executing " +
@@ -5836,7 +7347,7 @@ registerNodeType("loop.while", {
5836
7347
  // SESSION / AGENT MANAGEMENT — Direct session control
5837
7348
  // ═══════════════════════════════════════════════════════════════════════════
5838
7349
 
5839
- registerNodeType("action.continue_session", {
7350
+ registerBuiltinNodeType("action.continue_session", {
5840
7351
  describe: () => "Re-attach to an existing agent session and send a continuation prompt",
5841
7352
  schema: {
5842
7353
  type: "object",
@@ -5894,7 +7405,7 @@ registerNodeType("action.continue_session", {
5894
7405
  },
5895
7406
  });
5896
7407
 
5897
- registerNodeType("action.restart_agent", {
7408
+ registerBuiltinNodeType("action.restart_agent", {
5898
7409
  describe: () => "Kill and restart an agent session from scratch",
5899
7410
  schema: {
5900
7411
  type: "object",
@@ -5950,7 +7461,7 @@ registerNodeType("action.restart_agent", {
5950
7461
  },
5951
7462
  });
5952
7463
 
5953
- registerNodeType("action.bosun_cli", {
7464
+ registerBuiltinNodeType("action.bosun_cli", {
5954
7465
  describe: () => "Run a bosun CLI command (task, monitor, agent, etc.)",
5955
7466
  schema: {
5956
7467
  type: "object",
@@ -6020,7 +7531,7 @@ async function getKanbanMod() {
6020
7531
  // input/output. Unlike action.bosun_cli (which shells out), this executes
6021
7532
  // the tool script directly in-process and returns parsed, structured data.
6022
7533
 
6023
- registerNodeType("action.bosun_tool", {
7534
+ registerBuiltinNodeType("action.bosun_tool", {
6024
7535
  describe: () =>
6025
7536
  "Invoke a Bosun built-in or custom tool programmatically. Returns " +
6026
7537
  "structured output that downstream workflow nodes can consume via " +
@@ -6223,7 +7734,7 @@ registerNodeType("action.bosun_tool", {
6223
7734
  // simpler ergonomics for the common case of "run workflow X and pipe
6224
7735
  // its output to the next node".
6225
7736
 
6226
- registerNodeType("action.invoke_workflow", {
7737
+ registerBuiltinNodeType("action.invoke_workflow", {
6227
7738
  describe: () =>
6228
7739
  "Invoke another workflow and pipe its output to downstream nodes. " +
6229
7740
  "Simpler than action.execute_workflow — designed for workflow-to-workflow " +
@@ -6649,7 +8160,7 @@ const BOSUN_FUNCTION_REGISTRY = Object.freeze({
6649
8160
  },
6650
8161
  });
6651
8162
 
6652
- registerNodeType("action.bosun_function", {
8163
+ registerBuiltinNodeType("action.bosun_function", {
6653
8164
  describe: () =>
6654
8165
  "Invoke an internal Bosun function directly (tasks, git, tools, workflows, config). " +
6655
8166
  "Returns structured output that downstream nodes can consume. More powerful " +
@@ -6762,7 +8273,7 @@ registerNodeType("action.bosun_function", {
6762
8273
  },
6763
8274
  });
6764
8275
 
6765
- registerNodeType("action.handle_rate_limit", {
8276
+ registerBuiltinNodeType("action.handle_rate_limit", {
6766
8277
  describe: () => "Intelligently handle API rate limits with exponential backoff and provider rotation",
6767
8278
  schema: {
6768
8279
  type: "object",
@@ -6809,7 +8320,7 @@ registerNodeType("action.handle_rate_limit", {
6809
8320
  },
6810
8321
  });
6811
8322
 
6812
- registerNodeType("action.ask_user", {
8323
+ registerBuiltinNodeType("action.ask_user", {
6813
8324
  describe: () => "Pause workflow and ask the user for input via Telegram or UI",
6814
8325
  schema: {
6815
8326
  type: "object",
@@ -6855,7 +8366,7 @@ registerNodeType("action.ask_user", {
6855
8366
  },
6856
8367
  });
6857
8368
 
6858
- registerNodeType("action.analyze_errors", {
8369
+ registerBuiltinNodeType("action.analyze_errors", {
6859
8370
  describe: () => "Run the error detector on recent logs and classify failures",
6860
8371
  schema: {
6861
8372
  type: "object",
@@ -6917,7 +8428,7 @@ registerNodeType("action.analyze_errors", {
6917
8428
  },
6918
8429
  });
6919
8430
 
6920
- registerNodeType("action.refresh_worktree", {
8431
+ registerBuiltinNodeType("action.refresh_worktree", {
6921
8432
  describe: () => "Refresh git worktree state — fetch, pull, or reset to clean state",
6922
8433
  schema: {
6923
8434
  type: "object",
@@ -7198,7 +8709,7 @@ async function _executeMcpToolCall(serverId, toolName, input, timeoutMs, ctx) {
7198
8709
  };
7199
8710
  }
7200
8711
 
7201
- registerNodeType("action.mcp_tool_call", {
8712
+ registerBuiltinNodeType("action.mcp_tool_call", {
7202
8713
  describe: () =>
7203
8714
  "Call a tool on an installed MCP server with structured output extraction. " +
7204
8715
  "Supports field extraction, output mapping, type coercion, and port-based " +
@@ -7350,7 +8861,7 @@ registerNodeType("action.mcp_tool_call", {
7350
8861
  },
7351
8862
  });
7352
8863
 
7353
- registerNodeType("action.mcp_list_tools", {
8864
+ registerBuiltinNodeType("action.mcp_list_tools", {
7354
8865
  describe: () =>
7355
8866
  "List available tools on an installed MCP server, including their input " +
7356
8867
  "schemas. Useful for dynamic tool discovery and auto-wiring in pipelines.",
@@ -7435,7 +8946,7 @@ registerNodeType("action.mcp_list_tools", {
7435
8946
 
7436
8947
  // ── action.mcp_pipeline — Chain multiple MCP tool calls with data piping ──
7437
8948
 
7438
- registerNodeType("action.mcp_pipeline", {
8949
+ registerBuiltinNodeType("action.mcp_pipeline", {
7439
8950
  describe: () =>
7440
8951
  "Execute a chain of MCP tool calls in sequence, piping structured output " +
7441
8952
  "from each step to the next. Each step can extract specific fields from " +
@@ -7659,7 +9170,7 @@ registerNodeType("action.mcp_pipeline", {
7659
9170
 
7660
9171
  // ── transform.mcp_extract — Extract structured data from any MCP output ──
7661
9172
 
7662
- registerNodeType("transform.mcp_extract", {
9173
+ registerBuiltinNodeType("transform.mcp_extract", {
7663
9174
  describe: () =>
7664
9175
  "Extract and reshape structured data from an upstream MCP tool call or " +
7665
9176
  "any node output. Supports dot-path fields, JSON pointers, array wildcards, " +
@@ -8112,14 +9623,76 @@ function isValidGitWorktreePath(worktreePath) {
8112
9623
  timeout: 5000,
8113
9624
  stdio: ["ignore", "pipe", "pipe"],
8114
9625
  }).trim().toLowerCase();
8115
- return inside === "true";
9626
+ if (inside !== "true") return false;
9627
+ // A nested folder inside the main repo also returns inside-work-tree=true.
9628
+ // Reuse is safe only when the path itself is the git top-level root.
9629
+ const topLevel = execGitArgsSync(["rev-parse", "--show-toplevel"], {
9630
+ cwd: worktreePath,
9631
+ encoding: "utf8",
9632
+ timeout: 5000,
9633
+ stdio: ["ignore", "pipe", "pipe"],
9634
+ }).trim();
9635
+ const normalize = (value) =>
9636
+ resolve(String(value || ""))
9637
+ .replace(/\\/g, "/")
9638
+ .replace(/\/+$/, "");
9639
+ return normalize(topLevel) === normalize(worktreePath);
8116
9640
  } catch {
8117
9641
  return false;
8118
9642
  }
8119
9643
  }
8120
9644
 
9645
+ function resolveGitDirForWorktree(worktreePath) {
9646
+ if (!worktreePath || !existsSync(worktreePath)) return "";
9647
+ try {
9648
+ const topLevel = execGitArgsSync(["rev-parse", "--show-toplevel"], {
9649
+ cwd: worktreePath,
9650
+ encoding: "utf8",
9651
+ timeout: 5000,
9652
+ stdio: ["ignore", "pipe", "pipe"],
9653
+ }).trim();
9654
+ const normalize = (value) =>
9655
+ resolve(String(value || ""))
9656
+ .replace(/\\/g, "/")
9657
+ .replace(/\/+$/, "")
9658
+ .toLowerCase();
9659
+ if (normalize(topLevel) !== normalize(worktreePath)) return "";
9660
+ const gitDir = execGitArgsSync(["rev-parse", "--git-dir"], {
9661
+ cwd: worktreePath,
9662
+ encoding: "utf8",
9663
+ timeout: 5000,
9664
+ stdio: ["ignore", "pipe", "pipe"],
9665
+ }).trim();
9666
+ if (!gitDir) return "";
9667
+ return resolve(worktreePath, gitDir);
9668
+ } catch {
9669
+ return "";
9670
+ }
9671
+ }
9672
+
9673
+ function hasUnresolvedGitOperation(worktreePath) {
9674
+ if (!worktreePath || !existsSync(worktreePath)) return false;
9675
+ try {
9676
+ const gitDir = resolveGitDirForWorktree(worktreePath);
9677
+ if (!gitDir || !existsSync(gitDir)) return true;
9678
+ for (const marker of ["rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"]) {
9679
+ if (existsSync(resolve(gitDir, marker))) return true;
9680
+ }
9681
+ const unmerged = execGitArgsSync(["diff", "--name-only", "--diff-filter=U"], {
9682
+ cwd: worktreePath,
9683
+ encoding: "utf8",
9684
+ timeout: 5000,
9685
+ stdio: ["ignore", "pipe", "pipe"],
9686
+ }).trim();
9687
+ return Boolean(unmerged);
9688
+ } catch {
9689
+ return true;
9690
+ }
9691
+ }
9692
+
8121
9693
  function cleanupBrokenManagedWorktree(repoRoot, worktreePath) {
8122
9694
  if (!worktreePath) return;
9695
+ const linkedGitDir = resolveGitDirForWorktree(worktreePath);
8123
9696
  try {
8124
9697
  execGitArgsSync(["worktree", "remove", String(worktreePath), "--force"], {
8125
9698
  cwd: repoRoot,
@@ -8135,6 +9708,13 @@ function cleanupBrokenManagedWorktree(repoRoot, worktreePath) {
8135
9708
  } catch {
8136
9709
  /* best-effort */
8137
9710
  }
9711
+ try {
9712
+ if (linkedGitDir && existsSync(linkedGitDir)) {
9713
+ rmSync(linkedGitDir, { recursive: true, force: true });
9714
+ }
9715
+ } catch {
9716
+ /* best-effort */
9717
+ }
8138
9718
  try {
8139
9719
  execGitArgsSync(["worktree", "prune"], {
8140
9720
  cwd: repoRoot,
@@ -8163,7 +9743,7 @@ const STRICT_START_GUARD_MISSING_TASK = /^(1|true|yes|on)$/i.test(
8163
9743
 
8164
9744
  // ── trigger.task_available ──────────────────────────────────────────────────
8165
9745
 
8166
- registerNodeType("trigger.task_available", {
9746
+ registerBuiltinNodeType("trigger.task_available", {
8167
9747
  describe: () =>
8168
9748
  "Polling trigger that fires when todo tasks are available. Handles " +
8169
9749
  "slot limits, anti-thrash filtering, cooldowns, task sorting (fire " +
@@ -8544,7 +10124,7 @@ registerNodeType("trigger.task_available", {
8544
10124
  });
8545
10125
  // ── condition.slot_available ────────────────────────────────────────────────
8546
10126
 
8547
- registerNodeType("condition.slot_available", {
10127
+ registerBuiltinNodeType("condition.slot_available", {
8548
10128
  describe: () =>
8549
10129
  "Gate checking both global and per-base-branch concurrency limits.",
8550
10130
  schema: {
@@ -8579,7 +10159,7 @@ registerNodeType("condition.slot_available", {
8579
10159
 
8580
10160
  // ── action.allocate_slot ────────────────────────────────────────────────────
8581
10161
 
8582
- registerNodeType("action.allocate_slot", {
10162
+ registerBuiltinNodeType("action.allocate_slot", {
8583
10163
  describe: () =>
8584
10164
  "Reserve a parallel execution slot. Saves process env snapshot for " +
8585
10165
  "parallel isolation and stores slot metadata in workflow context.",
@@ -8637,7 +10217,7 @@ registerNodeType("action.allocate_slot", {
8637
10217
 
8638
10218
  // ── action.release_slot ─────────────────────────────────────────────────────
8639
10219
 
8640
- registerNodeType("action.release_slot", {
10220
+ registerBuiltinNodeType("action.release_slot", {
8641
10221
  describe: () =>
8642
10222
  "Release a previously allocated execution slot. Restores saved env vars " +
8643
10223
  "for parallel isolation. Idempotent — safe on double-call.",
@@ -8672,7 +10252,7 @@ registerNodeType("action.release_slot", {
8672
10252
 
8673
10253
  // ── action.claim_task ───────────────────────────────────────────────────────
8674
10254
 
8675
- registerNodeType("action.claim_task", {
10255
+ registerBuiltinNodeType("action.claim_task", {
8676
10256
  describe: () =>
8677
10257
  "Acquire a distributed task claim with auto-renewal. Prevents duplicate " +
8678
10258
  "execution across orchestrators. Stores claim token + renewal timer in " +
@@ -8802,7 +10382,7 @@ registerNodeType("action.claim_task", {
8802
10382
 
8803
10383
  // ── action.release_claim ────────────────────────────────────────────────────
8804
10384
 
8805
- registerNodeType("action.release_claim", {
10385
+ registerBuiltinNodeType("action.release_claim", {
8806
10386
  describe: () =>
8807
10387
  "Release a distributed task claim + cancel renewal timer. Idempotent.",
8808
10388
  schema: {
@@ -8866,7 +10446,7 @@ registerNodeType("action.release_claim", {
8866
10446
 
8867
10447
  // ── action.resolve_executor ─────────────────────────────────────────────────
8868
10448
 
8869
- registerNodeType("action.resolve_executor", {
10449
+ registerBuiltinNodeType("action.resolve_executor", {
8870
10450
  describe: () =>
8871
10451
  "Pick SDK + model via complexity routing, env overrides, or defaults.",
8872
10452
  schema: {
@@ -8893,6 +10473,12 @@ registerNodeType("action.resolve_executor", {
8893
10473
  description: cfgOrCtx(node, ctx, "taskDescription"),
8894
10474
  tags: Array.isArray(ctx.data?.task?.tags) ? ctx.data.task.tags : [],
8895
10475
  };
10476
+ const requestedAgentProfileId = String(
10477
+ cfgOrCtx(node, ctx, "agentProfile")
10478
+ || ctx.data?.task?.agentProfile
10479
+ || ctx.data?.agentProfile
10480
+ || "",
10481
+ ).trim();
8896
10482
  let profileDecision = null;
8897
10483
  let configuredExecutorPreference = null;
8898
10484
 
@@ -8917,12 +10503,32 @@ registerNodeType("action.resolve_executor", {
8917
10503
  title: task.title,
8918
10504
  description: task.description,
8919
10505
  tags: task.tags,
10506
+ agentType: ctx.data?.task?.agentType || ctx.data?.agentType || "",
8920
10507
  repoRoot,
8921
10508
  },
8922
- { topN: 1 },
10509
+ { topN: Math.max(10, requestedAgentProfileId ? 25 : 10) },
8923
10510
  );
8924
- const profile = match?.best?.profile || null;
8925
- const profileId = String(match?.best?.id || "").trim();
10511
+ const candidates = Array.isArray(match?.candidates) ? match.candidates : [];
10512
+ const bestCandidate = match?.best || null;
10513
+ const autoMinScore = Number(match?.auto?.thresholds?.minScore || 12);
10514
+ const scoreQualified = Number(bestCandidate?.score || 0) >= autoMinScore;
10515
+ const matchedCandidate = requestedAgentProfileId
10516
+ ? candidates.find((candidate) => String(candidate?.id || "").trim() === requestedAgentProfileId) || null
10517
+ : ((match?.auto?.shouldAutoApply || scoreQualified) ? bestCandidate : null);
10518
+ if (!requestedAgentProfileId && bestCandidate && !match?.auto?.shouldAutoApply) {
10519
+ ctx.log(
10520
+ node.id,
10521
+ `Profile match below auto threshold; ignoring candidate ${String(bestCandidate.id || "unknown")}`,
10522
+ );
10523
+ }
10524
+ const profile = matchedCandidate?.profile || null;
10525
+ const profileId = String(matchedCandidate?.id || "").trim();
10526
+ if (requestedAgentProfileId && !profileId) {
10527
+ ctx.log(
10528
+ node.id,
10529
+ `Requested agent profile "${requestedAgentProfileId}" not found; falling back to executor defaults`,
10530
+ );
10531
+ }
8926
10532
  if (profileId && profile) {
8927
10533
  profileDecision = { id: profileId, profile };
8928
10534
  ctx.data.agentProfile = profileId;
@@ -9049,7 +10655,7 @@ registerNodeType("action.resolve_executor", {
9049
10655
 
9050
10656
  // ── action.acquire_worktree ─────────────────────────────────────────────────
9051
10657
 
9052
- registerNodeType("action.acquire_worktree", {
10658
+ registerBuiltinNodeType("action.acquire_worktree", {
9053
10659
  describe: () =>
9054
10660
  "Create or checkout a git worktree for isolated task execution. " +
9055
10661
  "Fetches base branch, creates worktree, handles branch conflicts.",
@@ -9126,6 +10732,9 @@ registerNodeType("action.acquire_worktree", {
9126
10732
  if (!isValidGitWorktreePath(worktreePath)) {
9127
10733
  ctx.log(node.id, `Managed worktree is invalid, recreating: ${worktreePath}`);
9128
10734
  cleanupBrokenManagedWorktree(repoRoot, worktreePath);
10735
+ } else if (hasUnresolvedGitOperation(worktreePath)) {
10736
+ ctx.log(node.id, `Managed worktree has unresolved git state, recreating: ${worktreePath}`);
10737
+ cleanupBrokenManagedWorktree(repoRoot, worktreePath);
9129
10738
  }
9130
10739
  }
9131
10740
 
@@ -9141,14 +10750,20 @@ registerNodeType("action.acquire_worktree", {
9141
10750
  } catch {
9142
10751
  /* rebase failures are non-fatal for reuse */
9143
10752
  }
10753
+ if (existsSync(worktreePath) && hasUnresolvedGitOperation(worktreePath)) {
10754
+ ctx.log(node.id, `Managed worktree refresh left unresolved git state, recreating: ${worktreePath}`);
10755
+ cleanupBrokenManagedWorktree(repoRoot, worktreePath);
10756
+ }
10757
+ }
10758
+ if (existsSync(worktreePath)) {
10759
+ ctx.data.worktreePath = worktreePath;
10760
+ ctx.data._worktreeCreated = false;
10761
+ ctx.data._worktreeManaged = true;
10762
+ ctx.log(node.id, `Reusing worktree: ${worktreePath}`);
10763
+ const cleared1 = clearBlockedWorktreeIdentity(worktreePath);
10764
+ if (cleared1) ctx.log(node.id, `Cleared blocked test git identity from worktree: ${worktreePath}`);
10765
+ return { success: true, worktreePath, created: false, reused: true, branch, baseBranch };
9144
10766
  }
9145
- ctx.data.worktreePath = worktreePath;
9146
- ctx.data._worktreeCreated = false;
9147
- ctx.data._worktreeManaged = true;
9148
- ctx.log(node.id, `Reusing worktree: ${worktreePath}`);
9149
- const cleared1 = clearBlockedWorktreeIdentity(worktreePath);
9150
- if (cleared1) ctx.log(node.id, `Cleared blocked test git identity from worktree: ${worktreePath}`);
9151
- return { success: true, worktreePath, created: false, reused: true, branch, baseBranch };
9152
10767
  }
9153
10768
 
9154
10769
  // Create fresh worktree
@@ -9223,7 +10838,7 @@ registerNodeType("action.acquire_worktree", {
9223
10838
 
9224
10839
  // ── action.release_worktree ─────────────────────────────────────────────────
9225
10840
 
9226
- registerNodeType("action.release_worktree", {
10841
+ registerBuiltinNodeType("action.release_worktree", {
9227
10842
  describe: () =>
9228
10843
  "Release a git worktree. Idempotent. Optionally prunes stale entries.",
9229
10844
  schema: {
@@ -9434,7 +11049,7 @@ registerNodeType("action.workflow_contract_validation", workflowContractValidati
9434
11049
 
9435
11050
  // ── action.build_task_prompt ────────────────────────────────────────────────
9436
11051
 
9437
- registerNodeType("action.build_task_prompt", {
11052
+ registerBuiltinNodeType("action.build_task_prompt", {
9438
11053
  describe: () =>
9439
11054
  "Compose the full agent prompt from task data, AGENTS.md, comments, " +
9440
11055
  "copilot-instructions.md, agent status endpoint, and co-author trailer.",
@@ -9554,70 +11169,161 @@ registerNodeType("action.build_task_prompt", {
9554
11169
  const allowedRepositories = normalizeStringArray(repositories, primaryRepository);
9555
11170
  const matchedSkills = findRelevantSkills(repoRoot, taskTitle, taskDescription || "", {});
9556
11171
  const activeSkillFiles = matchedSkills.map((skill) => skill.filename);
11172
+ const strictCacheAnchoring =
11173
+ String(process.env.BOSUN_CACHE_ANCHOR_MODE || "")
11174
+ .trim()
11175
+ .toLowerCase() === "strict";
11176
+
11177
+ const buildStableSystemPrompt = () => {
11178
+ const systemParts = [];
11179
+ if (includeAgentsMd) {
11180
+ const searchDirs = [repoRoot].filter(Boolean);
11181
+ const docFiles = ["AGENTS.md", ".github/copilot-instructions.md"];
11182
+ const loaded = new Set();
11183
+ for (const dir of searchDirs) {
11184
+ for (const doc of docFiles) {
11185
+ if (loaded.has(doc)) continue;
11186
+ const fullPath = resolve(dir, doc);
11187
+ try {
11188
+ if (!existsSync(fullPath)) continue;
11189
+ const content = readFileSync(fullPath, "utf8").trim();
11190
+ if (!content || content.length <= 10) continue;
11191
+ loaded.add(doc);
11192
+ systemParts.push(`## ${doc}`);
11193
+ systemParts.push(content);
11194
+ systemParts.push("");
11195
+ } catch {
11196
+ // best-effort only
11197
+ }
11198
+ }
11199
+ }
11200
+ }
11201
+
11202
+ if (includeStatusEndpoint) {
11203
+ const port = process.env.AGENT_ENDPOINT_PORT || process.env.BOSUN_AGENT_ENDPOINT_PORT || "";
11204
+ if (port) {
11205
+ systemParts.push("## Agent Status Endpoint");
11206
+ systemParts.push(`POST http://127.0.0.1:${port}/status — Report progress`);
11207
+ systemParts.push(`POST http://127.0.0.1:${port}/heartbeat — Heartbeat ping`);
11208
+ systemParts.push(`POST http://127.0.0.1:${port}/error — Report errors`);
11209
+ systemParts.push(`POST http://127.0.0.1:${port}/complete — Signal completion`);
11210
+ systemParts.push("");
11211
+ }
11212
+ }
11213
+
11214
+ systemParts.push("## Tool Discovery");
11215
+ systemParts.push(
11216
+ "Bosun uses a compact MCP discovery layer for external MCP servers and the custom tool library.",
11217
+ );
11218
+ systemParts.push(
11219
+ "Preferred flow: `search` -> `get_schema` -> `execute`.",
11220
+ );
11221
+ systemParts.push(
11222
+ "Only eager tools are preloaded below to keep context small. Use `call_discovered_tool` only as a direct fallback when orchestration code is unnecessary.",
11223
+ );
11224
+ systemParts.push("");
11225
+
11226
+ const eagerToolBlock = getToolsPromptBlock(repoRoot, {
11227
+ includeBuiltins: true,
11228
+ eagerOnly: true,
11229
+ discoveryMode: true,
11230
+ emitReflectHint: true,
11231
+ limit: 12,
11232
+ });
11233
+ if (eagerToolBlock) {
11234
+ systemParts.push(eagerToolBlock);
11235
+ systemParts.push("");
11236
+ }
11237
+
11238
+ systemParts.push("## Instructions");
11239
+ systemParts.push(
11240
+ "1. Follow the project instructions in AGENTS.md.\n" +
11241
+ "2. Use the discovery MCP tools for non-eager MCP/custom tools before assuming a capability is unavailable.\n" +
11242
+ "3. Implement the required changes.\n" +
11243
+ "4. Ensure tests pass and build is clean with 0 warnings.\n" +
11244
+ "5. Commit your changes using conventional commits.\n" +
11245
+ "6. Never ask for user input — you are autonomous.\n" +
11246
+ "7. Use all available tools to verify your work.",
11247
+ );
11248
+ systemParts.push("");
11249
+ systemParts.push("## Git Attribution");
11250
+ systemParts.push("Add this trailer to all commits:");
11251
+ systemParts.push("Co-authored-by: bosun[bot] <bosun@virtengine.com>");
11252
+ return systemParts.join("\n").trim();
11253
+ };
9557
11254
 
9558
11255
  if (customTemplate) {
11256
+ const stableSystemPrompt = buildStableSystemPrompt();
9559
11257
  ctx.data._taskPrompt = customTemplate;
11258
+ ctx.data._taskUserPrompt = customTemplate;
11259
+ ctx.data._taskSystemPrompt = stableSystemPrompt;
9560
11260
  ctx.log(node.id, `Prompt from custom template (${customTemplate.length} chars)`);
9561
- return { success: true, prompt: customTemplate, source: "custom" };
11261
+ return {
11262
+ success: true,
11263
+ prompt: customTemplate,
11264
+ userPrompt: customTemplate,
11265
+ systemPrompt: stableSystemPrompt,
11266
+ source: "custom",
11267
+ };
9562
11268
  }
9563
11269
 
9564
- const parts = [];
11270
+ const userParts = [];
9565
11271
 
9566
11272
  // Header
9567
- parts.push(`# Task: ${taskTitle}`);
9568
- if (taskId) parts.push(`Task ID: ${taskId}`);
9569
- parts.push("");
11273
+ userParts.push(`# Task: ${taskTitle}`);
11274
+ if (taskId) userParts.push(`Task ID: ${taskId}`);
11275
+ userParts.push("");
9570
11276
 
9571
11277
  // Retry context (if applicable)
9572
11278
  if (retryReason) {
9573
- parts.push("## Retry Context");
9574
- parts.push(`Previous attempt failed: ${retryReason}`);
9575
- parts.push("Try a different approach this time.");
9576
- parts.push("");
11279
+ userParts.push("## Retry Context");
11280
+ userParts.push(`Previous attempt failed: ${retryReason}`);
11281
+ userParts.push("Try a different approach this time.");
11282
+ userParts.push("");
9577
11283
  }
9578
11284
 
9579
11285
  // Description
9580
11286
  if (taskDescription) {
9581
- parts.push("## Description");
9582
- parts.push(taskDescription);
9583
- parts.push("");
11287
+ userParts.push("## Description");
11288
+ userParts.push(taskDescription);
11289
+ userParts.push("");
9584
11290
  }
9585
11291
 
9586
11292
  // Environment context
9587
- parts.push("## Environment");
11293
+ userParts.push("## Environment");
9588
11294
  const envLines = [];
9589
11295
  if (worktreePath) envLines.push(`- **Working Directory:** ${worktreePath}`);
9590
11296
  if (branch) envLines.push(`- **Branch:** ${branch}`);
9591
11297
  if (baseBranch) envLines.push(`- **Base Branch:** ${baseBranch}`);
9592
11298
  if (repoSlug) envLines.push(`- **Repository:** ${repoSlug}`);
9593
11299
  if (repoRoot) envLines.push(`- **Repo Root:** ${repoRoot}`);
9594
- if (envLines.length) parts.push(envLines.join("\n"));
9595
- parts.push("");
11300
+ if (envLines.length) userParts.push(envLines.join("\n"));
11301
+ userParts.push("");
9596
11302
 
9597
11303
  // Workspace and repository scope guardrails.
9598
- parts.push("## Workspace Scope Contract");
9599
- if (workspace) parts.push(`- **Workspace:** ${workspace}`);
9600
- if (primaryRepository) parts.push(`- **Primary Repository:** ${primaryRepository}`);
11304
+ userParts.push("## Workspace Scope Contract");
11305
+ if (workspace) userParts.push(`- **Workspace:** ${workspace}`);
11306
+ if (primaryRepository) userParts.push(`- **Primary Repository:** ${primaryRepository}`);
9601
11307
  if (allowedRepositories.length > 0) {
9602
- parts.push("- **Allowed Repositories:**");
11308
+ userParts.push("- **Allowed Repositories:**");
9603
11309
  for (const allowedRepo of allowedRepositories) {
9604
- parts.push(` - ${allowedRepo}`);
11310
+ userParts.push(` - ${allowedRepo}`);
9605
11311
  }
9606
11312
  } else {
9607
- parts.push("- **Allowed Repositories:** (not declared)");
11313
+ userParts.push("- **Allowed Repositories:** (not declared)");
9608
11314
  }
9609
- if (worktreePath) parts.push(`- **Write Scope Root:** ${worktreePath}`);
9610
- parts.push("");
9611
- parts.push("Hard boundaries:");
11315
+ if (worktreePath) userParts.push(`- **Write Scope Root:** ${worktreePath}`);
11316
+ userParts.push("");
11317
+ userParts.push("Hard boundaries:");
9612
11318
  if (worktreePath) {
9613
- parts.push(`1. Modify files only inside \`${worktreePath}\`.`);
11319
+ userParts.push(`1. Modify files only inside \`${worktreePath}\`.`);
9614
11320
  } else {
9615
- parts.push("1. Modify files only inside the active repository working directory.");
11321
+ userParts.push("1. Modify files only inside the active repository working directory.");
9616
11322
  }
9617
- parts.push("2. Modify code only in the allowed repositories listed above.");
9618
- parts.push("3. If required work depends on an unlisted repository, stop and report `blocked: cross-repo dependency`.");
9619
- parts.push("4. In completion notes, list every repository you touched and why.");
9620
- parts.push("");
11323
+ userParts.push("2. Modify code only in the allowed repositories listed above.");
11324
+ userParts.push("3. If required work depends on an unlisted repository, stop and report `blocked: cross-repo dependency`.");
11325
+ userParts.push("4. In completion notes, list every repository you touched and why.");
11326
+ userParts.push("");
9621
11327
 
9622
11328
  let workflowContractPromptBlock = String(ctx.data?._workflowContractPromptBlock || "").trim();
9623
11329
  if (!workflowContractPromptBlock) {
@@ -9636,8 +11342,8 @@ registerNodeType("action.build_task_prompt", {
9636
11342
  }
9637
11343
  }
9638
11344
  if (workflowContractPromptBlock) {
9639
- parts.push(workflowContractPromptBlock);
9640
- parts.push("");
11345
+ userParts.push(workflowContractPromptBlock);
11346
+ userParts.push("");
9641
11347
  }
9642
11348
 
9643
11349
  // AGENTS.md + copilot-instructions.md
@@ -9654,9 +11360,9 @@ registerNodeType("action.build_task_prompt", {
9654
11360
  const content = readFileSync(fullPath, "utf8").trim();
9655
11361
  if (content && content.length > 10) {
9656
11362
  loaded.add(doc);
9657
- parts.push(`## ${doc}`);
9658
- parts.push(content);
9659
- parts.push("");
11363
+ userParts.push(`## ${doc}`);
11364
+ userParts.push(content);
11365
+ userParts.push("");
9660
11366
  }
9661
11367
  }
9662
11368
  } catch { /* best-effort */ }
@@ -9668,12 +11374,12 @@ registerNodeType("action.build_task_prompt", {
9668
11374
  if (includeStatusEndpoint) {
9669
11375
  const port = process.env.AGENT_ENDPOINT_PORT || process.env.BOSUN_AGENT_ENDPOINT_PORT || "";
9670
11376
  if (port) {
9671
- parts.push("## Agent Status Endpoint");
9672
- parts.push(`POST http://127.0.0.1:${port}/status — Report progress`);
9673
- parts.push(`POST http://127.0.0.1:${port}/heartbeat — Heartbeat ping`);
9674
- parts.push(`POST http://127.0.0.1:${port}/error — Report errors`);
9675
- parts.push(`POST http://127.0.0.1:${port}/complete — Signal completion`);
9676
- parts.push("");
11377
+ userParts.push("## Agent Status Endpoint");
11378
+ userParts.push(`POST http://127.0.0.1:${port}/status — Report progress`);
11379
+ userParts.push(`POST http://127.0.0.1:${port}/heartbeat — Heartbeat ping`);
11380
+ userParts.push(`POST http://127.0.0.1:${port}/error — Report errors`);
11381
+ userParts.push(`POST http://127.0.0.1:${port}/complete — Signal completion`);
11382
+ userParts.push("");
9677
11383
  }
9678
11384
  }
9679
11385
 
@@ -9684,8 +11390,8 @@ registerNodeType("action.build_task_prompt", {
9684
11390
  {},
9685
11391
  );
9686
11392
  if (relevantSkillsBlock) {
9687
- parts.push(relevantSkillsBlock);
9688
- parts.push("");
11393
+ userParts.push(relevantSkillsBlock);
11394
+ userParts.push("");
9689
11395
  }
9690
11396
 
9691
11397
  // Inject library-resolved skills from agent.select_profile.
@@ -9711,70 +11417,73 @@ registerNodeType("action.build_task_prompt", {
9711
11417
  librarySkillParts.push("");
9712
11418
  }
9713
11419
  if (librarySkillParts.length > 0) {
9714
- parts.push("## Library Skills");
9715
- parts.push(...librarySkillParts);
11420
+ userParts.push("## Library Skills");
11421
+ userParts.push(...librarySkillParts);
9716
11422
  }
9717
11423
  } catch (err) {
9718
11424
  ctx.log(node.id, `Library skill injection failed (non-fatal): ${err.message}`);
9719
11425
  }
9720
11426
  }
9721
-
9722
- parts.push("## Tool Discovery");
9723
- parts.push(
9724
- "Bosun uses a compact MCP discovery layer for external MCP servers and the custom tool library.",
9725
- );
9726
- parts.push(
9727
- "Preferred flow: `search` -> `get_schema` -> `execute`.",
9728
- );
9729
- parts.push(
9730
- "Only eager tools are preloaded below to keep context small. Use `call_discovered_tool` only as a direct fallback when orchestration code is unnecessary.",
9731
- );
9732
- parts.push("");
9733
-
9734
- const eagerToolBlock = getToolsPromptBlock(repoRoot, {
11427
+ // Skill-driven eager tools belong with task context to preserve cache anchoring.
11428
+ const taskScopedEagerTools = getToolsPromptBlock(repoRoot, {
9735
11429
  activeSkills: activeSkillFiles,
9736
11430
  includeBuiltins: true,
9737
11431
  eagerOnly: true,
9738
11432
  discoveryMode: true,
9739
- emitReflectHint: true,
11433
+ emitReflectHint: false,
9740
11434
  limit: 12,
9741
11435
  });
9742
- if (eagerToolBlock) {
9743
- parts.push(eagerToolBlock);
9744
- parts.push("");
9745
- }
9746
-
9747
- // Instructions
9748
- parts.push("## Instructions");
9749
- parts.push(
9750
- "1. Read and understand the task description above.\n" +
9751
- "2. Follow the project instructions in AGENTS.md.\n" +
9752
- "3. Respect the Workspace Scope Contract and never cross repository boundaries.\n" +
9753
- "4. Load and apply the matched important skills already inlined above.\n" +
9754
- "5. Use the discovery MCP tools for non-eager MCP/custom tools before assuming a capability is unavailable.\n" +
9755
- "6. Implement the required changes.\n" +
9756
- "7. Ensure tests pass and build is clean with 0 warnings.\n" +
9757
- "8. Commit your changes using conventional commits.\n" +
9758
- "9. Never ask for user input — you are autonomous.\n" +
9759
- "10. Use all available tools to verify your work.",
9760
- );
9761
- parts.push("");
11436
+ if (taskScopedEagerTools) {
11437
+ userParts.push(taskScopedEagerTools);
11438
+ userParts.push("");
11439
+ }
9762
11440
 
9763
- // Co-author trailer
9764
- parts.push("## Git Attribution");
9765
- parts.push("Add this trailer to all commits:");
9766
- parts.push("Co-authored-by: bosun[bot] <bosun@virtengine.com>");
11441
+ const userPrompt = userParts.join("\n").trim();
11442
+ const systemPrompt = buildStableSystemPrompt();
11443
+
11444
+ if (strictCacheAnchoring) {
11445
+ const dynamicMarkers = [
11446
+ taskId,
11447
+ taskTitle,
11448
+ taskDescription,
11449
+ retryReason,
11450
+ branch,
11451
+ baseBranch,
11452
+ worktreePath,
11453
+ ]
11454
+ .map((value) => String(value || "").trim())
11455
+ .filter(Boolean);
11456
+ const leaked = dynamicMarkers.find((marker) => systemPrompt.includes(marker));
11457
+ if (leaked) {
11458
+ throw new Error(
11459
+ `BOSUN_CACHE_ANCHOR_MODE=strict violation: system prompt leaked task-specific marker "${leaked}"`,
11460
+ );
11461
+ }
11462
+ }
9767
11463
 
9768
- const prompt = parts.join("\n");
9769
- ctx.data._taskPrompt = prompt;
9770
- ctx.log(node.id, `Prompt built (${prompt.length} chars)`);
9771
- return { success: true, prompt, source: "generated", length: prompt.length };
11464
+ ctx.data._taskPrompt = userPrompt;
11465
+ ctx.data._taskUserPrompt = userPrompt;
11466
+ ctx.data._taskSystemPrompt = systemPrompt;
11467
+ ctx.log(
11468
+ node.id,
11469
+ `Prompt built (user=${userPrompt.length} chars, system=${systemPrompt.length} chars, strict=${strictCacheAnchoring})`,
11470
+ );
11471
+ return {
11472
+ success: true,
11473
+ prompt: userPrompt,
11474
+ userPrompt,
11475
+ systemPrompt,
11476
+ source: "generated",
11477
+ length: userPrompt.length,
11478
+ systemLength: systemPrompt.length,
11479
+ cacheAnchorMode: strictCacheAnchoring ? "strict" : "default",
11480
+ };
9772
11481
  },
9773
11482
  });
9774
11483
 
9775
11484
  // ── action.detect_new_commits ───────────────────────────────────────────────
9776
11485
 
9777
- registerNodeType("action.detect_new_commits", {
11486
+ registerBuiltinNodeType("action.detect_new_commits", {
9778
11487
  describe: () =>
9779
11488
  "Compare pre/post execution HEAD to detect new commits. Also checks " +
9780
11489
  "for unpushed commits vs base and collects diff stats.",
@@ -9898,7 +11607,7 @@ registerNodeType("action.detect_new_commits", {
9898
11607
 
9899
11608
  // ── action.push_branch ──────────────────────────────────────────────────────
9900
11609
 
9901
- registerNodeType("action.push_branch", {
11610
+ registerBuiltinNodeType("action.push_branch", {
9902
11611
  describe: () =>
9903
11612
  "Push the current branch to the remote. Includes rebase-before-push, " +
9904
11613
  "empty-diff guard, protected branch safety, and optional main-branch sync.",
@@ -9910,6 +11619,7 @@ registerNodeType("action.push_branch", {
9910
11619
  baseBranch: { type: "string", description: "Base branch to rebase onto" },
9911
11620
  remote: { type: "string", default: "origin", description: "Remote name" },
9912
11621
  forceWithLease: { type: "boolean", default: true, description: "Use --force-with-lease" },
11622
+ skipHooks: { type: "boolean", default: true, description: "Skip git pre-push hooks (--no-verify)" },
9913
11623
  rebaseBeforePush: { type: "boolean", default: true, description: "Rebase onto base before push" },
9914
11624
  emptyDiffGuard: { type: "boolean", default: true, description: "Abort if no files changed vs base" },
9915
11625
  syncMainForModuleBranch: { type: "boolean", default: false, description: "Also sync base with main" },
@@ -9928,6 +11638,7 @@ registerNodeType("action.push_branch", {
9928
11638
  const baseBranch = cfgOrCtx(node, ctx, "baseBranch", "origin/main");
9929
11639
  const remote = node.config?.remote || "origin";
9930
11640
  const forceWithLease = node.config?.forceWithLease !== false;
11641
+ const skipHooks = node.config?.skipHooks !== false;
9931
11642
  const rebaseBeforePush = node.config?.rebaseBeforePush !== false;
9932
11643
  const emptyDiffGuard = node.config?.emptyDiffGuard !== false;
9933
11644
  const syncMain = node.config?.syncMainForModuleBranch === true;
@@ -10039,8 +11750,10 @@ registerNodeType("action.push_branch", {
10039
11750
  }
10040
11751
 
10041
11752
  // ── Push ──
10042
- const pushFlags = forceWithLease ? "--force-with-lease" : "";
10043
- const cmd = `git push ${pushFlags} --set-upstream ${remote} HEAD`.trim();
11753
+ const pushFlags = [];
11754
+ if (forceWithLease) pushFlags.push("--force-with-lease");
11755
+ if (skipHooks) pushFlags.push("--no-verify");
11756
+ const cmd = `git push ${pushFlags.join(" ")} --set-upstream ${remote} HEAD`.trim();
10044
11757
 
10045
11758
  try {
10046
11759
  const output = execSync(cmd, {
@@ -10072,7 +11785,7 @@ registerNodeType("action.push_branch", {
10072
11785
  // WEB SEARCH — Structured web search for research workflows
10073
11786
  // ═══════════════════════════════════════════════════════════════════════════
10074
11787
 
10075
- registerNodeType("action.web_search", {
11788
+ registerBuiltinNodeType("action.web_search", {
10076
11789
  describe: () =>
10077
11790
  "Perform a structured web search query and return results. Useful for " +
10078
11791
  "research workflows (e.g., Aletheia-style math/science agents) that need " +
@@ -10255,5 +11968,24 @@ registerNodeType("action.web_search", {
10255
11968
  // ═══════════════════════════════════════════════════════════════════════════
10256
11969
 
10257
11970
  export { registerNodeType, getNodeType, listNodeTypes } from "./workflow-engine.mjs";
10258
-
10259
11971
  export { evaluateTaskAssignedTriggerConfig };
11972
+ export {
11973
+ CUSTOM_NODE_DIR_NAME,
11974
+ getCustomNodeDir,
11975
+ scaffoldCustomNodeFile,
11976
+ startCustomNodeDiscovery,
11977
+ stopCustomNodeDiscovery,
11978
+ unregisterNodeType,
11979
+ };
11980
+
11981
+ export async function ensureWorkflowNodeTypesLoaded(options = {}) {
11982
+ if (!customLoadPromise || options.forceReload) {
11983
+ customLoadPromise = ensureCustomWorkflowNodesLoaded(options);
11984
+ }
11985
+ await customLoadPromise;
11986
+ if (!customDiscoveryStarted) {
11987
+ startCustomNodeDiscovery(options);
11988
+ customDiscoveryStarted = true;
11989
+ }
11990
+ return listNodeTypes();
11991
+ }