bosun 0.41.2 → 0.41.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.env.example +1 -1
  2. package/agent/agent-prompt-catalog.mjs +971 -0
  3. package/agent/agent-prompts.mjs +2 -970
  4. package/agent/agent-supervisor.mjs +6 -3
  5. package/agent/autofix-git.mjs +33 -0
  6. package/agent/autofix-prompts.mjs +151 -0
  7. package/agent/autofix.mjs +11 -175
  8. package/agent/bosun-skills.mjs +3 -2
  9. package/bosun.config.example.json +17 -0
  10. package/bosun.schema.json +87 -188
  11. package/cli.mjs +34 -1
  12. package/config/config-doctor.mjs +5 -250
  13. package/config/config-file-names.mjs +5 -0
  14. package/config/config.mjs +89 -493
  15. package/config/executor-config.mjs +493 -0
  16. package/config/repo-root.mjs +1 -2
  17. package/config/workspace-health.mjs +242 -0
  18. package/git/git-safety.mjs +15 -0
  19. package/github/github-oauth-portal.mjs +46 -0
  20. package/infra/library-manager-utils.mjs +22 -0
  21. package/infra/library-manager-well-known-sources.mjs +578 -0
  22. package/infra/library-manager.mjs +512 -1030
  23. package/infra/monitor.mjs +28 -9
  24. package/infra/session-tracker.mjs +10 -7
  25. package/kanban/kanban-adapter.mjs +17 -1
  26. package/lib/codebase-audit-manifests.mjs +117 -0
  27. package/lib/codebase-audit.mjs +18 -115
  28. package/package.json +18 -3
  29. package/server/ui-server.mjs +1194 -79
  30. package/shell/codex-config-file.mjs +178 -0
  31. package/shell/codex-config.mjs +538 -575
  32. package/task/task-cli.mjs +54 -3
  33. package/task/task-executor.mjs +143 -13
  34. package/task/task-store.mjs +409 -1
  35. package/telegram/telegram-bot.mjs +127 -0
  36. package/tools/apply-pr-suggestions.mjs +401 -0
  37. package/tools/syntax-check.mjs +21 -9
  38. package/ui/app.js +3 -14
  39. package/ui/components/kanban-board.js +227 -4
  40. package/ui/components/session-list.js +85 -5
  41. package/ui/demo-defaults.js +334 -80
  42. package/ui/demo.html +155 -0
  43. package/ui/modules/session-api.js +96 -0
  44. package/ui/modules/settings-schema.js +1 -2
  45. package/ui/modules/state.js +21 -3
  46. package/ui/setup.html +4 -5
  47. package/ui/styles/components.css +58 -4
  48. package/ui/tabs/agents.js +12 -15
  49. package/ui/tabs/control.js +1 -0
  50. package/ui/tabs/library.js +484 -22
  51. package/ui/tabs/manual-flows.js +105 -29
  52. package/ui/tabs/tasks.js +785 -140
  53. package/ui/tabs/telemetry.js +129 -11
  54. package/ui/tabs/workflow-canvas-utils.mjs +130 -0
  55. package/ui/tabs/workflows.js +293 -23
  56. package/voice/voice-tool-definitions.mjs +757 -0
  57. package/voice/voice-tools.mjs +34 -778
  58. package/workflow/manual-flow-audit.mjs +165 -0
  59. package/workflow/manual-flows.mjs +164 -259
  60. package/workflow/workflow-engine.mjs +147 -58
  61. package/workflow/workflow-nodes/definitions.mjs +1207 -0
  62. package/workflow/workflow-nodes/transforms.mjs +612 -0
  63. package/workflow/workflow-nodes.mjs +304 -52
  64. package/workflow/workflow-templates.mjs +313 -191
  65. package/workflow-templates/_helpers.mjs +154 -0
  66. package/workflow-templates/agents.mjs +61 -4
  67. package/workflow-templates/code-quality.mjs +7 -7
  68. package/workflow-templates/github.mjs +20 -10
  69. package/workflow-templates/task-batch.mjs +20 -9
  70. package/workflow-templates/task-lifecycle.mjs +31 -6
  71. package/workspace/worktree-manager.mjs +277 -3
@@ -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;
@@ -3936,7 +3996,7 @@ registerBuiltinNodeType("action.update_task_status", {
3936
3996
  type: "object",
3937
3997
  properties: {
3938
3998
  taskId: { type: "string", description: "Task ID (supports {{variables}})" },
3939
- status: { type: "string", enum: ["todo", "inprogress", "inreview", "done", "archived"] },
3999
+ status: { type: "string", enum: ["todo", "inprogress", "inreview", "done", "blocked", "archived"] },
3940
4000
  taskTitle: { type: "string", description: "Optional task title for downstream event payloads" },
3941
4001
  previousStatus: { type: "string", description: "Optional explicit previous status" },
3942
4002
  workflowEvent: { type: "string", description: "Optional follow-up workflow event to emit after status update" },
@@ -4213,7 +4273,7 @@ registerBuiltinNodeType("action.create_pr", {
4213
4273
  autoMergeMethod: {
4214
4274
  type: "string",
4215
4275
  enum: ["merge", "squash", "rebase"],
4216
- default: "squash",
4276
+ default: "merge",
4217
4277
  description: "Merge method used with gh pr merge --auto",
4218
4278
  },
4219
4279
  mergeMethod: {
@@ -4229,7 +4289,12 @@ registerBuiltinNodeType("action.create_pr", {
4229
4289
  async execute(node, ctx) {
4230
4290
  const title = ctx.resolve(node.config?.title || "");
4231
4291
  const body = ctx.resolve(node.config?.body || "");
4232
- const base = ctx.resolve(node.config?.base || node.config?.baseBranch || "main");
4292
+ const baseInput = ctx.resolve(node.config?.base || node.config?.baseBranch || "main");
4293
+ let base = String(baseInput || "main").trim() || "main";
4294
+ try {
4295
+ base = normalizeBaseBranch(base).branch;
4296
+ } catch {
4297
+ }
4233
4298
  const branch = ctx.resolve(node.config?.branch || "");
4234
4299
  const repoSlug = String(
4235
4300
  ctx.resolve(node.config?.repoSlug || ctx.data?.repoSlug || ctx.data?.repository || ""),
@@ -4241,11 +4306,11 @@ registerBuiltinNodeType("action.create_pr", {
4241
4306
  false,
4242
4307
  );
4243
4308
  const autoMergeMethodRaw = String(
4244
- ctx.resolve(node.config?.autoMergeMethod || node.config?.mergeMethod || "squash"),
4309
+ ctx.resolve(node.config?.autoMergeMethod || node.config?.mergeMethod || process.env.BOSUN_MERGE_METHOD || "merge"),
4245
4310
  ).trim().toLowerCase();
4246
4311
  const autoMergeMethod = ["merge", "squash", "rebase"].includes(autoMergeMethodRaw)
4247
4312
  ? autoMergeMethodRaw
4248
- : "squash";
4313
+ : (process.env.BOSUN_MERGE_METHOD || "merge");
4249
4314
  const cwd = ctx.resolve(node.config?.cwd || ctx.data?.worktreePath || process.cwd());
4250
4315
 
4251
4316
  // Normalize labels/reviewers to arrays
@@ -9690,6 +9755,88 @@ function hasUnresolvedGitOperation(worktreePath) {
9690
9755
  }
9691
9756
  }
9692
9757
 
9758
+ function hasTrackedGitChanges(worktreePath) {
9759
+ if (!worktreePath || !existsSync(worktreePath)) return false;
9760
+ try {
9761
+ const status = execGitArgsSync(
9762
+ ["status", "--porcelain", "--untracked-files=no"],
9763
+ {
9764
+ cwd: worktreePath,
9765
+ encoding: "utf8",
9766
+ timeout: 5000,
9767
+ stdio: ["ignore", "pipe", "pipe"],
9768
+ },
9769
+ ).trim();
9770
+ return Boolean(status);
9771
+ } catch {
9772
+ return true;
9773
+ }
9774
+ }
9775
+
9776
+ function countCommitsBehindBase(worktreePath, baseBranch) {
9777
+ if (!worktreePath || !existsSync(worktreePath) || !baseBranch) return 0;
9778
+ try {
9779
+ const counts = execGitArgsSync(
9780
+ ["rev-list", "--left-right", "--count", `HEAD...${baseBranch}`],
9781
+ {
9782
+ cwd: worktreePath,
9783
+ encoding: "utf8",
9784
+ timeout: 5000,
9785
+ stdio: ["ignore", "pipe", "pipe"],
9786
+ },
9787
+ ).trim();
9788
+ const match = counts.match(/^(\d+)\s+(\d+)$/);
9789
+ return match ? Number(match[2]) : 0;
9790
+ } catch {
9791
+ return 0;
9792
+ }
9793
+ }
9794
+
9795
+ function refreshManagedWorktreeReuse(
9796
+ nodeId,
9797
+ ctx,
9798
+ repoRoot,
9799
+ worktreePath,
9800
+ baseBranch,
9801
+ baseBranchShort,
9802
+ fetchTimeout,
9803
+ ) {
9804
+ if (!existsSync(worktreePath) || shouldSkipGitRefreshForTests()) return existsSync(worktreePath);
9805
+ let refreshError = "";
9806
+ try {
9807
+ execSync(`git pull --rebase origin ${baseBranchShort}`, {
9808
+ cwd: worktreePath,
9809
+ encoding: "utf8",
9810
+ timeout: fetchTimeout,
9811
+ stdio: ["ignore", "pipe", "pipe"],
9812
+ });
9813
+ } catch (error) {
9814
+ refreshError = formatExecSyncError(error);
9815
+ }
9816
+ if (!existsSync(worktreePath)) return false;
9817
+ if (hasUnresolvedGitOperation(worktreePath)) {
9818
+ const detail = refreshError ? ` (${refreshError})` : "";
9819
+ ctx.log(
9820
+ nodeId,
9821
+ `Managed worktree refresh left unresolved git state, recreating: ${worktreePath}${detail}`,
9822
+ );
9823
+ cleanupBrokenManagedWorktree(repoRoot, worktreePath);
9824
+ return false;
9825
+ }
9826
+ const reasons = [];
9827
+ if (hasTrackedGitChanges(worktreePath)) reasons.push("tracked changes after refresh");
9828
+ const behindCount = countCommitsBehindBase(worktreePath, baseBranch);
9829
+ if (behindCount > 0) reasons.push(`${behindCount} commit(s) behind ${baseBranch}`);
9830
+ if (reasons.length === 0) return true;
9831
+ if (refreshError) reasons.unshift(`refresh failed: ${refreshError}`);
9832
+ ctx.log(
9833
+ nodeId,
9834
+ `Managed worktree refresh did not yield a clean up-to-date branch, recreating: ${worktreePath} (${reasons.join("; ")})`,
9835
+ );
9836
+ cleanupBrokenManagedWorktree(repoRoot, worktreePath);
9837
+ return false;
9838
+ }
9839
+
9693
9840
  function cleanupBrokenManagedWorktree(repoRoot, worktreePath) {
9694
9841
  if (!worktreePath) return;
9695
9842
  const linkedGitDir = resolveGitDirForWorktree(worktreePath);
@@ -9728,13 +9875,9 @@ function cleanupBrokenManagedWorktree(repoRoot, worktreePath) {
9728
9875
  }
9729
9876
 
9730
9877
  /**
9731
- * Anti-thrash state — module-scope to survive across workflow runs.
9732
- * Mirrors TaskExecutor._noCommitCounts / _skipUntil / _completedWithPR.
9878
+ * Anti-thrash state — imported from transforms.mjs (single source of truth).
9879
+ * Shared between monolithic workflow-nodes.mjs and modular triggers.mjs.
9733
9880
  */
9734
- const _noCommitCounts = new Map();
9735
- const _skipUntil = new Map();
9736
- const _completedWithPR = new Set();
9737
- const MAX_NO_COMMIT_ATTEMPTS = 3;
9738
9881
  const NO_COMMIT_BASE_COOLDOWN_MS = 15 * 60 * 1000; // 15 min
9739
9882
  const NO_COMMIT_MAX_COOLDOWN_MS = 2 * 60 * 60 * 1000; // 2 hours
9740
9883
  const STRICT_START_GUARD_MISSING_TASK = /^(1|true|yes|on)$/i.test(
@@ -9795,6 +9938,13 @@ registerBuiltinNodeType("trigger.task_available", {
9795
9938
  for (let attempt = 0; attempt <= listRetries; attempt++) {
9796
9939
  try {
9797
9940
  const kanban = ctx.data?._services?.kanban || engine?.services?.kanban;
9941
+ if (status === "todo") {
9942
+ try {
9943
+ await recoverTimedBlockedWorkflowTasks({ kanban, ctx, node, projectId });
9944
+ } catch (recoveryErr) {
9945
+ ctx.log(node.id, `Blocked task recovery warning: ${recoveryErr?.message || recoveryErr}`);
9946
+ }
9947
+ }
9798
9948
  if (kanban?.listTasks) {
9799
9949
  tasks = await kanban.listTasks(projectId, { status });
9800
9950
  } else {
@@ -10108,6 +10258,9 @@ registerBuiltinNodeType("trigger.task_available", {
10108
10258
  if (baseBranch) ctx.data.baseBranch = baseBranch;
10109
10259
  const branch = deriveTaskBranch(primaryTask);
10110
10260
  if (branch) ctx.data.branch = branch;
10261
+ ctx.data.taskMeta = primaryTask?.meta && typeof primaryTask.meta === "object"
10262
+ ? { ...primaryTask.meta }
10263
+ : {};
10111
10264
  }
10112
10265
 
10113
10266
  ctx.log(node.id, `Found ${toDispatch.length} task(s) ready (${remaining} slot(s) free)`);
@@ -10479,8 +10632,26 @@ registerBuiltinNodeType("action.resolve_executor", {
10479
10632
  || ctx.data?.agentProfile
10480
10633
  || "",
10481
10634
  ).trim();
10635
+ const taskText = [task.title, task.description].filter(Boolean).join("\n");
10636
+ const inferredResolutionTags = [];
10637
+ if (/\btest(?:s|ing)?\b/i.test(taskText)) inferredResolutionTags.push("test", "tests");
10638
+ if (/\b(?:ci|cd|pipeline|workflow|github actions?)\b/i.test(taskText)) inferredResolutionTags.push("ci", "cd", "pipeline");
10639
+ if (/\b(?:merge conflict|conflicts|rebase|cherry-pick)\b/i.test(taskText)) inferredResolutionTags.push("conflict", "merge");
10640
+ if (/\b(?:implement|implementation|feature|build|ship)\b/i.test(taskText)) inferredResolutionTags.push("implementation");
10641
+ if (/\b(?:docs?|documentation|readme)\b/i.test(taskText)) inferredResolutionTags.push("docs", "documentation");
10642
+ const resolutionTags = Array.from(new Set([
10643
+ ...task.tags,
10644
+ ...inferredResolutionTags,
10645
+ String(ctx.data?.task?.type || "").trim(),
10646
+ String(ctx.data?.task?.agentType || "").trim(),
10647
+ String(ctx.data?.task?.assignedAgentType || "").trim(),
10648
+ String(ctx.data?.agentType || "").trim(),
10649
+ String(ctx.data?.assignedAgentType || "").trim(),
10650
+ ].map((value) => String(value || "").trim()).filter(Boolean)));
10482
10651
  let profileDecision = null;
10483
10652
  let configuredExecutorPreference = null;
10653
+ ctx.data.resolvedSkillIds = [];
10654
+ ctx.data.resolvedLibraryPlan = null;
10484
10655
 
10485
10656
  // Check env var overrides (mirrors TaskExecutor behavior)
10486
10657
  const envModel =
@@ -10497,25 +10668,30 @@ registerBuiltinNodeType("action.resolve_executor", {
10497
10668
 
10498
10669
  try {
10499
10670
  const library = await ensureLibraryManagerMod();
10500
- const match = library.matchAgentProfiles?.(
10671
+ const criteria = {
10672
+ title: task.title,
10673
+ description: task.description,
10674
+ tags: resolutionTags,
10675
+ agentType: ctx.data?.task?.agentType || ctx.data?.agentType || "",
10501
10676
  repoRoot,
10677
+ changedFiles: Array.isArray(ctx.data?.changedFiles) ? ctx.data.changedFiles : [],
10678
+ };
10679
+ const planResult = library.resolveLibraryPlan?.(
10680
+ repoRoot,
10681
+ criteria,
10502
10682
  {
10503
- title: task.title,
10504
- description: task.description,
10505
- tags: task.tags,
10506
- agentType: ctx.data?.task?.agentType || ctx.data?.agentType || "",
10507
- repoRoot,
10683
+ topN: Math.max(10, requestedAgentProfileId ? 25 : 10),
10684
+ skillTopN: 5,
10508
10685
  },
10509
- { topN: Math.max(10, requestedAgentProfileId ? 25 : 10) },
10510
10686
  );
10511
- const candidates = Array.isArray(match?.candidates) ? match.candidates : [];
10512
- const bestCandidate = match?.best || null;
10513
- const autoMinScore = Number(match?.auto?.thresholds?.minScore || 12);
10687
+ const candidates = Array.isArray(planResult?.candidates) ? planResult.candidates : [];
10688
+ const bestCandidate = planResult?.best || null;
10689
+ const autoMinScore = Number(planResult?.auto?.thresholds?.minScore || 12);
10514
10690
  const scoreQualified = Number(bestCandidate?.score || 0) >= autoMinScore;
10515
10691
  const matchedCandidate = requestedAgentProfileId
10516
10692
  ? candidates.find((candidate) => String(candidate?.id || "").trim() === requestedAgentProfileId) || null
10517
- : ((match?.auto?.shouldAutoApply || scoreQualified) ? bestCandidate : null);
10518
- if (!requestedAgentProfileId && bestCandidate && !match?.auto?.shouldAutoApply) {
10693
+ : ((planResult?.auto?.shouldAutoApply || scoreQualified) ? bestCandidate : null);
10694
+ if (!requestedAgentProfileId && bestCandidate && !planResult?.auto?.shouldAutoApply) {
10519
10695
  ctx.log(
10520
10696
  node.id,
10521
10697
  `Profile match below auto threshold; ignoring candidate ${String(bestCandidate.id || "unknown")}`,
@@ -10534,13 +10710,19 @@ registerBuiltinNodeType("action.resolve_executor", {
10534
10710
  ctx.data.agentProfile = profileId;
10535
10711
  ctx.data.resolvedAgentProfile = {
10536
10712
  id: profileId,
10537
- name: match?.best?.name || profile?.name || profileId,
10713
+ name: matchedCandidate?.name || profile?.name || profileId,
10538
10714
  ...profile,
10539
10715
  };
10540
- const skillIds = Array.isArray(profile.skills)
10541
- ? profile.skills.map((value) => String(value || "").trim()).filter(Boolean)
10542
- : [];
10716
+ const resolvedPlan = planResult?.plan && planResult.plan.agentProfileId === profileId
10717
+ ? planResult.plan
10718
+ : null;
10719
+ const skillIds = resolvedPlan && Array.isArray(resolvedPlan.skillIds)
10720
+ ? resolvedPlan.skillIds.map((value) => String(value || "").trim()).filter(Boolean)
10721
+ : Array.isArray(profile.skills)
10722
+ ? profile.skills.map((value) => String(value || "").trim()).filter(Boolean)
10723
+ : [];
10543
10724
  ctx.data.resolvedSkillIds = skillIds;
10725
+ ctx.data.resolvedLibraryPlan = resolvedPlan;
10544
10726
  }
10545
10727
  } catch (err) {
10546
10728
  ctx.log(node.id, `Library profile resolution failed: ${err.message}`);
@@ -10739,22 +10921,15 @@ registerBuiltinNodeType("action.acquire_worktree", {
10739
10921
  }
10740
10922
 
10741
10923
  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
- }
10924
+ refreshManagedWorktreeReuse(
10925
+ node.id,
10926
+ ctx,
10927
+ repoRoot,
10928
+ worktreePath,
10929
+ baseBranch,
10930
+ baseBranchShort,
10931
+ fetchTimeout,
10932
+ );
10758
10933
  if (existsSync(worktreePath)) {
10759
10934
  ctx.data.worktreePath = worktreePath;
10760
10935
  ctx.data._worktreeCreated = false;
@@ -10767,6 +10942,7 @@ registerBuiltinNodeType("action.acquire_worktree", {
10767
10942
  }
10768
10943
 
10769
10944
  // Create fresh worktree
10945
+ let attachedExistingBranch = false;
10770
10946
  try {
10771
10947
  execSync(
10772
10948
  `git worktree add "${worktreePath}" -b "${branch}" "${baseBranch}" 2>&1`,
@@ -10782,21 +10958,39 @@ registerBuiltinNodeType("action.acquire_worktree", {
10782
10958
  `git worktree add "${worktreePath}" "${branch}" 2>&1`,
10783
10959
  { cwd: repoRoot, encoding: "utf8", timeout: worktreeTimeout },
10784
10960
  );
10961
+ attachedExistingBranch = true;
10785
10962
  } catch (reuseErr) {
10786
10963
  const existingBranchWorktree = findExistingWorktreePathForBranch(repoRoot, branch);
10787
10964
  if (existingBranchWorktree && existsSync(existingBranchWorktree)) {
10788
- if (!isValidGitWorktreePath(existingBranchWorktree) &&
10789
- isManagedBosunWorktree(existingBranchWorktree, repoRoot)
10790
- ) {
10965
+ const existingWorktreeIsBroken = (
10966
+ !isValidGitWorktreePath(existingBranchWorktree) ||
10967
+ hasUnresolvedGitOperation(existingBranchWorktree)
10968
+ ) && isManagedBosunWorktree(existingBranchWorktree, repoRoot);
10969
+ if (existingWorktreeIsBroken) {
10791
10970
  ctx.log(
10792
10971
  node.id,
10793
- `Existing branch worktree is invalid, recreating managed path: ${existingBranchWorktree}`,
10972
+ `Existing branch worktree is invalid or unresolved, recreating managed path: ${existingBranchWorktree}`,
10794
10973
  );
10795
10974
  cleanupBrokenManagedWorktree(repoRoot, existingBranchWorktree);
10796
10975
  }
10797
10976
  }
10798
10977
  if (existingBranchWorktree && existsSync(existingBranchWorktree) &&
10799
- isValidGitWorktreePath(existingBranchWorktree)
10978
+ isValidGitWorktreePath(existingBranchWorktree) &&
10979
+ !hasUnresolvedGitOperation(existingBranchWorktree)
10980
+ ) {
10981
+ refreshManagedWorktreeReuse(
10982
+ node.id,
10983
+ ctx,
10984
+ repoRoot,
10985
+ existingBranchWorktree,
10986
+ baseBranch,
10987
+ baseBranchShort,
10988
+ fetchTimeout,
10989
+ );
10990
+ }
10991
+ if (existingBranchWorktree && existsSync(existingBranchWorktree) &&
10992
+ isValidGitWorktreePath(existingBranchWorktree) &&
10993
+ !hasUnresolvedGitOperation(existingBranchWorktree)
10800
10994
  ) {
10801
10995
  ctx.data.worktreePath = existingBranchWorktree;
10802
10996
  ctx.data._worktreeCreated = false;
@@ -10820,6 +11014,22 @@ registerBuiltinNodeType("action.acquire_worktree", {
10820
11014
  );
10821
11015
  }
10822
11016
  }
11017
+ if (attachedExistingBranch) {
11018
+ refreshManagedWorktreeReuse(
11019
+ node.id,
11020
+ ctx,
11021
+ repoRoot,
11022
+ worktreePath,
11023
+ baseBranch,
11024
+ baseBranchShort,
11025
+ fetchTimeout,
11026
+ );
11027
+ if (!existsSync(worktreePath)) {
11028
+ throw new Error(
11029
+ `Worktree refresh failed for existing branch ${branch}; managed worktree was removed after stale refresh state`,
11030
+ );
11031
+ }
11032
+ }
10823
11033
  fixGitConfigCorruption(repoRoot);
10824
11034
  const cleared3 = clearBlockedWorktreeIdentity(worktreePath);
10825
11035
  if (cleared3) ctx.log(node.id, `Cleared blocked test git identity from worktree: ${worktreePath}`);
@@ -10830,8 +11040,28 @@ registerBuiltinNodeType("action.acquire_worktree", {
10830
11040
  ctx.log(node.id, `Worktree created: ${worktreePath} (branch: ${branch}, base: ${baseBranch})`);
10831
11041
  return { success: true, worktreePath, created: true, branch, baseBranch };
10832
11042
  } catch (err) {
10833
- ctx.log(node.id, `Worktree acquisition failed: ${err.message}`);
10834
- return { success: false, error: err.message, branch, baseBranch };
11043
+ const errorMessage = String(err?.message || err || "worktree_acquisition_failed");
11044
+ const retryable = !/managed worktree was removed after stale refresh state/i.test(errorMessage);
11045
+ const recordedAt = new Date().toISOString();
11046
+ const autoRecoverDelayMs = retryable ? 0 : getNonRetryableWorktreeRecoveryMs();
11047
+ const retryAt = retryable ? null : new Date(Date.now() + autoRecoverDelayMs).toISOString();
11048
+ const blockedReason = retryable
11049
+ ? errorMessage
11050
+ : "Managed worktree refresh conflict detected; Bosun will retry automatically after cooldown.";
11051
+ ctx.log(node.id, `Worktree acquisition failed: ${errorMessage}`);
11052
+ return {
11053
+ success: false,
11054
+ error: errorMessage,
11055
+ branch,
11056
+ baseBranch,
11057
+ retryable,
11058
+ failureKind: retryable ? "worktree_acquisition_failed" : "branch_refresh_conflict",
11059
+ recordedAt,
11060
+ autoRecoverDelayMs,
11061
+ retryAt,
11062
+ blockedReason,
11063
+ recoveryNote: retryable || !retryAt ? "" : ` — blocked until ${retryAt}`,
11064
+ };
10835
11065
  }
10836
11066
  },
10837
11067
  });
@@ -10887,6 +11117,8 @@ registerBuiltinNodeType("action.release_worktree", {
10887
11117
  } catch { /* best-effort */ }
10888
11118
  }
10889
11119
 
11120
+ fixGitConfigCorruption(repoRoot);
11121
+
10890
11122
  ctx.data._worktreeCreated = false;
10891
11123
  ctx.data._worktreeManaged = false;
10892
11124
  ctx.log(node.id, `Worktree released: ${worktreePath}`);
@@ -11412,6 +11644,11 @@ registerBuiltinNodeType("action.build_task_prompt", {
11412
11644
  const content = library.getEntryContent?.(libraryRoot, entry);
11413
11645
  if (!content || (typeof content === "string" && !content.trim())) continue;
11414
11646
  const body = typeof content === "string" ? content.trim() : JSON.stringify(content, null, 2);
11647
+ emitSkillInvokeEvent(skillId, entry.name || skillId, {
11648
+ taskId,
11649
+ executor: ctx.data?.resolvedSdk,
11650
+ source: "library",
11651
+ });
11415
11652
  librarySkillParts.push(`### Skill: ${entry.name || skillId} (\`${skillId}\`)`);
11416
11653
  librarySkillParts.push(body);
11417
11654
  librarySkillParts.push("");
@@ -11749,6 +11986,21 @@ registerBuiltinNodeType("action.push_branch", {
11749
11986
  }
11750
11987
  }
11751
11988
 
11989
+ // ── Hard zero-diff guard (always active) ──
11990
+ try {
11991
+ const headSha = execSync("git rev-parse HEAD", {
11992
+ cwd: worktreePath, encoding: "utf8", timeout: 5_000, stdio: ["pipe", "pipe", "pipe"],
11993
+ }).trim();
11994
+ const mainSha = execSync("git rev-parse origin/main", {
11995
+ cwd: worktreePath, encoding: "utf8", timeout: 5_000, stdio: ["pipe", "pipe", "pipe"],
11996
+ }).trim();
11997
+ if (headSha && mainSha && headSha === mainSha) {
11998
+ ctx.log(node.id, "HEAD is identical to origin/main — aborting push to prevent PR wipe");
11999
+ ctx.data._pushSkipped = true;
12000
+ return { success: false, error: "HEAD matches origin/main — refusing push", pushed: false };
12001
+ }
12002
+ } catch { /* best-effort */ }
12003
+
11752
12004
  // ── Push ──
11753
12005
  const pushFlags = [];
11754
12006
  if (forceWithLease) pushFlags.push("--force-with-lease");