bosun 0.37.1 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -244,14 +244,26 @@ export const PR_TRIAGE_TEMPLATE = {
244
244
  command: "gh pr edit {{prNumber}} --add-label \"size/L\"",
245
245
  }, { x: 650, y: 480 }),
246
246
 
247
- node("detect-breaking", "action.run_agent", "Detect Breaking Changes", {
248
- prompt: "Analyze the diff for PR #{{prNumber}} and determine if there are any breaking changes. Respond with JSON: { \"breaking\": true/false, \"reason\": \"...\" }",
249
- timeoutMs: 300000,
247
+ node("detect-breaking", "condition.expression", "Detect Breaking Changes", {
248
+ expression:
249
+ "(() => {" +
250
+ " const raw=$ctx.getNodeOutput('get-stats')?.output||'{}';" +
251
+ " let stats={};" +
252
+ " try{stats=typeof raw==='string'?JSON.parse(raw):raw;}catch{return false;}" +
253
+ " const title=String(stats?.title||'').toLowerCase();" +
254
+ " const body=String(stats?.body||'').toLowerCase();" +
255
+ " const files=Array.isArray(stats?.files)?stats.files.map((f)=>String(f?.path||f?.filename||f||'').toLowerCase()):[];" +
256
+ " const text=title+'\\n'+body;" +
257
+ " const explicit=/\\bbreaking\\b|\\bbreaking change\\b|\\bmajor\\b|\\bbackward incompatible\\b/.test(text);" +
258
+ " const apiTouch=files.some((f)=>f.includes('api/')||f.includes('/proto/')||f.includes('openapi')||f.includes('schema'));" +
259
+ " const contractWords=/\\bremove\\b|\\brename\\b|\\bdeprecate\\b|\\bdrop\\b/.test(text);" +
260
+ " return explicit || (apiTouch && contractWords);" +
261
+ "})()",
250
262
  }, { x: 400, y: 630 }),
251
263
 
252
264
  node("is-breaking", "condition.expression", "Breaking?", {
253
265
  expression:
254
- "(() => { const raw = $ctx.getNodeOutput('detect-breaking')?.output || '{}'; try { const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; return parsed?.breaking === true; } catch { return /\"breaking\"\\s*:\\s*true/i.test(String(raw)); } })()",
266
+ "(() => { return $ctx.getNodeOutput('detect-breaking')?.result === true; })()",
255
267
  }, { x: 400, y: 780, outputs: ["yes", "no"] }),
256
268
 
257
269
  node("label-breaking", "action.run_command", "Label: Breaking", {
@@ -876,43 +888,73 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
876
888
  "}catch(e){return false;}})()",
877
889
  }, { x: 200, y: 530 }),
878
890
 
879
- node("dispatch-fix", "action.run_agent", "Dispatch Fix Agent", {
891
+ node("programmatic-fix", "action.run_command", "Programmatic Fix Pass", {
892
+ command: [
893
+ "node -e \"",
894
+ "const {execFileSync}=require('child_process');",
895
+ "const raw=String(process.env.BOSUN_FETCH_AND_CLASSIFY||'');",
896
+ "const payload=(()=>{try{return JSON.parse(raw||'{}')}catch{return {}}})();",
897
+ "const ciFailures=Array.isArray(payload.ciFailures)?payload.ciFailures:[];",
898
+ "const conflicts=Array.isArray(payload.conflicts)?payload.conflicts:[];",
899
+ "const needsAgent=[];",
900
+ "let rerunRequested=0;",
901
+ "function runGh(args){return execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();}",
902
+ "for(const item of ciFailures){",
903
+ " const repo=String(item?.repo||'').trim();",
904
+ " const branch=String(item?.branch||'').trim();",
905
+ " const n=String(item?.n||'').trim();",
906
+ " if(!repo||!branch){needsAgent.push({repo,number:n,reason:'missing_repo_or_branch'});continue;}",
907
+ " try{",
908
+ " const listRaw=runGh(['run','list','--repo',repo,'--branch',branch,'--json','databaseId,conclusion,status','--limit','8']);",
909
+ " const runs=(()=>{try{return JSON.parse(listRaw||'[]')}catch{return []}})();",
910
+ " const failed=(Array.isArray(runs)?runs:[]).find((r)=>{",
911
+ " const c=String(r?.conclusion||'').toUpperCase();",
912
+ " return c==='FAILURE'||c==='ERROR'||c==='TIMED_OUT'||c==='CANCELLED';",
913
+ " });",
914
+ " if(failed?.databaseId){runGh(['run','rerun',String(failed.databaseId),'--repo',repo]);rerunRequested++;}",
915
+ " else{needsAgent.push({repo,number:n,reason:'no_rerunnable_failed_run_found'});}",
916
+ " }catch(e){needsAgent.push({repo,number:n,reason:'ci_rerun_failed',error:String(e?.message||e)});}",
917
+ "}",
918
+ "for(const item of conflicts){",
919
+ " 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'});",
920
+ "}",
921
+ "console.log(JSON.stringify({rerunRequested,ciFailureCount:ciFailures.length,conflictCount:conflicts.length,needsAgentCount:needsAgent.length,needsAgent}));",
922
+ "\"",
923
+ ].join(" "),
924
+ continueOnError: true,
925
+ failOnError: false,
926
+ env: {
927
+ BOSUN_FETCH_AND_CLASSIFY:
928
+ "(()=>{const r=$ctx.getNodeOutput('fetch-and-classify');return r&&r.success!==false?String(r.output||'{}'):'{}';})()",
929
+ },
930
+ }, { x: 200, y: 640 }),
931
+
932
+ node("fix-agent-needed", "condition.expression", "Needs Agent Fix?", {
933
+ expression:
934
+ "(()=>{try{" +
935
+ "const o=$ctx.getNodeOutput('programmatic-fix')?.output;" +
936
+ "return (JSON.parse(o||'{}').needsAgentCount||0)>0;" +
937
+ "}catch(e){return false;}})()",
938
+ }, { x: 200, y: 750 }),
939
+
940
+ node("dispatch-fix-agent", "action.run_agent", "Dispatch Fix Agent (Fallback)", {
880
941
  prompt:
881
- "You are a Bosun PR repair agent. A watchdog workflow has identified " +
882
- "bosun-attached PRs that need fixing.\n\n" +
883
- "For EACH target repo, list PRs needing work. Always include --repo <owner/repo>.\n" +
884
- "Example:\n" +
885
- " gh pr list --repo <owner/repo> --label bosun-needs-fix --label bosun-attached --state open \\\n" +
886
- " --json number,title,headRefName,baseRefName,mergeable,statusCheckRollup,labels,url \\\n" +
887
- " --limit 10\n\n" +
888
- "For each PR returned, follow the appropriate path:\n\n" +
889
- "CONFLICT (mergeable is CONFLICTING, BEHIND, or DIRTY):\n" +
890
- "1. git fetch origin\n" +
891
- "2. git checkout <headRefName>\n" +
892
- "3. git rebase origin/<baseRefName> (or git merge origin/<baseRefName> if rebase is too complex)\n" +
893
- "4. Resolve merge conflicts, preserving the intent of both sides.\n" +
894
- "5. Run the repo's build + test suite to confirm nothing is broken.\n" +
895
- "6. git push --force-with-lease origin <headRefName>\n\n" +
896
- "CI FAILURE (statusCheckRollup has FAILURE/ERROR/TIMED_OUT entries):\n" +
897
- "1. git checkout <headRefName>\n" +
898
- "2. Inspect CI logs: gh run list --repo <owner/repo> --branch <headRefName> --limit 3\n" +
899
- " Then: gh run view <run-id> --repo <owner/repo> --log-failed\n" +
900
- "3. Fix the root cause (lint, type error, failing test, build error).\n" +
901
- "4. Commit: fix(<scope>): <description> (conventional commit format)\n" +
902
- "5. Push the branch — CI will re-trigger automatically.\n\n" +
903
- "AFTER fixing either type:\n" +
904
- "- Remove the bosun-needs-fix label: gh pr edit <number> --repo <owner/repo> --remove-label bosun-needs-fix\n\n" +
942
+ "You are a Bosun PR repair fallback agent. A deterministic CLI fix pass has already run. " +
943
+ "Only work unresolved items from this JSON:\n\n" +
944
+ "{{$ctx.getNodeOutput('programmatic-fix')?.output}}\n\n" +
945
+ "For conflict items: rebase/merge branch onto base, resolve conflicts, run tests, push with --force-with-lease if needed.\n" +
946
+ "For CI-failure items: inspect failed logs, apply minimal fix, commit and push.\n" +
947
+ "After successful repair remove bosun-needs-fix label.\n\n" +
905
948
  "STRICT RULES:\n" +
906
- "- Fix only what breaks CI or causes the conflict. No scope creep.\n" +
907
- "- Do NOT merge, close, or approve any PR.\n" +
908
- "- Do NOT touch PRs that do not have the bosun-attached label.\n" +
909
- "- If a conflict cannot be resolved cleanly, leave it labeled and add a comment explaining why.",
949
+ "- Fix only CI/conflict issues. No scope creep.\n" +
950
+ "- Do NOT merge/close/approve PRs.\n" +
951
+ "- Do NOT touch PRs without bosun-attached.",
910
952
  sdk: "auto",
911
953
  timeoutMs: 1_800_000,
912
954
  maxRetries: 2,
913
955
  retryDelayMs: 30_000,
914
956
  continueOnError: true,
915
- }, { x: 200, y: 700 }),
957
+ }, { x: 200, y: 860 }),
916
958
 
917
959
  // ─────────────────────────────────────────────────────────────────────────
918
960
  // STEP 2b: Review gate — MANDATORY before any merge.
@@ -927,50 +969,65 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
927
969
  "}catch(e){return false;}})()",
928
970
  }, { x: 600, y: 530 }),
929
971
 
930
- node("dispatch-review", "action.run_agent", "Review Gate: Inspect & Merge", {
931
- prompt:
932
- "You are the Bosun PR merge review agent — the LAST LINE OF DEFENCE before " +
933
- "any PR is merged. Your job is to inspect each merge candidate and decide " +
934
- "whether it is safe to merge.\n\n" +
935
- "MERGE CANDIDATES (CI passing, no conflicts, bosun-attached):\n" +
936
- " Run per target repo: gh pr list --repo <owner/repo> --label bosun-attached --state open \\\n" +
937
- " --json number,title,headRefName,isDraft,statusCheckRollup,labels,url \\\n" +
938
- " --limit {{maxPrs}}\n" +
939
- " Filter to PRs where: not isDraft, CI all-passing, no bosun-needs-fix label.\n\n" +
940
- "FOR EACH CANDIDATE — before merging, run:\n" +
941
- " gh pr view <number> --repo <owner/repo> --json number,title,additions,deletions,changedFiles,body,baseRefName\n\n" +
942
- "SAFETY CHECKS (ALL must pass before merging):\n\n" +
943
- "1. DESTRUCTIVE DIFF CHECK:\n" +
944
- " If (deletions > additions × {{suspiciousDeletionRatio}}) AND (deletions > {{minDestructiveDeletions}}):\n" +
945
- " This PR deletes far more than it adds — HOLD IT.\n" +
946
- " → Run: gh pr edit <number> --repo <owner/repo> --add-label {{labelNeedsReview}}\n" +
947
- " → Run: gh pr comment <number> --repo <owner/repo> --body ':alert: **Bosun Review Agent: merge held** — " +
948
- "This PR deletes significantly more lines than it adds (deletions: <X>, additions: <Y>). " +
949
- "A human should verify this is intentional before merging.'\n" +
950
- " → Do NOT merge this PR. Move to next candidate.\n\n" +
951
- "2. DIFF SANITY CHECK (for PRs that pass the ratio check):\n" +
952
- " Run: gh pr diff <number> --repo <owner/repo> | head -200\n" +
953
- " Look for: mass file deletions, removal of entire modules/directories, " +
954
- " files changed that are unrelated to the PR description.\n" +
955
- " If something looks wrong → HOLD with bosun-needs-human-review label + comment.\n\n" +
956
- "3. CI STATUS RECONFIRM:\n" +
957
- " Run: gh pr checks <number> --repo <owner/repo> --json name,state,conclusion\n" +
958
- " Ensure ALL checks have conclusion SUCCESS/SKIPPED/NEUTRAL. " +
959
- " If any are pending or failing → do NOT merge (CI may still be running).\n\n" +
960
- "MERGE (only if ALL checks pass):\n" +
961
- " gh pr merge <number> --repo <owner/repo> --{{mergeMethod}} --delete-branch\n" +
962
- " Log: :check: Merged PR #<number> — <title>\n\n" +
963
- "STRICT RULES:\n" +
964
- "- NEVER merge if ANY safety check fails. When in doubt, HOLD.\n" +
965
- "- NEVER merge PRs without the bosun-attached label.\n" +
966
- "- NEVER merge draft PRs.\n" +
967
- "- The bosun-needs-human-review label means a human must look at it before bosun touches it again.\n" +
968
- "- After processing all candidates, output a summary: { merged: N, held: N, skipped: N }",
969
- sdk: "auto",
970
- timeoutMs: 1_200_000,
971
- maxRetries: 1,
972
- retryDelayMs: 30_000,
972
+ node("programmatic-review", "action.run_command", "Review Gate: Programmatic Merge", {
973
+ command: [
974
+ "node -e \"",
975
+ "const {execFileSync}=require('child_process');",
976
+ "const raw=String(process.env.BOSUN_FETCH_AND_CLASSIFY||'');",
977
+ "const payload=(()=>{try{return JSON.parse(raw||'{}')}catch{return {}}})();",
978
+ "const candidates=Array.isArray(payload.readyCandidates)?payload.readyCandidates:[];",
979
+ "const ratio=Number('{{suspiciousDeletionRatio}}')||3;",
980
+ "const minDel=Number('{{minDestructiveDeletions}}')||500;",
981
+ "const labelReview=String('{{labelNeedsReview}}'||'bosun-needs-human-review');",
982
+ "const method=String('{{mergeMethod}}'||'squash').toLowerCase();",
983
+ "const merged=[]; const held=[]; const skipped=[];",
984
+ "function gh(args){return execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();}",
985
+ "for(const c of candidates){",
986
+ " const repo=String(c?.repo||'').trim();",
987
+ " const n=String(c?.n||'').trim();",
988
+ " if(!repo||!n){skipped.push({repo,number:n,reason:'missing_repo_or_pr'});continue;}",
989
+ " try{",
990
+ " const viewRaw=gh(['pr','view',n,'--repo',repo,'--json','number,title,additions,deletions,changedFiles,isDraft']);",
991
+ " const view=(()=>{try{return JSON.parse(viewRaw||'{}')}catch{return {}}})();",
992
+ " if(view?.isDraft===true){skipped.push({repo,number:n,reason:'draft'});continue;}",
993
+ " const add=Number(view?.additions||0);",
994
+ " const del=Number(view?.deletions||0);",
995
+ " const changed=Number(view?.changedFiles||0);",
996
+ " const destructive=(del>(add*ratio))&&(del>minDel);",
997
+ " const tooWide=changed>250;",
998
+ " if(destructive||tooWide){",
999
+ " gh(['pr','edit',n,'--repo',repo,'--add-label',labelReview]);",
1000
+ " gh(['pr','comment',n,'--repo',repo,'--body',':warning: Bosun held this PR for human review due to suspicious diff footprint.']);",
1001
+ " held.push({repo,number:n,reason:destructive?'destructive_diff':'changed_files_too_large',additions:add,deletions:del,changedFiles:changed});",
1002
+ " continue;",
1003
+ " }",
1004
+ " const checksRaw=gh(['pr','checks',n,'--repo',repo,'--json','name,state,conclusion']);",
1005
+ " const checks=(()=>{try{return JSON.parse(checksRaw||'[]')}catch{return []}})();",
1006
+ " const bad=(Array.isArray(checks)?checks:[]).some((x)=>{",
1007
+ " const c=String(x?.conclusion||'').toUpperCase();",
1008
+ " const s=String(x?.state||'').toUpperCase();",
1009
+ " return ['FAILURE','ERROR','TIMED_OUT','CANCELLED'].includes(c) || ['PENDING','IN_PROGRESS','QUEUED','WAITING','REQUESTED','EXPECTED'].includes(s);",
1010
+ " });",
1011
+ " if(bad){skipped.push({repo,number:n,reason:'ci_not_green'});continue;}",
1012
+ " const mergeArgs=['pr','merge',n,'--repo',repo,'--delete-branch'];",
1013
+ " if(method==='rebase') mergeArgs.push('--rebase');",
1014
+ " else if(method==='merge') mergeArgs.push('--merge');",
1015
+ " else mergeArgs.push('--squash');",
1016
+ " gh(mergeArgs);",
1017
+ " merged.push({repo,number:n,title:String(view?.title||'')});",
1018
+ " }catch(e){",
1019
+ " held.push({repo,number:n,reason:'merge_attempt_failed',error:String(e?.message||e)});",
1020
+ " }",
1021
+ "}",
1022
+ "console.log(JSON.stringify({mergedCount:merged.length,heldCount:held.length,skippedCount:skipped.length,merged,held,skipped}));",
1023
+ "\"",
1024
+ ].join(" "),
973
1025
  continueOnError: true,
1026
+ failOnError: false,
1027
+ env: {
1028
+ BOSUN_FETCH_AND_CLASSIFY:
1029
+ "(()=>{const r=$ctx.getNodeOutput('fetch-and-classify');return r&&r.success!==false?String(r.output||'{}'):'{}';})()",
1030
+ },
974
1031
  }, { x: 600, y: 700 }),
975
1032
 
976
1033
  node("notify", "notify.telegram", "Watchdog Report", {
@@ -991,31 +1048,33 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
991
1048
  edge("has-prs", "fix-needed", { condition: "$output?.result === true" }),
992
1049
  edge("has-prs", "no-prs", { condition: "$output?.result !== true" }),
993
1050
  // Fix path (conflicts + CI failures)
994
- edge("fix-needed", "dispatch-fix", { condition: "$output?.result === true" }),
1051
+ edge("fix-needed", "programmatic-fix",{ condition: "$output?.result === true" }),
995
1052
  edge("fix-needed", "review-needed", { condition: "$output?.result !== true" }),
996
- edge("dispatch-fix", "review-needed"),
1053
+ edge("programmatic-fix", "fix-agent-needed"),
1054
+ edge("fix-agent-needed", "dispatch-fix-agent", { condition: "$output?.result === true" }),
1055
+ edge("fix-agent-needed", "review-needed", { condition: "$output?.result !== true" }),
1056
+ edge("dispatch-fix-agent","review-needed"),
997
1057
  // Review gate (merge candidates)
998
- edge("review-needed", "dispatch-review", { condition: "$output?.result === true" }),
1058
+ edge("review-needed", "programmatic-review", { condition: "$output?.result === true" }),
999
1059
  edge("review-needed", "notify", { condition: "$output?.result !== true" }),
1000
- edge("dispatch-review", "notify"),
1060
+ edge("programmatic-review","notify"),
1001
1061
  ],
1002
1062
  metadata: {
1003
1063
  author: "bosun",
1004
- version: 3,
1064
+ version: 4,
1005
1065
  createdAt: "2025-07-01T00:00:00Z",
1006
- templateVersion: "2.1.0",
1066
+ templateVersion: "2.2.0",
1007
1067
  tags: ["github", "pr", "ci", "merge", "watchdog", "bosun-attached", "safety"],
1008
1068
  replaces: {
1009
1069
  module: "agent-hooks.mjs",
1010
1070
  functions: ["registerBuiltinHooks (PostPR block)"],
1011
1071
  calledFrom: [],
1012
1072
  description:
1013
- "v2: Consolidates PR polling into one gh pr list fetch per target repo per cycle. " +
1014
- "Adds mandatory review gate agent that checks diff stats (additions/deletions " +
1015
- "ratio) and diff content before any merge preventing destructive PRs from " +
1016
- "being auto-merged. Adds conflict detection via the 'mergeable' field. " +
1017
- "Single fix agent handles both conflict resolution and CI failures. " +
1018
- "All external PRs (no bosun-attached label) are never touched.",
1073
+ "v2.2: Consolidates PR polling into one gh pr list fetch per target repo per cycle. " +
1074
+ "Uses deterministic-first remediation and review/merge command nodes; " +
1075
+ "agent execution is now fallback-only for unresolved conflicts or failed " +
1076
+ "automatic remediation attempts. All external PRs (no bosun-attached label) " +
1077
+ "remain untouched.",
1019
1078
  },
1020
1079
  },
1021
1080
  };
@@ -1087,10 +1146,59 @@ export const GITHUB_KANBAN_SYNC_TEMPLATE = {
1087
1146
  "}catch{return false;}})()",
1088
1147
  }, { x: 400, y: 370 }),
1089
1148
 
1090
- node("sync-agent", "action.run_agent", "Sync PR State → Kanban", {
1149
+ node("sync-programmatic", "action.run_command", "Sync PR State → Kanban (Programmatic)", {
1150
+ command: [
1151
+ "node -e \"",
1152
+ "const {execFileSync}=require('child_process');",
1153
+ "const fs=require('fs');",
1154
+ "const raw=String(process.env.BOSUN_FETCH_PR_STATE||'');",
1155
+ "const data=(()=>{try{return JSON.parse(raw||'{}')}catch{return {}}})();",
1156
+ "const merged=Array.isArray(data.merged)?data.merged:[];",
1157
+ "const open=Array.isArray(data.open)?data.open:[];",
1158
+ "const updates=[]; const unresolved=[];",
1159
+ "const taskCli=fs.existsSync('task-cli.mjs')?'task-cli.mjs':'';",
1160
+ "if(!taskCli){",
1161
+ " console.log(JSON.stringify({updated:0,unresolved:[{reason:'task_cli_missing'}],needsAgent:true}));",
1162
+ " process.exit(0);",
1163
+ "}",
1164
+ "function runTask(args){return execFileSync('node',[taskCli,...args],{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();}",
1165
+ "for(const item of merged){",
1166
+ " const id=String(item?.taskId||'').trim();",
1167
+ " if(!id) continue;",
1168
+ " try{runTask(['update',id,'--status','done']);updates.push({taskId:id,status:'done'});}catch(e){unresolved.push({taskId:id,status:'done',error:String(e?.message||e)});}",
1169
+ "}",
1170
+ "for(const item of open){",
1171
+ " const id=String(item?.taskId||'').trim();",
1172
+ " if(!id) continue;",
1173
+ " try{runTask(['update',id,'--status','inreview']);updates.push({taskId:id,status:'inreview'});}catch(e){unresolved.push({taskId:id,status:'inreview',error:String(e?.message||e)});}",
1174
+ "}",
1175
+ "console.log(JSON.stringify({updated:updates.length,updates,unresolved,needsAgent:unresolved.length>0}));",
1176
+ "\"",
1177
+ ].join(" "),
1178
+ continueOnError: true,
1179
+ failOnError: false,
1180
+ env: {
1181
+ BOSUN_FETCH_PR_STATE:
1182
+ "(()=>{const r=$ctx.getNodeOutput('fetch-pr-state');return r&&r.success!==false?String(r.output||'{}'):'{}';})()",
1183
+ },
1184
+ }, { x: 400, y: 530 }),
1185
+
1186
+ node("sync-agent-needed", "condition.expression", "Needs Agent Sync?", {
1187
+ expression:
1188
+ "(()=>{try{" +
1189
+ "const raw=$ctx.getNodeOutput('sync-programmatic')?.output||'{}';" +
1190
+ "const d=JSON.parse(raw);" +
1191
+ "return d?.needsAgent===true || (Array.isArray(d?.unresolved)&&d.unresolved.length>0);" +
1192
+ "}catch{return true;}})()",
1193
+ }, { x: 400, y: 615 }),
1194
+
1195
+ node("sync-agent", "action.run_agent", "Sync PR State → Kanban (Fallback)", {
1091
1196
  prompt:
1092
- "You are the Bosun GitHub-Kanban sync agent. Sync the kanban board " +
1093
- "to match the GitHub PR state shown below.\n\n" +
1197
+ "You are the Bosun GitHub-Kanban sync fallback agent. A deterministic sync pass already ran.\n\n" +
1198
+ "Programmatic sync output:\n" +
1199
+ "{{$ctx.getNodeOutput('sync-programmatic')?.output}}\n\n" +
1200
+ "Now complete only unresolved updates.\n\n" +
1201
+ "GitHub PR state:\n" +
1094
1202
  "PR state (JSON from fetch-pr-state node output):\n" +
1095
1203
  "{{$ctx.getNodeOutput('fetch-pr-state')?.output}}\n\n" +
1096
1204
  "RULES:\n" +
@@ -1107,7 +1215,7 @@ export const GITHUB_KANBAN_SYNC_TEMPLATE = {
1107
1215
  sdk: "auto",
1108
1216
  timeoutMs: 300_000,
1109
1217
  continueOnError: true,
1110
- }, { x: 400, y: 530 }),
1218
+ }, { x: 400, y: 700 }),
1111
1219
 
1112
1220
  node("done", "notify.log", "Sync Complete", {
1113
1221
  message: "GitHub ↔ Kanban sync cycle complete",
@@ -1122,8 +1230,11 @@ export const GITHUB_KANBAN_SYNC_TEMPLATE = {
1122
1230
  edges: [
1123
1231
  edge("trigger", "fetch-pr-state"),
1124
1232
  edge("fetch-pr-state", "has-updates"),
1125
- edge("has-updates", "sync-agent", { condition: "$output?.result === true" }),
1233
+ edge("has-updates", "sync-programmatic", { condition: "$output?.result === true" }),
1126
1234
  edge("has-updates", "skip", { condition: "$output?.result !== true" }),
1235
+ edge("sync-programmatic", "sync-agent-needed"),
1236
+ edge("sync-agent-needed", "sync-agent", { condition: "$output?.result === true" }),
1237
+ edge("sync-agent-needed", "done", { condition: "$output?.result !== true" }),
1127
1238
  edge("sync-agent", "done"),
1128
1239
  ],
1129
1240
  metadata: {
@@ -0,0 +1,247 @@
1
+ /**
2
+ * task-batch.mjs — Task Batch Processor Workflow Template
3
+ *
4
+ * Picks up multiple tasks from the kanban backlog and dispatches them in
5
+ * parallel using the loop.for_each fan-out node. Each task is executed via
6
+ * the Task Lifecycle sub-workflow (template-task-lifecycle).
7
+ *
8
+ * Templates:
9
+ * - TASK_BATCH_PROCESSOR_TEMPLATE (primary batch dispatch)
10
+ * - TASK_BATCH_PR_TEMPLATE (batch → agent → PR shortcut)
11
+ *
12
+ * DAG overview:
13
+ * trigger.task_low
14
+ * → condition.expression (is coordinator or solo?)
15
+ * → action.run_command (list todo tasks)
16
+ * → loop.for_each (fan-out, maxConcurrent tasks at a time)
17
+ * → sub-workflow: template-task-lifecycle per task
18
+ * → action.set_variable (record batch results)
19
+ * → notify.log (summary)
20
+ */
21
+
22
+ import { node, edge, resetLayout } from "./_helpers.mjs";
23
+
24
+ // ═══════════════════════════════════════════════════════════════════════════
25
+ // Task Batch Processor — Parallel Task Dispatch
26
+ // ═══════════════════════════════════════════════════════════════════════════
27
+
28
+ resetLayout();
29
+
30
+ export const TASK_BATCH_PROCESSOR_TEMPLATE = {
31
+ id: "template-task-batch-processor",
32
+ name: "Task Batch Processor",
33
+ description:
34
+ "Monitors the task backlog and dispatches multiple tasks in parallel " +
35
+ "using the Task Lifecycle sub-workflow. Automatically picks up tasks " +
36
+ "when backlog drops below threshold, fans out execution across " +
37
+ "available slots, and reports batch results.",
38
+ category: "lifecycle",
39
+ enabled: true,
40
+ recommended: true,
41
+ trigger: "trigger.task_low",
42
+ variables: {
43
+ backlogThreshold: 3,
44
+ maxConcurrent: 3,
45
+ pollStatus: "todo",
46
+ maxBatchSize: 10,
47
+ subWorkflow: "template-task-lifecycle",
48
+ notifyChannel: "telegram",
49
+ },
50
+ nodes: [
51
+ // ── Trigger: Backlog drops below threshold ───────────────────────────
52
+ node("trigger", "trigger.task_low", "Backlog Low?", {
53
+ threshold: "{{backlogThreshold}}",
54
+ status: "{{pollStatus}}",
55
+ }, { x: 400, y: 50 }),
56
+
57
+ // ── Gate: Fleet coordinator check (skip if not coordinator) ──────────
58
+ node("check-coordinator", "condition.expression", "Is Coordinator?", {
59
+ expression: "$data?.isCoordinator !== false",
60
+ }, { x: 400, y: 180 }),
61
+
62
+ // ── Query kanban for available tasks ─────────────────────────────────
63
+ node("query-tasks", "action.run_command", "Query Task Backlog", {
64
+ command: "node",
65
+ args: ["-e", `
66
+ import("./kanban-adapter.mjs")
67
+ .then(k => k.listTasks(undefined, { status: "todo" }))
68
+ .then(tasks => {
69
+ const batch = (tasks || []).slice(0, parseInt(process.env.MAX_BATCH || "10"));
70
+ console.log(JSON.stringify(batch.map(t => ({
71
+ taskId: t.id,
72
+ taskTitle: t.title || t.id,
73
+ status: t.status,
74
+ branch: t.branch || t.metadata?.branch || null,
75
+ scope: t.scope || t.metadata?.scope || null,
76
+ }))));
77
+ })
78
+ .catch(e => { console.error(e.message); process.exit(1); });
79
+ `],
80
+ env: { MAX_BATCH: "{{maxBatchSize}}" },
81
+ parseJson: true,
82
+ }, { x: 400, y: 310 }),
83
+
84
+ // ── Fan-out: dispatch each task to the lifecycle workflow ─────────────
85
+ node("dispatch-tasks", "loop.for_each", "Dispatch Tasks", {
86
+ items: "{{queryResult}}",
87
+ itemVariable: "currentTask",
88
+ indexVariable: "taskIndex",
89
+ maxConcurrent: "{{maxConcurrent}}",
90
+ workflowId: "{{subWorkflow}}",
91
+ }, { x: 400, y: 440 }),
92
+
93
+ // ── Record batch results ─────────────────────────────────────────────
94
+ node("record-results", "action.set_variable", "Record Results", {
95
+ key: "batchResult",
96
+ value: "{{dispatchResult}}",
97
+ }, { x: 400, y: 570 }),
98
+
99
+ // ── Notify on completion ─────────────────────────────────────────────
100
+ node("notify-complete", "notify.telegram", "Batch Summary", {
101
+ channel: "{{notifyChannel}}",
102
+ message: "Task batch completed: {{batchResult.successCount}}/{{batchResult.totalItems}} succeeded",
103
+ }, { x: 400, y: 700 }),
104
+ ],
105
+ edges: [
106
+ edge("trigger", "check-coordinator"),
107
+ edge("check-coordinator", "query-tasks", { condition: "result.result === true" }),
108
+ edge("query-tasks", "dispatch-tasks"),
109
+ edge("dispatch-tasks", "record-results"),
110
+ edge("record-results", "notify-complete"),
111
+ ],
112
+ metadata: {
113
+ author: "bosun",
114
+ version: 1,
115
+ createdAt: "2026-03-15T00:00:00Z",
116
+ templateVersion: "1.0.0",
117
+ tags: ["task", "batch", "parallel", "dispatch", "lifecycle"],
118
+ },
119
+ };
120
+
121
+ // ═══════════════════════════════════════════════════════════════════════════
122
+ // Task Batch → PR Shortcut — Pick tasks, run agent, create PRs
123
+ // ═══════════════════════════════════════════════════════════════════════════
124
+
125
+ resetLayout();
126
+
127
+ export const TASK_BATCH_PR_TEMPLATE = {
128
+ id: "template-task-batch-pr",
129
+ name: "Task Batch → PR",
130
+ description:
131
+ "Simplified batch processor that picks todo tasks, runs the agent on " +
132
+ "each, and creates pull requests for any that produce commits. Ideal " +
133
+ "for autonomous mode where tasks should flow straight to PRs.",
134
+ category: "lifecycle",
135
+ enabled: true,
136
+ recommended: false,
137
+ trigger: "trigger.task_low",
138
+ variables: {
139
+ backlogThreshold: 3,
140
+ maxConcurrent: 2,
141
+ pollStatus: "todo",
142
+ maxBatchSize: 5,
143
+ defaultBaseBranch: "main",
144
+ draftPR: true,
145
+ notifyChannel: "telegram",
146
+ },
147
+ nodes: [
148
+ // ── Trigger ──────────────────────────────────────────────────────────
149
+ node("trigger", "trigger.task_low", "Backlog Low?", {
150
+ threshold: "{{backlogThreshold}}",
151
+ status: "{{pollStatus}}",
152
+ }, { x: 400, y: 50 }),
153
+
154
+ // ── Query for tasks ──────────────────────────────────────────────────
155
+ node("query-tasks", "action.run_command", "List Todo Tasks", {
156
+ command: "node",
157
+ args: ["-e", `
158
+ import("./kanban-adapter.mjs")
159
+ .then(k => k.listTasks(undefined, { status: "todo" }))
160
+ .then(tasks => {
161
+ const batch = (tasks || []).slice(0, parseInt(process.env.MAX_BATCH || "5"));
162
+ console.log(JSON.stringify(batch.map(t => ({
163
+ taskId: t.id,
164
+ taskTitle: t.title || t.id,
165
+ branch: t.branch || t.metadata?.branch || null,
166
+ }))));
167
+ })
168
+ .catch(e => { console.error(e.message); process.exit(1); });
169
+ `],
170
+ env: { MAX_BATCH: "{{maxBatchSize}}" },
171
+ parseJson: true,
172
+ }, { x: 400, y: 180 }),
173
+
174
+ // ── Fan-out: per-task agent + PR ─────────────────────────────────────
175
+ node("for-each-task", "loop.for_each", "Process Each Task", {
176
+ items: "{{queryResult}}",
177
+ itemVariable: "task",
178
+ indexVariable: "idx",
179
+ maxConcurrent: "{{maxConcurrent}}",
180
+ }, { x: 400, y: 310 }),
181
+
182
+ // ── Per-task: set status to inprogress ───────────────────────────────
183
+ node("set-inprogress", "action.update_task_status", "Mark In-Progress", {
184
+ taskId: "{{task.taskId}}",
185
+ status: "inprogress",
186
+ }, { x: 400, y: 440 }),
187
+
188
+ // ── Per-task: run agent ──────────────────────────────────────────────
189
+ node("run-agent", "action.run_agent", "Run Agent", {
190
+ taskId: "{{task.taskId}}",
191
+ taskTitle: "{{task.taskTitle}}",
192
+ branch: "{{task.branch}}",
193
+ }, { x: 400, y: 570 }),
194
+
195
+ // ── Per-task: detect commits ─────────────────────────────────────────
196
+ node("detect-commits", "action.detect_new_commits", "Check Commits", {
197
+ taskId: "{{task.taskId}}",
198
+ worktreePath: "{{worktreePath}}",
199
+ failOnError: false,
200
+ }, { x: 400, y: 700 }),
201
+
202
+ // ── Per-task: push + create PR ───────────────────────────────────────
203
+ node("push-branch", "action.git_operations", "Push Branch", {
204
+ operation: "push",
205
+ cwd: "{{worktreePath}}",
206
+ }, { x: 400, y: 830 }),
207
+
208
+ node("create-pr", "action.create_pr", "Create PR", {
209
+ title: "{{task.taskTitle}}",
210
+ body: "Automated PR for task {{task.taskId}}",
211
+ base: "{{defaultBaseBranch}}",
212
+ branch: "{{task.branch}}",
213
+ draft: "{{draftPR}}",
214
+ }, { x: 400, y: 960 }),
215
+
216
+ // ── Per-task: mark review ────────────────────────────────────────────
217
+ node("set-inreview", "action.update_task_status", "Mark In-Review", {
218
+ taskId: "{{task.taskId}}",
219
+ status: "inreview",
220
+ }, { x: 400, y: 1090 }),
221
+
222
+ // ── Batch complete notification ──────────────────────────────────────
223
+ node("notify", "notify.telegram", "Batch Complete", {
224
+ channel: "{{notifyChannel}}",
225
+ message: "Task batch PR pipeline complete",
226
+ }, { x: 400, y: 1220 }),
227
+ ],
228
+ edges: [
229
+ edge("trigger", "query-tasks"),
230
+ edge("query-tasks", "for-each-task"),
231
+ edge("for-each-task", "set-inprogress"),
232
+ edge("set-inprogress", "run-agent"),
233
+ edge("run-agent", "detect-commits"),
234
+ edge("detect-commits", "push-branch", { condition: "result.hasNewCommits === true" }),
235
+ edge("push-branch", "create-pr"),
236
+ edge("create-pr", "set-inreview"),
237
+ edge("detect-commits", "notify", { condition: "result.hasNewCommits !== true" }),
238
+ edge("set-inreview", "notify"),
239
+ ],
240
+ metadata: {
241
+ author: "bosun",
242
+ version: 1,
243
+ createdAt: "2026-03-15T00:00:00Z",
244
+ templateVersion: "1.0.0",
245
+ tags: ["task", "batch", "pr", "agent", "autonomous"],
246
+ },
247
+ };