mandrel 2.0.0 → 2.2.0

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 (323) hide show
  1. package/.agents/README.md +59 -28
  2. package/.agents/agents/acceptance-critic.md +20 -9
  3. package/.agents/agents/story-worker.md +45 -48
  4. package/.agents/audit-checklists/performance.md +1 -1
  5. package/.agents/docs/SDLC.md +60 -46
  6. package/.agents/docs/agentrc-reference.json +8 -13
  7. package/.agents/docs/configuration.md +33 -57
  8. package/.agents/docs/execution-reference.md +39 -10
  9. package/.agents/docs/quality-gates.md +17 -19
  10. package/.agents/docs/workflows.md +6 -6
  11. package/.agents/instructions.md +64 -79
  12. package/.agents/rules/ci-remediation.md +3 -3
  13. package/.agents/rules/gherkin-standards.md +10 -0
  14. package/.agents/rules/git-conventions-reference.md +42 -51
  15. package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
  16. package/.agents/schemas/agentrc.schema.json +35 -46
  17. package/.agents/schemas/audit-rules.json +59 -1
  18. package/.agents/schemas/audit-rules.schema.json +33 -1
  19. package/.agents/schemas/lifecycle/README.md +1 -2
  20. package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
  21. package/.agents/schemas/lifecycle/merge.flip-failed.schema.json +33 -0
  22. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +1 -0
  23. package/.agents/schemas/lifecycle/story.merged.schema.json +1 -1
  24. package/.agents/schemas/signal-event.schema.json +3 -3
  25. package/.agents/schemas/story-deliver-terminal.schema.json +152 -0
  26. package/.agents/schemas/validation-evidence.schema.json +1 -1
  27. package/.agents/scripts/acceptance-eval.js +24 -68
  28. package/.agents/scripts/agents-bootstrap-github.js +1 -1
  29. package/.agents/scripts/bootstrap.js +3 -3
  30. package/.agents/scripts/check-dead-exports.js +43 -104
  31. package/.agents/scripts/check-doc-links.js +2 -2
  32. package/.agents/scripts/check-lifecycle-lint.js +1 -1
  33. package/.agents/scripts/check-workflow-cli-lint.js +91 -0
  34. package/.agents/scripts/deliver-recover.js +122 -0
  35. package/.agents/scripts/drain-pending-cleanup.js +1 -1
  36. package/.agents/scripts/evidence-gate.js +20 -50
  37. package/.agents/scripts/generate-skills-index.js +17 -1
  38. package/.agents/scripts/generate-workflows-doc.js +4 -4
  39. package/.agents/scripts/lib/ITicketingProvider.js +1 -19
  40. package/.agents/scripts/lib/audit-suite/selector.js +323 -23
  41. package/.agents/scripts/lib/baselines/kinds/maintainability.js +0 -11
  42. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +28 -33
  43. package/.agents/scripts/lib/bootstrap/manifest.js +8 -11
  44. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +30 -53
  45. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -2
  46. package/.agents/scripts/lib/checks/core-bare-clean.js +4 -1
  47. package/.agents/scripts/lib/checks/index.js +1 -1
  48. package/.agents/scripts/lib/checks/loop-health.js +12 -11
  49. package/.agents/scripts/lib/checks/state.js +17 -248
  50. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +3 -3
  51. package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +3 -4
  52. package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +2 -2
  53. package/.agents/scripts/lib/checks/worktree-residue-biome.js +3 -3
  54. package/.agents/scripts/lib/cli-args.js +23 -2
  55. package/.agents/scripts/lib/close-validation/gates.js +13 -13
  56. package/.agents/scripts/lib/close-validation/projections/inputs.js +7 -7
  57. package/.agents/scripts/lib/close-validation/projections/maintainability.js +12 -12
  58. package/.agents/scripts/lib/close-validation/runner.js +13 -21
  59. package/.agents/scripts/lib/close-validation/telemetry.js +17 -8
  60. package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
  61. package/.agents/scripts/lib/config/delivery-routing.js +7 -6
  62. package/.agents/scripts/lib/config/explain.js +10 -16
  63. package/.agents/scripts/lib/config/github.js +7 -5
  64. package/.agents/scripts/lib/config/limits.js +15 -25
  65. package/.agents/scripts/lib/config/quality.js +11 -14
  66. package/.agents/scripts/lib/config/runners.js +8 -21
  67. package/.agents/scripts/lib/config/temp-paths.js +18 -56
  68. package/.agents/scripts/lib/config-settings-schema-delivery.js +34 -16
  69. package/.agents/scripts/lib/config-settings-schema-quality.js +9 -2
  70. package/.agents/scripts/lib/config-settings-schema.js +48 -22
  71. package/.agents/scripts/lib/dead-exports-knip.js +105 -0
  72. package/.agents/scripts/lib/dead-exports-mode.js +51 -0
  73. package/.agents/scripts/lib/duplicate-search.js +38 -7
  74. package/.agents/scripts/lib/findings/promote-finding.js +23 -14
  75. package/.agents/scripts/lib/format-generated-json.js +97 -0
  76. package/.agents/scripts/lib/framework-version.js +19 -189
  77. package/.agents/scripts/lib/gh-exec.js +8 -0
  78. package/.agents/scripts/lib/git-branch-lifecycle.js +0 -158
  79. package/.agents/scripts/lib/git-utils.js +0 -14
  80. package/.agents/scripts/lib/json-utils.js +1 -2
  81. package/.agents/scripts/lib/label-constants.js +0 -15
  82. package/.agents/scripts/lib/label-taxonomy.js +1 -12
  83. package/.agents/scripts/lib/observability/active-story-env.js +42 -163
  84. package/.agents/scripts/lib/observability/runtime-friction.js +243 -0
  85. package/.agents/scripts/lib/observability/signal-validator.js +4 -4
  86. package/.agents/scripts/lib/observability/signals-writer.js +6 -82
  87. package/.agents/scripts/lib/observability/source-classifier.js +2 -2
  88. package/.agents/scripts/lib/observability/tool-trace-hook.js +2 -12
  89. package/.agents/scripts/lib/orchestration/acceptance-clusters.js +1 -1
  90. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +2 -2
  91. package/.agents/scripts/lib/orchestration/ceremony-routing.js +43 -45
  92. package/.agents/scripts/lib/orchestration/change-set.js +103 -0
  93. package/.agents/scripts/lib/orchestration/code-review.js +70 -191
  94. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +3 -3
  95. package/.agents/scripts/lib/orchestration/deliver-recover.js +328 -0
  96. package/.agents/scripts/lib/orchestration/detectors-phase.js +12 -6
  97. package/.agents/scripts/lib/orchestration/git-cleanup/phases/fast-forward.js +34 -0
  98. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +3 -2
  99. package/.agents/scripts/lib/orchestration/lifecycle/emit-ledger-event.js +142 -0
  100. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +9 -11
  101. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-flip-failed.js +86 -0
  102. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +37 -103
  103. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +7 -3
  104. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +50 -85
  105. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +3 -14
  106. package/.agents/scripts/lib/orchestration/merge-block-class.js +76 -20
  107. package/.agents/scripts/lib/orchestration/merge-poll.js +104 -0
  108. package/.agents/scripts/lib/orchestration/plan-context.js +116 -33
  109. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +26 -36
  110. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +31 -22
  111. package/.agents/scripts/lib/orchestration/plan-metrics.js +38 -6
  112. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +16 -6
  113. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +173 -25
  114. package/.agents/scripts/lib/orchestration/plan-persist/plan-context-source.js +116 -0
  115. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +280 -100
  116. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +472 -55
  117. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +21 -16
  118. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +509 -0
  119. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
  120. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +41 -40
  121. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
  122. package/.agents/scripts/lib/orchestration/planning/spec-authoring-grounding.js +1 -1
  123. package/.agents/scripts/lib/orchestration/resolve-stories.js +344 -0
  124. package/.agents/scripts/lib/orchestration/retro-proposals.js +7 -7
  125. package/.agents/scripts/lib/orchestration/review-depth.js +105 -40
  126. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +3 -13
  127. package/.agents/scripts/lib/orchestration/review-providers/native.js +1 -154
  128. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +3 -2
  129. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +1 -1
  130. package/.agents/scripts/lib/orchestration/review-providers/types.js +5 -4
  131. package/.agents/scripts/lib/orchestration/review-providers/ultrareview.js +1 -1
  132. package/.agents/scripts/lib/orchestration/run-epilogue.js +374 -16
  133. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +24 -0
  134. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +11 -9
  135. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +4 -4
  136. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +4 -13
  137. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +608 -152
  138. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +72 -30
  139. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +305 -0
  140. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +1 -1
  141. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +12 -8
  142. package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +37 -4
  143. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +2 -2
  144. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +264 -43
  145. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +1 -1
  146. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +10 -10
  147. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +104 -279
  148. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
  149. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
  150. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +360 -0
  151. package/.agents/scripts/lib/orchestration/story-follow-ups.js +75 -14
  152. package/.agents/scripts/lib/orchestration/story-init-remote.js +12 -8
  153. package/.agents/scripts/lib/orchestration/story-plan-state.js +14 -29
  154. package/.agents/scripts/lib/orchestration/task-body-validator.js +52 -7
  155. package/.agents/scripts/lib/orchestration/ticket-lease.js +27 -74
  156. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +119 -14
  157. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +3 -4
  158. package/.agents/scripts/lib/orchestration/ticket-validator.js +121 -18
  159. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +14 -47
  160. package/.agents/scripts/lib/orchestration/ticketing/reads.js +19 -32
  161. package/.agents/scripts/lib/orchestration/ticketing/transition.js +61 -1
  162. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  163. package/.agents/scripts/lib/plan-phase-cleanup.js +12 -14
  164. package/.agents/scripts/lib/planning-corpus.js +12 -286
  165. package/.agents/scripts/lib/preflight-runner.js +2 -2
  166. package/.agents/scripts/lib/qa/qa-context-hydrator.js +5 -5
  167. package/.agents/scripts/lib/signals/index.js +4 -17
  168. package/.agents/scripts/lib/signals/read.js +35 -35
  169. package/.agents/scripts/lib/signals/schema.js +8 -11
  170. package/.agents/scripts/lib/signals/span-tree.js +7 -7
  171. package/.agents/scripts/lib/signals/write.js +0 -1
  172. package/.agents/scripts/lib/single-story/story-merged-notify.js +13 -2
  173. package/.agents/scripts/lib/skills/parse-skill.js +16 -3
  174. package/.agents/scripts/lib/story-adjacency.js +8 -7
  175. package/.agents/scripts/lib/story-body/story-body.js +81 -13
  176. package/.agents/scripts/lib/templates/decomposer-prompts.js +15 -16
  177. package/.agents/scripts/lib/test-env.js +14 -1
  178. package/.agents/scripts/lib/test-tiers.js +0 -3
  179. package/.agents/scripts/lib/ticket-body-sections.js +0 -14
  180. package/.agents/scripts/lib/validation-evidence.js +31 -59
  181. package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
  182. package/.agents/scripts/lib/wave-runner/ready-set.js +32 -6
  183. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +1 -1
  184. package/.agents/scripts/lib/worktree/lifecycle/reap.js +68 -19
  185. package/.agents/scripts/lib/worktree/lifecycle-manager.js +1 -2
  186. package/.agents/scripts/plan-context.js +38 -7
  187. package/.agents/scripts/plan-critics.js +203 -0
  188. package/.agents/scripts/plan-persist.js +145 -35
  189. package/.agents/scripts/plan-run-epilogue.js +83 -38
  190. package/.agents/scripts/post-structured-comment.js +0 -38
  191. package/.agents/scripts/pr-watch-with-update.js +43 -22
  192. package/.agents/scripts/providers/github/compose.js +0 -1
  193. package/.agents/scripts/providers/github/errors.js +0 -19
  194. package/.agents/scripts/providers/github/issues.js +1 -11
  195. package/.agents/scripts/providers/github/mappers.js +5 -0
  196. package/.agents/scripts/providers/github/sub-issues.js +0 -47
  197. package/.agents/scripts/providers/github/tickets.js +33 -153
  198. package/.agents/scripts/providers/github.js +17 -6
  199. package/.agents/scripts/quality-preview.js +13 -6
  200. package/.agents/scripts/resolve-stories.js +236 -0
  201. package/.agents/scripts/run-coverage.js +4 -1
  202. package/.agents/scripts/run-lint.js +2 -2
  203. package/.agents/scripts/run-verify.js +31 -2
  204. package/.agents/scripts/signals-view.js +9 -10
  205. package/.agents/scripts/single-story-close.js +173 -18
  206. package/.agents/scripts/single-story-confirm-merge.js +288 -15
  207. package/.agents/scripts/single-story-init.js +6 -10
  208. package/.agents/scripts/stories-wave-tick.js +380 -53
  209. package/.agents/scripts/story-plan.js +3 -3
  210. package/.agents/scripts/update-ticket-state.js +8 -50
  211. package/.agents/skills/core/code-review-and-quality/SKILL.md +28 -450
  212. package/.agents/skills/core/code-review-and-quality/reference.md +458 -0
  213. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +22 -315
  214. package/.agents/skills/core/debugging-and-error-recovery/reference.md +323 -0
  215. package/.agents/skills/core/diagnose-friction/SKILL.md +14 -18
  216. package/.agents/skills/core/documentation-and-adrs/SKILL.md +25 -397
  217. package/.agents/skills/core/documentation-and-adrs/reference.md +403 -0
  218. package/.agents/skills/core/gates-and-baselines/SKILL.md +12 -12
  219. package/.agents/skills/core/idea-refinement/SKILL.md +3 -3
  220. package/.agents/skills/core/scope-triage/SKILL.md +3 -0
  221. package/.agents/skills/core/security-and-hardening/SKILL.md +22 -367
  222. package/.agents/skills/core/security-and-hardening/reference.md +375 -0
  223. package/.agents/skills/skills.index.json +2 -12
  224. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +2 -4
  225. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +1 -1
  226. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -3
  227. package/.agents/workflows/audit-architecture.md +3 -4
  228. package/.agents/workflows/audit-clean-code.md +4 -4
  229. package/.agents/workflows/audit-documentation.md +4 -5
  230. package/.agents/workflows/audit-lighthouse.md +8 -0
  231. package/.agents/workflows/audit-navigability.md +10 -0
  232. package/.agents/workflows/audit-performance.md +2 -3
  233. package/.agents/workflows/audit-quality.md +8 -9
  234. package/.agents/workflows/audit-security.md +1 -2
  235. package/.agents/workflows/audit-seo.md +10 -0
  236. package/.agents/workflows/audit-ux-ui.md +7 -0
  237. package/.agents/workflows/deliver.md +133 -45
  238. package/.agents/workflows/git-cleanup.md +2 -2
  239. package/.agents/workflows/git-deliver.md +1 -1
  240. package/.agents/workflows/helpers/acceptance-self-eval.md +34 -17
  241. package/.agents/workflows/helpers/code-quality-guardrails.md +15 -12
  242. package/.agents/workflows/helpers/code-review.md +14 -12
  243. package/.agents/workflows/helpers/deliver-story-reference.md +73 -32
  244. package/.agents/workflows/helpers/deliver-story.md +209 -118
  245. package/.agents/workflows/helpers/parallel-tooling.md +2 -2
  246. package/.agents/workflows/helpers/worktree-lifecycle.md +28 -32
  247. package/.agents/workflows/plan.md +239 -19
  248. package/.agents/workflows/qa-assist.md +6 -6
  249. package/.agents/workflows/qa-explore.md +3 -3
  250. package/.agents/workflows/qa-run.md +1 -5
  251. package/bin/mandrel.js +12 -1
  252. package/docs/CHANGELOG.md +62 -0
  253. package/lib/cli/registry.js +262 -19
  254. package/lib/cli/sync-agents.js +157 -0
  255. package/lib/cli/sync-commands.js +115 -6
  256. package/lib/cli/sync.js +168 -6
  257. package/lib/cli/update.js +105 -8
  258. package/lib/cli/version-helpers.js +131 -0
  259. package/lib/migrations/README.md +7 -5
  260. package/lib/migrations/index.js +17 -9
  261. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +100 -0
  262. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +101 -0
  263. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
  264. package/package.json +2 -2
  265. package/.agents/schemas/epic-perf-report.schema.json +0 -89
  266. package/.agents/schemas/lifecycle/acceptance.reconcile.failed.schema.json +0 -13
  267. package/.agents/schemas/lifecycle/acceptance.reconcile.ok.schema.json +0 -13
  268. package/.agents/schemas/lifecycle/acceptance.reconcile.skipped.schema.json +0 -13
  269. package/.agents/schemas/lifecycle/acceptance.reconcile.start.schema.json +0 -12
  270. package/.agents/schemas/lifecycle/acceptance.reconcile.waived.schema.json +0 -13
  271. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +0 -15
  272. package/.agents/schemas/lifecycle/epic.automerge.start.schema.json +0 -13
  273. package/.agents/schemas/lifecycle/epic.blocked.schema.json +0 -13
  274. package/.agents/schemas/lifecycle/epic.cleanup.end.schema.json +0 -12
  275. package/.agents/schemas/lifecycle/epic.cleanup.start.schema.json +0 -12
  276. package/.agents/schemas/lifecycle/epic.close.end.schema.json +0 -12
  277. package/.agents/schemas/lifecycle/epic.complete.schema.json +0 -13
  278. package/.agents/schemas/lifecycle/epic.finalize.end.schema.json +0 -13
  279. package/.agents/schemas/lifecycle/epic.finalize.start.schema.json +0 -12
  280. package/.agents/schemas/lifecycle/epic.merge.armed.schema.json +0 -13
  281. package/.agents/schemas/lifecycle/epic.merge.blocked.schema.json +0 -14
  282. package/.agents/schemas/lifecycle/epic.merge.confirmed.schema.json +0 -17
  283. package/.agents/schemas/lifecycle/epic.merge.ready.schema.json +0 -15
  284. package/.agents/schemas/lifecycle/epic.plan.end.schema.json +0 -18
  285. package/.agents/schemas/lifecycle/epic.plan.start.schema.json +0 -12
  286. package/.agents/schemas/lifecycle/epic.snapshot.end.schema.json +0 -16
  287. package/.agents/schemas/lifecycle/epic.snapshot.start.schema.json +0 -12
  288. package/.agents/schemas/lifecycle/epic.watch.end.schema.json +0 -29
  289. package/.agents/schemas/lifecycle/epic.watch.start.schema.json +0 -16
  290. package/.agents/schemas/lifecycle/story.heartbeat.schema.json +0 -20
  291. package/.agents/schemas/risk-verdict.schema.json +0 -53
  292. package/.agents/schemas/story-perf-summary.schema.json +0 -73
  293. package/.agents/scripts/analyze-execution.js +0 -444
  294. package/.agents/scripts/check-prepush-recovery.js +0 -90
  295. package/.agents/scripts/lib/git-merge-orchestrator.js +0 -261
  296. package/.agents/scripts/lib/observability/baseline-refresh-rate.js +0 -221
  297. package/.agents/scripts/lib/observability/hook-heartbeat.js +0 -187
  298. package/.agents/scripts/lib/observability/perf-aggregator.js +0 -813
  299. package/.agents/scripts/lib/observability/perf-report-readers.js +0 -328
  300. package/.agents/scripts/lib/observability/perf-report-render.js +0 -182
  301. package/.agents/scripts/lib/orchestration/audit-lens-routing.js +0 -128
  302. package/.agents/scripts/lib/orchestration/bookkeeping-outbox.js +0 -273
  303. package/.agents/scripts/lib/orchestration/error-journal.js +0 -139
  304. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +0 -155
  305. package/.agents/scripts/lib/orchestration/lifecycle/ledger-diff.js +0 -140
  306. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +0 -665
  307. package/.agents/scripts/lib/orchestration/plan-review-routing.js +0 -63
  308. package/.agents/scripts/lib/orchestration/planning/risk-verdict.js +0 -104
  309. package/.agents/scripts/lib/orchestration/planning-context-budget.js +0 -213
  310. package/.agents/scripts/lib/orchestration/planning-risk.js +0 -194
  311. package/.agents/scripts/lib/orchestration/post-merge/phases/branch-cleanup.js +0 -56
  312. package/.agents/scripts/lib/orchestration/post-merge/phases/dashboard-refresh.js +0 -21
  313. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +0 -78
  314. package/.agents/scripts/lib/orchestration/post-merge/phases/temp-cleanup.js +0 -68
  315. package/.agents/scripts/lib/orchestration/post-merge/phases/ticket-closure.js +0 -118
  316. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +0 -397
  317. package/.agents/scripts/lib/orchestration/preflight-cache.js +0 -187
  318. package/.agents/scripts/lib/orchestration/resolve-plan-run.js +0 -155
  319. package/.agents/scripts/lib/orchestration/retro-perf-heuristics.js +0 -275
  320. package/.agents/scripts/lib/orchestration/story-progress/story-run-progress-writer.js +0 -400
  321. package/.agents/scripts/lib/single-story/confirm-merge-follow-ups.js +0 -36
  322. package/.agents/scripts/resolve-plan-run.js +0 -117
  323. package/.agents/skills/core/analyze-execution/SKILL.md +0 -101
@@ -1,36 +1,75 @@
1
1
  /**
2
- * phases/confirm-merge.js — headless must-land terminal step (Story #4428,
3
- * Epic #4425 slice 3: standalone-path must-land terminal step).
2
+ * phases/confirm-merge.js — the close-and-land merge wait (Story #4428,
3
+ * reworked into a resumable, checks-aware wait by Story #4543).
4
4
  *
5
- * `runSingleStoryClose` (`../runner.js`) arms GitHub native auto-merge and
6
- * historically rests the Story at `agent::closing` with the issue OPEN —
7
- * merge confirmation is a separate manual step
8
- * (`single-story-confirm-merge.js`) a headless (unattended) run never
9
- * executes. A hung check or a failed arm then leaves the PR silently open
10
- * forever with no operator watching.
5
+ * This is the **default terminal step for every run** — attended and
6
+ * headless alike because `waitForMerge` defaults from
7
+ * `delivery.routing.closeAndLand` (`true`); `--no-wait-merge` is the opt-out,
8
+ * and a PR the operator deliberately left un-armed (`--no-auto-merge` /
9
+ * `autoMerge: "strict"`) resolves to no-wait and rests at `agent::closing`
10
+ * for the human.
11
11
  *
12
- * This phase closes that gap for **headless** runs (`--wait-merge`, threaded
13
- * from `phases/options.js`): instead of returning at `agent::closing`, it
14
- * polls the armed PR to merge confirmation — reusing the SAME
15
- * `confirmStoryMerged` flip logic `single-story-confirm-merge.js` calls
16
- * (Story #4428 AC4: exactly one merged/`agent::done` implementation) — on
17
- * the `delivery.mergeWatch.intervalSeconds` / `maxBudgetSeconds` cadence
18
- * (mirroring `MergeWatcher`'s poll/budget shape rather than forking it).
12
+ * ## The timing model (Story #4543 the load-bearing design decision)
19
13
  *
20
- * Terminal outcomes:
21
- * - `{ confirmed: true }` the PR merged; `confirmStoryMerged` already
22
- * flipped `agent::closing agent::done` and closed the issue.
23
- * - `{ confirmed: false, blockClass, reason }` the arm failed outright,
24
- * the PR closed without merging, or the poll budget was exhausted
25
- * first. The block is classified via the shared
26
- * `classifyMergeBlock` (`../../merge-block-class.js`), a
27
- * `merge.unlanded` lifecycle event is emitted (`scope: 'story'`), a
28
- * `friction` comment is posted, and the Story is transitioned to
29
- * `agent::blocked`. The caller (`runSingleStoryClose`) throws so the
30
- * CLI process exits non-zero never a silent `agent::closing` rest.
14
+ * The original wait polled a single budget: `maxBudgetSeconds`, one hour.
15
+ * The host caps a single tool invocation at ~10 minutes, and the close gates
16
+ * burn minutes of that before the wait even starts. So a close-and-land
17
+ * whose CI took longer than roughly eight minutes was **killed mid-poll**
18
+ * with no terminal path taken no `merge.unlanded` event, no `agent::blocked`
19
+ * flip, the Story parked at `agent::closing`: precisely the strand the
20
+ * must-land contract exists to eliminate.
21
+ *
22
+ * The fix splits the two timing domains that were conflated:
23
+ *
24
+ * - **`maxWaitSeconds`** bounds THIS invocation (default 300s, comfortably
25
+ * inside the host ceiling). On expiry the wait returns
26
+ * `terminal: 'pending'` — **no label mutation, no `merge.unlanded`
27
+ * event** — and the caller surfaces a resumable terminal with its own
28
+ * exit code. Merely shrinking `maxBudgetSeconds` instead would have been
29
+ * wrong: that path conflates slow CI with a hard block, so most runs
30
+ * would have been misfiled as blocked.
31
+ * - **`maxBudgetSeconds`** bounds the CUMULATIVE wait, anchored at the
32
+ * PR's `createdAt` rather than this invocation's start, so resumes do not
33
+ * restart the clock. Exhausting it is the genuine give-up: classify,
34
+ * emit, block. `agent::blocked` stays reserved for hard blocks.
35
+ *
36
+ * Backgrounding is not a workaround here and does not need to be: an
37
+ * interrupted poll is stateless and re-entrant by construction.
38
+ *
39
+ * ## The wait is not weaker than the watch it displaced
40
+ *
41
+ * The pre-#4543 poll read only `state` / `mergedAt`. A check that went red
42
+ * at minute one therefore burned the full hour and then classified as
43
+ * `branch-protection-human-required` (the exhaustion probe sees
44
+ * `mergeStateStatus: BLOCKED` with checks settled) — sending the operator to
45
+ * diagnose branch protection instead of their red check. This wait probes the
46
+ * checks every iteration: it fails fast on `checks-failed`, and runs a
47
+ * bounded `gh pr update-branch` on a BEHIND PR instead of waiting out the
48
+ * budget behind a base it could have caught up to.
49
+ *
50
+ * The per-iteration `provider.getTicket` is also gone. It was re-fetched
51
+ * every poll for an idempotence check whose answer cannot change mid-poll —
52
+ * ~240 reads per Story per hour. The loop now probes the PR only, and calls
53
+ * the shared `confirmStoryMerged` exactly once, after a merge is observed.
31
54
  *
32
- * Attended (non-headless) runs never call this phase — `runner.js` only
33
- * invokes it when `options.waitForMerge` is `true`.
55
+ * Terminal outcomes:
56
+ * - `{ confirmed: true, action, tail }` the PR merged; `confirmStoryMerged`
57
+ * flipped `agent::closing → agent::done` and closed the issue, and the
58
+ * shared post-land tail ran.
59
+ * - `{ confirmed: false, terminal: 'pending', waitBudget }` — this
60
+ * invocation's bound expired with the PR still in flight. Resumable;
61
+ * nothing was mutated.
62
+ * - `{ confirmed: false, terminal: 'blocked', blockClass, reason }` — the
63
+ * arm failed outright, the PR closed without merging, a required check
64
+ * went red, or the cumulative budget was exhausted. Classified via the
65
+ * shared `classifyMergeBlock`, emitted as `merge.unlanded`, friction
66
+ * posted, Story transitioned to `agent::blocked`.
67
+ * - `{ confirmed: false, terminal: 'blocked', blockClass: 'merged-flip-failed' }`
68
+ * — the PR merged but the `agent::done` label write failed. Its own
69
+ * `merge.flip-failed` event and friction wording (Story #4539): the merge
70
+ * landed, so attributing it to an unlanded merge would send the operator
71
+ * to diagnose branch protection instead of re-running the idempotent
72
+ * confirm.
34
73
  */
35
74
 
36
75
  import { gh as defaultGh } from '../../../gh-exec.js';
@@ -38,42 +77,84 @@ import {
38
77
  confirmStoryMerged as defaultConfirmStoryMerged,
39
78
  readPrMergeState as defaultReadPrMergeState,
40
79
  } from '../../../single-story/confirm-merge.js';
80
+ import {
81
+ emitMergeFlipFailed as defaultEmitMergeFlipFailed,
82
+ MERGED_FLIP_FAILED_BLOCK_CLASS,
83
+ } from '../../lifecycle/emit-merge-flip-failed.js';
41
84
  import { emitMergeUnlanded as defaultEmitMergeUnlanded } from '../../lifecycle/emit-merge-unlanded.js';
85
+ import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-block-class.js';
42
86
  import {
43
87
  DEFAULT_INTERVAL_SECONDS,
44
88
  DEFAULT_MAX_BUDGET_SECONDS,
45
89
  deriveChecksStatus,
46
- } from '../../lifecycle/listeners/merge-watcher.js';
47
- import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-block-class.js';
90
+ failingChecksBlockMerge,
91
+ } from '../../merge-poll.js';
92
+ import { NEXT_COMMANDS } from '../../story-deliver-terminal.js';
48
93
  import {
49
94
  postStructuredComment,
50
95
  STATE_LABELS,
51
96
  transitionTicketState,
52
97
  } from '../../ticketing.js';
98
+ import { runPostLandTail as defaultRunPostLandTail } from './post-land.js';
99
+
100
+ /**
101
+ * Per-invocation merge-wait bound. 300s fits inside a single host tool
102
+ * invocation (~10 min ceiling) with room for the close gates that precede
103
+ * the wait. A headless caller with no such ceiling raises
104
+ * `delivery.mergeWatch.maxWaitSeconds` to keep single-block semantics.
105
+ */
106
+ export const DEFAULT_MAX_WAIT_SECONDS = 300;
107
+
108
+ /** Bounded `gh pr update-branch` attempts for a BEHIND PR. */
109
+ export const DEFAULT_UPDATE_ATTEMPTS = 3;
110
+
111
+ /**
112
+ * Minimum polls before the CUMULATIVE budget may block.
113
+ *
114
+ * The cumulative clock is anchored at the PR's `createdAt` so resumes do not
115
+ * restart it — but that alone means a PR older than `maxBudgetSeconds` (1h by
116
+ * default) is already over budget on its very first probe. Resuming a Story
117
+ * the next morning, or landing a long-open PR, would then flip
118
+ * `agent::blocked` and emit `merge.unlanded` against a perfectly healthy PR
119
+ * that was seconds from merging, without ever having waited.
120
+ *
121
+ * The floor gives every invocation at least one real poll cycle before the
122
+ * cumulative bound can fire. A genuinely stuck PR still blocks within one
123
+ * interval (~30s), so the give-up bound keeps its meaning; a PR about to go
124
+ * green gets the chance it earned.
125
+ */
126
+ export const MIN_POLLS_BEFORE_BUDGET_BLOCK = 2;
53
127
 
54
128
  function defaultSleep(ms) {
55
129
  return new Promise((resolve) => setTimeout(resolve, ms));
56
130
  }
57
131
 
58
132
  /**
59
- * Fresh PR probe for terminal classification. Fetches the fields
60
- * `classifyMergeBlock` keys on (`mergeStateStatus`, `reviewDecision`,
61
- * `statusCheckRollup` derived `checksStatus`) so a budget exhaustion
62
- * is classified from the REAL PR state instead of a hardcoded
63
- * `checksStatus: 'pending'` stamp (which mislabeled every timeout as
64
- * `checks-pending-timeout` a review-required block was never
65
- * diagnosable). Returns a degraded `{ checksStatus: 'pending', error }`
66
- * probe when the read itself fails, preserving the prior conservative
67
- * classification on probe errors.
133
+ * One probe per poll iteration, carrying every field the loop and the
134
+ * terminal classifier need: merge state, the checks rollup, the merge-state
135
+ * status (for BEHIND recovery and human-required classification), and
136
+ * `createdAt` (the cumulative-budget anchor).
137
+ *
138
+ * Returns a degraded `{ checksStatus: 'pending', error }` probe when the read
139
+ * itself fails, preserving the conservative classification on probe errors —
140
+ * a flaky API read must not be mistaken for a definitive verdict.
141
+ *
142
+ * @returns {Promise<object>}
68
143
  */
69
- async function readPrClassificationProbe({ prNumber, gh = defaultGh }) {
144
+ export async function readPrWaitProbe({ prNumber, gh = defaultGh }) {
70
145
  try {
71
146
  const view = await gh.pr.view(prNumber, [
147
+ 'state',
148
+ 'mergedAt',
149
+ 'createdAt',
72
150
  'mergeStateStatus',
73
151
  'reviewDecision',
74
152
  'statusCheckRollup',
75
153
  ]);
76
154
  return {
155
+ state: typeof view?.state === 'string' ? view.state : null,
156
+ mergedAt: typeof view?.mergedAt === 'string' ? view.mergedAt : null,
157
+ createdAt: typeof view?.createdAt === 'string' ? view.createdAt : null,
77
158
  mergeStateStatus:
78
159
  typeof view?.mergeStateStatus === 'string'
79
160
  ? view.mergeStateStatus
@@ -86,37 +167,77 @@ async function readPrClassificationProbe({ prNumber, gh = defaultGh }) {
86
167
  };
87
168
  } catch (err) {
88
169
  return {
170
+ state: null,
171
+ mergedAt: null,
172
+ createdAt: null,
89
173
  checksStatus: 'pending',
90
- error: `classification probe failed: ${err?.message ?? err}`,
174
+ error: `PR probe failed: ${err?.message ?? err}`,
91
175
  };
92
176
  }
93
177
  }
94
178
 
95
179
  /**
96
- * Resolve the poll cadence from `delivery.mergeWatch.*`, falling back to
97
- * the same defaults `MergeWatcher` uses when the config key is absent.
180
+ * Resolve the wait cadence and both budgets from `delivery.mergeWatch.*`,
181
+ * falling back to the framework defaults when a key is absent or invalid.
182
+ *
183
+ * `maxWaitSecondsOverride` is the per-run `--max-wait-seconds` flag and wins
184
+ * over the config: a headless caller with no host tool-invocation ceiling
185
+ * raises the per-invocation bound to keep single-block semantics without
186
+ * editing the consumer's config.
98
187
  *
99
188
  * @param {object} [config]
100
- * @returns {{ intervalSeconds: number, maxBudgetSeconds: number }}
189
+ * @param {number} [maxWaitSecondsOverride]
190
+ * @returns {{ intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
101
191
  */
102
- function resolveMergeWatchCadence(config) {
192
+ export function resolveMergeWaitConfig(config, maxWaitSecondsOverride) {
103
193
  const mergeWatch = config?.delivery?.mergeWatch ?? {};
104
- const intervalSeconds =
105
- Number.isInteger(mergeWatch.intervalSeconds) &&
106
- mergeWatch.intervalSeconds >= 1
107
- ? mergeWatch.intervalSeconds
108
- : DEFAULT_INTERVAL_SECONDS;
109
- const maxBudgetSeconds =
110
- Number.isInteger(mergeWatch.maxBudgetSeconds) &&
111
- mergeWatch.maxBudgetSeconds >= 1
112
- ? mergeWatch.maxBudgetSeconds
113
- : DEFAULT_MAX_BUDGET_SECONDS;
114
- return { intervalSeconds, maxBudgetSeconds };
194
+ const int = (value, fallback, min = 1) =>
195
+ Number.isInteger(value) && value >= min ? value : fallback;
196
+ const maxWaitSeconds = int(
197
+ maxWaitSecondsOverride,
198
+ int(mergeWatch.maxWaitSeconds, DEFAULT_MAX_WAIT_SECONDS),
199
+ );
200
+ // A poll interval longer than the wait bound is incoherent, and silently
201
+ // harmful: the pending check would fire on poll 1 every time, so the wait
202
+ // could never sleep, `polls` could never reach
203
+ // MIN_POLLS_BEFORE_BUDGET_BLOCK, and the cumulative budget would become
204
+ // unreachable across ANY number of resumes — a Story stuck in permanent
205
+ // `pending` that never escalates. Clamping the interval to the bound keeps
206
+ // at least one real poll cycle possible, which is what both the floor and
207
+ // the give-up bound depend on.
208
+ const intervalSeconds = Math.min(
209
+ int(mergeWatch.intervalSeconds, DEFAULT_INTERVAL_SECONDS),
210
+ maxWaitSeconds,
211
+ );
212
+ return {
213
+ intervalSeconds,
214
+ maxWaitSeconds,
215
+ maxBudgetSeconds: int(
216
+ mergeWatch.maxBudgetSeconds,
217
+ DEFAULT_MAX_BUDGET_SECONDS,
218
+ ),
219
+ updateAttempts: int(mergeWatch.updateAttempts, DEFAULT_UPDATE_ATTEMPTS, 0),
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Anchor the cumulative budget at the PR's `createdAt` so a resumed wait
225
+ * does not restart the clock. Falls back to this invocation's start when the
226
+ * probe carried no timestamp — a conservative degrade: the worst case is a
227
+ * resume getting a fresh cumulative budget, which is exactly the pre-#4543
228
+ * behaviour, never a premature block.
229
+ *
230
+ * @returns {number} epoch ms
231
+ */
232
+ export function resolveBudgetAnchorMs({ createdAt, fallbackMs }) {
233
+ if (typeof createdAt !== 'string' || !createdAt) return fallbackMs;
234
+ const parsed = Date.parse(createdAt);
235
+ return Number.isFinite(parsed) ? parsed : fallbackMs;
115
236
  }
116
237
 
117
238
  /**
118
239
  * Format the `friction` comment body posted alongside the `agent::blocked`
119
- * transition when a headless close gives up without a confirmed merge.
240
+ * transition when a landing attempt gives up without a confirmed merge.
120
241
  */
121
242
  function formatUnlandedFriction({
122
243
  storyId,
@@ -130,26 +251,165 @@ function formatUnlandedFriction({
130
251
  Number.isInteger(prNumber) && prNumber > 0
131
252
  ? `PR #${prNumber}${prUrl ? ` (${prUrl})` : ''}`
132
253
  : (prUrl ?? 'the PR');
254
+ const remedy =
255
+ blockClass === 'checks-failed'
256
+ ? `A required check is **red**. Fix the failure and push a new commit on \`story-${storyId}\`; ` +
257
+ `auto-merge stays armed across retries. Watch the checks with:\n\n` +
258
+ `\`\`\`bash\n${NEXT_COMMANDS.watchCi(storyId, prNumber)}\n\`\`\``
259
+ : `Resolve the underlying condition (branch protection, required checks, ` +
260
+ `or a manual merge), then resume the land:\n\n` +
261
+ `\`\`\`bash\n${NEXT_COMMANDS.resumeLand(storyId)}\n\`\`\``;
133
262
  return (
134
- `### headless must-land: merge did not land\n\n` +
135
- `Story #${storyId}: the headless close polled ${prLabel} for merge ` +
136
- `confirmation and gave up after ${elapsedSeconds}s without observing a ` +
137
- `confirmed merge.\n\n` +
263
+ `### close-and-land: merge did not land\n\n` +
264
+ `Story #${storyId}: the close polled ${prLabel} for merge confirmation and ` +
265
+ `gave up after ${elapsedSeconds}s without observing a confirmed merge.\n\n` +
138
266
  `**Block class:** \`${blockClass}\`\n\n` +
139
267
  `**Reason:** ${reason}\n\n` +
140
- `Story transitioned to \`agent::blocked\`. Resolve the underlying ` +
141
- `condition (branch protection, required checks, or a manual merge), ` +
142
- `then re-run \`single-story-confirm-merge.js\` or resume delivery.`
268
+ `Story transitioned to \`agent::blocked\`.\n\n${remedy}`
143
269
  );
144
270
  }
145
271
 
272
+ /**
273
+ * Format the `friction` comment for a merge that **landed** while the
274
+ * `agent::done` label write failed. Deliberately not the unlanded wording:
275
+ * the merge is not in question, so pointing the operator at branch
276
+ * protection and required checks would send them to diagnose a fault that
277
+ * does not exist. Name the actual remedy instead.
278
+ */
279
+ function formatFlipFailedFriction({
280
+ storyId,
281
+ prNumber,
282
+ prUrl,
283
+ reason,
284
+ elapsedSeconds,
285
+ }) {
286
+ const prLabel =
287
+ Number.isInteger(prNumber) && prNumber > 0
288
+ ? `PR #${prNumber}${prUrl ? ` (${prUrl})` : ''}`
289
+ : (prUrl ?? 'the PR');
290
+ return (
291
+ `### merge landed; the agent::done flip failed\n\n` +
292
+ `Story #${storyId}: ${prLabel} **merged successfully** after ${elapsedSeconds}s, ` +
293
+ `but the \`agent::closing\` → \`agent::done\` label write failed. The code is ` +
294
+ `on the base branch — this is a label-write fault, not a merge fault, so ` +
295
+ `there is nothing to diagnose about branch protection or required checks.\n\n` +
296
+ `**Block class:** \`${MERGED_FLIP_FAILED_BLOCK_CLASS}\`\n\n` +
297
+ `**Reason:** ${reason}\n\n` +
298
+ `Story transitioned to \`agent::blocked\` so the merged-but-mislabelled ` +
299
+ `state is explicit rather than silently resting at \`agent::closing\`.\n\n` +
300
+ `**Remedy:** re-run the merge confirmation — it is idempotent and flips ` +
301
+ `the label from the already-merged PR:\n\n` +
302
+ `\`\`\`bash\n${NEXT_COMMANDS.confirmMerge(storyId)}\n\`\`\``
303
+ );
304
+ }
305
+
306
+ /**
307
+ * Post a friction comment best-effort and return its id when the provider
308
+ * surfaces one. The id is the terminal envelope's `frictionCommentId`
309
+ * pointer, so a caller can link the operator straight at the remediation
310
+ * instead of telling them to go find it.
311
+ *
312
+ * @returns {Promise<string|null>}
313
+ */
314
+ async function postFriction({ provider, storyId, body, progress }) {
315
+ try {
316
+ const posted = await postStructuredComment(
317
+ provider,
318
+ storyId,
319
+ 'friction',
320
+ body,
321
+ );
322
+ const id = posted?.id ?? posted?.commentId ?? null;
323
+ return id == null ? null : String(id);
324
+ } catch (err) {
325
+ progress?.(
326
+ 'CONFIRM',
327
+ `⚠️ Failed to post friction comment: ${err?.message ?? err}`,
328
+ );
329
+ return null;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Terminal for a confirmed merge whose `agent::done` flip failed. Emits
335
+ * `merge.flip-failed` (NOT `merge.unlanded` — the merge landed), posts the
336
+ * flip-failed friction, and blocks explicitly. Best-effort throughout: the
337
+ * caller owns the non-zero exit.
338
+ */
339
+ async function blockOnFlipFailed({
340
+ storyId,
341
+ prNumber,
342
+ prUrl,
343
+ reason,
344
+ elapsedSeconds,
345
+ provider,
346
+ progress,
347
+ emitMergeFlipFailedFn,
348
+ prProbe,
349
+ }) {
350
+ if (Number.isInteger(prNumber) && prNumber > 0) {
351
+ try {
352
+ emitMergeFlipFailedFn({
353
+ scope: 'story',
354
+ ticketId: storyId,
355
+ prNumber,
356
+ reason,
357
+ elapsedSeconds,
358
+ });
359
+ } catch (err) {
360
+ progress?.(
361
+ 'CONFIRM',
362
+ `⚠️ merge.flip-failed emit failed (continuing): ${err?.message ?? err}`,
363
+ );
364
+ }
365
+ }
366
+
367
+ const frictionCommentId = await postFriction({
368
+ provider,
369
+ storyId,
370
+ body: formatFlipFailedFriction({
371
+ storyId,
372
+ prNumber,
373
+ prUrl,
374
+ reason,
375
+ elapsedSeconds,
376
+ }),
377
+ progress,
378
+ });
379
+
380
+ try {
381
+ await transitionTicketState(provider, storyId, STATE_LABELS.BLOCKED, {});
382
+ progress?.(
383
+ 'CONFIRM',
384
+ `🛑 Story #${storyId} → agent::blocked (${MERGED_FLIP_FAILED_BLOCK_CLASS}) — merge landed, label flip failed.`,
385
+ );
386
+ } catch (err) {
387
+ progress?.(
388
+ 'CONFIRM',
389
+ `⚠️ Failed to flip Story #${storyId} to agent::blocked: ${err?.message ?? err}`,
390
+ );
391
+ }
392
+
393
+ return {
394
+ confirmed: false,
395
+ terminal: 'blocked',
396
+ blockClass: MERGED_FLIP_FAILED_BLOCK_CLASS,
397
+ reason,
398
+ frictionCommentId,
399
+ elapsedSeconds,
400
+ // The merge is CONFIRMED here — only the label write failed — so the
401
+ // envelope must say MERGED even when the probe that got us here was read
402
+ // before the merge landed. Reporting the stale OPEN (or null) would tell
403
+ // the operator to chase a merge that already happened.
404
+ prProbe: { ...(prProbe ?? {}), state: 'MERGED' },
405
+ };
406
+ }
407
+
146
408
  /**
147
409
  * Classify the unlanded merge, emit `merge.unlanded`, post a `friction`
148
410
  * comment, and transition the Story to `agent::blocked`. Every side effect
149
- * is best-effort logged rather than thrown — the caller (`runSingleStoryClose`)
150
- * owns surfacing the non-zero exit via its own throw once this returns.
151
- *
152
- * @returns {Promise<{ confirmed: false, blockClass: string, reason: string, elapsedSeconds: number }>}
411
+ * is best-effort logged rather than thrown — the caller owns surfacing the
412
+ * non-zero exit once this returns.
153
413
  */
154
414
  async function blockOnUnlanded({
155
415
  storyId,
@@ -193,46 +453,183 @@ async function blockOnUnlanded({
193
453
  );
194
454
  }
195
455
 
196
- const body = formatUnlandedFriction({
456
+ const frictionCommentId = await postFriction({
457
+ provider,
197
458
  storyId,
198
- prNumber,
199
- prUrl,
200
- blockClass,
201
- reason,
202
- elapsedSeconds,
459
+ body: formatUnlandedFriction({
460
+ storyId,
461
+ prNumber,
462
+ prUrl,
463
+ blockClass,
464
+ reason,
465
+ elapsedSeconds,
466
+ }),
467
+ progress,
203
468
  });
469
+
204
470
  try {
205
- await postStructuredComment(provider, storyId, 'friction', body);
471
+ await transitionTicketState(provider, storyId, STATE_LABELS.BLOCKED, {});
472
+ progress?.(
473
+ 'CONFIRM',
474
+ `🛑 Story #${storyId} → agent::blocked (${blockClass}).`,
475
+ );
206
476
  } catch (err) {
207
477
  progress?.(
208
478
  'CONFIRM',
209
- `⚠️ Failed to post merge.unlanded friction comment: ${err?.message ?? err}`,
479
+ `⚠️ Failed to flip Story #${storyId} to agent::blocked: ${err?.message ?? err}`,
210
480
  );
211
481
  }
212
482
 
483
+ return {
484
+ confirmed: false,
485
+ terminal: 'blocked',
486
+ blockClass,
487
+ reason,
488
+ frictionCommentId,
489
+ elapsedSeconds,
490
+ // The probe the classifier just read. The terminal envelope reports
491
+ // `pr.state` / `pr.checksStatus` from here; dropping it made every
492
+ // blocked envelope claim `null` for facts we had just observed — a
493
+ // `checks-failed` envelope reporting `checksStatus: null` contradicts
494
+ // itself. Schema wants "live PR facts as observed at terminal time".
495
+ prProbe,
496
+ };
497
+ }
498
+
499
+ /**
500
+ * Bring a BEHIND PR up to date, bounded by `updateAttempts`. Best-effort:
501
+ * a failed update is not itself a terminal — the next poll re-reads the
502
+ * real state and lets the normal classification decide.
503
+ *
504
+ * @returns {Promise<boolean>} whether an update was actually attempted.
505
+ */
506
+ async function maybeUpdateBehindPr({
507
+ probe,
508
+ prNumber,
509
+ updatesUsed,
510
+ updateAttempts,
511
+ gh,
512
+ progress,
513
+ }) {
514
+ if (probe.mergeStateStatus !== 'BEHIND') return false;
515
+ if (updatesUsed >= updateAttempts) {
516
+ progress?.(
517
+ 'CONFIRM',
518
+ `⚠️ PR #${prNumber} is BEHIND but the update budget (${updateAttempts}) is spent — not updating again.`,
519
+ );
520
+ return false;
521
+ }
213
522
  try {
214
- await transitionTicketState(provider, storyId, STATE_LABELS.BLOCKED, {});
523
+ await (gh ?? defaultGh).pr.updateBranch(prNumber);
215
524
  progress?.(
216
525
  'CONFIRM',
217
- `🛑 Story #${storyId} agent::blocked (${blockClass}).`,
526
+ `⏫ PR #${prNumber} was BEHIND its base — updated (attempt ${updatesUsed + 1}/${updateAttempts}).`,
218
527
  );
219
528
  } catch (err) {
220
529
  progress?.(
221
530
  'CONFIRM',
222
- `⚠️ Failed to flip Story #${storyId} to agent::blocked: ${err?.message ?? err}`,
531
+ `⚠️ gh pr update-branch failed (continuing): ${err?.message ?? err}`,
532
+ );
533
+ }
534
+ return true;
535
+ }
536
+
537
+ /**
538
+ * Handle an observed merge: run the shared `confirmStoryMerged` flip, then
539
+ * the shared post-land tail. Called at most once per wait — the loop probes
540
+ * the PR, not the ticket.
541
+ */
542
+ async function onMergeObserved({
543
+ storyId,
544
+ storyBranch,
545
+ baseBranch,
546
+ prNumber,
547
+ prUrl,
548
+ cwd,
549
+ config,
550
+ provider,
551
+ progress,
552
+ injectedGh,
553
+ injectedNotify,
554
+ readPrMergeStateFn,
555
+ confirmStoryMergedFn,
556
+ runPostLandTailFn,
557
+ emitMergeFlipFailedFn,
558
+ prProbe,
559
+ elapsedSeconds,
560
+ }) {
561
+ const confirmation = await confirmStoryMergedFn({
562
+ provider,
563
+ storyId,
564
+ prNumber,
565
+ prUrl,
566
+ cwd,
567
+ config,
568
+ progress,
569
+ injectedGh,
570
+ injectedNotify,
571
+ readPrMergeStateFn,
572
+ });
573
+
574
+ if (confirmation.merged && confirmation.action === 'flip-failed') {
575
+ // The PR merged but the agent::closing → agent::done label flip itself
576
+ // threw. Blocking explicitly is right — reporting confirmed:true would
577
+ // strand the Story at agent::closing with no notification. Reporting it
578
+ // as UNLANDED was not (Story #4539): the merge landed, so the
579
+ // merge.unlanded event would be false and its friction would send the
580
+ // operator to branch protection instead of the one-line remedy.
581
+ progress?.(
582
+ 'CONFIRM',
583
+ `⚠️ Story #${storyId} merge confirmed but the agent::done flip failed — blocking explicitly.`,
223
584
  );
585
+ return blockOnFlipFailed({
586
+ storyId,
587
+ prNumber,
588
+ prUrl,
589
+ reason:
590
+ confirmation.reason ??
591
+ 'merge confirmed but the agent::done label write failed',
592
+ elapsedSeconds,
593
+ provider,
594
+ progress,
595
+ emitMergeFlipFailedFn,
596
+ prProbe,
597
+ });
224
598
  }
225
599
 
226
- return { confirmed: false, blockClass, reason, elapsedSeconds };
600
+ progress?.('CONFIRM', `✅ Story #${storyId} merge confirmed agent::done.`);
601
+ const tail = await runPostLandTailFn({
602
+ storyId,
603
+ storyBranch,
604
+ baseBranch,
605
+ cwd,
606
+ provider,
607
+ config,
608
+ progress,
609
+ });
610
+ return {
611
+ confirmed: true,
612
+ terminal: 'landed',
613
+ action: confirmation.action,
614
+ tail,
615
+ // Carry the OBSERVED rollup rather than stamping 'success'. A merge landed
616
+ // by admin override, or with non-required checks red, must not be reported
617
+ // as a green run nobody actually saw — that is the same
618
+ // report-an-outcome-you-never-checked shape the land tail's per-step
619
+ // booleans exist to prevent.
620
+ prProbe,
621
+ };
227
622
  }
228
623
 
229
624
  /**
230
- * Poll an armed standalone-Story PR to merge confirmation, or terminate
231
- * `agent::blocked` with a classified `merge.unlanded` event.
625
+ * Poll an armed Story PR to merge confirmation, a resumable `pending`
626
+ * expiry, or a classified `agent::blocked` terminal.
232
627
  *
233
628
  * @param {object} args
234
- * @param {string} args.cwd
629
+ * @param {string} args.cwd The MAIN checkout.
235
630
  * @param {number} args.storyId
631
+ * @param {string} [args.storyBranch]
632
+ * @param {string} [args.baseBranch]
236
633
  * @param {number|null} args.prNumber
237
634
  * @param {string} args.prUrl
238
635
  * @param {boolean} args.autoMergeEnabled
@@ -245,42 +642,49 @@ async function blockOnUnlanded({
245
642
  * @param {Function} [args.confirmStoryMergedFn] Test seam — defaults to the
246
643
  * SAME `confirmStoryMerged` export `single-story-confirm-merge.js` calls
247
644
  * (Story #4428 AC4: one merged/`agent::done` implementation).
248
- * @param {Function} [args.readPrMergeStateFn] Test seam for the PR-state reader.
645
+ * @param {Function} [args.readPrWaitProbeFn] Test seam for the poll probe.
646
+ * @param {Function} [args.readPrMergeStateFn] Test seam for the PR-state reader.
249
647
  * @param {Function} [args.classifyMergeBlockFn] Test seam for the classifier.
250
- * @param {Function} [args.emitMergeUnlandedFn] Test seam for the lifecycle emitter.
648
+ * @param {Function} [args.emitMergeUnlandedFn] Test seam for the emitter.
649
+ * @param {Function} [args.runPostLandTailFn] Test seam for the land tail.
251
650
  * @param {(ms: number) => Promise<void>} [args.sleepFn] Test seam so the
252
651
  * suite does not actually wait.
253
652
  * @param {() => number} [args.nowMsFn] Test seam; returns epoch ms.
254
- * @returns {Promise<{ confirmed: boolean, action?: string, blockClass?: string, reason?: string, elapsedSeconds?: number }>}
653
+ * @returns {Promise<object>}
255
654
  */
256
655
  export async function runConfirmMergePhase({
257
656
  cwd,
258
657
  storyId,
658
+ storyBranch,
659
+ baseBranch,
259
660
  prNumber,
260
661
  prUrl,
261
662
  autoMergeEnabled,
262
663
  autoMergeReason,
263
664
  provider,
264
665
  config,
666
+ maxWaitSeconds: maxWaitSecondsOverride,
265
667
  progress,
266
668
  injectedGh,
267
669
  injectedNotify,
268
670
  confirmStoryMergedFn = defaultConfirmStoryMerged,
671
+ readPrWaitProbeFn = readPrWaitProbe,
269
672
  readPrMergeStateFn = defaultReadPrMergeState,
270
- readPrClassificationProbeFn = readPrClassificationProbe,
271
673
  classifyMergeBlockFn = defaultClassifyMergeBlock,
272
674
  emitMergeUnlandedFn = defaultEmitMergeUnlanded,
675
+ emitMergeFlipFailedFn = defaultEmitMergeFlipFailed,
676
+ runPostLandTailFn = defaultRunPostLandTail,
273
677
  sleepFn = defaultSleep,
274
678
  nowMsFn = Date.now,
275
679
  }) {
276
680
  // The arm itself never succeeded (gh failure, unparseable PR number, or a
277
681
  // deliberate disablement) — there is no "armed but unconfirmed" PR to
278
- // poll. Headless mode still requires an explicit terminal state, so
279
- // classify and block immediately rather than resting silently.
682
+ // poll. An explicit terminal state is still required, so classify and
683
+ // block immediately rather than resting silently.
280
684
  if (!autoMergeEnabled) {
281
685
  progress?.(
282
686
  'CONFIRM',
283
- `⚠️ Auto-merge not enabled (${autoMergeReason ?? 'unknown'}) — headless close cannot wait for a merge that was never armed.`,
687
+ `⚠️ Auto-merge not enabled (${autoMergeReason ?? 'unknown'}) — cannot wait for a merge that was never armed.`,
284
688
  );
285
689
  return blockOnUnlanded({
286
690
  storyId,
@@ -295,61 +699,81 @@ export async function runConfirmMergePhase({
295
699
  });
296
700
  }
297
701
 
298
- const { intervalSeconds, maxBudgetSeconds } =
299
- resolveMergeWatchCadence(config);
702
+ const { intervalSeconds, maxWaitSeconds, maxBudgetSeconds, updateAttempts } =
703
+ resolveMergeWaitConfig(config, maxWaitSecondsOverride);
300
704
  const intervalMs = intervalSeconds * 1000;
301
- const budgetMs = maxBudgetSeconds * 1000;
302
705
  const startedAtMs = nowMsFn();
706
+ let anchorMs = startedAtMs;
707
+ let updatesUsed = 0;
708
+ let polls = 0;
303
709
 
304
710
  progress?.(
305
711
  'CONFIRM',
306
- `⏳ Headless must-land: polling PR #${prNumber} for merge confirmation (budget=${maxBudgetSeconds}s)...`,
712
+ `⏳ Close-and-land: polling PR #${prNumber} for merge confirmation ` +
713
+ `(wait=${maxWaitSeconds}s this invocation, cumulative budget=${maxBudgetSeconds}s)...`,
307
714
  );
308
715
 
309
716
  while (true) {
310
- const confirmation = await confirmStoryMergedFn({
311
- provider,
312
- storyId,
313
- prNumber,
314
- prUrl,
315
- cwd,
316
- config,
317
- progress,
318
- injectedGh,
319
- injectedNotify,
320
- readPrMergeStateFn,
717
+ const probe = await readPrWaitProbeFn({ prNumber, gh: injectedGh });
718
+ polls += 1;
719
+
720
+ // Anchor the cumulative budget at the PR's creation the first time we
721
+ // learn it, so a resumed wait continues the clock instead of restarting.
722
+ anchorMs = resolveBudgetAnchorMs({
723
+ createdAt: probe.createdAt,
724
+ fallbackMs: startedAtMs,
321
725
  });
322
726
 
323
- if (confirmation.merged && confirmation.action !== 'flip-failed') {
324
- progress?.(
325
- 'CONFIRM',
326
- `✅ Story #${storyId} merge confirmed — agent::done.`,
327
- );
328
- return { confirmed: true, action: confirmation.action };
727
+ const waitedMs = nowMsFn() - startedAtMs;
728
+ const cumulativeMs = Math.max(nowMsFn() - anchorMs, waitedMs);
729
+ const waitBudget = {
730
+ maxWaitSeconds,
731
+ waitedSeconds: Math.round(waitedMs / 1000),
732
+ cumulativeSeconds: Math.round(cumulativeMs / 1000),
733
+ maxBudgetSeconds,
734
+ };
735
+
736
+ if (probe.state === 'MERGED' || probe.mergedAt) {
737
+ return onMergeObserved({
738
+ storyId,
739
+ storyBranch,
740
+ baseBranch,
741
+ prNumber,
742
+ prUrl,
743
+ cwd,
744
+ config,
745
+ provider,
746
+ progress,
747
+ injectedGh,
748
+ injectedNotify,
749
+ readPrMergeStateFn,
750
+ confirmStoryMergedFn,
751
+ runPostLandTailFn,
752
+ emitMergeFlipFailedFn,
753
+ prProbe: probe,
754
+ elapsedSeconds: Math.round(waitedMs / 1000),
755
+ });
329
756
  }
330
757
 
331
- if (confirmation.merged && confirmation.action === 'flip-failed') {
332
- // The PR merged but the agent::closing agent::done label flip
333
- // itself threw reporting confirmed:true here would strand the
334
- // Story at agent::closing with no notification and no block, the
335
- // silent-terminal-state gap the Epic exists to close (audit-quality
336
- // Critical finding, Epic #4425). Route through the same
337
- // blockOnUnlanded path as an unlanded merge so the run still
338
- // terminates in an explicit agent::blocked state with a diagnosis.
339
- progress?.(
340
- 'CONFIRM',
341
- `⚠️ Story #${storyId} merge confirmed but the agent::done flip failed — blocking explicitly.`,
342
- );
758
+ if (probe.state === 'CLOSED') {
759
+ // Closed without merging a definitive terminal, not a "still
760
+ // pending" condition the budget should keep waiting on. checksStatus
761
+ // MUST be a non-pending, non-undefined value here: the classifier's
762
+ // budget-exhausted branch treats an undefined checksStatus as "still
763
+ // pending", which would misclassify this definitive case as
764
+ // checks-pending-timeout instead of reaching the api-race-other
765
+ // reason built from prProbe.error.
343
766
  return blockOnUnlanded({
344
767
  storyId,
345
768
  prNumber,
346
769
  prUrl,
347
770
  prProbe: {
348
- error: 'merge confirmed but agent::done label flip failed',
771
+ checksStatus: 'closed',
772
+ error: 'PR closed without merging (state=CLOSED)',
349
773
  },
350
774
  budget: {
351
775
  exhausted: true,
352
- elapsedSeconds: Math.round((nowMsFn() - startedAtMs) / 1000),
776
+ elapsedSeconds: Math.round(waitedMs / 1000),
353
777
  },
354
778
  provider,
355
779
  progress,
@@ -358,27 +782,26 @@ export async function runConfirmMergePhase({
358
782
  });
359
783
  }
360
784
 
361
- if (confirmation.reason === 'pr-not-merged') {
362
- // The PR was closed without merging a definitive terminal state,
363
- // not a "still pending" condition the budget should keep waiting
364
- // on. checksStatus MUST be a non-pending, non-undefined value here
365
- // (audit-clean-code finding, Epic #4425): classifyMergeBlock's
366
- // budget-exhausted branch treats an undefined checksStatus as
367
- // "still pending", which would misclassify this definitive
368
- // closed-without-merging case as checks-pending-timeout instead
369
- // of falling through to the api-race-other reason built from
370
- // prProbe.error below.
785
+ // Fail fast on a red REQUIRED check. No remaining budget turns a failed
786
+ // check green, and waiting it out is what made the pre-#4543 wait report
787
+ // the operator's red test run as a branch-protection block.
788
+ //
789
+ // Gated on `failingChecksBlockMerge`, not on the raw rollup: a red
790
+ // OPTIONAL check does not stop native auto-merge, so failing fast on it
791
+ // would block the Story while the PR lands anyway.
792
+ if (failingChecksBlockMerge(probe)) {
793
+ progress?.(
794
+ 'CONFIRM',
795
+ `🛑 PR #${prNumber}: a required check went red — failing fast rather than burning the budget.`,
796
+ );
371
797
  return blockOnUnlanded({
372
798
  storyId,
373
799
  prNumber,
374
800
  prUrl,
375
- prProbe: {
376
- checksStatus: 'closed',
377
- error: 'PR closed without merging (state=CLOSED)',
378
- },
801
+ prProbe: probe,
379
802
  budget: {
380
- exhausted: true,
381
- elapsedSeconds: Math.round((nowMsFn() - startedAtMs) / 1000),
803
+ exhausted: false,
804
+ elapsedSeconds: Math.round(waitedMs / 1000),
382
805
  },
383
806
  provider,
384
807
  progress,
@@ -387,25 +810,36 @@ export async function runConfirmMergePhase({
387
810
  });
388
811
  }
389
812
 
390
- const elapsedMs = nowMsFn() - startedAtMs;
391
- if (elapsedMs + intervalMs > budgetMs) {
392
- // Terminal classification from the REAL PR state — one fresh probe
393
- // of the fields classifyMergeBlock keys on, instead of stamping
394
- // every timeout `checksStatus: 'pending'` (which made a
395
- // review-required block undiagnosable). The probe degrades to the
396
- // prior conservative pending stamp when the read itself fails.
397
- const prProbe = await readPrClassificationProbeFn({
813
+ if (
814
+ await maybeUpdateBehindPr({
815
+ probe,
398
816
  prNumber,
817
+ updatesUsed,
818
+ updateAttempts,
399
819
  gh: injectedGh,
400
- });
820
+ progress,
821
+ })
822
+ ) {
823
+ updatesUsed += 1;
824
+ }
825
+
826
+ // Cumulative budget exhausted → the genuine give-up. Classify from the
827
+ // probe we already hold. Gated behind the poll floor so an
828
+ // already-over-budget PR (anchored at a createdAt older than the budget —
829
+ // a resume the next day, or a long-open PR) still gets a real poll cycle
830
+ // instead of being blocked before this invocation waited at all.
831
+ if (
832
+ polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK &&
833
+ cumulativeMs + intervalMs > maxBudgetSeconds * 1000
834
+ ) {
401
835
  return blockOnUnlanded({
402
836
  storyId,
403
837
  prNumber,
404
838
  prUrl,
405
- prProbe,
839
+ prProbe: probe,
406
840
  budget: {
407
841
  exhausted: true,
408
- elapsedSeconds: Math.round(elapsedMs / 1000),
842
+ elapsedSeconds: Math.round(cumulativeMs / 1000),
409
843
  },
410
844
  provider,
411
845
  progress,
@@ -414,6 +848,28 @@ export async function runConfirmMergePhase({
414
848
  });
415
849
  }
416
850
 
851
+ // This invocation's bound expired → PENDING. Deliberately NOT a block:
852
+ // nothing is wrong, the run simply reached the edge of its host slot.
853
+ // No label mutation, no merge.unlanded event — the caller surfaces a
854
+ // resumable terminal and the next invocation continues the cumulative
855
+ // clock from the PR's createdAt.
856
+ if (waitedMs + intervalMs > maxWaitSeconds * 1000) {
857
+ progress?.(
858
+ 'CONFIRM',
859
+ `⏸ Merge wait bound reached (${waitBudget.waitedSeconds}s of ${maxWaitSeconds}s this invocation; ` +
860
+ `${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative). PR #${prNumber} still in flight ` +
861
+ `(checks=${probe.checksStatus ?? 'unknown'}). Story stays at agent::closing — resumable.`,
862
+ );
863
+ return {
864
+ confirmed: false,
865
+ terminal: 'pending',
866
+ reason: `merge wait bound reached with the PR still in flight (checks=${probe.checksStatus ?? 'unknown'})`,
867
+ prProbe: probe,
868
+ waitBudget,
869
+ elapsedSeconds: waitBudget.waitedSeconds,
870
+ };
871
+ }
872
+
417
873
  await sleepFn(intervalMs);
418
874
  }
419
875
  }