forge-workflow 0.0.10 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (454) hide show
  1. package/.claude/rules/{greptile-review-process.md → review-process.md} +56 -41
  2. package/.claude/scripts/{greptile-resolve.sh → review-resolve.sh} +13 -3
  3. package/.cursor/rules/permissions-guidance.mdc +2 -2
  4. package/.forge/hooks/check-tdd.js +3 -0
  5. package/.forge/hooks/forge-native-hook.js +245 -0
  6. package/.forge/protected-paths.yaml +157 -0
  7. package/AGENTS.md +150 -61
  8. package/CHANGELOG.md +681 -0
  9. package/CLAUDE.md +9 -118
  10. package/QUICKSTART.md +171 -0
  11. package/README.md +271 -363
  12. package/bin/forge-cmd.js +120 -9
  13. package/bin/forge-preflight.js +26 -5
  14. package/bin/forge.js +461 -489
  15. package/docs/INDEX.md +93 -0
  16. package/docs/PROJECT_DESIGN.md +685 -0
  17. package/docs/architecture/index.md +66 -0
  18. package/docs/architecture/notes/README.md +35 -0
  19. package/docs/architecture/subsystems/README.md +46 -0
  20. package/docs/forge/TOOLCHAIN.md +670 -0
  21. package/docs/forge/VALIDATION.md +82 -0
  22. package/docs/{AGENT_INSTALL_PROMPT.md → guides/AGENT_INSTALL_PROMPT.md} +3 -3
  23. package/docs/guides/BEADS_GITHUB_SYNC.md +32 -0
  24. package/docs/{ENHANCED_ONBOARDING.md → guides/ENHANCED_ONBOARDING.md} +16 -12
  25. package/docs/guides/GREPTILE_SETUP.md +46 -0
  26. package/docs/guides/MANUAL_REVIEW_GUIDE.md +58 -0
  27. package/docs/guides/MIGRATION.md +56 -0
  28. package/docs/guides/SETUP.md +118 -0
  29. package/docs/guides/SUPPORT.md +185 -0
  30. package/docs/guides/WORKFLOW_TEMPLATES.md +74 -0
  31. package/docs/guides/memory-backends.md +183 -0
  32. package/docs/reference/ADAPTERS.md +128 -0
  33. package/docs/reference/AGENT_SKILL_PARITY.md +175 -0
  34. package/docs/reference/COMMANDS.md +205 -0
  35. package/docs/reference/DECISION_DRIFT_GUARDS.md +97 -0
  36. package/docs/{EXAMPLES.md → reference/EXAMPLES.md} +7 -5
  37. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +135 -0
  38. package/docs/reference/HERMES_INTEGRATION.md +118 -0
  39. package/docs/reference/INSIGHTS_RECAP.md +63 -0
  40. package/docs/reference/INSTALL.md +164 -0
  41. package/docs/reference/KERNEL_TAXONOMY_VALIDATION.md +161 -0
  42. package/docs/reference/PROTECTED_PATH_MANIFEST.md +25 -0
  43. package/docs/reference/RELEASE.md +68 -0
  44. package/docs/reference/RESEARCH_TEMPLATE.md +292 -0
  45. package/docs/{ROADMAP.md → reference/ROADMAP.md} +12 -9
  46. package/docs/reference/SKILLS.md +35 -0
  47. package/docs/reference/STATUS_BOARD.md +80 -0
  48. package/docs/reference/TEMPLATES.md +106 -0
  49. package/docs/{TOOLCHAIN.md → reference/TOOLCHAIN.md} +62 -47
  50. package/docs/reference/VALIDATION.md +82 -0
  51. package/docs/reference/agent-permissions.md +169 -0
  52. package/docs/reference/beads-to-kernel-migration-ux.md +61 -0
  53. package/docs/reference/control-plane-guarantees.md +125 -0
  54. package/docs/reference/dependency-chain.md +331 -0
  55. package/docs/reference/forge-kernel-issue-command-contract.md +161 -0
  56. package/docs/reference/forge-kernel-schema.md +72 -0
  57. package/docs/reference/kernel-conflict-evaluators.md +27 -0
  58. package/docs/reference/patch-md-format.md +77 -0
  59. package/docs/reference/protected-state-surfaces.md +59 -0
  60. package/docs/reference/shepherd.md +115 -0
  61. package/docs/reference/superpowers-analysis.md +320 -0
  62. package/docs/reference/superpowers-integration-options.md +404 -0
  63. package/docs/reference/test-environment.md +519 -0
  64. package/docs/reference/upgrade-safety.md +59 -0
  65. package/lefthook.yml +18 -0
  66. package/lib/adapter-cli.js +307 -0
  67. package/lib/adapters/beads-issue-adapter.js +127 -0
  68. package/lib/adapters/beads-kernel-compat.js +1042 -0
  69. package/lib/adapters/greptile-review-adapter.js +141 -0
  70. package/lib/adapters/kernel-issue-adapter.js +101 -0
  71. package/lib/adapters/pr-state-adapter.js +484 -0
  72. package/lib/adoption-profiles.js +126 -0
  73. package/lib/agents/README.md +2 -6
  74. package/lib/agents/claude.plugin.json +3 -8
  75. package/lib/agents/codex.plugin.json +9 -1
  76. package/lib/agents/cursor.plugin.json +2 -6
  77. package/lib/agents/hermes.plugin.json +22 -0
  78. package/lib/agents-config.js +39 -1236
  79. package/lib/audit-evidence.js +282 -0
  80. package/lib/beads-setup.js +121 -0
  81. package/lib/beads-sync-scaffold.js +25 -101
  82. package/lib/codex-skills.js +51 -1
  83. package/lib/commands/_issue.js +741 -77
  84. package/lib/commands/_manifest.js +91 -0
  85. package/lib/commands/_registry.js +85 -34
  86. package/lib/commands/_resolve-command-opts.js +261 -0
  87. package/lib/commands/_serve-security.js +270 -0
  88. package/lib/commands/adapter.js +12 -0
  89. package/lib/commands/add.js +118 -0
  90. package/lib/commands/audit.js +70 -0
  91. package/lib/commands/blocked.js +5 -0
  92. package/lib/commands/board.js +64 -0
  93. package/lib/commands/claim.js +21 -2
  94. package/lib/commands/claims.js +7 -0
  95. package/lib/commands/clean.js +485 -75
  96. package/lib/commands/close.js +2 -2
  97. package/lib/commands/comment.js +5 -0
  98. package/lib/commands/control.js +148 -0
  99. package/lib/commands/create.js +2 -2
  100. package/lib/commands/dev.js +185 -7
  101. package/lib/commands/doc-gate.js +336 -0
  102. package/lib/commands/doctor.js +156 -0
  103. package/lib/commands/explain.js +15 -0
  104. package/lib/commands/export.js +237 -0
  105. package/lib/commands/gate.js +192 -0
  106. package/lib/commands/hooks.js +242 -0
  107. package/lib/commands/inbox.js +118 -0
  108. package/lib/commands/init.js +598 -0
  109. package/lib/commands/insights.js +79 -0
  110. package/lib/commands/issue.js +12 -1
  111. package/lib/commands/issues.js +17 -0
  112. package/lib/commands/lint.js +5 -0
  113. package/lib/commands/list.js +2 -2
  114. package/lib/commands/merge.js +312 -0
  115. package/lib/commands/migrate.js +523 -0
  116. package/lib/commands/new.js +12 -0
  117. package/lib/commands/options.js +241 -0
  118. package/lib/commands/orient.js +13 -0
  119. package/lib/commands/orphans.js +5 -0
  120. package/lib/commands/patch.js +67 -0
  121. package/lib/commands/plan.js +436 -24
  122. package/lib/commands/preflight.js +211 -0
  123. package/lib/commands/prime.js +13 -0
  124. package/lib/commands/push.js +69 -2
  125. package/lib/commands/ready.js +2 -2
  126. package/lib/commands/recall.js +116 -0
  127. package/lib/commands/recap.js +61 -0
  128. package/lib/commands/recommend.js +0 -1
  129. package/lib/commands/release.js +91 -0
  130. package/lib/commands/remember.js +74 -0
  131. package/lib/commands/role.js +99 -0
  132. package/lib/commands/serve.js +581 -0
  133. package/lib/commands/setup.js +838 -972
  134. package/lib/commands/shepherd.js +436 -0
  135. package/lib/commands/ship.js +23 -1
  136. package/lib/commands/show.js +2 -2
  137. package/lib/commands/stage.js +192 -0
  138. package/lib/commands/stale.js +5 -0
  139. package/lib/commands/status.js +158 -21
  140. package/lib/commands/sync.js +34 -46
  141. package/lib/commands/team.js +4 -1
  142. package/lib/commands/test.js +43 -27
  143. package/lib/commands/update.js +2 -2
  144. package/lib/commands/upgrade.js +47 -0
  145. package/lib/commands/validate.js +43 -18
  146. package/lib/commands/worktree.js +307 -100
  147. package/lib/config-writer.js +202 -0
  148. package/lib/control-plane.js +236 -0
  149. package/lib/core/runtime-graph.js +946 -0
  150. package/lib/dep-guard/keyword-ripple.js +2 -2
  151. package/lib/deprecated-sync-cleanup.js +362 -0
  152. package/lib/detect-agent.js +2 -28
  153. package/lib/detect-worktree.js +35 -9
  154. package/lib/doc-gate/declaration.js +177 -0
  155. package/lib/doc-gate/detect.js +289 -0
  156. package/lib/doc-gate/gate.js +375 -0
  157. package/lib/doc-gate/okf-config.js +128 -0
  158. package/lib/doc-gate/okf.js +429 -0
  159. package/lib/docs-command.js +1161 -6
  160. package/lib/forge-issues.js +382 -11
  161. package/lib/forge-lock.js +262 -0
  162. package/lib/gate-events.js +193 -0
  163. package/lib/global-flags.js +74 -0
  164. package/lib/greptile-match.js +7 -63
  165. package/lib/harness-capability-matrix.js +380 -0
  166. package/lib/hook-global-installer.js +347 -0
  167. package/lib/hook-renderer.js +451 -0
  168. package/lib/inbox.js +391 -0
  169. package/lib/insights.js +397 -0
  170. package/lib/issue-adapter.js +156 -0
  171. package/lib/issue-backend.js +145 -0
  172. package/lib/issue-render.js +220 -0
  173. package/lib/kernel/backing-issue.js +305 -0
  174. package/lib/kernel/broker.js +1218 -0
  175. package/lib/kernel/cli-broker-factory.js +130 -0
  176. package/lib/kernel/conflict-signal.js +82 -0
  177. package/lib/kernel/evaluators.js +195 -0
  178. package/lib/kernel/fs-class.js +495 -0
  179. package/lib/kernel/issue-command-contract.js +559 -0
  180. package/lib/kernel/issue-id-resolver.js +186 -0
  181. package/lib/kernel/lease-enforcer.js +158 -0
  182. package/lib/kernel/migrations.js +333 -0
  183. package/lib/kernel/planning-buckets-schema.js +109 -0
  184. package/lib/kernel/projection-jsonl-writer.js +450 -0
  185. package/lib/kernel/readiness-model.js +329 -0
  186. package/lib/kernel/schema.js +356 -0
  187. package/lib/kernel/sqlite-driver.js +2504 -0
  188. package/lib/kernel/taxonomy-validator.js +394 -0
  189. package/lib/lefthook-check.js +3 -2
  190. package/lib/lefthook-wiring.js +413 -0
  191. package/lib/mcp-config-renderer.js +288 -0
  192. package/lib/memory/graphiti-mcp.js +106 -0
  193. package/lib/memory/router.js +387 -0
  194. package/lib/memory/typed-api.js +102 -0
  195. package/lib/memory-digest.js +195 -0
  196. package/lib/merge-rules.js +395 -0
  197. package/lib/migrate-dry-run.js +466 -0
  198. package/lib/orientation.js +863 -0
  199. package/lib/package-manager-remediation.js +103 -0
  200. package/lib/package-root.js +381 -0
  201. package/lib/patch-intent.js +890 -0
  202. package/lib/plugin-catalog.js +3 -4
  203. package/lib/plugin-manager.js +0 -5
  204. package/lib/pr-bundle.js +186 -0
  205. package/lib/pr-monitor/differ.js +195 -0
  206. package/lib/pr-monitor/events.js +0 -0
  207. package/lib/pr-monitor/gather.js +124 -0
  208. package/lib/pr-monitor/journal.js +299 -0
  209. package/lib/pr-monitor/monitor.js +146 -0
  210. package/lib/pr-monitor/render-sticky.js +157 -0
  211. package/lib/pr-monitor/watch-lifecycle.js +95 -0
  212. package/lib/pr-monitor/watch.js +247 -0
  213. package/lib/pr-pull.js +1273 -0
  214. package/lib/pr-shepherd.js +494 -0
  215. package/lib/pr-state-validator.js +59 -0
  216. package/lib/preflight/gates.js +237 -0
  217. package/lib/preflight/runner.js +116 -0
  218. package/lib/project-discovery.js +0 -53
  219. package/lib/project-memory.js +99 -497
  220. package/lib/protected-path-manifest.js +281 -0
  221. package/lib/protected-state-surfaces.js +387 -0
  222. package/lib/release-readiness.js +2089 -0
  223. package/lib/reset.js +59 -45
  224. package/lib/review-adapter.js +68 -0
  225. package/lib/rules-sync.js +260 -0
  226. package/lib/runtime-health.js +241 -20
  227. package/lib/safety-config-renderer.js +268 -0
  228. package/lib/setup-action-log.js +1 -7
  229. package/lib/setup.js +27 -65
  230. package/lib/shell-utils.js +76 -6
  231. package/lib/skills-sync.js +330 -0
  232. package/lib/smart-status/scoring.js +17 -3
  233. package/lib/status/beads-snapshot.js +45 -2
  234. package/lib/status/presenter.js +169 -18
  235. package/lib/status/snapshot.js +186 -0
  236. package/lib/sync-backend.js +202 -0
  237. package/lib/untrusted-content.js +52 -0
  238. package/lib/upgrade-safety.js +199 -0
  239. package/lib/workflow/enforce-stage.js +296 -47
  240. package/lib/workflow/stage-transition.js +115 -0
  241. package/lib/workflow/stages.js +30 -6
  242. package/lib/workflow/state-manager.js +11 -22
  243. package/lib/workflow/state.js +23 -1
  244. package/lib/workflow-profiles.js +17 -5
  245. package/package.json +37 -35
  246. package/rules/documentation.md +19 -0
  247. package/rules/kernel-tracking.md +26 -0
  248. package/rules/security.md +22 -0
  249. package/rules/tdd.md +20 -0
  250. package/rules/workflow.md +27 -0
  251. package/scripts/auto-backing-issue.js +47 -0
  252. package/scripts/beads-context.sh +81 -57
  253. package/scripts/beads-upgrade-smoke.sh +24 -3
  254. package/scripts/bootstrap-windows-tools.sh +78 -0
  255. package/scripts/branch-protection.js +2 -3
  256. package/scripts/check-agents.js +34 -137
  257. package/scripts/commitlint.js +3 -1
  258. package/scripts/conflict-detect.sh +3 -0
  259. package/scripts/dep-guard.sh +22 -3
  260. package/scripts/file-index.sh +3 -0
  261. package/scripts/forge-team/lib/claim.sh +34 -18
  262. package/scripts/forge-team/lib/dashboard.sh +61 -86
  263. package/scripts/forge-team/lib/epic.sh +99 -263
  264. package/scripts/forge-team/lib/hooks.sh +26 -28
  265. package/scripts/forge-team/lib/identity.sh +4 -4
  266. package/scripts/forge-team/lib/sync-github.sh +49 -84
  267. package/scripts/forge-team/lib/verify.sh +93 -83
  268. package/scripts/forge-team/lib/workload.sh +41 -65
  269. package/scripts/forge-team/tests/claim.test.sh +25 -19
  270. package/scripts/forge-team/tests/dashboard.test.sh +31 -46
  271. package/scripts/forge-team/tests/epic.test.sh +52 -71
  272. package/scripts/forge-team/tests/hooks.test.sh +38 -50
  273. package/scripts/forge-team/tests/identity.test.sh +3 -3
  274. package/scripts/forge-team/tests/integration.test.sh +44 -66
  275. package/scripts/forge-team/tests/sync-github.test.sh +50 -83
  276. package/scripts/forge-team/tests/verify.test.sh +37 -46
  277. package/scripts/forge-team/tests/workflow-integration.test.sh +4 -4
  278. package/scripts/forge-team/tests/workload.test.sh +32 -66
  279. package/scripts/gen-command-manifest.js +153 -0
  280. package/scripts/gen-embedded-assets.mjs +129 -0
  281. package/scripts/install.ps1 +139 -0
  282. package/scripts/install.sh +268 -0
  283. package/scripts/lib/release-asset.mjs +84 -0
  284. package/scripts/parity-check.mjs +145 -0
  285. package/scripts/parity-check.test.mjs +58 -0
  286. package/scripts/pin-agentic-workflow-images.js +112 -0
  287. package/scripts/pr-coordinator.sh +3 -0
  288. package/scripts/preflight-sonar.eslint.config.mjs +44 -0
  289. package/scripts/preflight.sh +21 -94
  290. package/scripts/protected-state-check.js +104 -0
  291. package/scripts/smart-status.sh +60 -57
  292. package/scripts/spikes/config-race-bench.js +111 -0
  293. package/scripts/spikes/harness-capability-matrix.js +13 -0
  294. package/scripts/spikes/patch-anchor-stability-bench.js +125 -0
  295. package/scripts/spikes/protected-path-manifest.js +20 -0
  296. package/scripts/spikes/skill-auto-invoke-parity.js +292 -0
  297. package/scripts/sync-agent-skills.js +62 -0
  298. package/scripts/sync-utils.sh +3 -0
  299. package/scripts/test-ci-shard.js +13 -6
  300. package/scripts/test.js +95 -12
  301. package/skills/claim-safety/SKILL.md +102 -0
  302. package/skills/claim-safety/evals/evals.json +46 -0
  303. package/{.github/prompts/dev.prompt.md → skills/dev/SKILL.md} +44 -50
  304. package/skills/dev/evals/evals.json +50 -0
  305. package/skills/hermes-forge/SKILL.md +185 -0
  306. package/skills/hermes-forge/evals/evals.json +46 -0
  307. package/skills/issue-basics/SKILL.md +111 -0
  308. package/skills/issue-basics/evals/evals.json +46 -0
  309. package/skills/kernel/SKILL.md +166 -0
  310. package/skills/kernel/evals/evals.json +50 -0
  311. package/skills/memory/SKILL.md +102 -0
  312. package/skills/parallel-deep-research/SKILL.md +14 -11
  313. package/skills/parallel-deep-research/evals/evals.json +11 -27
  314. package/{.github/prompts/plan.prompt.md → skills/plan/SKILL.md} +132 -157
  315. package/skills/plan/evals/evals.json +42 -0
  316. package/skills/research/SKILL.md +195 -0
  317. package/skills/research/evals/evals.json +42 -0
  318. package/{.github/prompts/review.prompt.md → skills/review/SKILL.md} +98 -62
  319. package/skills/review/evals/evals.json +42 -0
  320. package/skills/rollback/SKILL.md +110 -0
  321. package/skills/rollback/evals/evals.json +46 -0
  322. package/skills/rollback/references/methods.md +204 -0
  323. package/{.cursor/commands/rollback.md → skills/rollback/references/workflow-integration.md} +10 -284
  324. package/skills/shepherd/SKILL.md +66 -0
  325. package/skills/shepherd/evals/evals.json +42 -0
  326. package/{.github/prompts/ship.prompt.md → skills/ship/SKILL.md} +81 -45
  327. package/skills/ship/evals/evals.json +42 -0
  328. package/skills/smith/SKILL.md +142 -0
  329. package/skills/smith/evals/evals.json +46 -0
  330. package/skills/smith/references/autonomy-and-gates.md +94 -0
  331. package/{.github/prompts/sonarcloud.prompt.md → skills/sonarcloud/SKILL.md} +14 -3
  332. package/skills/sonarcloud/evals/evals.json +46 -0
  333. package/skills/sonarcloud-analysis/SKILL.md +18 -13
  334. package/skills/sonarcloud-analysis/evals/evals.json +11 -15
  335. package/{.github/prompts/status.prompt.md → skills/status/SKILL.md} +20 -10
  336. package/skills/status/evals/evals.json +50 -0
  337. package/skills/triage-ready/SKILL.md +121 -0
  338. package/skills/triage-ready/evals/evals.json +42 -0
  339. package/{.github/prompts/validate.prompt.md → skills/validate/SKILL.md} +52 -29
  340. package/skills/validate/evals/evals.json +42 -0
  341. package/skills/verify/SKILL.md +299 -0
  342. package/skills/verify/evals/evals.json +50 -0
  343. package/.claude/commands/dev.md +0 -345
  344. package/.claude/commands/plan.md +0 -566
  345. package/.claude/commands/premerge.md +0 -186
  346. package/.claude/commands/research.md +0 -42
  347. package/.claude/commands/review.md +0 -451
  348. package/.claude/commands/rollback.md +0 -721
  349. package/.claude/commands/ship.md +0 -213
  350. package/.claude/commands/sonarcloud.md +0 -152
  351. package/.claude/commands/status.md +0 -90
  352. package/.claude/commands/validate.md +0 -288
  353. package/.claude/commands/verify.md +0 -269
  354. package/.claude/rules/workflow.md +0 -121
  355. package/.cline/workflows/dev.md +0 -342
  356. package/.cline/workflows/plan.md +0 -563
  357. package/.cline/workflows/premerge.md +0 -183
  358. package/.cline/workflows/research.md +0 -39
  359. package/.cline/workflows/review.md +0 -448
  360. package/.cline/workflows/rollback.md +0 -718
  361. package/.cline/workflows/ship.md +0 -210
  362. package/.cline/workflows/sonarcloud.md +0 -146
  363. package/.cline/workflows/status.md +0 -87
  364. package/.cline/workflows/validate.md +0 -285
  365. package/.cline/workflows/verify.md +0 -266
  366. package/.codex/config.toml +0 -11
  367. package/.codex/skills/dev/SKILL.md +0 -345
  368. package/.codex/skills/plan/SKILL.md +0 -566
  369. package/.codex/skills/premerge/SKILL.md +0 -186
  370. package/.codex/skills/research/SKILL.md +0 -42
  371. package/.codex/skills/review/SKILL.md +0 -451
  372. package/.codex/skills/rollback/SKILL.md +0 -721
  373. package/.codex/skills/ship/SKILL.md +0 -213
  374. package/.codex/skills/sonarcloud/SKILL.md +0 -149
  375. package/.codex/skills/status/SKILL.md +0 -90
  376. package/.codex/skills/validate/SKILL.md +0 -288
  377. package/.codex/skills/verify/SKILL.md +0 -269
  378. package/.cursor/commands/dev.md +0 -342
  379. package/.cursor/commands/plan.md +0 -563
  380. package/.cursor/commands/premerge.md +0 -183
  381. package/.cursor/commands/research.md +0 -39
  382. package/.cursor/commands/review.md +0 -448
  383. package/.cursor/commands/ship.md +0 -210
  384. package/.cursor/commands/sonarcloud.md +0 -146
  385. package/.cursor/commands/status.md +0 -87
  386. package/.cursor/commands/validate.md +0 -285
  387. package/.cursor/commands/verify.md +0 -266
  388. package/.cursorrules +0 -149
  389. package/.github/prompts/premerge.prompt.md +0 -188
  390. package/.github/prompts/research.prompt.md +0 -44
  391. package/.github/prompts/rollback.prompt.md +0 -723
  392. package/.github/prompts/verify.prompt.md +0 -271
  393. package/.github/workflows/beads-to-github.yml +0 -89
  394. package/.github/workflows/github-to-beads.yml +0 -100
  395. package/.kilocode/workflows/dev.md +0 -346
  396. package/.kilocode/workflows/plan.md +0 -567
  397. package/.kilocode/workflows/premerge.md +0 -187
  398. package/.kilocode/workflows/research.md +0 -43
  399. package/.kilocode/workflows/review.md +0 -452
  400. package/.kilocode/workflows/rollback.md +0 -722
  401. package/.kilocode/workflows/ship.md +0 -214
  402. package/.kilocode/workflows/sonarcloud.md +0 -150
  403. package/.kilocode/workflows/status.md +0 -91
  404. package/.kilocode/workflows/validate.md +0 -289
  405. package/.kilocode/workflows/verify.md +0 -270
  406. package/.opencode/commands/dev.md +0 -345
  407. package/.opencode/commands/plan.md +0 -566
  408. package/.opencode/commands/premerge.md +0 -186
  409. package/.opencode/commands/research.md +0 -42
  410. package/.opencode/commands/review.md +0 -451
  411. package/.opencode/commands/rollback.md +0 -721
  412. package/.opencode/commands/ship.md +0 -213
  413. package/.opencode/commands/sonarcloud.md +0 -149
  414. package/.opencode/commands/status.md +0 -90
  415. package/.opencode/commands/validate.md +0 -288
  416. package/.opencode/commands/verify.md +0 -269
  417. package/.roo/commands/dev.md +0 -346
  418. package/.roo/commands/plan.md +0 -567
  419. package/.roo/commands/premerge.md +0 -187
  420. package/.roo/commands/research.md +0 -43
  421. package/.roo/commands/review.md +0 -452
  422. package/.roo/commands/rollback.md +0 -722
  423. package/.roo/commands/ship.md +0 -214
  424. package/.roo/commands/sonarcloud.md +0 -150
  425. package/.roo/commands/status.md +0 -91
  426. package/.roo/commands/validate.md +0 -289
  427. package/.roo/commands/verify.md +0 -270
  428. package/docs/BEADS_GITHUB_SYNC.md +0 -281
  429. package/docs/GREPTILE_SETUP.md +0 -400
  430. package/docs/MANUAL_REVIEW_GUIDE.md +0 -106
  431. package/docs/SETUP.md +0 -663
  432. package/docs/VALIDATION.md +0 -363
  433. package/lib/agents/cline.plugin.json +0 -29
  434. package/lib/agents/copilot.plugin.json +0 -24
  435. package/lib/agents/kilocode.plugin.json +0 -22
  436. package/lib/agents/opencode.plugin.json +0 -23
  437. package/lib/agents/roo.plugin.json +0 -30
  438. package/lib/beads-bootstrap.js +0 -225
  439. package/lib/beads-health-check.js +0 -188
  440. package/lib/commands/commands-reset.js +0 -147
  441. package/opencode.json +0 -67
  442. package/scripts/beads-context.test.js +0 -584
  443. package/scripts/github-beads-sync/comment.mjs +0 -64
  444. package/scripts/github-beads-sync/config.mjs +0 -148
  445. package/scripts/github-beads-sync/github-api.mjs +0 -131
  446. package/scripts/github-beads-sync/index.mjs +0 -356
  447. package/scripts/github-beads-sync/label-mapper.mjs +0 -54
  448. package/scripts/github-beads-sync/mapping.mjs +0 -132
  449. package/scripts/github-beads-sync/reverse-sync-cli.mjs +0 -31
  450. package/scripts/github-beads-sync/reverse-sync.mjs +0 -162
  451. package/scripts/github-beads-sync/run-bd.mjs +0 -161
  452. package/scripts/github-beads-sync/sanitize.mjs +0 -121
  453. package/scripts/github-beads-sync.config.json +0 -26
  454. package/scripts/sync-commands.js +0 -600
package/lib/pr-pull.js ADDED
@@ -0,0 +1,1273 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PR pull-signal — the "extract WHY, hand back ONE compact fix-payload" half of
5
+ * the shepherd (issue 33e1bbd3).
6
+ *
7
+ * `forge shepherd <pr>` decides a STATE (MERGE_READY/PENDING/ESCALATE...). It
8
+ * does not tell an agent *why* a check failed or *what* review feedback to fix.
9
+ * Without this, an agent must manually run `gh pr checks`, `gh run view
10
+ * --log-failed`, grep the logs, and GraphQL-query threads — token-heavy and slow.
11
+ *
12
+ * `gatherPullSignal` does ALL of that IN CODE and returns one bounded payload:
13
+ * - `failures[]` — per FAILED check: name, conclusion, jobUrl, the ACTUAL
14
+ * failure lines pulled from that job's log (not the whole log), and
15
+ * `alsoFailedOn` (identical excerpts across matrix jobs collapse to one).
16
+ * - `reviewThreads[]` — unresolved, non-outdated threads that need action,
17
+ * INCLUDING review bots (CodeRabbit etc.) because those comments ARE the
18
+ * fixes — mapped to `{ file, line, author, body, threadId, commentId }`.
19
+ * - `state` + a one-line `summary` from the existing decision pass.
20
+ *
21
+ * It NEVER merges and NEVER resolves threads — it only reads and extracts. The
22
+ * state machine (lib/pr-shepherd.js) is untouched; this module composes over it.
23
+ *
24
+ * All `gh` I/O goes through an INJECTABLE runner (`runGh`) and a validated
25
+ * pr-state adapter, so unit tests exercise the extraction/dedupe/shaping logic
26
+ * against fixtures without ever touching real GitHub.
27
+ *
28
+ * @module pr-pull
29
+ */
30
+
31
+ const { isFailed, isGreen, runShepherdPass } = require('./pr-shepherd');
32
+ const { fenceUntrusted } = require('./untrusted-content');
33
+
34
+ /** Token caps that keep the payload bounded regardless of PR size. */
35
+ const DEFAULT_MAX_FAILURES = 10;
36
+ const DEFAULT_MAX_THREADS = 20;
37
+ const DEFAULT_MAX_EXCERPT_LINES = 30;
38
+ // Fetch a few more logs than we ultimately show, so matrix duplicates can
39
+ // collapse (via dedupe) BEFORE the maxFailures slice — otherwise N identical
40
+ // matrix failures would eat the whole failure budget and hide distinct ones.
41
+ const LOG_FETCH_MULTIPLIER = 3;
42
+ // Merge cannot be declared clean until activity has settled for this long — a
43
+ // re-review or a late reviewer comment inside the window means the PR is still
44
+ // in flight. Mirrors the merge-rules `settle_min` idea (default 10 minutes).
45
+ const DEFAULT_SETTLE_WINDOW_MS = 600000;
46
+
47
+ /**
48
+ * Bot logins whose review threads ARE actionable fixes (their comments tell you
49
+ * what to change). Distinct from pure-automation bots (github-actions, codecov,
50
+ * dependabot) whose threads are noise, not review feedback.
51
+ */
52
+ const REVIEW_BOT_LOGINS = new Set([
53
+ 'coderabbitai', 'coderabbitai[bot]',
54
+ 'greptile-apps', 'greptile-apps[bot]',
55
+ 'qodo-merge-pro', 'qodo-merge-pro[bot]',
56
+ 'sonarqubecloud', 'sonarqubecloud[bot]',
57
+ ]);
58
+
59
+ /** Pure-automation bots whose review THREADS are never review feedback. NOTE:
60
+ * codecov lives here for THREAD classification (its inline threads are noise),
61
+ * but its plain status COMMENT is still scanned for a failure signal by the
62
+ * bot-status scanner below — the two paths are deliberately independent so a
63
+ * failure-signalling status comment is never dropped as "automation noise". */
64
+ const AUTOMATION_BOT_LOGINS = new Set([
65
+ 'github-actions', 'github-actions[bot]',
66
+ 'codecov', 'codecov[bot]',
67
+ 'dependabot', 'dependabot[bot]',
68
+ ]);
69
+
70
+ /**
71
+ * Status/deploy/quality bots that report FAILURE by posting a plain PR ISSUE
72
+ * comment (a Quality-Gate/Deployment/coverage summary) rather than — or in
73
+ * addition to — a check-run or commit-status. Their latest comment is scanned
74
+ * for a failure signal by `buildBotStatusBlockers`. This is the FALLBACK path
75
+ * for bots that only comment; the structured statusCheckRollup signal (handled
76
+ * by classifyRequiredChecks) stays the primary, reliable catch.
77
+ */
78
+ const STATUS_BOT_LOGINS = new Set([
79
+ 'vercel', 'vercel[bot]',
80
+ 'netlify', 'netlify[bot]',
81
+ 'sonarqubecloud', 'sonarqubecloud[bot]',
82
+ 'codecov', 'codecov[bot]', 'codecov-commenter',
83
+ 'cloudflare-pages', 'cloudflare-pages[bot]',
84
+ 'cloudflare-workers-and-pages', 'cloudflare-workers-and-pages[bot]',
85
+ 'render', 'render[bot]',
86
+ ]);
87
+
88
+ // A line in a bot status comment that signals FAILURE / not-ready: an explicit
89
+ // failed quality gate, a failed/errored deployment, dropped coverage, or a
90
+ // failure glyph. Kept line-scoped (matched per line) so an unrelated word in a
91
+ // success comment ("0 failed") is unlikely to trip it, and so the matched line
92
+ // itself becomes the human-readable summary.
93
+ // Split across several smaller alternations (grouped by theme) rather than one
94
+ // giant regex — each stays simple to read and none is individually over-complex.
95
+ // `botFailureSummary` matches a line against ANY of them.
96
+ const BOT_STATUS_FAILURE_PATTERNS = [
97
+ /quality gate failed|failed the quality gate/i,
98
+ /deployment (?:has )?failed|deploy(?:ment)? (?:error|errored)|failed to deploy/i,
99
+ /build failed|coverage (?:decreased|dropped|declined|reduced)|patch coverage[^\n]*\bfail/i,
100
+ /❌|✖|✗|:x:|:no_entry(?:_sign)?:|⛔/,
101
+ ];
102
+
103
+ // Signals that a log line is part of the actual failure (test framework "fail"
104
+ // markers, assertion diffs, error prose) rather than passing/progress noise.
105
+ const FAILURE_SIGNAL = /\(fail\)|✗|✘|✖|×|\bFAIL(?:ED|URE)?\b|\bError:|\berror:|AssertionError|Assertion failed|expect\(|Expected:|Received:|^\s*not ok\b|npm ERR!/;
106
+
107
+ /**
108
+ * Strip the `gh run view --log[-failed]` prefix from a line.
109
+ *
110
+ * gh emits `jobName\tstepName\t<ISO-timestamp> content` (and sometimes a bare
111
+ * leading timestamp). Removing everything up to and including the timestamp
112
+ * leaves just the content — which is what makes two matrix jobs' excerpts
113
+ * byte-identical (the job-name/timestamp prefix is the only thing that differs).
114
+ *
115
+ * @param {string} line
116
+ * @returns {string}
117
+ */
118
+ function cleanLogLine(line) {
119
+ const s = String(line).replace(/\r$/, '');
120
+ const m = s.match(/\d{4}-\d{2}-\d{2}T[\d:.]+Z\s?/);
121
+ if (m) return s.slice(m.index + m[0].length);
122
+ return s;
123
+ }
124
+
125
+ /**
126
+ * Extract the ACTUAL failure lines from a raw job log — the `(fail)` test lines,
127
+ * assertion diffs, and error text — not the whole log. Falls back to the log
128
+ * tail (where the error usually lands) when no failure signal is present.
129
+ * Repeated identical lines collapse; the result is capped at `maxLines`.
130
+ *
131
+ * @param {string} logText
132
+ * @param {{ maxLines?: number }} [opts]
133
+ * @returns {string} newline-joined excerpt
134
+ */
135
+ function extractFailureExcerpt(logText, opts = {}) {
136
+ const maxLines = opts.maxLines || DEFAULT_MAX_EXCERPT_LINES;
137
+ const cleaned = String(logText || '').split(/\r?\n/).map(cleanLogLine);
138
+ const nonEmpty = cleaned.filter((l) => l.trim());
139
+ const signal = nonEmpty.filter((l) => FAILURE_SIGNAL.test(l));
140
+ const chosen = signal.length > 0 ? signal : nonEmpty.slice(-maxLines);
141
+
142
+ // Collapse duplicate lines (matrix logs repeat the same assertion many times)
143
+ // while preserving first-seen order.
144
+ const seen = new Set();
145
+ const unique = [];
146
+ for (const line of chosen) {
147
+ if (seen.has(line)) continue;
148
+ seen.add(line);
149
+ unique.push(line);
150
+ }
151
+ return unique.slice(0, maxLines).join('\n');
152
+ }
153
+
154
+ /**
155
+ * Pull the numeric job id out of an Actions "details" URL
156
+ * (`.../actions/runs/<run>/job/<job>`). Returns null when absent.
157
+ *
158
+ * @param {string} url
159
+ * @returns {string | null}
160
+ */
161
+ function jobIdFromUrl(url) {
162
+ const m = String(url || '').match(/\/job\/(\d+)/);
163
+ return m ? m[1] : null;
164
+ }
165
+
166
+ /**
167
+ * Collapse identical failure excerpts across matrix jobs into ONE entry, keeping
168
+ * the first job as the representative and recording how many OTHER jobs shared
169
+ * the identical failure in `alsoFailedOn` (0 when unique). Distinct failures
170
+ * with an empty excerpt are NOT merged (an empty excerpt is not evidence of
171
+ * sameness).
172
+ *
173
+ * @param {Array<{name:string,conclusion:string,jobUrl:string,excerpt:string}>} rawFailures
174
+ * @returns {Array<{name:string,conclusion:string,jobUrl:string,excerpt:string,alsoFailedOn:number}>}
175
+ */
176
+ function dedupeFailures(rawFailures) {
177
+ const groups = new Map();
178
+ const order = [];
179
+ (Array.isArray(rawFailures) ? rawFailures : []).forEach((f, index) => {
180
+ const excerpt = String(f.excerpt || '');
181
+ // Empty excerpts get a per-item key so unrelated "no evidence" failures stay
182
+ // separate instead of collapsing into a single misleading entry.
183
+ const key = excerpt.trim() ? excerpt : `__empty__${index}`;
184
+ if (!groups.has(key)) {
185
+ groups.set(key, { rep: f, count: 0 });
186
+ order.push(key);
187
+ } else {
188
+ groups.get(key).count += 1;
189
+ }
190
+ });
191
+ return order.map((key) => {
192
+ const { rep, count } = groups.get(key);
193
+ return {
194
+ name: rep.name,
195
+ conclusion: rep.conclusion,
196
+ jobUrl: rep.jobUrl,
197
+ excerpt: String(rep.excerpt || ''),
198
+ alsoFailedOn: count,
199
+ };
200
+ });
201
+ }
202
+
203
+ function commentAuthorClass(author) {
204
+ const a = String(author || '').toLowerCase();
205
+ if (!a) return 'unknown';
206
+ if (REVIEW_BOT_LOGINS.has(a)) return 'review-bot';
207
+ if (AUTOMATION_BOT_LOGINS.has(a)) return 'automation';
208
+ return 'human';
209
+ }
210
+
211
+ /** Lowercased login of an issue comment ({author:{login}} or a bare string). */
212
+ function issueCommentLogin(comment) {
213
+ return String(comment.author?.login || comment.author || '').toLowerCase();
214
+ }
215
+
216
+ /** Drop the trailing `[bot]` suffix for a readable bot name in the blocker text. */
217
+ function prettyBotName(login) {
218
+ return String(login || '').replace(/\[bot\]$/i, '');
219
+ }
220
+
221
+ /**
222
+ * The first line of a bot comment body that signals FAILURE, trimmed and capped —
223
+ * or null when the comment shows no failure signal (i.e. it is healthy/ready, so
224
+ * an earlier failure has been superseded).
225
+ */
226
+ function botFailureSummary(body) {
227
+ const lines = String(body || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
228
+ for (const line of lines) {
229
+ if (BOT_STATUS_FAILURE_PATTERNS.some((re) => re.test(line))) return line.slice(0, 200);
230
+ }
231
+ return null;
232
+ }
233
+
234
+ /**
235
+ * Scan plain PR ISSUE comments from known status/deploy/quality bots and surface
236
+ * an actionable blocker for each bot whose LATEST comment signals failure.
237
+ *
238
+ * ACTIONABLE-ONLY + SUPERSESSION: only the newest comment per bot (by
239
+ * `createdAt`, not array order) is examined — a later success comment supersedes
240
+ * an earlier failure, and a bot edit-in-place is reflected in that one comment's
241
+ * final body. Comments from non-status bots and humans are ignored (their
242
+ * feedback flows through the review-thread path, not here). Capped at `cap`.
243
+ *
244
+ * @param {Array<{author:(object|string),body:string,createdAt?:string}>} comments
245
+ * @param {{ cap?: number }} [opts]
246
+ * @returns {Array<{ type: 'bot-status', detail: string }>}
247
+ */
248
+ function buildBotStatusBlockers(comments, opts = {}) {
249
+ const cap = opts.cap || DEFAULT_MAX_THREADS;
250
+ const latestByBot = new Map();
251
+ for (const c of (Array.isArray(comments) ? comments : [])) {
252
+ const login = issueCommentLogin(c);
253
+ if (!STATUS_BOT_LOGINS.has(login)) continue;
254
+ const ts = Date.parse(c.createdAt || c.updatedAt || '') || 0;
255
+ const prev = latestByBot.get(login);
256
+ // `>=` so a same-timestamp (or timestamp-less) tie keeps the LATER array
257
+ // entry — GitHub returns issue comments oldest-first.
258
+ if (!prev || ts >= prev.ts) latestByBot.set(login, { ts, login, body: String(c.body || '') });
259
+ }
260
+ const out = [];
261
+ for (const { login, body } of latestByBot.values()) {
262
+ const summary = botFailureSummary(body);
263
+ if (!summary) continue; // latest comment is healthy → superseded, not actionable
264
+ out.push({
265
+ type: 'bot-status',
266
+ detail: `${prettyBotName(login)} reports a failing status: ${summary}`,
267
+ });
268
+ if (out.length >= cap) break;
269
+ }
270
+ return out;
271
+ }
272
+
273
+ /**
274
+ * Filter review threads to the ones that need action and map them to the compact
275
+ * fix shape. Actionable = unresolved AND not outdated AND authored (in at least
276
+ * one comment) by a human OR a REVIEW bot (CodeRabbit et al.), excluding the
277
+ * shepherd's own login and pure-automation bots. Capped at `maxThreads`.
278
+ *
279
+ * @param {object[]} threads - from adapter.readComments
280
+ * @param {string} [self] - the shepherd's own login (excluded to avoid self-wake)
281
+ * @param {{ maxThreads?: number }} [opts]
282
+ * @returns {Array<{file:string|null,line:number|null,author:string,body:string,threadId:string|null,commentId:string|null}>}
283
+ */
284
+ function buildReviewThreads(threads, self, opts = {}) {
285
+ const maxThreads = opts.maxThreads || DEFAULT_MAX_THREADS;
286
+ const selfLower = String(self || '').toLowerCase();
287
+ const out = [];
288
+ for (const t of (Array.isArray(threads) ? threads : [])) {
289
+ if (t.isResolved || t.resolved || t.isOutdated || t.outdated) continue;
290
+ const comments = Array.isArray(t.comments) ? t.comments : [];
291
+ // Anchor on the first comment whose author is a human or a review bot and is
292
+ // not the shepherd itself — that comment carries the fix to act on.
293
+ const anchor = comments.find((c) => {
294
+ const author = String((c.author && c.author.login) || c.author || '').toLowerCase();
295
+ if (!author || author === selfLower) return false;
296
+ const cls = commentAuthorClass(author);
297
+ return cls === 'human' || cls === 'review-bot';
298
+ });
299
+ if (!anchor) continue;
300
+ out.push({
301
+ file: t.path || null,
302
+ line: typeof t.line === 'number' ? t.line : null,
303
+ author: String((anchor.author && anchor.author.login) || anchor.author || ''),
304
+ body: String(anchor.body || ''),
305
+ threadId: t.threadId || t.id || null,
306
+ commentId: anchor.commentId || anchor.databaseId || anchor.id || null,
307
+ });
308
+ if (out.length >= maxThreads) break;
309
+ }
310
+ return out;
311
+ }
312
+
313
+ /** A check whose conclusion is SKIPPED — green-ish by `isGreen`, but for a
314
+ * REQUIRED context a skip means the gate never actually ran to success, so
315
+ * branch protection keeps the PR blocked. Detected separately from real greens. */
316
+ function isSkipped(check) {
317
+ return String(check.conclusion || '').toUpperCase() === 'SKIPPED';
318
+ }
319
+
320
+ /** A check still in flight (not green, not failed) — e.g. IN_PROGRESS/QUEUED or
321
+ * a status context with no conclusion yet. */
322
+ function isPending(check) {
323
+ return !isGreen(check) && !isFailed(check);
324
+ }
325
+
326
+ /**
327
+ * Classify the branch-protection REQUIRED set against what the PR actually
328
+ * produced — the ONLY reliable way to explain a PR that is BLOCKED while every
329
+ * visible check is green. A required context is:
330
+ * - `missing` — it never reported at all (a workflow that didn't trigger);
331
+ * - `skipped` — every instance resolved SKIPPED (the policy-block cause: a
332
+ * required gate that skipped is NOT a success to branch protection);
333
+ * - `failing` — any instance failed;
334
+ * - `pending` — still running / not yet reported a conclusion.
335
+ * Green required checks are intentionally OMITTED — the payload is actionable-only.
336
+ * Matrix duplicates (same context name reported by multiple jobs) are aggregated
337
+ * with failing > pending > skipped > green precedence.
338
+ *
339
+ * @param {object[]} checks - normalized rollup from readState.
340
+ * @param {string[]|null} requiredSet - branch-protection contexts, or null when unreadable.
341
+ * @returns {{ missing: string[], skipped: string[], pending: string[], failing: string[], unreadable: boolean }}
342
+ */
343
+ function classifyRequiredChecks(checks, requiredSet) {
344
+ if (!Array.isArray(requiredSet)) {
345
+ return { missing: [], skipped: [], pending: [], failing: [], unreadable: true };
346
+ }
347
+ const byName = new Map();
348
+ for (const c of (Array.isArray(checks) ? checks : [])) {
349
+ const name = c.name || c.context || '';
350
+ if (!byName.has(name)) byName.set(name, []);
351
+ byName.get(name).push(c);
352
+ }
353
+ const missing = []; const skipped = []; const pending = []; const failing = [];
354
+ for (const name of requiredSet) {
355
+ const instances = byName.get(name);
356
+ if (!instances || instances.length === 0) { missing.push(name); continue; }
357
+ if (instances.some(isFailed)) { failing.push(name); continue; }
358
+ if (instances.some(isPending)) { pending.push(name); continue; }
359
+ // All instances are green-ish. If EVERY instance only ever skipped, the
360
+ // required gate did not truly pass → policy block.
361
+ if (instances.every(isSkipped)) { skipped.push(name); continue; }
362
+ // otherwise a genuine success — omitted (actionable-only).
363
+ }
364
+ return { missing, skipped, pending, failing, unreadable: false };
365
+ }
366
+
367
+ /**
368
+ * The unique names of checks still pending (any check, not just required),
369
+ * deduped and bounded — surfaced so an agent knows the PR is simply not done yet
370
+ * versus actively broken.
371
+ */
372
+ function pendingCheckNames(checks, cap = DEFAULT_MAX_THREADS) {
373
+ const seen = new Set();
374
+ for (const c of (Array.isArray(checks) ? checks : [])) {
375
+ if (isPending(c)) seen.add(c.name || c.context || '');
376
+ }
377
+ return [...seen].filter(Boolean).slice(0, cap);
378
+ }
379
+
380
+ const AND_LIST_CAP = 6;
381
+
382
+ /** Join a list of names for a blocker detail, truncating with a count. */
383
+ function joinNames(names) {
384
+ const list = names.slice(0, AND_LIST_CAP);
385
+ const extra = names.length - list.length;
386
+ return extra > 0 ? `${list.join(', ')} (+${extra} more)` : list.join(', ');
387
+ }
388
+
389
+ /** Push `item` onto `arr` only when it is truthy (small helper so the blocker
390
+ * assembly reads as a flat list without repeated `if`/`push` nesting). */
391
+ function pushMaybe(arr, item) {
392
+ if (item) arr.push(item);
393
+ }
394
+
395
+ /** The draft blocker, or null when the PR is not a draft. */
396
+ function draftBlocker(draft) {
397
+ return draft
398
+ ? { type: 'draft', detail: 'PR is a draft — mark it "Ready for review" before it can merge.' }
399
+ : null;
400
+ }
401
+
402
+ /**
403
+ * The conflict blocker. A predicted conflict (`conflicts.conflicted === true`)
404
+ * ALWAYS produces a blocker — even when the `files` list is empty/unparseable and
405
+ * `mergeable`/`mergeStateStatus` don't literally say CONFLICTING/DIRTY — so the
406
+ * payload's `conflicts` object and `blockers[]` never disagree. Falls back to the
407
+ * mergeable/status signal when no file-level prediction is available.
408
+ */
409
+ function conflictBlocker(conflicts, merge, status) {
410
+ if (conflicts && conflicts.conflicted) {
411
+ const files = Array.isArray(conflicts.files) ? conflicts.files : [];
412
+ const detail = files.length > 0
413
+ ? `Merge conflict in ${files.length} file(s): ${joinNames(files)} — resolve against base.`
414
+ : 'Merge conflict detected against base — resolve and push.';
415
+ return { type: 'conflict', detail };
416
+ }
417
+ if (merge === 'CONFLICTING' || status === 'DIRTY') {
418
+ return { type: 'conflict', detail: 'Branch conflicts with base (mergeable=CONFLICTING) — rebase/merge base and resolve.' };
419
+ }
420
+ return null;
421
+ }
422
+
423
+ /** All required-check blockers (failing / missing / skipped / pending) in
424
+ * most-actionable-first order. Empty when the required set is all green. */
425
+ function requiredCheckBlockers(rc) {
426
+ const out = [];
427
+ if ((rc.failing || []).length > 0) {
428
+ out.push({ type: 'check-failing', detail: `Required check(s) failing: ${joinNames(rc.failing)} — see failures[] for the exact log excerpt.` });
429
+ }
430
+ if ((rc.missing || []).length > 0) {
431
+ out.push({ type: 'check-missing', detail: `Required check(s) never reported: ${joinNames(rc.missing)} — the workflow did not trigger; push a commit or re-run CI.` });
432
+ }
433
+ if ((rc.skipped || []).length > 0) {
434
+ out.push({ type: 'check-skipped', detail: `Required check(s) SKIPPED: ${joinNames(rc.skipped)} — a required gate that skips is NOT a pass to branch protection; it must run to success (this is why an all-green PR can stay BLOCKED).` });
435
+ }
436
+ if ((rc.pending || []).length > 0) {
437
+ out.push({ type: 'check-pending', detail: `Required check(s) still running: ${joinNames(rc.pending)} — wait for them to finish.` });
438
+ }
439
+ return out;
440
+ }
441
+
442
+ /** The review-decision blocker (changes requested / review required), or null
443
+ * when the decision is APPROVED or not required (actionable-only). */
444
+ function reviewDecisionBlocker(decision) {
445
+ if (decision === 'CHANGES_REQUESTED') {
446
+ return { type: 'changes-requested', detail: 'A reviewer requested changes — address the feedback and re-request review.' };
447
+ }
448
+ if (decision === 'REVIEW_REQUIRED') {
449
+ return { type: 'review-required', detail: 'An approving review is still required before merge.' };
450
+ }
451
+ return null;
452
+ }
453
+
454
+ /**
455
+ * Last-resort blocker for a PR that is NOT clean yet nothing concrete explained
456
+ * why. Emitted ONLY when no specific blocker fired, so a non-mergeable PR is
457
+ * never silently invisible (the #353 failure mode). Covers, in priority order:
458
+ * - unreadable required checks (a branch-protection 403 must not stay hidden);
459
+ * - UNSTABLE with a failing non-required check;
460
+ * - BLOCKED by branch protection;
461
+ * - any OTHER non-clean, known status (BEHIND, HAS_HOOKS, …) whose cause wasn't
462
+ * otherwise derivable — e.g. BEHIND when the commit count was unavailable.
463
+ * A clean or unknown status yields no fallback.
464
+ */
465
+ function fallbackBlocker(status, rc, failuresCount) {
466
+ if (rc.unreadable) {
467
+ return { type: 'check-required-unreadable', detail: 'Branch-protection required checks could not be read (e.g. a 403 from the protection API) — cannot confirm the required gates passed; verify branch-protection settings and token permissions.' };
468
+ }
469
+ if (status === 'UNSTABLE') {
470
+ return failuresCount > 0
471
+ ? { type: 'unstable', detail: 'A non-required check is failing (mergeStateStatus=UNSTABLE). It does not gate merge but is worth fixing — see failures[].' }
472
+ : null;
473
+ }
474
+ if (status === 'BLOCKED') {
475
+ return { type: 'blocked-unknown', detail: 'Merge is blocked by branch protection (mergeStateStatus=BLOCKED) but no failing check, missing/skipped required check, unresolved thread, or negative review was detected — check required reviews, code-owner approval, or other protection rules.' };
476
+ }
477
+ if (status && status !== 'CLEAN' && status !== 'UNKNOWN') {
478
+ return { type: 'blocked-unknown', detail: `Merge is not clean (mergeStateStatus=${status}) but no specific cause was derivable — update the branch and re-check required gates, reviews, or protection rules.` };
479
+ }
480
+ return null;
481
+ }
482
+
483
+ /**
484
+ * Compute the ordered, deduped list of concrete merge BLOCKERS — the
485
+ * human-readable WHY behind `mergeStateStatus`. Ordered most-actionable-first so
486
+ * an agent fixes the real gate, not a symptom. Every entry is something to ACT
487
+ * on; nothing that is already satisfied is listed (actionable-only). When the
488
+ * status is non-clean but no specific cause is derivable from the available
489
+ * signals, a single explicit fallback blocker is emitted so the block is never
490
+ * silently invisible (the #353 failure mode). Per-concern logic lives in the
491
+ * small helpers above; this function just orders and assembles them.
492
+ *
493
+ * @param {object} args
494
+ * @returns {Array<{ type: string, detail: string }>}
495
+ */
496
+ function computeBlockers({
497
+ mergeable,
498
+ mergeStateStatus,
499
+ draft = false,
500
+ reviewDecision = null,
501
+ requiredClass = { missing: [], skipped: [], pending: [], failing: [], unreadable: false },
502
+ botStatusBlockers = [],
503
+ unresolvedThreadCount = 0,
504
+ behind = 0,
505
+ conflicts = null,
506
+ failuresCount = 0,
507
+ }) {
508
+ const status = String(mergeStateStatus || '').toUpperCase();
509
+ const merge = String(mergeable || '').toUpperCase();
510
+ const decision = String(reviewDecision || '').toUpperCase();
511
+ const rc = requiredClass || {};
512
+ const out = [];
513
+
514
+ pushMaybe(out, draftBlocker(draft));
515
+ pushMaybe(out, conflictBlocker(conflicts, merge, status));
516
+ // Required-check blockers, then bot status/deploy/quality comment failures
517
+ // (Vercel/SonarCloud/Codecov...) — as actionable as a failing check, so they
518
+ // slot in right after them. Combined into ONE push.
519
+ out.push(
520
+ ...requiredCheckBlockers(rc),
521
+ ...(Array.isArray(botStatusBlockers) ? botStatusBlockers : []),
522
+ );
523
+ if (behind > 0) {
524
+ out.push({ type: 'behind', detail: `Branch is ${behind} commit(s) behind base — update/rebase the branch (protection requires branches be up to date).` });
525
+ }
526
+ pushMaybe(out, reviewDecisionBlocker(decision));
527
+ if (unresolvedThreadCount > 0) {
528
+ out.push({ type: 'unresolved-threads', detail: `${unresolvedThreadCount} unresolved review thread(s) must be resolved before merge (see reviewThreads[]).` });
529
+ }
530
+
531
+ // Nothing concrete explained a non-clean status → make the block visible.
532
+ if (out.length === 0) {
533
+ pushMaybe(out, fallbackBlocker(status, rc, failuresCount));
534
+ }
535
+
536
+ return out;
537
+ }
538
+
539
+ /** Summary lines for the blockers section (numbered, or a "none" note). */
540
+ function blockerLines(blockers) {
541
+ if (blockers.length === 0) {
542
+ return ['Blockers: none detected (nothing actionable this pass).'];
543
+ }
544
+ const lines = [`Blockers (${blockers.length}):`];
545
+ blockers.forEach((b, i) => lines.push(` ${i + 1}. [${b.type}] ${b.detail}`));
546
+ return lines;
547
+ }
548
+
549
+ /** Summary lines for the failing-checks section (empty when none). */
550
+ function failureLines(failures) {
551
+ if (failures.length === 0) return [];
552
+ const lines = [`Failing checks (${failures.length}):`];
553
+ for (const f of failures) {
554
+ const also = f.alsoFailedOn ? ` (+${f.alsoFailedOn} matrix job(s))` : '';
555
+ lines.push(` • ${f.name}${also}`);
556
+ const first = String(f.excerpt || '').split('\n').filter(Boolean)[0];
557
+ // CI-log excerpts are untrusted external text; fence before surfacing to the agent.
558
+ if (first) lines.push(` ${fenceUntrusted(first, { source: 'ci-log' })}`);
559
+ }
560
+ return lines;
561
+ }
562
+
563
+ /** Summary lines for the unresolved-review-threads section (empty when none). */
564
+ function threadLines(threads) {
565
+ if (threads.length === 0) return [];
566
+ const lines = [`Unresolved review threads (${threads.length}):`];
567
+ for (const t of threads) {
568
+ const loc = t.file ? `${t.file}${t.line != null ? `:${t.line}` : ''}` : '(general)';
569
+ const firstLine = String(t.body || '').split('\n').filter(Boolean)[0] || '';
570
+ // Review-comment bodies are untrusted external text; fence before surfacing to the agent.
571
+ const fenced = fenceUntrusted(firstLine.slice(0, 120), { source: 'pr-review-comment' });
572
+ lines.push(` • ${loc} — ${t.author}: ${fenced}`);
573
+ }
574
+ return lines;
575
+ }
576
+
577
+ /**
578
+ * Render the bounded payload as a compact human-readable summary (the non-JSON
579
+ * view). Actionable-only, so a maintainer reads exactly what to fix. Section
580
+ * construction lives in the small helpers above; this function just orders them.
581
+ *
582
+ * @param {object} payload - a payload produced by buildPullPayload.
583
+ * @returns {string}
584
+ */
585
+ function renderPullSummary(payload) {
586
+ const p = payload || {};
587
+ const lines = [];
588
+ const merge = `mergeable=${p.mergeable || 'UNKNOWN'}, mergeStateStatus=${p.mergeStateStatus || 'UNKNOWN'}`;
589
+ lines.push(`PR #${p.pr || '?'} — ${p.state || 'UNKNOWN'} (${merge})`);
590
+ if (p.summary) lines.push(p.summary);
591
+ lines.push(...blockerLines(Array.isArray(p.blockers) ? p.blockers : []));
592
+ lines.push(...failureLines(Array.isArray(p.failures) ? p.failures : []));
593
+ lines.push(...threadLines(Array.isArray(p.reviewThreads) ? p.reviewThreads : []));
594
+ return lines.join('\n');
595
+ }
596
+
597
+ /** `{ pr }` (stringified) when a PR number is given, else `{}`. */
598
+ function prField(pr) {
599
+ return pr !== undefined ? { pr: String(pr) } : {};
600
+ }
601
+
602
+ /** Surface reviewDecision only when it is a BLOCKER (actionable-only): APPROVED
603
+ * and "not required" ('') are omitted. */
604
+ function reviewDecisionField(reviewDecision) {
605
+ return (reviewDecision === 'CHANGES_REQUESTED' || reviewDecision === 'REVIEW_REQUIRED')
606
+ ? { reviewDecision }
607
+ : {};
608
+ }
609
+
610
+ /**
611
+ * Actionable-only required-check block: included only when SOMETHING about the
612
+ * required set needs attention (missing/skipped/pending/failing) or it was
613
+ * unreadable. All-green required sets are omitted entirely.
614
+ */
615
+ function requiredChecksField(requiredChecks) {
616
+ const rc = requiredChecks || {};
617
+ const hasMissing = !!(rc.missing && rc.missing.length);
618
+ const hasSkipped = !!(rc.skipped && rc.skipped.length);
619
+ const hasPending = !!(rc.pending && rc.pending.length);
620
+ const hasFailing = !!(rc.failing && rc.failing.length);
621
+ if (!(hasMissing || hasSkipped || hasPending || hasFailing || rc.unreadable)) return {};
622
+ return {
623
+ requiredChecks: {
624
+ ...(hasMissing ? { missing: rc.missing } : {}),
625
+ ...(hasSkipped ? { skipped: rc.skipped } : {}),
626
+ ...(hasPending ? { pending: rc.pending } : {}),
627
+ ...(hasFailing ? { failing: rc.failing } : {}),
628
+ ...(rc.unreadable ? { unreadable: true } : {}),
629
+ },
630
+ };
631
+ }
632
+
633
+ /** `{ conflicts }` when a conflict is predicted, else `{}`. Mirrors the
634
+ * conflict blocker: any `conflicted === true` surfaces here. */
635
+ function conflictsField(conflicts) {
636
+ return (conflicts && conflicts.conflicted)
637
+ ? { conflicts: { conflicted: true, files: Array.isArray(conflicts.files) ? conflicts.files : [] } }
638
+ : {};
639
+ }
640
+
641
+ /**
642
+ * Assemble the final bounded payload, enforcing every token cap (failures,
643
+ * threads, per-excerpt line count) and flagging truncation so a consumer knows
644
+ * the view was trimmed. Carries the full actionable-only blocker picture:
645
+ * mergeability + WHY (`blockers`), required-check classification, pending checks,
646
+ * behind-base, conflicts, review decision, and draft state. The conditional
647
+ * field construction lives in the small helpers above.
648
+ *
649
+ * @param {object} args
650
+ * @returns {object}
651
+ */
652
+ function buildPullPayload({
653
+ pr,
654
+ state,
655
+ verdict,
656
+ evidence,
657
+ degraded = [],
658
+ summary,
659
+ reason,
660
+ mergeable = 'UNKNOWN',
661
+ mergeStateStatus = 'UNKNOWN',
662
+ draft = false,
663
+ reviewDecision = null,
664
+ blockers = [],
665
+ requiredChecks = null,
666
+ pendingChecks = [],
667
+ behind = 0,
668
+ conflicts = null,
669
+ failures = [],
670
+ reviewThreads = [],
671
+ maxFailures = DEFAULT_MAX_FAILURES,
672
+ maxThreads = DEFAULT_MAX_THREADS,
673
+ maxExcerptLines = DEFAULT_MAX_EXCERPT_LINES,
674
+ }) {
675
+ const cappedFailures = failures.slice(0, maxFailures).map((f) => ({
676
+ ...f,
677
+ excerpt: String(f.excerpt || '').split('\n').slice(0, maxExcerptLines).join('\n'),
678
+ }));
679
+ const cappedThreads = reviewThreads.slice(0, maxThreads);
680
+
681
+ return {
682
+ ...prField(pr),
683
+ state,
684
+ // `verdict` is the trustworthy, fail-closed merge signal (never false-clean);
685
+ // `state` remains the legacy decision-pass state for back-compat.
686
+ ...(verdict ? { verdict } : {}),
687
+ ...(evidence ? { evidence } : {}),
688
+ summary,
689
+ ...(reason ? { reason } : {}),
690
+ // Surfaced (not swallowed): which reads degraded and why — empty on a clean gather.
691
+ ...(degraded && degraded.length ? { degraded } : {}),
692
+ mergeable,
693
+ mergeStateStatus,
694
+ ...(draft ? { draft: true } : {}),
695
+ ...reviewDecisionField(reviewDecision),
696
+ blockers,
697
+ ...requiredChecksField(requiredChecks),
698
+ ...(pendingChecks && pendingChecks.length ? { pendingChecks } : {}),
699
+ ...(behind > 0 ? { behind } : {}),
700
+ ...conflictsField(conflicts),
701
+ failures: cappedFailures,
702
+ reviewThreads: cappedThreads,
703
+ truncated: {
704
+ failures: failures.length > maxFailures,
705
+ reviewThreads: reviewThreads.length > maxThreads,
706
+ },
707
+ };
708
+ }
709
+
710
+ /**
711
+ * Order failed checks so REQUIRED ones are diagnosed first (they gate merge),
712
+ * then bound how many logs we fetch.
713
+ */
714
+ function orderFailedChecks(checks, requiredSet) {
715
+ const required = Array.isArray(requiredSet) ? requiredSet : [];
716
+ const failed = (Array.isArray(checks) ? checks : []).filter(isFailed);
717
+ return failed
718
+ .map((c, i) => ({ c, i, req: required.includes(c.name) ? 0 : 1 }))
719
+ .sort((a, b) => (a.req - b.req) || (a.i - b.i))
720
+ .map((x) => x.c);
721
+ }
722
+
723
+ /**
724
+ * One-line human summary of the pull signal — leads with the primary (first,
725
+ * most-actionable) blocker so the WHY is legible at a glance.
726
+ */
727
+ function summarize({ state, failureCount, threadCount, blockers = [] }) {
728
+ const parts = [];
729
+ parts.push(`${failureCount} failing check${failureCount === 1 ? '' : 's'}`);
730
+ parts.push(`${threadCount} review thread${threadCount === 1 ? '' : 's'} to address`);
731
+ const primary = (Array.isArray(blockers) && blockers[0]) ? ` Primary blocker: ${blockers[0].detail}` : '';
732
+ return `${state}: ${parts.join(', ')}.${primary}`;
733
+ }
734
+
735
+ // --- AGNOSTIC ACTOR CLASSIFICATION (by mechanism, not by a hardcoded name
736
+ // list). New/unknown review bots exist that we cannot enumerate; a "known
737
+ // actionable" name list would fail closed the WRONG way (an unknown bot's signal
738
+ // silently ignored → false-clean). So: classify by HOW the signal arrived, and
739
+ // default an unrecognized actor to BLOCKING.
740
+ // - THREADS: any unresolved, non-outdated review thread blocks, author-agnostic.
741
+ // - CHECKS/STATUS: any failing/pending/skipped-required check OR a failing
742
+ // bot-status comment blocks — no name knowledge needed.
743
+ // - DIRECT COMMENTS: a non-human top-level comment newer than the last head
744
+ // push blocks, UNLESS its author is on the small SUPPRESSION allowlist below.
745
+ // The ONLY name list is that suppression allowlist, and it INVERTS the old model:
746
+ // it lists KNOWN-safe-to-suppress status bots; every OTHER bot (known or unknown)
747
+ // is actionable.
748
+
749
+ /** Suppression ALLOWLIST: status/deploy/quality bots whose failing gate already
750
+ * surfaces via a check or the bot-status-comment scanner (BLOCKED-CHECKS), so
751
+ * their plain PR comment must NOT also drive an unresolvable BLOCKED-THREADS.
752
+ * Reuses STATUS_BOT_LOGINS — the same bots buildBotStatusBlockers scans. Every
753
+ * bot NOT on this list is treated as actionable (fail-closed for unknowns). */
754
+ const SUPPRESSED_COMMENT_BOT_LOGINS = STATUS_BOT_LOGINS;
755
+
756
+ /** Detect a NON-HUMAN comment author by MECHANISM: the GraphQL actor type is a
757
+ * Bot, or the login carries the GitHub App `[bot]` suffix. No name list. */
758
+ function isBotAuthor(comment) {
759
+ if (!comment) return false;
760
+ if (String(comment.authorTypename || '') === 'Bot') return true;
761
+ return String(comment.author || '').toLowerCase().endsWith('[bot]');
762
+ }
763
+
764
+ /** A bot's direct PR comment is ACTIONABLE (can block) unless it is the
765
+ * shepherd's own comment or a suppressed status/deploy/quality bot. Humans do
766
+ * not block via direct comments (they block via threads/reviews). */
767
+ function isActionableBotComment(comment, self) {
768
+ const login = String(comment?.author || '').toLowerCase();
769
+ if (!login || login === String(self || '').toLowerCase()) return false;
770
+ if (!isBotAuthor(comment)) return false;
771
+ return !SUPPRESSED_COMMENT_BOT_LOGINS.has(login);
772
+ }
773
+
774
+ /** Count unresolved, non-outdated review threads AUTHOR-AGNOSTICALLY — any such
775
+ * thread blocks regardless of who opened it (human OR any bot, known or not). */
776
+ function countUnresolvedThreads(threads) {
777
+ return (Array.isArray(threads) ? threads : [])
778
+ .filter((t) => !(t.isResolved || t.resolved || t.isOutdated || t.outdated))
779
+ .length;
780
+ }
781
+
782
+ /** Parse an ISO timestamp to epoch ms, or null when unparseable. */
783
+ function parseTime(s) {
784
+ const t = Date.parse(String(s || ''));
785
+ return Number.isFinite(t) ? t : null;
786
+ }
787
+
788
+ /** The ONLY mergeStateStatus values from which CLEAN-MERGEABLE is reachable.
789
+ * Everything else — UNKNOWN, '', BLOCKED, BEHIND, DIRTY, UNSTABLE, HAS_HOOKS —
790
+ * fails closed (GitHub returns UNKNOWN while recomputing right after a push, and
791
+ * the adapter defaults to UNKNOWN, so a fresh conflict must NOT read clean). */
792
+ const KNOWN_GOOD_MSS = new Set(['CLEAN']);
793
+
794
+ /**
795
+ * Compute the fail-closed merge VERDICT from already-gathered signals. PURE and
796
+ * independently testable. Precedence (top wins):
797
+ * UNKNOWN > BLOCKED-CONFLICT > BEHIND > BLOCKED-CHECKS > BLOCKED-THREADS >
798
+ * REVIEW-PENDING > CLEAN-MERGEABLE
799
+ *
800
+ * Never defaults clean: an unreadable input, an unreadable required set, an
801
+ * unknown head oid, a torn read (head moved mid-gather), a non-explicitly-good
802
+ * merge state, or an unknown head-push time all force a non-clean verdict.
803
+ *
804
+ * @param {object} input
805
+ * @returns {{ verdict: string, evidence: object }}
806
+ */
807
+ /**
808
+ * Normalize raw verdict input into a `v` context object carrying the parsed
809
+ * signals plus a mutable `evidence` accumulator. Each rank predicate reads from
810
+ * (and annotates) this object; keeping the shape in one place lets computeVerdict
811
+ * stay a thin dispatch loop (SonarCloud S3776 cognitive-complexity).
812
+ */
813
+ function buildVerdictContext(input) {
814
+ const {
815
+ headOidStart, headOidEnd,
816
+ mergeStateStatus, mergeable, reviewDecision,
817
+ requiredClass = {}, behind = 0, conflicts = null,
818
+ unresolvedThreadCount = 0,
819
+ botStatusBlockerCount = 0,
820
+ botDirectComments = [],
821
+ reviews = [],
822
+ headPushTimeMs = null, headPushKnown = false,
823
+ issueComments = [],
824
+ now = Date.now(), settleWindowMs = DEFAULT_SETTLE_WINDOW_MS,
825
+ unreadable = [],
826
+ } = input || {};
827
+
828
+ const mss = String(mergeStateStatus || '').toUpperCase();
829
+ const rc = requiredClass || {};
830
+ const evidence = {
831
+ headOid: headOidStart || null,
832
+ mergeStateStatus: mss || null,
833
+ unreadable: [...unreadable],
834
+ tornRead: false,
835
+ failingRequired: rc.failing || [],
836
+ missingRequired: rc.missing || [],
837
+ skippedRequired: rc.skipped || [],
838
+ pendingRequired: rc.pending || [],
839
+ botStatusBlockerCount,
840
+ conflict: false,
841
+ behind,
842
+ unresolvedThreadCount,
843
+ changesRequested: false,
844
+ botComments: [],
845
+ staleReviews: [],
846
+ settleRemainingMs: 0,
847
+ };
848
+
849
+ return {
850
+ headOidStart, headOidEnd, mss, mergeable, reviewDecision,
851
+ requiredClass: rc, behind, conflicts,
852
+ unresolvedThreadCount, botStatusBlockerCount, botDirectComments,
853
+ reviews, headPushTimeMs, headPushKnown, issueComments,
854
+ now, settleWindowMs, evidence,
855
+ };
856
+ }
857
+
858
+ // --- Rank predicates. Each takes the verdict context `v`, annotates `v.evidence`
859
+ // as needed, and returns a verdict string when its rank fires or `null` to fall
860
+ // through to the next rank. They run in strict precedence order. ---
861
+
862
+ /** Rank 1: UNKNOWN (fail-closed) — unreadable input/required set, missing head
863
+ * oid, or a torn read (head moved during the gather). */
864
+ function rankUnknown(v) {
865
+ const torn = Boolean(v.headOidStart) && Boolean(v.headOidEnd) && v.headOidStart !== v.headOidEnd;
866
+ v.evidence.tornRead = torn;
867
+ if (v.requiredClass.unreadable && !v.evidence.unreadable.includes('requiredChecks')) {
868
+ v.evidence.unreadable.push('requiredChecks');
869
+ }
870
+ if (!v.headOidStart && !v.evidence.unreadable.includes('headOid')) {
871
+ v.evidence.unreadable.push('headOid');
872
+ }
873
+ return (v.evidence.unreadable.length > 0 || torn) ? 'UNKNOWN' : null;
874
+ }
875
+
876
+ /** Rank 2: hard merge conflict. */
877
+ function rankConflict(v) {
878
+ if (v.conflicts?.conflicted
879
+ || String(v.mergeable || '').toUpperCase() === 'CONFLICTING'
880
+ || v.mss === 'DIRTY') {
881
+ v.evidence.conflict = true;
882
+ return 'BLOCKED-CONFLICT';
883
+ }
884
+ return null;
885
+ }
886
+
887
+ /** Rank 3: branch behind base. */
888
+ function rankBehind(v) {
889
+ return (v.mss === 'BEHIND' || v.behind > 0) ? 'BEHIND' : null;
890
+ }
891
+
892
+ /** Rank 4: checks — failing/missing/skipped/pending REQUIRED checks, a failing
893
+ * bot-status quality gate (buildBotStatusBlockers), or an UNSTABLE merge state. */
894
+ function rankChecks(v) {
895
+ const e = v.evidence;
896
+ const blocked = e.failingRequired.length || e.missingRequired.length
897
+ || e.skippedRequired.length || e.pendingRequired.length
898
+ || v.botStatusBlockerCount > 0 || v.mss === 'UNSTABLE';
899
+ return blocked ? 'BLOCKED-CHECKS' : null;
900
+ }
901
+
902
+ /** Rank 5: threads — unresolved inline threads (RAW, author-agnostic count), a
903
+ * fresh non-human direct comment (posted AFTER the last head push, anchored to
904
+ * the push not to later agent chatter), or a CHANGES_REQUESTED review decision. */
905
+ function rankThreads(v) {
906
+ const anchor = v.headPushKnown ? v.headPushTimeMs : 0;
907
+ const botComments = v.botDirectComments.filter((c) => {
908
+ const t = parseTime(c.createdAt);
909
+ return t !== null && t > anchor;
910
+ });
911
+ v.evidence.botComments = botComments.map((c) => c.commentId || null).filter(Boolean);
912
+ const changesRequested = String(v.reviewDecision || '').toUpperCase() === 'CHANGES_REQUESTED';
913
+ v.evidence.changesRequested = changesRequested;
914
+ const blocked = v.unresolvedThreadCount > 0 || botComments.length > 0 || changesRequested;
915
+ return blocked ? 'BLOCKED-THREADS' : null;
916
+ }
917
+
918
+ /** Rank 6: review-pending — an ANY-bot review against an OLDER commit (stale
919
+ * re-review, #365), a REVIEW_REQUIRED decision, or activity that has not settled
920
+ * (window anchored to the head push). */
921
+ function rankReviewPending(v) {
922
+ const staleReviews = v.reviews.filter(
923
+ (r) => isBotAuthor(r) && r.commitOid && r.commitOid !== v.headOidStart,
924
+ );
925
+ v.evidence.staleReviews = staleReviews.map((r) => r.author).filter(Boolean);
926
+
927
+ const anchors = [];
928
+ if (v.headPushKnown && v.headPushTimeMs != null) anchors.push(v.headPushTimeMs);
929
+ for (const r of v.reviews) { const t = parseTime(r.submittedAt); if (t) anchors.push(t); }
930
+ for (const c of v.issueComments) { const t = parseTime(c.createdAt); if (t) anchors.push(t); }
931
+ const lastActivity = anchors.length ? Math.max(...anchors) : null;
932
+ const settleRemaining = lastActivity === null ? 0 : v.settleWindowMs - (v.now - lastActivity);
933
+ v.evidence.settleRemainingMs = Math.max(settleRemaining, 0);
934
+
935
+ const reviewRequired = String(v.reviewDecision || '').toUpperCase() === 'REVIEW_REQUIRED';
936
+ return (staleReviews.length > 0 || reviewRequired || settleRemaining > 0) ? 'REVIEW-PENDING' : null;
937
+ }
938
+
939
+ /** Rank 7 (terminal): CLEAN only when the merge state is explicitly good AND the
940
+ * head push time is known & settled — otherwise fail closed to UNKNOWN. */
941
+ function rankClean(v) {
942
+ return (KNOWN_GOOD_MSS.has(v.mss) && v.headPushKnown) ? 'CLEAN-MERGEABLE' : 'UNKNOWN';
943
+ }
944
+
945
+ /** Precedence-ordered rank predicates (top wins). */
946
+ const VERDICT_RANKS = [
947
+ rankUnknown, rankConflict, rankBehind, rankChecks, rankThreads, rankReviewPending,
948
+ ];
949
+
950
+ function computeVerdict(input) {
951
+ const v = buildVerdictContext(input);
952
+ for (const rank of VERDICT_RANKS) {
953
+ const verdict = rank(v);
954
+ if (verdict) return { verdict, evidence: v.evidence };
955
+ }
956
+ return { verdict: rankClean(v), evidence: v.evidence };
957
+ }
958
+
959
+ /**
960
+ * Run an optional read and SURFACE any failure instead of swallowing it: on
961
+ * throw, record `{ source, error }` into `degraded` (and optionally mark `source`
962
+ * as `unreadable` so the verdict fails closed), then return `fallback`. This is
963
+ * what keeps gatherPullSignal a thin orchestrator with NO empty catch blocks —
964
+ * every failed read stays diagnosable (WHICH read failed and WHY).
965
+ *
966
+ * @param {string} source
967
+ * @param {() => (Promise<*>|*)} fn
968
+ * @param {{ degraded?: object[], unreadable?: string[], fallback?: * }} [opts]
969
+ * @returns {Promise<*>}
970
+ */
971
+ async function safeRead(source, fn, { degraded, unreadable, fallback } = {}) {
972
+ try {
973
+ return await fn();
974
+ } catch (err) {
975
+ if (degraded) degraded.push({ source, error: (err && err.message) || String(err) });
976
+ if (unreadable) unreadable.push(source);
977
+ return fallback;
978
+ }
979
+ }
980
+
981
+ /** Call `adapter[method](args)` when the method exists, else return `fallback`.
982
+ * Keeps the optional-capability ternaries out of the orchestrator. */
983
+ function callIfPresent(adapter, method, args, fallback) {
984
+ return typeof adapter[method] === 'function' ? adapter[method](args) : fallback;
985
+ }
986
+
987
+ /**
988
+ * Fetch + extract the failure excerpts for the FAILED checks (required first),
989
+ * matrix-deduped. A per-job log fetch that throws degrades to an empty excerpt
990
+ * but is surfaced in `degraded` (which job's log was unreadable) rather than
991
+ * silently swallowed.
992
+ */
993
+ function gatherFailureExcerpts(runGh, checks, requiredSet, { maxFailures, maxExcerptLines, degraded }) {
994
+ const failedChecks = orderFailedChecks(checks, requiredSet)
995
+ .slice(0, maxFailures * LOG_FETCH_MULTIPLIER);
996
+ const rawFailures = failedChecks.map((check) => {
997
+ const jobId = jobIdFromUrl(check.detailsUrl);
998
+ let log = '';
999
+ if (jobId) {
1000
+ try {
1001
+ log = runGh(['run', 'view', '--job', jobId, '--log-failed']) || '';
1002
+ } catch (err) {
1003
+ // Degrade to an empty excerpt but SURFACE which job's log was unreadable.
1004
+ if (degraded) degraded.push({ source: `log:${jobId}`, error: (err && err.message) || String(err) });
1005
+ }
1006
+ }
1007
+ return {
1008
+ name: check.name,
1009
+ conclusion: check.conclusion,
1010
+ jobUrl: check.detailsUrl || null,
1011
+ excerpt: extractFailureExcerpt(log, { maxLines: maxExcerptLines }),
1012
+ };
1013
+ });
1014
+ return dedupeFailures(rawFailures);
1015
+ }
1016
+
1017
+ /**
1018
+ * Gather the complete pull signal for a PR: decision state + why-it-failed
1019
+ * excerpts (matrix-deduped) + the review-thread fix-list. Pure orchestration
1020
+ * over a validated pr-state adapter and an injected `gh` runner — no live
1021
+ * GitHub, no merging, no thread resolution.
1022
+ *
1023
+ * @param {object} ctx
1024
+ * @param {string} ctx.pr
1025
+ * @param {string} ctx.owner
1026
+ * @param {string} ctx.repo
1027
+ * @param {string} ctx.base - base BRANCH name (branch-protection lookup)
1028
+ * @param {string} ctx.baseRef - base REF for divergence (e.g. origin/master)
1029
+ * @param {string} [ctx.cwd]
1030
+ * @param {string} [ctx.self] - shepherd's own login
1031
+ * @param {object} ctx.adapter - validated pr-state adapter
1032
+ * @param {(args: string[]) => string} ctx.runGh - injected `gh` runner (args → stdout)
1033
+ * @param {Function} [ctx.runPass] - decision pass (default runShepherdPass), injectable for tests
1034
+ * @param {number} [ctx.maxFailures]
1035
+ * @param {number} [ctx.maxThreads]
1036
+ * @param {number} [ctx.maxExcerptLines]
1037
+ * @returns {Promise<object>} the bounded pull payload
1038
+ */
1039
+ async function gatherPrSnapshot(ctx) {
1040
+ const {
1041
+ pr, owner, repo, base, cwd, self, adapter,
1042
+ maxThreads = DEFAULT_MAX_THREADS,
1043
+ } = ctx;
1044
+
1045
+ if (!adapter || typeof adapter.readState !== 'function') {
1046
+ throw new Error('gatherPrSnapshot requires a pr-state adapter with readState');
1047
+ }
1048
+
1049
+ // `degraded` records WHICH read failed and WHY (surfaced downstream); `unreadable`
1050
+ // marks verdict-relevant reads so computeVerdict fails closed to UNKNOWN (never
1051
+ // defaults clean on a bad read). Every optional read goes through safeRead.
1052
+ const degraded = [];
1053
+ const unreadable = [];
1054
+
1055
+ // readState is the one REQUIRED read — if it throws, the whole gather fails.
1056
+ const state = await adapter.readState(pr);
1057
+ const requiredSet = await safeRead(
1058
+ 'requiredChecks',
1059
+ () => adapter.readRequiredChecks({ owner, repo, base }),
1060
+ { degraded, fallback: null },
1061
+ );
1062
+
1063
+ // Review threads (author-agnostic; never resolves anything).
1064
+ const threads = await safeRead(
1065
+ 'threads',
1066
+ () => callIfPresent(adapter, 'readComments', { owner, repo, pr, cwd }, []),
1067
+ { degraded, unreadable, fallback: [] },
1068
+ );
1069
+
1070
+ // Fetch the base ref FIRST so divergence/conflicts compare against the CURRENT
1071
+ // origin/<base>, not a stale local ref (audit A6: a stale ref yields a false
1072
+ // behind=0 / false "no conflict"). Fail CLOSED: a failed refresh is verdict-
1073
+ // relevant — record it in `unreadable` (not just `degraded`) so the divergence
1074
+ // and conflict reads that follow cannot produce a clean verdict from a stale
1075
+ // ref; computeVerdict flips to UNKNOWN instead of a false "not behind".
1076
+ await safeRead(
1077
+ 'fetchBase',
1078
+ () => callIfPresent(adapter, 'fetchBase', { baseRef: ctx.baseRef, cwd }, null),
1079
+ { degraded, unreadable, fallback: null },
1080
+ );
1081
+
1082
+ // Branch divergence (behind base → needs an update/rebase).
1083
+ const div = await safeRead(
1084
+ 'divergence',
1085
+ () => callIfPresent(adapter, 'readDivergence', { baseRef: ctx.baseRef, cwd }, { behind: 0 }),
1086
+ { degraded, fallback: { behind: 0 } },
1087
+ );
1088
+ const behind = div.behind || 0;
1089
+
1090
+ // Predicted merge conflicts (which files) — optional adapter capability.
1091
+ const conflicts = await safeRead(
1092
+ 'conflicts',
1093
+ () => callIfPresent(adapter, 'detectConflicts', { baseRef: ctx.baseRef, cwd }, null),
1094
+ { degraded, fallback: null },
1095
+ );
1096
+
1097
+ // Bot STATUS COMMENTS (Sonar/Vercel/Netlify/Codecov quality-gate + deployment
1098
+ // summaries) — plain PR issue comments scanned for a failure signal.
1099
+ const issueComments = await safeRead(
1100
+ 'issueComments',
1101
+ () => callIfPresent(adapter, 'readIssueComments', { owner, repo, pr, cwd }, []),
1102
+ { degraded, unreadable, fallback: [] },
1103
+ );
1104
+ const botStatusBlockers = buildBotStatusBlockers(issueComments, { cap: maxThreads });
1105
+
1106
+ // Required-vs-produced classification (missing / skipped / pending / failing) —
1107
+ // the reliable explanation for an all-green-but-BLOCKED PR.
1108
+ const requiredChecks = classifyRequiredChecks(state.checks, requiredSet);
1109
+ const pendingChecks = pendingCheckNames(state.checks, maxThreads);
1110
+
1111
+ // Hoisted once and reused by computeVerdict and downstream consumers.
1112
+ const draft = state.isDraft || state.draft || false;
1113
+ const reviewDecision = state.reviewDecision || null;
1114
+
1115
+ // --- Verdict inputs: review-at-head (#365), head-push time (settle-window
1116
+ // anchor), and the torn-read guard. ---
1117
+ const reviews = await safeRead(
1118
+ 'reviews',
1119
+ () => callIfPresent(adapter, 'readReviews', { owner, repo, pr }, []),
1120
+ { degraded, unreadable, fallback: [] },
1121
+ );
1122
+ const headPushTimeMs = await safeRead(
1123
+ 'headPushTime',
1124
+ () => callIfPresent(adapter, 'readHeadCommitTime', { pr }, null),
1125
+ { degraded, unreadable, fallback: null },
1126
+ );
1127
+ const headPushKnown = headPushTimeMs != null;
1128
+ // Torn-read guard: re-read the head oid at the END of the gather.
1129
+ const endState = await safeRead('headEnd', () => adapter.readState(pr), { degraded, unreadable });
1130
+ const headOidEnd = endState ? endState.headSha : state.headSha;
1131
+
1132
+ // Actionable NON-HUMAN direct comments (mechanism-detected, suppression-list
1133
+ // filtered) and an AUTHOR-AGNOSTIC unresolved-thread count — both independent
1134
+ // of the display fix-list so the verdict never drops an unknown-bot signal.
1135
+ const botDirectComments = (Array.isArray(issueComments) ? issueComments : [])
1136
+ .filter((c) => isActionableBotComment(c, self));
1137
+ const unresolvedThreadCount = countUnresolvedThreads(threads);
1138
+
1139
+ const { verdict, evidence } = computeVerdict({
1140
+ headOidStart: state.headSha,
1141
+ headOidEnd,
1142
+ mergeStateStatus: state.mergeStateStatus,
1143
+ mergeable: state.mergeable,
1144
+ reviewDecision,
1145
+ requiredClass: requiredChecks,
1146
+ behind,
1147
+ conflicts,
1148
+ unresolvedThreadCount,
1149
+ botStatusBlockerCount: botStatusBlockers.length,
1150
+ botDirectComments,
1151
+ reviews,
1152
+ headPushTimeMs,
1153
+ headPushKnown,
1154
+ issueComments,
1155
+ self,
1156
+ now: ctx.now,
1157
+ settleWindowMs: ctx.settleWindowMs,
1158
+ unreadable,
1159
+ });
1160
+
1161
+ return {
1162
+ state, requiredSet, threads, behind, conflicts, issueComments,
1163
+ botStatusBlockers, requiredChecks, pendingChecks, draft, reviewDecision,
1164
+ reviews, headPushTimeMs, headPushKnown, headOidEnd,
1165
+ botDirectComments, unresolvedThreadCount, verdict, evidence,
1166
+ degraded, unreadable,
1167
+ };
1168
+ }
1169
+
1170
+ async function gatherPullSignal(ctx) {
1171
+ const {
1172
+ pr, self, adapter, runGh,
1173
+ runPass = runShepherdPass,
1174
+ maxFailures = DEFAULT_MAX_FAILURES,
1175
+ maxThreads = DEFAULT_MAX_THREADS,
1176
+ maxExcerptLines = DEFAULT_MAX_EXCERPT_LINES,
1177
+ } = ctx;
1178
+
1179
+ if (!adapter || typeof adapter.readState !== 'function') {
1180
+ throw new Error('gatherPullSignal requires a pr-state adapter with readState');
1181
+ }
1182
+ if (typeof runGh !== 'function') {
1183
+ throw new Error('gatherPullSignal requires an injected `runGh` runner');
1184
+ }
1185
+
1186
+ // ONE gather + ONE verdict core: reads + verdict come from gatherPrSnapshot,
1187
+ // shared verbatim with the PR monitor so the verdict and monitor events can
1188
+ // never disagree. This function then does only the bounded --pull PROJECTION
1189
+ // (failure log excerpts, review-thread fix-list, summary, payload).
1190
+ const snap = await gatherPrSnapshot(ctx);
1191
+ const {
1192
+ state, requiredSet, threads, behind, conflicts, botStatusBlockers,
1193
+ requiredChecks, pendingChecks, draft, reviewDecision, verdict, evidence, degraded,
1194
+ } = snap;
1195
+
1196
+ // Legacy decision pass (READ-ONLY dryRun) for back-compat `state`. Shares the
1197
+ // snapshot's `degraded` so a pass-read failure is still surfaced in the payload.
1198
+ const pass = await safeRead('pass', () => runPass({ ...ctx, adapter, dryRun: true }), {
1199
+ degraded,
1200
+ fallback: { state: 'UNKNOWN', reason: 'Decision pass could not complete — a read failed; see verdict evidence.' },
1201
+ });
1202
+
1203
+ const failures = gatherFailureExcerpts(runGh, state.checks, requiredSet, { maxFailures, maxExcerptLines, degraded });
1204
+ const reviewThreads = buildReviewThreads(threads, self, { maxThreads });
1205
+
1206
+ const blockers = computeBlockers({
1207
+ mergeable: state.mergeable,
1208
+ mergeStateStatus: state.mergeStateStatus,
1209
+ draft,
1210
+ reviewDecision,
1211
+ requiredClass: requiredChecks,
1212
+ botStatusBlockers,
1213
+ unresolvedThreadCount: reviewThreads.length,
1214
+ behind,
1215
+ conflicts,
1216
+ failuresCount: failures.length,
1217
+ });
1218
+
1219
+ const summary = summarize({
1220
+ state: pass.state,
1221
+ failureCount: failures.length,
1222
+ threadCount: reviewThreads.length,
1223
+ blockers,
1224
+ });
1225
+
1226
+ return buildPullPayload({
1227
+ pr,
1228
+ state: pass.state,
1229
+ verdict,
1230
+ evidence,
1231
+ degraded,
1232
+ reason: pass.reason,
1233
+ summary,
1234
+ mergeable: state.mergeable || 'UNKNOWN',
1235
+ mergeStateStatus: state.mergeStateStatus || 'UNKNOWN',
1236
+ draft,
1237
+ reviewDecision,
1238
+ blockers,
1239
+ requiredChecks,
1240
+ pendingChecks,
1241
+ behind,
1242
+ conflicts,
1243
+ failures,
1244
+ reviewThreads,
1245
+ maxFailures,
1246
+ maxThreads,
1247
+ maxExcerptLines,
1248
+ });
1249
+ }
1250
+
1251
+ module.exports = {
1252
+ gatherPullSignal,
1253
+ gatherPrSnapshot,
1254
+ cleanLogLine,
1255
+ extractFailureExcerpt,
1256
+ jobIdFromUrl,
1257
+ dedupeFailures,
1258
+ buildReviewThreads,
1259
+ classifyRequiredChecks,
1260
+ pendingCheckNames,
1261
+ computeBlockers,
1262
+ renderPullSummary,
1263
+ buildPullPayload,
1264
+ computeVerdict,
1265
+ isSkipped,
1266
+ isPending,
1267
+ buildBotStatusBlockers,
1268
+ botFailureSummary,
1269
+ REVIEW_BOT_LOGINS,
1270
+ AUTOMATION_BOT_LOGINS,
1271
+ STATUS_BOT_LOGINS,
1272
+ DEFAULT_SETTLE_WINDOW_MS,
1273
+ };