bosun 0.40.21 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.env.example +8 -0
  2. package/README.md +20 -0
  3. package/agent/agent-custom-tools.mjs +23 -5
  4. package/agent/agent-event-bus.mjs +248 -6
  5. package/agent/agent-pool.mjs +131 -30
  6. package/agent/agent-work-analyzer.mjs +8 -16
  7. package/agent/primary-agent.mjs +81 -7
  8. package/agent/retry-queue.mjs +164 -0
  9. package/bench/swebench/bosun-swebench.mjs +5 -0
  10. package/bosun.config.example.json +25 -0
  11. package/bosun.schema.json +825 -183
  12. package/cli.mjs +267 -8
  13. package/config/config-doctor.mjs +51 -2
  14. package/config/config.mjs +232 -5
  15. package/github/github-auth-manager.mjs +70 -19
  16. package/infra/library-manager.mjs +894 -60
  17. package/infra/monitor.mjs +701 -69
  18. package/infra/runtime-accumulator.mjs +376 -84
  19. package/infra/session-tracker.mjs +95 -28
  20. package/infra/test-runtime.mjs +267 -0
  21. package/lib/codebase-audit.mjs +133 -18
  22. package/package.json +30 -8
  23. package/server/setup-web-server.mjs +29 -1
  24. package/server/ui-server.mjs +1571 -49
  25. package/setup.mjs +27 -24
  26. package/shell/codex-shell.mjs +34 -3
  27. package/shell/copilot-shell.mjs +50 -8
  28. package/task/msg-hub.mjs +193 -0
  29. package/task/pipeline.mjs +544 -0
  30. package/task/task-claims.mjs +6 -10
  31. package/task/task-cli.mjs +38 -2
  32. package/task/task-executor-pipeline.mjs +143 -0
  33. package/task/task-executor.mjs +36 -27
  34. package/telegram/get-telegram-chat-id.mjs +57 -47
  35. package/ui/components/chat-view.js +18 -1
  36. package/ui/components/workspace-switcher.js +321 -9
  37. package/ui/demo-defaults.js +17830 -10433
  38. package/ui/demo.html +9 -1
  39. package/ui/modules/router.js +1 -1
  40. package/ui/modules/settings-schema.js +2 -0
  41. package/ui/modules/state.js +54 -57
  42. package/ui/modules/voice-client-sdk.js +376 -37
  43. package/ui/modules/voice-client.js +173 -33
  44. package/ui/setup.html +68 -2
  45. package/ui/styles/components.css +571 -1
  46. package/ui/styles.css +201 -1
  47. package/ui/tabs/dashboard.js +74 -0
  48. package/ui/tabs/library.js +410 -55
  49. package/ui/tabs/logs.js +10 -0
  50. package/ui/tabs/settings.js +178 -99
  51. package/ui/tabs/tasks.js +1083 -507
  52. package/ui/tabs/telemetry.js +34 -0
  53. package/ui/tabs/workflow-canvas-utils.mjs +38 -1
  54. package/ui/tabs/workflows.js +1275 -402
  55. package/voice/voice-agents-sdk.mjs +2 -2
  56. package/voice/voice-relay.mjs +28 -20
  57. package/workflow/declarative-workflows.mjs +145 -0
  58. package/workflow/msg-hub.mjs +237 -0
  59. package/workflow/pipeline-workflows.mjs +287 -0
  60. package/workflow/pipeline.mjs +828 -315
  61. package/workflow/project-detection.mjs +559 -0
  62. package/workflow/workflow-cli.mjs +128 -0
  63. package/workflow/workflow-contract.mjs +433 -232
  64. package/workflow/workflow-engine.mjs +510 -47
  65. package/workflow/workflow-nodes/custom-loader.mjs +251 -0
  66. package/workflow/workflow-nodes.mjs +2024 -184
  67. package/workflow/workflow-templates.mjs +118 -24
  68. package/workflow-templates/agents.mjs +20 -20
  69. package/workflow-templates/bosun-native.mjs +212 -2
  70. package/workflow-templates/code-quality.mjs +20 -14
  71. package/workflow-templates/continuation-loop.mjs +339 -0
  72. package/workflow-templates/github.mjs +516 -40
  73. package/workflow-templates/planning.mjs +446 -17
  74. package/workflow-templates/reliability.mjs +65 -12
  75. package/workflow-templates/task-batch.mjs +27 -10
  76. package/workflow-templates/task-execution.mjs +752 -0
  77. package/workflow-templates/task-lifecycle.mjs +117 -14
  78. package/workspace/context-cache.mjs +66 -18
  79. package/workspace/workspace-manager.mjs +153 -1
  80. package/workflow-templates/issue-continuation.mjs +0 -243
@@ -679,6 +679,303 @@ Omit empty sections. Include contributor attribution. Be concise.`,
679
679
  },
680
680
  };
681
681
 
682
+ // ═══════════════════════════════════════════════════════════════════════════
683
+ // Bosun PR Progressor
684
+ // ═══════════════════════════════════════════════════════════════════════════
685
+
686
+ resetLayout();
687
+
688
+ export const BOSUN_PR_PROGRESSOR_TEMPLATE = {
689
+ id: "template-bosun-pr-progressor",
690
+ name: "Bosun PR Progressor",
691
+ description:
692
+ "Direct per-PR progression workflow for bosun-managed tasks. Runs immediately " +
693
+ "after PR handoff, evaluates a single PR, retries simple CI failures, " +
694
+ "dispatches focused repair when needed, and performs the first merge-review pass " +
695
+ "without waiting for the periodic watchdog.",
696
+ category: "github",
697
+ enabled: true,
698
+ recommended: true,
699
+ trigger: "trigger.workflow_call",
700
+ variables: {
701
+ mergeMethod: "squash",
702
+ labelNeedsFix: "bosun-needs-fix",
703
+ labelNeedsReview: "bosun-needs-human-review",
704
+ suspiciousDeletionRatio: 3,
705
+ minDestructiveDeletions: 500,
706
+ },
707
+ nodes: [
708
+ node("trigger", "trigger.workflow_call", "PR Handoff", {
709
+ inputs: {
710
+ taskId: { type: "string", required: false },
711
+ taskTitle: { type: "string", required: false },
712
+ branch: { type: "string", required: false },
713
+ baseBranch: { type: "string", required: false, default: "main" },
714
+ prNumber: { type: "number", required: false },
715
+ prUrl: { type: "string", required: false },
716
+ repo: { type: "string", required: false },
717
+ },
718
+ }, { x: 400, y: 50 }),
719
+
720
+ node("normalize-context", "action.set_variable", "Normalize PR Context", {
721
+ key: "prProgressContext",
722
+ value:
723
+ "(() => {" +
724
+ " const prOut = $ctx.getNodeOutput('create-pr') || $ctx.getNodeOutput('create-pr-retry') || {};" +
725
+ " const prUrl = String($data?.prUrl || prOut?.prUrl || prOut?.url || '').trim();" +
726
+ " const repoMatch = prUrl.match(/github\\.com\\/([^/]+\\/[^/?#]+)/i);" +
727
+ " const repo = String($data?.repo || (repoMatch ? repoMatch[1] : '')).trim();" +
728
+ " const rawPrNumber = $data?.prNumber ?? prOut?.prNumber ?? null;" +
729
+ " const parsedPrNumber = Number.parseInt(String(rawPrNumber || ''), 10);" +
730
+ " return {" +
731
+ " taskId: String($data?.taskId || '').trim() || null," +
732
+ " taskTitle: String($data?.taskTitle || '').trim() || null," +
733
+ " repo: repo || null," +
734
+ " branch: String($data?.branch || prOut?.branch || '').trim() || null," +
735
+ " baseBranch: String($data?.baseBranch || prOut?.base || 'main').trim() || 'main'," +
736
+ " prNumber: Number.isFinite(parsedPrNumber) && parsedPrNumber > 0 ? parsedPrNumber : null," +
737
+ " prUrl: prUrl || null," +
738
+ " };" +
739
+ "})()",
740
+ isExpression: true,
741
+ }, { x: 400, y: 180 }),
742
+
743
+ node("has-pr-target", "condition.expression", "Has PR Target?", {
744
+ expression:
745
+ "Boolean($data?.prProgressContext?.prNumber && ($data?.prProgressContext?.repo || $data?.prProgressContext?.prUrl))",
746
+ }, { x: 400, y: 300 }),
747
+
748
+ node("inspect-pr", "action.run_command", "Inspect Single PR", {
749
+ command: [
750
+ "node -e \"",
751
+ "const {execFileSync}=require('child_process');",
752
+ "const ctx=(()=>{try{return JSON.parse(String(process.env.BOSUN_PR_CONTEXT||'{}'))}catch{return {}}})();",
753
+ "const repo=String(ctx.repo||'').trim();",
754
+ "const branch=String(ctx.branch||'').trim();",
755
+ "const baseBranch=String(ctx.baseBranch||'main').trim()||'main';",
756
+ "const rawNumber=String(ctx.prNumber||'').trim();",
757
+ "const prNumber=Number.parseInt(rawNumber,10);",
758
+ "if(!repo||!Number.isFinite(prNumber)||prNumber<=0){",
759
+ " console.log(JSON.stringify({success:false,classification:'missing',reason:'missing_repo_or_pr',repo,prNumber:Number.isFinite(prNumber)?prNumber:null,branch,baseBranch}));",
760
+ " process.exit(0);",
761
+ "}",
762
+ "function gh(args){return execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();}",
763
+ "const raw=gh(['pr','view',String(prNumber),'--repo',repo,'--json','number,title,url,headRefName,baseRefName,isDraft,mergeable,statusCheckRollup']);",
764
+ "const pr=(()=>{try{return JSON.parse(raw||'{}')}catch{return {}}})();",
765
+ "const checks=Array.isArray(pr.statusCheckRollup)?pr.statusCheckRollup:[];",
766
+ "const failStates=new Set(['FAILURE','ERROR','TIMED_OUT','CANCELLED','STARTUP_FAILURE']);",
767
+ "const pendingStates=new Set(['QUEUED','IN_PROGRESS','PENDING','WAITING','REQUESTED']);",
768
+ "const conflictMergeables=new Set(['CONFLICTING','DIRTY','UNKNOWN']);",
769
+ "const failedCheckNames=checks.filter((c)=>{const s=String(c?.state||'').toUpperCase();const b=String(c?.bucket||'').toUpperCase();return failStates.has(s)||b==='FAIL';}).map((c)=>String(c?.name||c?.context||c?.workflowName||'').trim()).filter(Boolean);",
770
+ "const hasFailure=checks.some((c)=>{const s=String(c?.state||'').toUpperCase();const b=String(c?.bucket||'').toUpperCase();return failStates.has(s)||b==='FAIL';});",
771
+ "const hasPending=checks.some((c)=>pendingStates.has(String(c?.state||'').toUpperCase()));",
772
+ "let classification='ready';",
773
+ "let reason='ready_for_review';",
774
+ "let ciKicked=false;",
775
+ "if(pr?.isDraft===true){classification='draft';reason='draft_pr';}",
776
+ "else if(conflictMergeables.has(String(pr?.mergeable||'').toUpperCase())){classification='conflict';reason='merge_conflict';}",
777
+ "else if(hasFailure){classification='ci_failure';reason='ci_failed';}",
778
+ "else if(hasPending){classification='pending';reason='ci_pending';}",
779
+ "else if(checks.length===0 && branch){",
780
+ " try{gh(['workflow','run','ci.yaml','--repo',repo,'--ref',branch]);ciKicked=true;classification='pending';reason='ci_kicked';}",
781
+ " catch{classification='ready';reason='ready_without_checks';}",
782
+ "}",
783
+ "console.log(JSON.stringify({success:true,repo,prNumber,url:String(pr?.url||ctx.prUrl||''),branch:String(pr?.headRefName||branch||''),baseBranch:String(pr?.baseRefName||baseBranch||'main'),title:String(pr?.title||ctx.taskTitle||''),classification,reason,ciKicked,hasFailure,hasPending,failedCheckNames}));",
784
+ "\"",
785
+ ].join(" "),
786
+ continueOnError: true,
787
+ failOnError: false,
788
+ env: {
789
+ BOSUN_PR_CONTEXT:
790
+ "{{$data?.prProgressContext ? JSON.stringify($data.prProgressContext) : '{}'}}",
791
+ },
792
+ }, { x: 400, y: 430 }),
793
+
794
+ node("fix-needed", "condition.expression", "Needs Repair?", {
795
+ expression:
796
+ "(()=>{try{" +
797
+ "const d=JSON.parse($ctx.getNodeOutput('inspect-pr')?.output||'{}');" +
798
+ "return d?.classification==='ci_failure' || d?.classification==='conflict';" +
799
+ "}catch{return false;}})()",
800
+ }, { x: 220, y: 560 }),
801
+
802
+ node("programmatic-fix", "action.run_command", "Repair Attempt", {
803
+ command: [
804
+ "node -e \"",
805
+ "const {execFileSync}=require('child_process');",
806
+ "const data=(()=>{try{return JSON.parse(String(process.env.BOSUN_PR_INSPECT||'{}'))}catch{return {}}})();",
807
+ "const repo=String(data.repo||'').trim();",
808
+ "const branch=String(data.branch||'').trim();",
809
+ "const prNumber=Number.parseInt(String(data.prNumber||''),10);",
810
+ "const classification=String(data.classification||'').trim();",
811
+ "const failedCheckNames=Array.isArray(data.failedCheckNames)?data.failedCheckNames:[];",
812
+ "const labelFix=String('{{labelNeedsFix}}'||'bosun-needs-fix');",
813
+ "const FAIL_STATES=new Set(['FAILURE','ERROR','TIMED_OUT','CANCELLED','STARTUP_FAILURE']);",
814
+ "const MAX_AUTO_RERUN_ATTEMPT=1;",
815
+ "function gh(args){return execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();}",
816
+ "function normalizeRun(run){if(!run||typeof run!=='object')return null;return {databaseId:Number(run.databaseId||0)||null,attempt:Number(run.attempt||0)||0,conclusion:String(run.conclusion||''),status:String(run.status||''),workflowName:String(run.workflowName||run.name||''),displayTitle:String(run.displayTitle||run.name||''),url:String(run.url||''),createdAt:String(run.createdAt||''),updatedAt:String(run.updatedAt||'')}}",
817
+ "function normalizeJob(job){if(!job||typeof job!=='object')return null;const steps=Array.isArray(job.steps)?job.steps:[];return {databaseId:Number(job.databaseId||0)||null,name:String(job.name||''),status:String(job.status||''),conclusion:String(job.conclusion||''),url:String(job.url||''),failedSteps:steps.filter((step)=>FAIL_STATES.has(String(step?.conclusion||step?.status||'').toUpperCase())).map((step)=>({name:String(step?.name||''),number:Number(step?.number||0)||null,status:String(step?.status||''),conclusion:String(step?.conclusion||'')})).filter((step)=>step.name).slice(0,10)}}",
818
+ "function truncateText(value,max){const text=String(value||'').replace(/\\r/g,'').trim();if(!text)return '';return text.length>max?text.slice(0,Math.max(0,max-19))+'\\n...[truncated]':text;}",
819
+ "function collectCiDiagnostics(run){const info={failedRun:normalizeRun(run),failedJobs:[],failedLogExcerpt:'',diagnosticsError:''};const runId=Number(run?.databaseId||0)||0;if(!runId)return info;try{const viewRaw=gh(['run','view',String(runId),'--repo',repo,'--json','attempt,conclusion,status,workflowName,displayTitle,url,createdAt,updatedAt,jobs']);const view=(()=>{try{return JSON.parse(viewRaw||'{}')}catch{return {}}})();info.failedRun=normalizeRun({...run,...view});const jobs=Array.isArray(view.jobs)?view.jobs:[];info.failedJobs=jobs.map(normalizeJob).filter((job)=>job&&(FAIL_STATES.has(String(job.conclusion||'').toUpperCase())||job.failedSteps.length>0)).slice(0,10);}catch(e){info.diagnosticsError=String(e?.message||e);}try{info.failedLogExcerpt=truncateText(gh(['run','view',String(runId),'--repo',repo,'--log-failed']),6000);}catch(e){const message=String(e?.message||e);if(message&&message!==info.diagnosticsError){info.diagnosticsError=info.diagnosticsError?info.diagnosticsError+' | '+message:message;}}return info;}",
820
+ "if(repo&&Number.isFinite(prNumber)&&prNumber>0){",
821
+ " try{gh(['pr','edit',String(prNumber),'--repo',repo,'--add-label',labelFix]);}catch{}",
822
+ "}",
823
+ "if(classification==='ci_failure'&&repo&&branch){",
824
+ " try{",
825
+ " const listRaw=gh(['run','list','--repo',repo,'--branch',branch,'--json','databaseId,attempt,conclusion,status,workflowName,displayTitle,url,createdAt,updatedAt','--limit','8']);",
826
+ " const runs=(()=>{try{return JSON.parse(listRaw||'[]')}catch{return []}})();",
827
+ " const failed=(Array.isArray(runs)?runs:[]).find((r)=>FAIL_STATES.has(String(r?.conclusion||'').toUpperCase()));",
828
+ " const failedRun=normalizeRun(failed);",
829
+ " if(failedRun?.databaseId&&failedRun.attempt<=MAX_AUTO_RERUN_ATTEMPT){gh(['run','rerun',String(failedRun.databaseId),'--repo',repo]);console.log(JSON.stringify({success:true,rerunRequested:true,needsAgent:false,reason:'rerun_requested',failedCheckNames,failedRun}));process.exit(0);}",
830
+ " if(failedRun?.databaseId){const diagnostics=collectCiDiagnostics(failedRun);console.log(JSON.stringify({success:false,rerunRequested:false,needsAgent:true,reason:'auto_rerun_limit_reached',failedCheckNames,rerunAttempts:failedRun.attempt||0,...diagnostics}));process.exit(0);}",
831
+ " console.log(JSON.stringify({success:false,rerunRequested:false,needsAgent:true,reason:'no_rerunnable_failed_run_found',failedCheckNames,recentRuns:(Array.isArray(runs)?runs:[]).map(normalizeRun).filter(Boolean).slice(0,5)}));",
832
+ " process.exit(0);",
833
+ " }catch(e){",
834
+ " console.log(JSON.stringify({success:false,rerunRequested:false,needsAgent:true,reason:'ci_rerun_failed',failedCheckNames,error:String(e?.message||e)}));",
835
+ " process.exit(0);",
836
+ " }",
837
+ "}",
838
+ "console.log(JSON.stringify({success:false,rerunRequested:false,needsAgent:true,reason:classification==='conflict'?'merge_conflict_requires_code_resolution':'repair_required',failedCheckNames}));",
839
+ "\"",
840
+ ].join(" "),
841
+ continueOnError: true,
842
+ failOnError: false,
843
+ env: {
844
+ BOSUN_PR_INSPECT:
845
+ "{{$ctx.getNodeOutput('inspect-pr')?.output || '{}'}}",
846
+ },
847
+ }, { x: 220, y: 690 }),
848
+
849
+ node("fix-agent-needed", "condition.expression", "Needs Fix Agent?", {
850
+ expression:
851
+ "(()=>{try{" +
852
+ "const d=JSON.parse($ctx.getNodeOutput('programmatic-fix')?.output||'{}');" +
853
+ "return d?.needsAgent===true;" +
854
+ "}catch{return false;}})()",
855
+ }, { x: 220, y: 820 }),
856
+
857
+ node("dispatch-fix-agent", "action.run_agent", "Dispatch Focused Fix Agent", {
858
+ prompt:
859
+ "You are a Bosun PR repair fallback agent working one PR only.\n\n" +
860
+ "PR context:\n{{$ctx.getNodeOutput('inspect-pr')?.output}}\n\n" +
861
+ "Repair attempt output:\n{{$ctx.getNodeOutput('programmatic-fix')?.output}}\n\n" +
862
+ "Rules:\n" +
863
+ "- Only fix this PR's CI or merge-conflict issue.\n" +
864
+ "- Do not merge, approve, or close the PR.\n" +
865
+ "- Keep the patch minimal and scoped to the reported failure.\n" +
866
+ "- If you repair the PR, remove the bosun-needs-fix label.\n",
867
+ sdk: "auto",
868
+ timeoutMs: 1_800_000,
869
+ maxRetries: 2,
870
+ retryDelayMs: 30_000,
871
+ continueOnError: true,
872
+ }, { x: 220, y: 950 }),
873
+
874
+ node("review-needed", "condition.expression", "Ready For Review?", {
875
+ expression:
876
+ "(()=>{try{" +
877
+ "const d=JSON.parse($ctx.getNodeOutput('inspect-pr')?.output||'{}');" +
878
+ "return d?.classification==='ready';" +
879
+ "}catch{return false;}})()",
880
+ }, { x: 620, y: 560 }),
881
+
882
+ node("programmatic-review", "action.run_command", "Review Gate: Merge Single PR", {
883
+ command: [
884
+ "node -e \"",
885
+ "const {execFileSync}=require('child_process');",
886
+ "const pr=(()=>{try{return JSON.parse(String(process.env.BOSUN_PR_INSPECT||'{}'))}catch{return {}}})();",
887
+ "const repo=String(pr.repo||'').trim();",
888
+ "const n=String(pr.prNumber||'').trim();",
889
+ "const ratio=Number('{{suspiciousDeletionRatio}}')||3;",
890
+ "const minDel=Number('{{minDestructiveDeletions}}')||500;",
891
+ "const labelReview=String('{{labelNeedsReview}}'||'bosun-needs-human-review');",
892
+ "const method=String('{{mergeMethod}}'||'squash').toLowerCase();",
893
+ "function gh(args){return execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();}",
894
+ "if(!repo||!n){console.log(JSON.stringify({mergedCount:0,heldCount:0,skippedCount:1,skipped:[{repo,number:n,reason:'missing_repo_or_pr'}]}));process.exit(0);}",
895
+ "try{",
896
+ " const viewRaw=gh(['pr','view',n,'--repo',repo,'--json','number,title,additions,deletions,changedFiles,isDraft']);",
897
+ " const view=(()=>{try{return JSON.parse(viewRaw||'{}')}catch{return {}}})();",
898
+ " if(view?.isDraft===true){console.log(JSON.stringify({mergedCount:0,heldCount:0,skippedCount:1,skipped:[{repo,number:n,reason:'draft'}]}));process.exit(0);}",
899
+ " const add=Number(view?.additions||0);",
900
+ " const del=Number(view?.deletions||0);",
901
+ " const changed=Number(view?.changedFiles||0);",
902
+ " const destructive=(del>(add*ratio))&&(del>minDel);",
903
+ " const tooWide=changed>250;",
904
+ " if(destructive||tooWide){",
905
+ " gh(['pr','edit',n,'--repo',repo,'--add-label',labelReview]);",
906
+ " gh(['pr','comment',n,'--repo',repo,'--body',':warning: Bosun held this PR for human review due to suspicious diff footprint.']);",
907
+ " console.log(JSON.stringify({mergedCount:0,heldCount:1,skippedCount:0,held:[{repo,number:n,reason:destructive?'destructive_diff':'changed_files_too_large',additions:add,deletions:del,changedFiles:changed}]}));",
908
+ " process.exit(0);",
909
+ " }",
910
+ " const checksRaw=gh(['pr','checks',n,'--repo',repo,'--json','name,state,bucket']);",
911
+ " const checks=(()=>{try{return JSON.parse(checksRaw||'[]')}catch{return []}})();",
912
+ " const hasFailure=(Array.isArray(checks)?checks:[]).some((x)=>{const s=String(x?.state||'').toUpperCase();const b=String(x?.bucket||'').toUpperCase();return ['FAILURE','ERROR','TIMED_OUT','CANCELLED','STARTUP_FAILURE'].includes(s)||b==='FAIL';});",
913
+ " const hasPending=(Array.isArray(checks)?checks:[]).some((x)=>['QUEUED','IN_PROGRESS','PENDING','WAITING','REQUESTED'].includes(String(x?.state||'').toUpperCase()));",
914
+ " if(hasFailure){console.log(JSON.stringify({mergedCount:0,heldCount:0,skippedCount:1,skipped:[{repo,number:n,reason:'ci_failed'}]}));process.exit(0);}",
915
+ " if(hasPending){console.log(JSON.stringify({mergedCount:0,heldCount:0,skippedCount:1,skipped:[{repo,number:n,reason:'ci_pending'}]}));process.exit(0);}",
916
+ " const mergeArgs=['pr','merge',n,'--repo',repo,'--delete-branch'];",
917
+ " if(method==='rebase') mergeArgs.push('--rebase');",
918
+ " else if(method==='merge') mergeArgs.push('--merge');",
919
+ " else mergeArgs.push('--squash');",
920
+ " try{gh(mergeArgs);}catch(directErr){mergeArgs.push('--auto');gh(mergeArgs);}",
921
+ " console.log(JSON.stringify({mergedCount:1,heldCount:0,skippedCount:0,merged:[{repo,number:n,title:String(view?.title||'')}] }));",
922
+ "}catch(e){",
923
+ " console.log(JSON.stringify({mergedCount:0,heldCount:1,skippedCount:0,held:[{repo,number:n,reason:'merge_attempt_failed',error:String(e?.message||e)}]}));",
924
+ "}",
925
+ "\"",
926
+ ].join(" "),
927
+ continueOnError: true,
928
+ failOnError: false,
929
+ env: {
930
+ BOSUN_PR_INSPECT:
931
+ "{{$ctx.getNodeOutput('inspect-pr')?.output || '{}'}}",
932
+ },
933
+ }, { x: 620, y: 690 }),
934
+
935
+ node("log-deferred", "notify.log", "Deferred", {
936
+ message:
937
+ "Bosun PR Progressor deferred PR #{{prProgressContext.prNumber}}: {{$ctx.getNodeOutput('inspect-pr')?.output || '{}'}}",
938
+ level: "info",
939
+ }, { x: 620, y: 820 }),
940
+
941
+ node("log-missing", "notify.log", "Missing PR Context", {
942
+ message: "Bosun PR Progressor skipped: missing PR context for task {{taskId}}",
943
+ level: "warn",
944
+ }, { x: 400, y: 560 }),
945
+
946
+ node("notify-complete", "notify.log", "Log Outcome", {
947
+ message:
948
+ "Bosun PR Progressor finished for task {{taskId}} / PR {{prProgressContext.prNumber}}",
949
+ level: "info",
950
+ }, { x: 400, y: 1090 }),
951
+ ],
952
+ edges: [
953
+ edge("trigger", "normalize-context"),
954
+ edge("normalize-context", "has-pr-target"),
955
+ edge("has-pr-target", "inspect-pr", { condition: "$output?.result === true" }),
956
+ edge("has-pr-target", "log-missing", { condition: "$output?.result !== true" }),
957
+ edge("inspect-pr", "fix-needed"),
958
+ edge("fix-needed", "programmatic-fix", { condition: "$output?.result === true" }),
959
+ edge("fix-needed", "review-needed", { condition: "$output?.result !== true" }),
960
+ edge("programmatic-fix", "fix-agent-needed"),
961
+ edge("fix-agent-needed", "dispatch-fix-agent", { condition: "$output?.result === true" }),
962
+ edge("fix-agent-needed", "notify-complete", { condition: "$output?.result !== true" }),
963
+ edge("dispatch-fix-agent", "notify-complete"),
964
+ edge("review-needed", "programmatic-review", { condition: "$output?.result === true" }),
965
+ edge("review-needed", "log-deferred", { condition: "$output?.result !== true" }),
966
+ edge("programmatic-review", "notify-complete"),
967
+ edge("log-deferred", "notify-complete"),
968
+ edge("log-missing", "notify-complete"),
969
+ ],
970
+ metadata: {
971
+ author: "bosun",
972
+ version: 1,
973
+ createdAt: "2026-03-13T00:00:00Z",
974
+ templateVersion: "1.0.0",
975
+ tags: ["github", "pr", "handoff", "progression", "event-driven"],
976
+ },
977
+ };
978
+
682
979
  // ═══════════════════════════════════════════════════════════════════════════
683
980
  // Bosun PR Watchdog
684
981
  // ═══════════════════════════════════════════════════════════════════════════
@@ -697,9 +994,11 @@ resetLayout();
697
994
  * 2. Merge any PR whose CI checks are all passing (not draft, not pending).
698
995
  * 3. Label any PR whose CI checks have failures with `bosun-needs-fix` and
699
996
  * dispatch a repair agent to fix the branch.
997
+ * 4. Route CodeQL/code-scanning failures through a dedicated security repair
998
+ * branch so security findings are fixed instead of treated as generic CI.
700
999
  *
701
1000
  * Disable: set `enabled: false` in your bosun config, or delete the workflow.
702
- * Interval: default 5 min — change `intervalMs` / `cron` variables.
1001
+ * Interval: default 90s — change `intervalMs`.
703
1002
  */
704
1003
  export const BOSUN_PR_WATCHDOG_TEMPLATE = {
705
1004
  id: "template-bosun-pr-watchdog",
@@ -723,16 +1022,15 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
723
1022
  // all/current/<owner/repo>/comma,list also supported.
724
1023
  repoScope: "auto",
725
1024
  maxPrs: 25,
726
- intervalMs: 300_000, // 5 minutes
1025
+ intervalMs: 90_000, // 90 seconds
727
1026
  // Merge-safety thresholds checked by the review agent:
728
1027
  // If net deletions > additions × ratio AND deletions > minDestructiveDeletions → HOLD
729
1028
  suspiciousDeletionRatio: 3, // e.g. deletes 3× more lines than it adds
730
1029
  minDestructiveDeletions: 500, // absolute floor — small PRs are fine even if net negative
731
1030
  },
732
1031
  nodes: [
733
- node("trigger", "trigger.schedule", "Poll Every 5 min", {
1032
+ node("trigger", "trigger.schedule", "Poll Every 90s", {
734
1033
  intervalMs: "{{intervalMs}}",
735
- cron: "*/5 * * * *",
736
1034
  }, { x: 400, y: 50 }),
737
1035
 
738
1036
  // ─────────────────────────────────────────────────────────────────────────
@@ -743,8 +1041,8 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
743
1041
  // Fetches all open bosun-attached PRs with every field needed for
744
1042
  // classification. Runs one list call per target repo (auto-discovered
745
1043
  // from bosun.config.json workspaces by default), then:
746
- // • Classifies each PR into: ready | conflict | ci_failure | pending | draft
747
- // • Labels conflict/ci_failure PRs with bosun-needs-fix (skips if already present)
1044
+ // • Classifies each PR into: ready | conflict | security_failure | ci_failure | pending | draft
1045
+ // • Labels conflict/security_failure/ci_failure PRs with bosun-needs-fix (skips if already present)
748
1046
  // • Outputs a JSON summary used by all downstream nodes/agents
749
1047
  // Total gh API calls this node makes: R list calls + N edits
750
1048
  // (R = target repos, N = newly-broken PRs needing fix label).
@@ -760,6 +1058,10 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
760
1058
  "const FAIL_STATES=new Set(['FAILURE','ERROR','TIMED_OUT','CANCELLED','STARTUP_FAILURE']);",
761
1059
  "const PEND_STATES=new Set(['PENDING','IN_PROGRESS','QUEUED','WAITING','REQUESTED','EXPECTED']);",
762
1060
  "const CONFLICT_MERGEABLES=new Set(['CONFLICTING','BEHIND','DIRTY']);",
1061
+ "const SECURITY_CHECK_RE=/(^|[^a-z])(codeql|code scanning|security|sarif|codacy)([^a-z]|$)/i;",
1062
+ "function readCheckName(check){return String(check?.name||check?.context||check?.workflowName||check?.displayTitle||'').trim();}",
1063
+ "function isFailedCheck(check){return FAIL_STATES.has(check?.conclusion||check?.state||'');}",
1064
+ "function isSecurityCheckName(name){return SECURITY_CHECK_RE.test(String(name||''));}",
763
1065
  "function ghJson(args){const out=execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();return out?JSON.parse(out):[];}",
764
1066
  "function configPath(){",
765
1067
  " const home=String(process.env.BOSUN_HOME||process.env.VK_PROJECT_DIR||'').trim();",
@@ -829,13 +1131,17 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
829
1131
  " repoErrors.push({repo:repo||'current',error:String(e?.message||e)});",
830
1132
  " }",
831
1133
  "}",
832
- "const readyCandidates=[],conflicts=[],ciFailures=[],pending=[],drafted=[];",
1134
+ "const readyCandidates=[],conflicts=[],securityFailures=[],ciFailures=[],pending=[],drafted=[];",
833
1135
  "let newlyLabeled=0,staleLabelCleared=0,ciKicked=0;",
834
1136
  "for(const pr of prs){",
835
1137
  " const labels=(pr.labels||[]).map(l=>typeof l==='string'?l:l?.name).filter(Boolean);",
836
1138
  " const hasFixLabel=labels.includes(LABEL_FIX);",
837
1139
  " const checks=pr.statusCheckRollup||[];",
838
- " const hasFail=checks.some(c=>FAIL_STATES.has(c.conclusion||c.state||''));",
1140
+ " const failedChecks=checks.filter(isFailedCheck);",
1141
+ " const failedCheckNames=failedChecks.map(readCheckName).filter(Boolean);",
1142
+ " const securityCheckNames=failedCheckNames.filter(isSecurityCheckName);",
1143
+ " const hasFail=failedChecks.length>0;",
1144
+ " const hasSecurityFail=securityCheckNames.length>0;",
839
1145
  " const hasPend=checks.some(c=>PEND_STATES.has(c.conclusion||c.state||''));",
840
1146
  " const isConflict=CONFLICT_MERGEABLES.has(String(pr.mergeable||'').toUpperCase());",
841
1147
  " const isDraft=pr.isDraft===true;",
@@ -847,8 +1153,14 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
847
1153
  " try{const editArgs=['pr','edit',String(pr.number),'--add-label',LABEL_FIX];if(repo)editArgs.push('--repo',repo);execFileSync('gh',editArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});newlyLabeled++;}",
848
1154
  " catch(e){process.stderr.write('label err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');}",
849
1155
  " }",
1156
+ " } else if(hasSecurityFail){",
1157
+ " securityFailures.push({n:pr.number,repo,branch:pr.headRefName,base:pr.baseRefName,url:pr.url,title:pr.title,failedCheckNames,securityCheckNames});",
1158
+ " if(!hasFixLabel){",
1159
+ " try{const editArgs=['pr','edit',String(pr.number),'--add-label',LABEL_FIX];if(repo)editArgs.push('--repo',repo);execFileSync('gh',editArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});newlyLabeled++;}",
1160
+ " catch(e){process.stderr.write('label err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\n');}",
1161
+ " }",
850
1162
  " } else if(hasFail){",
851
- " ciFailures.push({n:pr.number,repo,branch:pr.headRefName,url:pr.url});",
1163
+ " ciFailures.push({n:pr.number,repo,branch:pr.headRefName,url:pr.url,failedCheckNames});",
852
1164
  " if(!hasFixLabel){",
853
1165
  " try{const editArgs=['pr','edit',String(pr.number),'--add-label',LABEL_FIX];if(repo)editArgs.push('--repo',repo);execFileSync('gh',editArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});newlyLabeled++;}",
854
1166
  " catch(e){process.stderr.write('label err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');}",
@@ -877,13 +1189,14 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
877
1189
  " repoErrors,",
878
1190
  " readyCandidates,",
879
1191
  " conflicts,",
1192
+ " securityFailures,",
880
1193
  " ciFailures,",
881
1194
  " pending:pending.length,",
882
1195
  " drafted:drafted.length,",
883
1196
  " newlyLabeled,",
884
1197
  " staleLabelCleared,",
885
1198
  " ciKicked,",
886
- " fixNeeded:conflicts.length+ciFailures.length",
1199
+ " fixNeeded:conflicts.length+securityFailures.length+ciFailures.length",
887
1200
  "}));",
888
1201
  "\"",
889
1202
  ].join(" "),
@@ -902,8 +1215,8 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
902
1215
  }, { x: 400, y: 370 }),
903
1216
 
904
1217
  // ─────────────────────────────────────────────────────────────────────────
905
- // STEP 2a: Fix path — dispatch ONE agent for all conflicts + CI failures.
906
- // The agent handles rebase/conflict-resolution AND CI lint/test fixes.
1218
+ // STEP 2a: Fix path — route security failures separately, then dispatch
1219
+ // the generic agent path for conflicts + non-security CI failures.
907
1220
  // ─────────────────────────────────────────────────────────────────────────
908
1221
  node("fix-needed", "condition.expression", "Fix Needed?", {
909
1222
  expression:
@@ -913,6 +1226,105 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
913
1226
  "}catch(e){return false;}})()",
914
1227
  }, { x: 200, y: 530 }),
915
1228
 
1229
+ node("security-fix-needed", "condition.expression", "Security Fix Needed?", {
1230
+ expression:
1231
+ "(()=>{try{" +
1232
+ "const o=$ctx.getNodeOutput('fetch-and-classify')?.output;" +
1233
+ "return (JSON.parse(o||'{}').securityFailures||[]).length>0;" +
1234
+ "}catch(e){return false;}})()",
1235
+ }, { x: 120, y: 640 }),
1236
+
1237
+ node("programmatic-security-fix", "action.run_command", "Collect Security Alerts", {
1238
+ command: [
1239
+ "node -e \"",
1240
+ "const {execFileSync}=require('child_process');",
1241
+ "const raw=String(process.env.BOSUN_FETCH_AND_CLASSIFY||'');",
1242
+ "const payload=(()=>{try{return JSON.parse(raw||'{}')}catch{return {}}})();",
1243
+ "const securityFailures=Array.isArray(payload.securityFailures)?payload.securityFailures:[];",
1244
+ "const needsAgent=[];",
1245
+ "let alertsFetched=0;",
1246
+ "function gh(args){return execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();}",
1247
+ "function compactAlert(alert){",
1248
+ " const instance=alert?.most_recent_instance||{};",
1249
+ " const location=instance?.location||{};",
1250
+ " const rule=alert?.rule||{};",
1251
+ " const tool=alert?.tool||{};",
1252
+ " return {",
1253
+ " number: alert?.number ?? null,",
1254
+ " state: String(alert?.state||''),",
1255
+ " ruleId: String(rule?.id||alert?.rule_id||''),",
1256
+ " ruleName: String(rule?.name||alert?.rule_name||''),",
1257
+ " severity: String(rule?.severity||alert?.severity||''),",
1258
+ " securitySeverity: String(rule?.security_severity_level||alert?.security_severity_level||''),",
1259
+ " tool: String(tool?.name||alert?.tool_name||''),",
1260
+ " path: String(location?.path||''),",
1261
+ " startLine: Number(location?.start_line||0)||null,",
1262
+ " url: String(alert?.html_url||''),",
1263
+ " };",
1264
+ "}",
1265
+ "for(const item of securityFailures){",
1266
+ " const repo=String(item?.repo||'').trim();",
1267
+ " const branch=String(item?.branch||'').trim();",
1268
+ " const n=String(item?.n||'').trim();",
1269
+ " const securityCheckNames=Array.isArray(item?.securityCheckNames)?item.securityCheckNames:[];",
1270
+ " if(!repo||!branch){needsAgent.push({repo,number:n,branch,reason:'missing_repo_or_branch',securityCheckNames,alerts:[]});continue;}",
1271
+ " let alerts=[];",
1272
+ " let fetchError='';",
1273
+ " try{",
1274
+ " const alertsRaw=gh(['api','--method','GET','repos/'+repo+'/code-scanning/alerts','--raw-field','state=open','--raw-field','per_page=20','--raw-field','ref=refs/heads/'+branch]);",
1275
+ " const parsed=(()=>{try{return JSON.parse(alertsRaw||'[]')}catch{return []}})();",
1276
+ " alerts=(Array.isArray(parsed)?parsed:[]).map(compactAlert).filter(a=>a.ruleId||a.ruleName||a.path).slice(0,10);",
1277
+ " if(alerts.length>0) alertsFetched++;",
1278
+ " }catch(e){fetchError=String(e?.message||e);}",
1279
+ " needsAgent.push({repo,number:n,branch,base:String(item?.base||'').trim(),url:String(item?.url||''),title:String(item?.title||''),reason:'security_code_scanning_failure',securityCheckNames,failedCheckNames:Array.isArray(item?.failedCheckNames)?item.failedCheckNames:[],alerts,fetchError});",
1280
+ "}",
1281
+ "console.log(JSON.stringify({securityFailureCount:securityFailures.length,alertsFetched,needsAgentCount:needsAgent.length,needsAgent}));",
1282
+ "\"",
1283
+ ].join(" "),
1284
+ continueOnError: true,
1285
+ failOnError: false,
1286
+ env: {
1287
+ BOSUN_FETCH_AND_CLASSIFY:
1288
+ "{{$ctx.getNodeOutput('fetch-and-classify')?.output || '{}'}}",
1289
+ },
1290
+ }, { x: 120, y: 750 }),
1291
+
1292
+ node("security-agent-needed", "condition.expression", "Needs Security Agent?", {
1293
+ expression:
1294
+ "(()=>{try{" +
1295
+ "const o=$ctx.getNodeOutput('programmatic-security-fix')?.output;" +
1296
+ "return (JSON.parse(o||'{}').needsAgentCount||0)>0;" +
1297
+ "}catch(e){return false;}})()",
1298
+ }, { x: 120, y: 860 }),
1299
+
1300
+ node("dispatch-security-fix-agent", "action.run_agent", "Dispatch Security Fix Agent", {
1301
+ prompt:
1302
+ "You are a Bosun PR security remediation agent. Work only the PRs in this JSON:\n\n" +
1303
+ "{{$ctx.getNodeOutput('programmatic-security-fix')?.output}}\n\n" +
1304
+ "Each item represents a bosun-attached PR blocked by CodeQL or GitHub code scanning.\n" +
1305
+ "Use the supplied alert data and failing security check names to make the smallest safe code change that resolves the finding.\n" +
1306
+ "For each repaired PR: check out the branch, fix only the reported code-scanning issue, run targeted validation, push the branch, and remove bosun-needs-fix after success.\n\n" +
1307
+ "STRICT RULES:\n" +
1308
+ "- Only fix the listed code-scanning or CodeQL findings.\n" +
1309
+ "- No unrelated refactors, dependency churn, merges, approvals, or PR closure.\n" +
1310
+ "- If alert fetch failed, inspect the PR checks and relevant source to resolve the security failure directly.\n" +
1311
+ "- Do NOT touch PRs that are not bosun-attached.",
1312
+ sdk: "auto",
1313
+ timeoutMs: 1_800_000,
1314
+ maxRetries: 2,
1315
+ retryDelayMs: 30_000,
1316
+ continueOnError: true,
1317
+ }, { x: 120, y: 970 }),
1318
+
1319
+ node("generic-fix-needed", "condition.expression", "Generic Fix Needed?", {
1320
+ expression:
1321
+ "(()=>{try{" +
1322
+ "const o=$ctx.getNodeOutput('fetch-and-classify')?.output;" +
1323
+ "const d=JSON.parse(o||'{}');" +
1324
+ "return ((d.conflicts||[]).length+(d.ciFailures||[]).length)>0;" +
1325
+ "}catch(e){return false;}})()",
1326
+ }, { x: 280, y: 640 }),
1327
+
916
1328
  node("programmatic-fix", "action.run_command", "Programmatic Fix Pass", {
917
1329
  command: [
918
1330
  "node -e \"",
@@ -923,22 +1335,35 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
923
1335
  "const conflicts=Array.isArray(payload.conflicts)?payload.conflicts:[];",
924
1336
  "const needsAgent=[];",
925
1337
  "let rerunRequested=0;",
1338
+ "const FAIL_STATES=new Set(['FAILURE','ERROR','TIMED_OUT','CANCELLED','STARTUP_FAILURE']);",
1339
+ "const MAX_AUTO_RERUN_ATTEMPT=1;",
926
1340
  "function runGh(args){return execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();}",
1341
+ "function normalizeRun(run){if(!run||typeof run!=='object')return null;return {databaseId:Number(run.databaseId||0)||null,attempt:Number(run.attempt||0)||0,conclusion:String(run.conclusion||''),status:String(run.status||''),workflowName:String(run.workflowName||run.name||''),displayTitle:String(run.displayTitle||run.name||''),url:String(run.url||''),createdAt:String(run.createdAt||''),updatedAt:String(run.updatedAt||'')}}",
1342
+ "function normalizeJob(job){if(!job||typeof job!=='object')return null;const steps=Array.isArray(job.steps)?job.steps:[];return {databaseId:Number(job.databaseId||0)||null,name:String(job.name||''),status:String(job.status||''),conclusion:String(job.conclusion||''),url:String(job.url||''),failedSteps:steps.filter((step)=>FAIL_STATES.has(String(step?.conclusion||step?.status||'').toUpperCase())).map((step)=>({name:String(step?.name||''),number:Number(step?.number||0)||null,status:String(step?.status||''),conclusion:String(step?.conclusion||'')})).filter((step)=>step.name).slice(0,10)}}",
1343
+ "function truncateText(value,max){const text=String(value||'').replace(/\\r/g,'').trim();if(!text)return '';return text.length>max?text.slice(0,Math.max(0,max-19))+'\\n...[truncated]':text;}",
1344
+ "function collectCiDiagnostics(repo,run){const info={failedRun:normalizeRun(run),failedJobs:[],failedLogExcerpt:'',diagnosticsError:''};const runId=Number(run?.databaseId||0)||0;if(!runId)return info;try{const viewRaw=runGh(['run','view',String(runId),'--repo',repo,'--json','attempt,conclusion,status,workflowName,displayTitle,url,createdAt,updatedAt,jobs']);const view=(()=>{try{return JSON.parse(viewRaw||'{}')}catch{return {}}})();info.failedRun=normalizeRun({...run,...view});const jobs=Array.isArray(view.jobs)?view.jobs:[];info.failedJobs=jobs.map(normalizeJob).filter((job)=>job&&(FAIL_STATES.has(String(job.conclusion||'').toUpperCase())||job.failedSteps.length>0)).slice(0,10);}catch(e){info.diagnosticsError=String(e?.message||e);}try{info.failedLogExcerpt=truncateText(runGh(['run','view',String(runId),'--repo',repo,'--log-failed']),6000);}catch(e){const message=String(e?.message||e);if(message&&message!==info.diagnosticsError){info.diagnosticsError=info.diagnosticsError?info.diagnosticsError+' | '+message:message;}}return info;}",
927
1345
  "for(const item of ciFailures){",
928
1346
  " const repo=String(item?.repo||'').trim();",
929
1347
  " const branch=String(item?.branch||'').trim();",
930
1348
  " const n=String(item?.n||'').trim();",
931
- " if(!repo||!branch){needsAgent.push({repo,number:n,reason:'missing_repo_or_branch'});continue;}",
1349
+ " const failedCheckNames=Array.isArray(item?.failedCheckNames)?item.failedCheckNames:[];",
1350
+ " const url=String(item?.url||'').trim();",
1351
+ " const title=String(item?.title||'').trim();",
1352
+ " if(!repo||!branch){needsAgent.push({repo,number:n,branch,url,title,failedCheckNames,reason:'missing_repo_or_branch'});continue;}",
1353
+ " let runs=[];",
932
1354
  " try{",
933
- " const listRaw=runGh(['run','list','--repo',repo,'--branch',branch,'--json','databaseId,conclusion,status','--limit','8']);",
934
- " const runs=(()=>{try{return JSON.parse(listRaw||'[]')}catch{return []}})();",
935
- " const failed=(Array.isArray(runs)?runs:[]).find((r)=>{",
936
- " const c=String(r?.conclusion||'').toUpperCase();",
937
- " return c==='FAILURE'||c==='ERROR'||c==='TIMED_OUT'||c==='CANCELLED';",
938
- " });",
939
- " if(failed?.databaseId){runGh(['run','rerun',String(failed.databaseId),'--repo',repo]);rerunRequested++;}",
940
- " else{needsAgent.push({repo,number:n,reason:'no_rerunnable_failed_run_found'});}",
941
- " }catch(e){needsAgent.push({repo,number:n,reason:'ci_rerun_failed',error:String(e?.message||e)});}",
1355
+ " const listRaw=runGh(['run','list','--repo',repo,'--branch',branch,'--json','databaseId,attempt,conclusion,status,workflowName,displayTitle,url,createdAt,updatedAt','--limit','8']);",
1356
+ " const parsedRuns=(()=>{try{return JSON.parse(listRaw||'[]')}catch{return []}})();",
1357
+ " runs=Array.isArray(parsedRuns)?parsedRuns:[];",
1358
+ " }catch(e){needsAgent.push({repo,number:n,branch,url,title,failedCheckNames,reason:'ci_run_listing_failed',error:String(e?.message||e)});continue;}",
1359
+ " const failed=runs.find((r)=>FAIL_STATES.has(String(r?.conclusion||'').toUpperCase()));",
1360
+ " const failedRun=normalizeRun(failed);",
1361
+ " if(failedRun?.databaseId&&failedRun.attempt<=MAX_AUTO_RERUN_ATTEMPT){",
1362
+ " try{runGh(['run','rerun',String(failedRun.databaseId),'--repo',repo]);rerunRequested++;continue;}",
1363
+ " catch(e){needsAgent.push({repo,number:n,branch,url,title,failedCheckNames,reason:'ci_rerun_failed',error:String(e?.message||e),...collectCiDiagnostics(repo,failedRun)});continue;}",
1364
+ " }",
1365
+ " if(failedRun?.databaseId){needsAgent.push({repo,number:n,branch,url,title,failedCheckNames,reason:'auto_rerun_limit_reached',rerunAttempts:failedRun.attempt||0,...collectCiDiagnostics(repo,failedRun)});continue;}",
1366
+ " needsAgent.push({repo,number:n,branch,url,title,failedCheckNames,reason:'no_rerunnable_failed_run_found',recentRuns:runs.map(normalizeRun).filter(Boolean).slice(0,5)});",
942
1367
  "}",
943
1368
  "for(const item of conflicts){",
944
1369
  " needsAgent.push({repo:String(item?.repo||'').trim(),number:String(item?.n||'').trim(),branch:String(item?.branch||'').trim(),base:String(item?.base||'').trim(),reason:'merge_conflict_requires_code_resolution'});",
@@ -950,9 +1375,9 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
950
1375
  failOnError: false,
951
1376
  env: {
952
1377
  BOSUN_FETCH_AND_CLASSIFY:
953
- "(()=>{const r=$ctx.getNodeOutput('fetch-and-classify');return r&&r.success!==false?String(r.output||'{}'):'{}';})()",
1378
+ "{{$ctx.getNodeOutput('fetch-and-classify')?.output || '{}'}}",
954
1379
  },
955
- }, { x: 200, y: 640 }),
1380
+ }, { x: 280, y: 750 }),
956
1381
 
957
1382
  node("fix-agent-needed", "condition.expression", "Needs Agent Fix?", {
958
1383
  expression:
@@ -960,7 +1385,7 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
960
1385
  "const o=$ctx.getNodeOutput('programmatic-fix')?.output;" +
961
1386
  "return (JSON.parse(o||'{}').needsAgentCount||0)>0;" +
962
1387
  "}catch(e){return false;}})()",
963
- }, { x: 200, y: 750 }),
1388
+ }, { x: 280, y: 860 }),
964
1389
 
965
1390
  node("dispatch-fix-agent", "action.run_agent", "Dispatch Fix Agent (Fallback)", {
966
1391
  prompt:
@@ -968,7 +1393,7 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
968
1393
  "Only work unresolved items from this JSON:\n\n" +
969
1394
  "{{$ctx.getNodeOutput('programmatic-fix')?.output}}\n\n" +
970
1395
  "For conflict items: rebase/merge branch onto base, resolve conflicts, run tests, push with --force-with-lease if needed.\n" +
971
- "For CI-failure items: inspect failed logs, apply minimal fix, commit and push.\n" +
1396
+ "For CI-failure items: start from failedCheckNames, failedRun, failedJobs, and failedLogExcerpt to identify the actual failing workflow step, then apply the minimal fix, commit, and push.\n" +
972
1397
  "After successful repair remove bosun-needs-fix label.\n\n" +
973
1398
  "STRICT RULES:\n" +
974
1399
  "- Fix only CI/conflict issues. No scope creep.\n" +
@@ -979,7 +1404,7 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
979
1404
  maxRetries: 2,
980
1405
  retryDelayMs: 30_000,
981
1406
  continueOnError: true,
982
- }, { x: 200, y: 860 }),
1407
+ }, { x: 280, y: 970 }),
983
1408
 
984
1409
  // ─────────────────────────────────────────────────────────────────────────
985
1410
  // STEP 2b: Review gate — MANDATORY before any merge.
@@ -1026,20 +1451,27 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
1026
1451
  " held.push({repo,number:n,reason:destructive?'destructive_diff':'changed_files_too_large',additions:add,deletions:del,changedFiles:changed});",
1027
1452
  " continue;",
1028
1453
  " }",
1029
- " const checksRaw=gh(['pr','checks',n,'--repo',repo,'--json','name,state,conclusion']);",
1454
+ " const checksRaw=gh(['pr','checks',n,'--repo',repo,'--json','name,state,bucket']);",
1030
1455
  " const checks=(()=>{try{return JSON.parse(checksRaw||'[]')}catch{return []}})();",
1031
1456
  " const hasFailure=(Array.isArray(checks)?checks:[]).some((x)=>{",
1032
- " const c=String(x?.conclusion||'').toUpperCase();",
1033
1457
  " const s=String(x?.state||'').toUpperCase();",
1034
- " return ['FAILURE','ERROR','TIMED_OUT','CANCELLED'].includes(c) || ['STARTUP_FAILURE'].includes(s);",
1458
+ " const b=String(x?.bucket||'').toUpperCase();",
1459
+ " return ['FAILURE','ERROR','TIMED_OUT','CANCELLED','STARTUP_FAILURE'].includes(s) || b==='FAIL';",
1460
+ " });",
1461
+ " const hasPending=(Array.isArray(checks)?checks:[]).some((x)=>{",
1462
+ " const s=String(x?.state||'').toUpperCase();",
1463
+ " return ['QUEUED','IN_PROGRESS','PENDING','WAITING','REQUESTED'].includes(s);",
1035
1464
  " });",
1036
1465
  " if(hasFailure){skipped.push({repo,number:n,reason:'ci_failed'});continue;}",
1466
+ " if(hasPending){skipped.push({repo,number:n,reason:'ci_pending'});continue;}",
1037
1467
  " const mergeArgs=['pr','merge',n,'--repo',repo,'--delete-branch'];",
1038
- " mergeArgs.push('--auto');",
1039
1468
  " if(method==='rebase') mergeArgs.push('--rebase');",
1040
1469
  " else if(method==='merge') mergeArgs.push('--merge');",
1041
1470
  " else mergeArgs.push('--squash');",
1042
- " gh(mergeArgs);",
1471
+ " try{gh(mergeArgs);}catch(directErr){",
1472
+ " mergeArgs.push('--auto');",
1473
+ " gh(mergeArgs);",
1474
+ " }",
1043
1475
  " merged.push({repo,number:n,title:String(view?.title||'')});",
1044
1476
  " }catch(e){",
1045
1477
  " held.push({repo,number:n,reason:'merge_attempt_failed',error:String(e?.message||e)});",
@@ -1052,7 +1484,7 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
1052
1484
  failOnError: false,
1053
1485
  env: {
1054
1486
  BOSUN_FETCH_AND_CLASSIFY:
1055
- "(()=>{const r=$ctx.getNodeOutput('fetch-and-classify');return r&&r.success!==false?String(r.output||'{}'):'{}';})()",
1487
+ "{{$ctx.getNodeOutput('fetch-and-classify')?.output || '{}'}}",
1056
1488
  },
1057
1489
  }, { x: 600, y: 700 }),
1058
1490
 
@@ -1067,15 +1499,55 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
1067
1499
  message: "Bosun PR Watchdog: no open bosun-attached PRs found — idle",
1068
1500
  level: "info",
1069
1501
  }, { x: 700, y: 370 }),
1502
+
1503
+ // ── Sweep: delete remote branches for already-merged PRs ────────────
1504
+ // Squash merges leave orphan branches because --auto defers deletion.
1505
+ // This node runs after the merge gate and prunes any lingering heads.
1506
+ node("cleanup-merged-branches", "action.run_command", "Prune Merged Branches", {
1507
+ command: [
1508
+ "node -e \"",
1509
+ "const {execFileSync}=require('child_process');",
1510
+ "function gh(a){return execFileSync('gh',a,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();}",
1511
+ "const repos=String(process.env.BOSUN_REPO_LIST||'').split(',').map(s=>s.trim()).filter(Boolean);",
1512
+ "let deleted=0;",
1513
+ "for(const repo of repos){",
1514
+ " try{",
1515
+ " const raw=gh(['pr','list','--repo',repo,'--state','merged','--label','bosun-attached','--json','number,headRefName','--limit','50']);",
1516
+ " const prs=(()=>{try{return JSON.parse(raw||'[]')}catch{return []}})();",
1517
+ " for(const pr of prs){",
1518
+ " const branch=String(pr?.headRefName||'').trim();",
1519
+ " if(!branch||branch==='main'||branch==='master')continue;",
1520
+ " try{gh(['api','repos/'+repo+'/git/refs/heads/'+branch,'--method','DELETE','--silent']);deleted++;}catch(e){}",
1521
+ " }",
1522
+ " }catch(e){}",
1523
+ "}",
1524
+ "console.log(JSON.stringify({deletedBranches:deleted}));",
1525
+ "\"",
1526
+ ].join(" "),
1527
+ continueOnError: true,
1528
+ failOnError: false,
1529
+ env: {
1530
+ BOSUN_REPO_LIST:
1531
+ "{{$ctx.getNodeOutput('fetch-and-classify')?.output ? (()=>{try{const o=JSON.parse($ctx.getNodeOutput('fetch-and-classify').output);return [...new Set([...(o.fixCandidates||[]),...(o.readyCandidates||[])].map(c=>c.repo).filter(Boolean))].join(',')}catch{return ''}})() : ''}}",
1532
+ },
1533
+ }, { x: 400, y: 1020 }),
1070
1534
  ],
1071
1535
  edges: [
1072
1536
  edge("trigger", "fetch-and-classify"),
1073
1537
  edge("fetch-and-classify","has-prs"),
1074
1538
  edge("has-prs", "fix-needed", { condition: "$output?.result === true" }),
1075
1539
  edge("has-prs", "no-prs", { condition: "$output?.result !== true" }),
1076
- // Fix path (conflicts + CI failures)
1077
- edge("fix-needed", "programmatic-fix",{ condition: "$output?.result === true" }),
1078
- edge("fix-needed", "review-needed", { condition: "$output?.result !== true" }),
1540
+ // Fix path (security failures, then conflicts + non-security CI failures)
1541
+ edge("fix-needed", "security-fix-needed", { condition: "$output?.result === true" }),
1542
+ edge("fix-needed", "review-needed", { condition: "$output?.result !== true" }),
1543
+ edge("security-fix-needed","programmatic-security-fix", { condition: "$output?.result === true" }),
1544
+ edge("security-fix-needed","generic-fix-needed", { condition: "$output?.result !== true" }),
1545
+ edge("programmatic-security-fix", "security-agent-needed"),
1546
+ edge("security-agent-needed", "dispatch-security-fix-agent", { condition: "$output?.result === true" }),
1547
+ edge("security-agent-needed", "generic-fix-needed", { condition: "$output?.result !== true" }),
1548
+ edge("dispatch-security-fix-agent", "generic-fix-needed"),
1549
+ edge("generic-fix-needed", "programmatic-fix", { condition: "$output?.result === true" }),
1550
+ edge("generic-fix-needed", "review-needed", { condition: "$output?.result !== true" }),
1079
1551
  edge("programmatic-fix", "fix-agent-needed"),
1080
1552
  edge("fix-agent-needed", "dispatch-fix-agent", { condition: "$output?.result === true" }),
1081
1553
  edge("fix-agent-needed", "review-needed", { condition: "$output?.result !== true" }),
@@ -1084,6 +1556,8 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
1084
1556
  edge("review-needed", "programmatic-review", { condition: "$output?.result === true" }),
1085
1557
  edge("review-needed", "notify", { condition: "$output?.result !== true" }),
1086
1558
  edge("programmatic-review","notify"),
1559
+ // Post-merge cleanup
1560
+ edge("notify", "cleanup-merged-branches"),
1087
1561
  ],
1088
1562
  metadata: {
1089
1563
  author: "bosun",
@@ -1188,8 +1662,9 @@ export const GITHUB_KANBAN_SYNC_TEMPLATE = {
1188
1662
  " process.exit(0);",
1189
1663
  "}",
1190
1664
  "function runTask(args){return execFileSync('node',[taskCli,...args],{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();}",
1191
- "function getTaskStatus(id){",
1192
- " try{const raw=runTask(['get',id,'--json']);return JSON.parse(raw||'{}')?.status||null;}catch{return null;}",
1665
+ "function parseJsonObject(raw){const txt=String(raw||'').trim();if(!txt)return null;try{return JSON.parse(txt);}catch{}const lines=txt.split(/\\r?\\n/).map(s=>s.trim()).filter(Boolean);for(let i=lines.length-1;i>=0;i--){const line=lines[i];if(!(line.startsWith('{')||line.startsWith('[')))continue;try{return JSON.parse(line);}catch{}}const start=txt.indexOf('{');const end=txt.lastIndexOf('}');if(start>=0&&end>start){try{return JSON.parse(txt.slice(start,end+1));}catch{}}return null;}",
1666
+ "function getTaskSnapshot(id){",
1667
+ " try{const raw=runTask(['get',id,'--json']);const task=parseJsonObject(raw);return {status:task?.status||null,reviewStatus:task?.reviewStatus||null};}catch{return {status:null,reviewStatus:null};}",
1193
1668
  "}",
1194
1669
  "for(const item of merged){",
1195
1670
  " const id=String(item?.taskId||'').trim();",
@@ -1199,7 +1674,7 @@ export const GITHUB_KANBAN_SYNC_TEMPLATE = {
1199
1674
  "for(const item of open){",
1200
1675
  " const id=String(item?.taskId||'').trim();",
1201
1676
  " if(!id) continue;",
1202
- " try{const current=getTaskStatus(id);if(current==='inreview'||current==='done'){updates.push({taskId:id,status:current,skipped:true});continue;}runTask(['update',id,'--status','inreview']);updates.push({taskId:id,status:'inreview'});}catch(e){unresolved.push({taskId:id,status:'inreview',error:String(e?.message||e)});}",
1677
+ " try{const snap=getTaskSnapshot(id);const current=snap?.status;const review=String(snap?.reviewStatus||'').toLowerCase();if(current==='inreview'||current==='done'){updates.push({taskId:id,status:current,skipped:true});continue;}if(current==='todo'||current==='inprogress'){const reason=(review==='changes_requested'||review==='change_requested'||review==='requested_changes')?'changes_requested_pending_fix':'local_progress_state';updates.push({taskId:id,status:current,skipped:true,reason});continue;}runTask(['update',id,'--status','inreview']);updates.push({taskId:id,status:'inreview'});}catch(e){unresolved.push({taskId:id,status:'inreview',error:String(e?.message||e)});}",
1203
1678
  "}",
1204
1679
  "console.log(JSON.stringify({updated:updates.length,updates,unresolved,needsAgent:unresolved.length>0}));",
1205
1680
  "\"",
@@ -1208,7 +1683,7 @@ export const GITHUB_KANBAN_SYNC_TEMPLATE = {
1208
1683
  failOnError: false,
1209
1684
  env: {
1210
1685
  BOSUN_FETCH_PR_STATE:
1211
- "(()=>{const r=$ctx.getNodeOutput('fetch-pr-state');return r&&r.success!==false?String(r.output||'{}'):'{}';})()",
1686
+ "{{$ctx.getNodeOutput('fetch-pr-state')?.output || '{}'}}",
1212
1687
  },
1213
1688
  }, { x: 400, y: 530 }),
1214
1689
 
@@ -1517,3 +1992,4 @@ export const SDK_CONFLICT_RESOLVER_TEMPLATE = {
1517
1992
  },
1518
1993
  };
1519
1994
 
1995
+