bosun 0.41.2 → 0.41.4

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 (73) hide show
  1. package/.env.example +1 -1
  2. package/agent/agent-pool.mjs +9 -2
  3. package/agent/agent-prompt-catalog.mjs +971 -0
  4. package/agent/agent-prompts.mjs +2 -970
  5. package/agent/agent-supervisor.mjs +119 -6
  6. package/agent/autofix-git.mjs +33 -0
  7. package/agent/autofix-prompts.mjs +151 -0
  8. package/agent/autofix.mjs +11 -175
  9. package/agent/bosun-skills.mjs +3 -2
  10. package/bosun.config.example.json +17 -0
  11. package/bosun.schema.json +87 -188
  12. package/cli.mjs +34 -1
  13. package/config/config-doctor.mjs +5 -250
  14. package/config/config-file-names.mjs +5 -0
  15. package/config/config.mjs +89 -493
  16. package/config/executor-config.mjs +493 -0
  17. package/config/repo-root.mjs +1 -2
  18. package/config/workspace-health.mjs +242 -0
  19. package/git/git-safety.mjs +15 -0
  20. package/github/github-oauth-portal.mjs +46 -0
  21. package/infra/library-manager-utils.mjs +22 -0
  22. package/infra/library-manager-well-known-sources.mjs +578 -0
  23. package/infra/library-manager.mjs +512 -1030
  24. package/infra/monitor.mjs +35 -9
  25. package/infra/session-tracker.mjs +10 -7
  26. package/kanban/kanban-adapter.mjs +17 -1
  27. package/lib/codebase-audit-manifests.mjs +117 -0
  28. package/lib/codebase-audit.mjs +18 -115
  29. package/package.json +18 -3
  30. package/server/setup-web-server.mjs +58 -5
  31. package/server/ui-server.mjs +1394 -79
  32. package/shell/codex-config-file.mjs +178 -0
  33. package/shell/codex-config.mjs +538 -575
  34. package/task/task-cli.mjs +54 -3
  35. package/task/task-executor.mjs +143 -13
  36. package/task/task-store.mjs +409 -1
  37. package/telegram/telegram-bot.mjs +127 -0
  38. package/tools/apply-pr-suggestions.mjs +401 -0
  39. package/tools/syntax-check.mjs +28 -9
  40. package/ui/app.js +3 -14
  41. package/ui/components/kanban-board.js +227 -4
  42. package/ui/components/session-list.js +85 -5
  43. package/ui/demo-defaults.js +338 -84
  44. package/ui/demo.html +155 -0
  45. package/ui/modules/session-api.js +96 -0
  46. package/ui/modules/settings-schema.js +1 -2
  47. package/ui/modules/state.js +43 -3
  48. package/ui/setup.html +4 -5
  49. package/ui/styles/components.css +58 -4
  50. package/ui/tabs/agents.js +12 -15
  51. package/ui/tabs/control.js +1 -0
  52. package/ui/tabs/library.js +484 -22
  53. package/ui/tabs/manual-flows.js +105 -29
  54. package/ui/tabs/tasks.js +848 -141
  55. package/ui/tabs/telemetry.js +129 -11
  56. package/ui/tabs/workflow-canvas-utils.mjs +130 -0
  57. package/ui/tabs/workflows.js +293 -23
  58. package/voice/voice-tool-definitions.mjs +757 -0
  59. package/voice/voice-tools.mjs +34 -778
  60. package/workflow/manual-flow-audit.mjs +165 -0
  61. package/workflow/manual-flows.mjs +164 -259
  62. package/workflow/workflow-engine.mjs +147 -58
  63. package/workflow/workflow-nodes/definitions.mjs +1207 -0
  64. package/workflow/workflow-nodes/transforms.mjs +612 -0
  65. package/workflow/workflow-nodes.mjs +358 -63
  66. package/workflow/workflow-templates.mjs +313 -191
  67. package/workflow-templates/_helpers.mjs +154 -0
  68. package/workflow-templates/agents.mjs +61 -4
  69. package/workflow-templates/code-quality.mjs +7 -7
  70. package/workflow-templates/github.mjs +20 -10
  71. package/workflow-templates/task-batch.mjs +44 -11
  72. package/workflow-templates/task-lifecycle.mjs +31 -6
  73. package/workspace/worktree-manager.mjs +277 -3
@@ -24,13 +24,19 @@ import {
24
24
  registerNodeType,
25
25
  unregisterNodeType,
26
26
  } from "./workflow-engine.mjs";
27
+ import {
28
+ _completedWithPR,
29
+ _noCommitCounts,
30
+ _skipUntil,
31
+ MAX_NO_COMMIT_ATTEMPTS,
32
+ } from "./workflow-nodes/transforms.mjs";
27
33
  import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
28
34
  import { resolve, dirname } from "node:path";
29
35
  import { execSync, execFileSync, spawn, spawnSync } from "node:child_process";
30
36
  import { createHash, randomUUID } from "node:crypto";
31
37
  import { getAgentToolConfig, getEffectiveTools } from "../agent/agent-tool-config.mjs";
32
38
  import { getToolsPromptBlock } from "../agent/agent-custom-tools.mjs";
33
- import { buildRelevantSkillsPromptBlock, findRelevantSkills } from "../agent/bosun-skills.mjs";
39
+ import { buildRelevantSkillsPromptBlock, emitSkillInvokeEvent, findRelevantSkills } from "../agent/bosun-skills.mjs";
34
40
  import { readBenchmarkModeState, taskMatchesBenchmarkMode } from "../bench/benchmark-mode.mjs";
35
41
  import { getSessionTracker } from "../infra/session-tracker.mjs";
36
42
  import {
@@ -38,8 +44,9 @@ import {
38
44
  loadWorkflowContract,
39
45
  validateWorkflowContract,
40
46
  } from "./workflow-contract.mjs";
47
+ import { loadConfig } from "../config/config.mjs";
41
48
  import { fixGitConfigCorruption } from "../workspace/worktree-manager.mjs";
42
- import { clearBlockedWorktreeIdentity } from "../git/git-safety.mjs";
49
+ import { clearBlockedWorktreeIdentity, normalizeBaseBranch } from "../git/git-safety.mjs";
43
50
  import { getGitHubToken, invalidateTokenType } from "../github/github-auth-manager.mjs";
44
51
  import {
45
52
  CUSTOM_NODE_DIR_NAME,
@@ -55,6 +62,7 @@ let customLoadPromise = null;
55
62
  let customDiscoveryStarted = false;
56
63
  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');\"";
57
64
  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');\"";
65
+ const DEFAULT_NON_RETRYABLE_WORKTREE_RECOVERY_MS = 15 * 60 * 1000;
58
66
  const WORKFLOW_AGENT_HEARTBEAT_MS = (() => {
59
67
  const raw = Number(process.env.WORKFLOW_AGENT_HEARTBEAT_MS || 30000);
60
68
  if (!Number.isFinite(raw)) return 30000;
@@ -67,6 +75,19 @@ const WORKFLOW_AGENT_EVENT_PREVIEW_LIMIT = (() => {
67
75
  })();
68
76
  const BOSUN_ATTACHED_PR_LABEL = "bosun-attached";
69
77
 
78
+ function getNonRetryableWorktreeRecoveryMs() {
79
+ try {
80
+ const config = loadConfig();
81
+ const minutes = Number(config?.workflowWorktreeRecoveryCooldownMin);
82
+ if (!Number.isFinite(minutes)) {
83
+ return DEFAULT_NON_RETRYABLE_WORKTREE_RECOVERY_MS;
84
+ }
85
+ return Math.max(1, Math.min(1440, Math.trunc(minutes))) * 60 * 1000;
86
+ } catch {
87
+ return DEFAULT_NON_RETRYABLE_WORKTREE_RECOVERY_MS;
88
+ }
89
+ }
90
+
70
91
  const HTML_TEXT_BREAK_TAGS = new Set([
71
92
  "address",
72
93
  "article",
@@ -1334,6 +1355,45 @@ function parseBooleanSetting(value, defaultValue = false) {
1334
1355
  return defaultValue;
1335
1356
  }
1336
1357
 
1358
+ async function recoverTimedBlockedWorkflowTasks({ kanban, ctx, node, projectId }) {
1359
+ if (!kanban || typeof kanban.listTasks !== "function" || typeof kanban.updateTask !== "function") {
1360
+ return { recoveredTaskIds: [], recoveredCount: 0 };
1361
+ }
1362
+
1363
+ const blockedTasks = await kanban.listTasks(projectId, { status: "blocked" });
1364
+ const nowMs = Date.now();
1365
+ const recoveredTaskIds = [];
1366
+ for (const task of Array.isArray(blockedTasks) ? blockedTasks : []) {
1367
+ const autoRecovery = task?.meta?.autoRecovery;
1368
+ if (!autoRecovery || typeof autoRecovery !== "object") continue;
1369
+ if (autoRecovery.active === false) continue;
1370
+ if (String(autoRecovery.reason || "").trim() !== "worktree_failure") continue;
1371
+ const retryAtMs = Date.parse(String(autoRecovery.retryAt || task?.cooldownUntil || ""));
1372
+ if (!Number.isFinite(retryAtMs) || retryAtMs > nowMs) continue;
1373
+ await kanban.updateTask(task.id, {
1374
+ status: "todo",
1375
+ cooldownUntil: null,
1376
+ blockedReason: null,
1377
+ meta: {
1378
+ ...(task?.meta && typeof task.meta === "object" ? task.meta : {}),
1379
+ autoRecovery: {
1380
+ ...autoRecovery,
1381
+ active: false,
1382
+ recoveredAt: new Date(nowMs).toISOString(),
1383
+ recoveredStatus: "todo",
1384
+ },
1385
+ },
1386
+ });
1387
+ recoveredTaskIds.push(task.id);
1388
+ }
1389
+
1390
+ if (recoveredTaskIds.length > 0) {
1391
+ ctx.log(node.id, `Recovered ${recoveredTaskIds.length} blocked task(s): ${recoveredTaskIds.join(", ")}`);
1392
+ }
1393
+
1394
+ return { recoveredTaskIds, recoveredCount: recoveredTaskIds.length };
1395
+ }
1396
+
1337
1397
  function getPathValue(value, pathExpression) {
1338
1398
  const path = String(pathExpression || "").trim();
1339
1399
  if (!path) return undefined;
@@ -2917,11 +2977,19 @@ registerBuiltinNodeType("action.run_command", {
2917
2977
  type: "object",
2918
2978
  properties: {
2919
2979
  command: { type: "string", description: "Shell command to run" },
2980
+ args: {
2981
+ description: "Optional argv passed to the command without shell interpolation",
2982
+ oneOf: [
2983
+ { type: "array", items: { type: ["string", "number", "boolean"] } },
2984
+ { type: "string" },
2985
+ ],
2986
+ },
2920
2987
  cwd: { type: "string", description: "Working directory" },
2921
2988
  env: { type: "object", description: "Environment variables passed to the command (supports templates)", additionalProperties: true },
2922
2989
  timeoutMs: { type: "number", default: 300000 },
2923
2990
  shell: { type: "string", default: "auto", enum: ["auto", "bash", "pwsh", "cmd"] },
2924
2991
  captureOutput: { type: "boolean", default: true },
2992
+ parseJson: { type: "boolean", default: false, description: "Parse JSON output automatically" },
2925
2993
  failOnError: { type: "boolean", default: false, description: "Throw on non-zero exit status (enables workflow retries)" },
2926
2994
  },
2927
2995
  required: ["command"],
@@ -2945,28 +3013,63 @@ registerBuiltinNodeType("action.run_command", {
2945
3013
  }
2946
3014
 
2947
3015
  const timeout = node.config?.timeoutMs || 300000;
3016
+ const resolvedArgsConfig = resolveWorkflowNodeValue(node.config?.args ?? [], ctx);
3017
+ const commandArgs = Array.isArray(resolvedArgsConfig)
3018
+ ? resolvedArgsConfig.map((value) => String(value))
3019
+ : typeof resolvedArgsConfig === "string" && resolvedArgsConfig.trim()
3020
+ ? [resolvedArgsConfig]
3021
+ : [];
3022
+ const shouldParseJson = node.config?.parseJson === true;
3023
+ const parseOutput = (rawOutput) => {
3024
+ const trimmed = rawOutput?.trim?.() ?? "";
3025
+ if (!shouldParseJson || !trimmed) return trimmed;
3026
+ try {
3027
+ return JSON.parse(trimmed);
3028
+ } catch {
3029
+ const lines = String(trimmed)
3030
+ .split(/\r?\n/)
3031
+ .map((line) => line.trim())
3032
+ .filter(Boolean);
3033
+ const candidate = lines.length > 0 ? lines[lines.length - 1] : trimmed;
3034
+ try {
3035
+ return JSON.parse(candidate);
3036
+ } catch {
3037
+ return trimmed;
3038
+ }
3039
+ }
3040
+ };
3041
+ const usedArgv = commandArgs.length > 0;
2948
3042
 
2949
3043
  if (command !== resolvedCommand) {
2950
3044
  ctx.log(node.id, `Normalized legacy command for portability: ${command}`);
2951
3045
  }
2952
- ctx.log(node.id, `Running: ${command}`);
3046
+ ctx.log(node.id, `Running: ${usedArgv ? `${command} ${commandArgs.join(" ")}`.trim() : command}`);
2953
3047
  try {
2954
- const output = execSync(command, {
2955
- cwd,
2956
- timeout,
2957
- encoding: "utf8",
2958
- maxBuffer: 10 * 1024 * 1024,
2959
- stdio: node.config?.captureOutput !== false ? "pipe" : "inherit",
2960
- env: commandEnv,
2961
- });
3048
+ const output = usedArgv
3049
+ ? execFileSync(command, commandArgs, {
3050
+ cwd,
3051
+ timeout,
3052
+ encoding: "utf8",
3053
+ maxBuffer: 10 * 1024 * 1024,
3054
+ stdio: node.config?.captureOutput !== false ? "pipe" : "inherit",
3055
+ env: commandEnv,
3056
+ })
3057
+ : execSync(command, {
3058
+ cwd,
3059
+ timeout,
3060
+ encoding: "utf8",
3061
+ maxBuffer: 10 * 1024 * 1024,
3062
+ stdio: node.config?.captureOutput !== false ? "pipe" : "inherit",
3063
+ env: commandEnv,
3064
+ });
2962
3065
  ctx.log(node.id, `Command succeeded`);
2963
- return { success: true, output: output?.trim(), exitCode: 0 };
3066
+ return { success: true, output: parseOutput(output), exitCode: 0 };
2964
3067
  } catch (err) {
2965
3068
  const output = err.stdout?.toString() || "";
2966
3069
  const stderr = err.stderr?.toString() || "";
2967
3070
  const result = {
2968
3071
  success: false,
2969
- output,
3072
+ output: parseOutput(output),
2970
3073
  stderr,
2971
3074
  exitCode: err.status,
2972
3075
  error: err.message,
@@ -3936,7 +4039,7 @@ registerBuiltinNodeType("action.update_task_status", {
3936
4039
  type: "object",
3937
4040
  properties: {
3938
4041
  taskId: { type: "string", description: "Task ID (supports {{variables}})" },
3939
- status: { type: "string", enum: ["todo", "inprogress", "inreview", "done", "archived"] },
4042
+ status: { type: "string", enum: ["todo", "inprogress", "inreview", "done", "blocked", "archived"] },
3940
4043
  taskTitle: { type: "string", description: "Optional task title for downstream event payloads" },
3941
4044
  previousStatus: { type: "string", description: "Optional explicit previous status" },
3942
4045
  workflowEvent: { type: "string", description: "Optional follow-up workflow event to emit after status update" },
@@ -4213,7 +4316,7 @@ registerBuiltinNodeType("action.create_pr", {
4213
4316
  autoMergeMethod: {
4214
4317
  type: "string",
4215
4318
  enum: ["merge", "squash", "rebase"],
4216
- default: "squash",
4319
+ default: "merge",
4217
4320
  description: "Merge method used with gh pr merge --auto",
4218
4321
  },
4219
4322
  mergeMethod: {
@@ -4229,7 +4332,12 @@ registerBuiltinNodeType("action.create_pr", {
4229
4332
  async execute(node, ctx) {
4230
4333
  const title = ctx.resolve(node.config?.title || "");
4231
4334
  const body = ctx.resolve(node.config?.body || "");
4232
- const base = ctx.resolve(node.config?.base || node.config?.baseBranch || "main");
4335
+ const baseInput = ctx.resolve(node.config?.base || node.config?.baseBranch || "main");
4336
+ let base = String(baseInput || "main").trim() || "main";
4337
+ try {
4338
+ base = normalizeBaseBranch(base).branch;
4339
+ } catch {
4340
+ }
4233
4341
  const branch = ctx.resolve(node.config?.branch || "");
4234
4342
  const repoSlug = String(
4235
4343
  ctx.resolve(node.config?.repoSlug || ctx.data?.repoSlug || ctx.data?.repository || ""),
@@ -4241,11 +4349,11 @@ registerBuiltinNodeType("action.create_pr", {
4241
4349
  false,
4242
4350
  );
4243
4351
  const autoMergeMethodRaw = String(
4244
- ctx.resolve(node.config?.autoMergeMethod || node.config?.mergeMethod || "squash"),
4352
+ ctx.resolve(node.config?.autoMergeMethod || node.config?.mergeMethod || process.env.BOSUN_MERGE_METHOD || "merge"),
4245
4353
  ).trim().toLowerCase();
4246
4354
  const autoMergeMethod = ["merge", "squash", "rebase"].includes(autoMergeMethodRaw)
4247
4355
  ? autoMergeMethodRaw
4248
- : "squash";
4356
+ : (process.env.BOSUN_MERGE_METHOD || "merge");
4249
4357
  const cwd = ctx.resolve(node.config?.cwd || ctx.data?.worktreePath || process.cwd());
4250
4358
 
4251
4359
  // Normalize labels/reviewers to arrays
@@ -9690,6 +9798,88 @@ function hasUnresolvedGitOperation(worktreePath) {
9690
9798
  }
9691
9799
  }
9692
9800
 
9801
+ function hasTrackedGitChanges(worktreePath) {
9802
+ if (!worktreePath || !existsSync(worktreePath)) return false;
9803
+ try {
9804
+ const status = execGitArgsSync(
9805
+ ["status", "--porcelain", "--untracked-files=no"],
9806
+ {
9807
+ cwd: worktreePath,
9808
+ encoding: "utf8",
9809
+ timeout: 5000,
9810
+ stdio: ["ignore", "pipe", "pipe"],
9811
+ },
9812
+ ).trim();
9813
+ return Boolean(status);
9814
+ } catch {
9815
+ return true;
9816
+ }
9817
+ }
9818
+
9819
+ function countCommitsBehindBase(worktreePath, baseBranch) {
9820
+ if (!worktreePath || !existsSync(worktreePath) || !baseBranch) return 0;
9821
+ try {
9822
+ const counts = execGitArgsSync(
9823
+ ["rev-list", "--left-right", "--count", `HEAD...${baseBranch}`],
9824
+ {
9825
+ cwd: worktreePath,
9826
+ encoding: "utf8",
9827
+ timeout: 5000,
9828
+ stdio: ["ignore", "pipe", "pipe"],
9829
+ },
9830
+ ).trim();
9831
+ const match = counts.match(/^(\d+)\s+(\d+)$/);
9832
+ return match ? Number(match[2]) : 0;
9833
+ } catch {
9834
+ return 0;
9835
+ }
9836
+ }
9837
+
9838
+ function refreshManagedWorktreeReuse(
9839
+ nodeId,
9840
+ ctx,
9841
+ repoRoot,
9842
+ worktreePath,
9843
+ baseBranch,
9844
+ baseBranchShort,
9845
+ fetchTimeout,
9846
+ ) {
9847
+ if (!existsSync(worktreePath) || shouldSkipGitRefreshForTests()) return existsSync(worktreePath);
9848
+ let refreshError = "";
9849
+ try {
9850
+ execSync(`git pull --rebase origin ${baseBranchShort}`, {
9851
+ cwd: worktreePath,
9852
+ encoding: "utf8",
9853
+ timeout: fetchTimeout,
9854
+ stdio: ["ignore", "pipe", "pipe"],
9855
+ });
9856
+ } catch (error) {
9857
+ refreshError = formatExecSyncError(error);
9858
+ }
9859
+ if (!existsSync(worktreePath)) return false;
9860
+ if (hasUnresolvedGitOperation(worktreePath)) {
9861
+ const detail = refreshError ? ` (${refreshError})` : "";
9862
+ ctx.log(
9863
+ nodeId,
9864
+ `Managed worktree refresh left unresolved git state, recreating: ${worktreePath}${detail}`,
9865
+ );
9866
+ cleanupBrokenManagedWorktree(repoRoot, worktreePath);
9867
+ return false;
9868
+ }
9869
+ const reasons = [];
9870
+ if (hasTrackedGitChanges(worktreePath)) reasons.push("tracked changes after refresh");
9871
+ const behindCount = countCommitsBehindBase(worktreePath, baseBranch);
9872
+ if (behindCount > 0) reasons.push(`${behindCount} commit(s) behind ${baseBranch}`);
9873
+ if (reasons.length === 0) return true;
9874
+ if (refreshError) reasons.unshift(`refresh failed: ${refreshError}`);
9875
+ ctx.log(
9876
+ nodeId,
9877
+ `Managed worktree refresh did not yield a clean up-to-date branch, recreating: ${worktreePath} (${reasons.join("; ")})`,
9878
+ );
9879
+ cleanupBrokenManagedWorktree(repoRoot, worktreePath);
9880
+ return false;
9881
+ }
9882
+
9693
9883
  function cleanupBrokenManagedWorktree(repoRoot, worktreePath) {
9694
9884
  if (!worktreePath) return;
9695
9885
  const linkedGitDir = resolveGitDirForWorktree(worktreePath);
@@ -9728,13 +9918,9 @@ function cleanupBrokenManagedWorktree(repoRoot, worktreePath) {
9728
9918
  }
9729
9919
 
9730
9920
  /**
9731
- * Anti-thrash state — module-scope to survive across workflow runs.
9732
- * Mirrors TaskExecutor._noCommitCounts / _skipUntil / _completedWithPR.
9921
+ * Anti-thrash state — imported from transforms.mjs (single source of truth).
9922
+ * Shared between monolithic workflow-nodes.mjs and modular triggers.mjs.
9733
9923
  */
9734
- const _noCommitCounts = new Map();
9735
- const _skipUntil = new Map();
9736
- const _completedWithPR = new Set();
9737
- const MAX_NO_COMMIT_ATTEMPTS = 3;
9738
9924
  const NO_COMMIT_BASE_COOLDOWN_MS = 15 * 60 * 1000; // 15 min
9739
9925
  const NO_COMMIT_MAX_COOLDOWN_MS = 2 * 60 * 60 * 1000; // 2 hours
9740
9926
  const STRICT_START_GUARD_MISSING_TASK = /^(1|true|yes|on)$/i.test(
@@ -9795,6 +9981,13 @@ registerBuiltinNodeType("trigger.task_available", {
9795
9981
  for (let attempt = 0; attempt <= listRetries; attempt++) {
9796
9982
  try {
9797
9983
  const kanban = ctx.data?._services?.kanban || engine?.services?.kanban;
9984
+ if (status === "todo") {
9985
+ try {
9986
+ await recoverTimedBlockedWorkflowTasks({ kanban, ctx, node, projectId });
9987
+ } catch (recoveryErr) {
9988
+ ctx.log(node.id, `Blocked task recovery warning: ${recoveryErr?.message || recoveryErr}`);
9989
+ }
9990
+ }
9798
9991
  if (kanban?.listTasks) {
9799
9992
  tasks = await kanban.listTasks(projectId, { status });
9800
9993
  } else {
@@ -10108,6 +10301,9 @@ registerBuiltinNodeType("trigger.task_available", {
10108
10301
  if (baseBranch) ctx.data.baseBranch = baseBranch;
10109
10302
  const branch = deriveTaskBranch(primaryTask);
10110
10303
  if (branch) ctx.data.branch = branch;
10304
+ ctx.data.taskMeta = primaryTask?.meta && typeof primaryTask.meta === "object"
10305
+ ? { ...primaryTask.meta }
10306
+ : {};
10111
10307
  }
10112
10308
 
10113
10309
  ctx.log(node.id, `Found ${toDispatch.length} task(s) ready (${remaining} slot(s) free)`);
@@ -10479,8 +10675,26 @@ registerBuiltinNodeType("action.resolve_executor", {
10479
10675
  || ctx.data?.agentProfile
10480
10676
  || "",
10481
10677
  ).trim();
10678
+ const taskText = [task.title, task.description].filter(Boolean).join("\n");
10679
+ const inferredResolutionTags = [];
10680
+ if (/\btest(?:s|ing)?\b/i.test(taskText)) inferredResolutionTags.push("test", "tests");
10681
+ if (/\b(?:ci|cd|pipeline|workflow|github actions?)\b/i.test(taskText)) inferredResolutionTags.push("ci", "cd", "pipeline");
10682
+ if (/\b(?:merge conflict|conflicts|rebase|cherry-pick)\b/i.test(taskText)) inferredResolutionTags.push("conflict", "merge");
10683
+ if (/\b(?:implement|implementation|feature|build|ship)\b/i.test(taskText)) inferredResolutionTags.push("implementation");
10684
+ if (/\b(?:docs?|documentation|readme)\b/i.test(taskText)) inferredResolutionTags.push("docs", "documentation");
10685
+ const resolutionTags = Array.from(new Set([
10686
+ ...task.tags,
10687
+ ...inferredResolutionTags,
10688
+ String(ctx.data?.task?.type || "").trim(),
10689
+ String(ctx.data?.task?.agentType || "").trim(),
10690
+ String(ctx.data?.task?.assignedAgentType || "").trim(),
10691
+ String(ctx.data?.agentType || "").trim(),
10692
+ String(ctx.data?.assignedAgentType || "").trim(),
10693
+ ].map((value) => String(value || "").trim()).filter(Boolean)));
10482
10694
  let profileDecision = null;
10483
10695
  let configuredExecutorPreference = null;
10696
+ ctx.data.resolvedSkillIds = [];
10697
+ ctx.data.resolvedLibraryPlan = null;
10484
10698
 
10485
10699
  // Check env var overrides (mirrors TaskExecutor behavior)
10486
10700
  const envModel =
@@ -10497,25 +10711,30 @@ registerBuiltinNodeType("action.resolve_executor", {
10497
10711
 
10498
10712
  try {
10499
10713
  const library = await ensureLibraryManagerMod();
10500
- const match = library.matchAgentProfiles?.(
10714
+ const criteria = {
10715
+ title: task.title,
10716
+ description: task.description,
10717
+ tags: resolutionTags,
10718
+ agentType: ctx.data?.task?.agentType || ctx.data?.agentType || "",
10719
+ repoRoot,
10720
+ changedFiles: Array.isArray(ctx.data?.changedFiles) ? ctx.data.changedFiles : [],
10721
+ };
10722
+ const planResult = library.resolveLibraryPlan?.(
10501
10723
  repoRoot,
10724
+ criteria,
10502
10725
  {
10503
- title: task.title,
10504
- description: task.description,
10505
- tags: task.tags,
10506
- agentType: ctx.data?.task?.agentType || ctx.data?.agentType || "",
10507
- repoRoot,
10726
+ topN: Math.max(10, requestedAgentProfileId ? 25 : 10),
10727
+ skillTopN: 5,
10508
10728
  },
10509
- { topN: Math.max(10, requestedAgentProfileId ? 25 : 10) },
10510
10729
  );
10511
- const candidates = Array.isArray(match?.candidates) ? match.candidates : [];
10512
- const bestCandidate = match?.best || null;
10513
- const autoMinScore = Number(match?.auto?.thresholds?.minScore || 12);
10730
+ const candidates = Array.isArray(planResult?.candidates) ? planResult.candidates : [];
10731
+ const bestCandidate = planResult?.best || null;
10732
+ const autoMinScore = Number(planResult?.auto?.thresholds?.minScore || 12);
10514
10733
  const scoreQualified = Number(bestCandidate?.score || 0) >= autoMinScore;
10515
10734
  const matchedCandidate = requestedAgentProfileId
10516
10735
  ? candidates.find((candidate) => String(candidate?.id || "").trim() === requestedAgentProfileId) || null
10517
- : ((match?.auto?.shouldAutoApply || scoreQualified) ? bestCandidate : null);
10518
- if (!requestedAgentProfileId && bestCandidate && !match?.auto?.shouldAutoApply) {
10736
+ : ((planResult?.auto?.shouldAutoApply || scoreQualified) ? bestCandidate : null);
10737
+ if (!requestedAgentProfileId && bestCandidate && !planResult?.auto?.shouldAutoApply) {
10519
10738
  ctx.log(
10520
10739
  node.id,
10521
10740
  `Profile match below auto threshold; ignoring candidate ${String(bestCandidate.id || "unknown")}`,
@@ -10534,13 +10753,19 @@ registerBuiltinNodeType("action.resolve_executor", {
10534
10753
  ctx.data.agentProfile = profileId;
10535
10754
  ctx.data.resolvedAgentProfile = {
10536
10755
  id: profileId,
10537
- name: match?.best?.name || profile?.name || profileId,
10756
+ name: matchedCandidate?.name || profile?.name || profileId,
10538
10757
  ...profile,
10539
10758
  };
10540
- const skillIds = Array.isArray(profile.skills)
10541
- ? profile.skills.map((value) => String(value || "").trim()).filter(Boolean)
10542
- : [];
10759
+ const resolvedPlan = planResult?.plan && planResult.plan.agentProfileId === profileId
10760
+ ? planResult.plan
10761
+ : null;
10762
+ const skillIds = resolvedPlan && Array.isArray(resolvedPlan.skillIds)
10763
+ ? resolvedPlan.skillIds.map((value) => String(value || "").trim()).filter(Boolean)
10764
+ : Array.isArray(profile.skills)
10765
+ ? profile.skills.map((value) => String(value || "").trim()).filter(Boolean)
10766
+ : [];
10543
10767
  ctx.data.resolvedSkillIds = skillIds;
10768
+ ctx.data.resolvedLibraryPlan = resolvedPlan;
10544
10769
  }
10545
10770
  } catch (err) {
10546
10771
  ctx.log(node.id, `Library profile resolution failed: ${err.message}`);
@@ -10739,22 +10964,15 @@ registerBuiltinNodeType("action.acquire_worktree", {
10739
10964
  }
10740
10965
 
10741
10966
  if (existsSync(worktreePath)) {
10742
- // Reuse existing worktree — pull latest base if possible
10743
- if (!shouldSkipGitRefreshForTests()) {
10744
- try {
10745
- execSync(`git pull --rebase origin ${baseBranchShort}`, {
10746
- cwd: worktreePath, encoding: "utf8",
10747
- timeout: fetchTimeout,
10748
- stdio: ["ignore", "pipe", "pipe"],
10749
- });
10750
- } catch {
10751
- /* rebase failures are non-fatal for reuse */
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
- }
10967
+ refreshManagedWorktreeReuse(
10968
+ node.id,
10969
+ ctx,
10970
+ repoRoot,
10971
+ worktreePath,
10972
+ baseBranch,
10973
+ baseBranchShort,
10974
+ fetchTimeout,
10975
+ );
10758
10976
  if (existsSync(worktreePath)) {
10759
10977
  ctx.data.worktreePath = worktreePath;
10760
10978
  ctx.data._worktreeCreated = false;
@@ -10767,6 +10985,7 @@ registerBuiltinNodeType("action.acquire_worktree", {
10767
10985
  }
10768
10986
 
10769
10987
  // Create fresh worktree
10988
+ let attachedExistingBranch = false;
10770
10989
  try {
10771
10990
  execSync(
10772
10991
  `git worktree add "${worktreePath}" -b "${branch}" "${baseBranch}" 2>&1`,
@@ -10782,21 +11001,39 @@ registerBuiltinNodeType("action.acquire_worktree", {
10782
11001
  `git worktree add "${worktreePath}" "${branch}" 2>&1`,
10783
11002
  { cwd: repoRoot, encoding: "utf8", timeout: worktreeTimeout },
10784
11003
  );
11004
+ attachedExistingBranch = true;
10785
11005
  } catch (reuseErr) {
10786
11006
  const existingBranchWorktree = findExistingWorktreePathForBranch(repoRoot, branch);
10787
11007
  if (existingBranchWorktree && existsSync(existingBranchWorktree)) {
10788
- if (!isValidGitWorktreePath(existingBranchWorktree) &&
10789
- isManagedBosunWorktree(existingBranchWorktree, repoRoot)
10790
- ) {
11008
+ const existingWorktreeIsBroken = (
11009
+ !isValidGitWorktreePath(existingBranchWorktree) ||
11010
+ hasUnresolvedGitOperation(existingBranchWorktree)
11011
+ ) && isManagedBosunWorktree(existingBranchWorktree, repoRoot);
11012
+ if (existingWorktreeIsBroken) {
10791
11013
  ctx.log(
10792
11014
  node.id,
10793
- `Existing branch worktree is invalid, recreating managed path: ${existingBranchWorktree}`,
11015
+ `Existing branch worktree is invalid or unresolved, recreating managed path: ${existingBranchWorktree}`,
10794
11016
  );
10795
11017
  cleanupBrokenManagedWorktree(repoRoot, existingBranchWorktree);
10796
11018
  }
10797
11019
  }
10798
11020
  if (existingBranchWorktree && existsSync(existingBranchWorktree) &&
10799
- isValidGitWorktreePath(existingBranchWorktree)
11021
+ isValidGitWorktreePath(existingBranchWorktree) &&
11022
+ !hasUnresolvedGitOperation(existingBranchWorktree)
11023
+ ) {
11024
+ refreshManagedWorktreeReuse(
11025
+ node.id,
11026
+ ctx,
11027
+ repoRoot,
11028
+ existingBranchWorktree,
11029
+ baseBranch,
11030
+ baseBranchShort,
11031
+ fetchTimeout,
11032
+ );
11033
+ }
11034
+ if (existingBranchWorktree && existsSync(existingBranchWorktree) &&
11035
+ isValidGitWorktreePath(existingBranchWorktree) &&
11036
+ !hasUnresolvedGitOperation(existingBranchWorktree)
10800
11037
  ) {
10801
11038
  ctx.data.worktreePath = existingBranchWorktree;
10802
11039
  ctx.data._worktreeCreated = false;
@@ -10820,6 +11057,22 @@ registerBuiltinNodeType("action.acquire_worktree", {
10820
11057
  );
10821
11058
  }
10822
11059
  }
11060
+ if (attachedExistingBranch) {
11061
+ refreshManagedWorktreeReuse(
11062
+ node.id,
11063
+ ctx,
11064
+ repoRoot,
11065
+ worktreePath,
11066
+ baseBranch,
11067
+ baseBranchShort,
11068
+ fetchTimeout,
11069
+ );
11070
+ if (!existsSync(worktreePath)) {
11071
+ throw new Error(
11072
+ `Worktree refresh failed for existing branch ${branch}; managed worktree was removed after stale refresh state`,
11073
+ );
11074
+ }
11075
+ }
10823
11076
  fixGitConfigCorruption(repoRoot);
10824
11077
  const cleared3 = clearBlockedWorktreeIdentity(worktreePath);
10825
11078
  if (cleared3) ctx.log(node.id, `Cleared blocked test git identity from worktree: ${worktreePath}`);
@@ -10830,8 +11083,28 @@ registerBuiltinNodeType("action.acquire_worktree", {
10830
11083
  ctx.log(node.id, `Worktree created: ${worktreePath} (branch: ${branch}, base: ${baseBranch})`);
10831
11084
  return { success: true, worktreePath, created: true, branch, baseBranch };
10832
11085
  } catch (err) {
10833
- ctx.log(node.id, `Worktree acquisition failed: ${err.message}`);
10834
- return { success: false, error: err.message, branch, baseBranch };
11086
+ const errorMessage = String(err?.message || err || "worktree_acquisition_failed");
11087
+ const retryable = !/managed worktree was removed after stale refresh state/i.test(errorMessage);
11088
+ const recordedAt = new Date().toISOString();
11089
+ const autoRecoverDelayMs = retryable ? 0 : getNonRetryableWorktreeRecoveryMs();
11090
+ const retryAt = retryable ? null : new Date(Date.now() + autoRecoverDelayMs).toISOString();
11091
+ const blockedReason = retryable
11092
+ ? errorMessage
11093
+ : "Managed worktree refresh conflict detected; Bosun will retry automatically after cooldown.";
11094
+ ctx.log(node.id, `Worktree acquisition failed: ${errorMessage}`);
11095
+ return {
11096
+ success: false,
11097
+ error: errorMessage,
11098
+ branch,
11099
+ baseBranch,
11100
+ retryable,
11101
+ failureKind: retryable ? "worktree_acquisition_failed" : "branch_refresh_conflict",
11102
+ recordedAt,
11103
+ autoRecoverDelayMs,
11104
+ retryAt,
11105
+ blockedReason,
11106
+ recoveryNote: retryable || !retryAt ? "" : ` — blocked until ${retryAt}`,
11107
+ };
10835
11108
  }
10836
11109
  },
10837
11110
  });
@@ -10887,6 +11160,8 @@ registerBuiltinNodeType("action.release_worktree", {
10887
11160
  } catch { /* best-effort */ }
10888
11161
  }
10889
11162
 
11163
+ fixGitConfigCorruption(repoRoot);
11164
+
10890
11165
  ctx.data._worktreeCreated = false;
10891
11166
  ctx.data._worktreeManaged = false;
10892
11167
  ctx.log(node.id, `Worktree released: ${worktreePath}`);
@@ -11412,6 +11687,11 @@ registerBuiltinNodeType("action.build_task_prompt", {
11412
11687
  const content = library.getEntryContent?.(libraryRoot, entry);
11413
11688
  if (!content || (typeof content === "string" && !content.trim())) continue;
11414
11689
  const body = typeof content === "string" ? content.trim() : JSON.stringify(content, null, 2);
11690
+ emitSkillInvokeEvent(skillId, entry.name || skillId, {
11691
+ taskId,
11692
+ executor: ctx.data?.resolvedSdk,
11693
+ source: "library",
11694
+ });
11415
11695
  librarySkillParts.push(`### Skill: ${entry.name || skillId} (\`${skillId}\`)`);
11416
11696
  librarySkillParts.push(body);
11417
11697
  librarySkillParts.push("");
@@ -11749,6 +12029,21 @@ registerBuiltinNodeType("action.push_branch", {
11749
12029
  }
11750
12030
  }
11751
12031
 
12032
+ // ── Hard zero-diff guard (always active) ──
12033
+ try {
12034
+ const headSha = execSync("git rev-parse HEAD", {
12035
+ cwd: worktreePath, encoding: "utf8", timeout: 5_000, stdio: ["pipe", "pipe", "pipe"],
12036
+ }).trim();
12037
+ const mainSha = execSync("git rev-parse origin/main", {
12038
+ cwd: worktreePath, encoding: "utf8", timeout: 5_000, stdio: ["pipe", "pipe", "pipe"],
12039
+ }).trim();
12040
+ if (headSha && mainSha && headSha === mainSha) {
12041
+ ctx.log(node.id, "HEAD is identical to origin/main — aborting push to prevent PR wipe");
12042
+ ctx.data._pushSkipped = true;
12043
+ return { success: false, error: "HEAD matches origin/main — refusing push", pushed: false };
12044
+ }
12045
+ } catch { /* best-effort */ }
12046
+
11752
12047
  // ── Push ──
11753
12048
  const pushFlags = [];
11754
12049
  if (forceWithLease) pushFlags.push("--force-with-lease");