@zerwiz/ymir 0.1.3 → 0.1.5

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 (850) hide show
  1. package/.agents/README.md +291 -0
  2. package/.agents/agents/bragi-marketer.md +111 -0
  3. package/.agents/agents/brokk.md +352 -0
  4. package/.agents/agents/forseti-reviewer.md +93 -0
  5. package/.agents/agents/frigg-consent.md +77 -0
  6. package/.agents/agents/groa-update.md +77 -0
  7. package/.agents/agents/hnoss-designer.md +69 -0
  8. package/.agents/agents/huginn-researcher.md +99 -0
  9. package/.agents/agents/jord-projects.md +78 -0
  10. package/.agents/agents/kvasir-scout.md +91 -0
  11. package/.agents/agents/mimir-planner.md +92 -0
  12. package/.agents/agents/muninn-stow.md +77 -0
  13. package/.agents/agents/saga-bearings.md +78 -0
  14. package/.agents/agents/sindri-developer.md +114 -0
  15. package/.agents/agents/snotra-documenter.md +105 -0
  16. package/.agents/agents/syn-recovery.md +78 -0
  17. package/.agents/agents/tyr-check.md +78 -0
  18. package/.agents/agents/urdh-hold.md +77 -0
  19. package/.agents/agents/volundr-smith.md +80 -0
  20. package/.agents/agents/vor-diagnostics.md +77 -0
  21. package/.agents/assets/agents/naming.md +50 -0
  22. package/.agents/assets/agents/registry.md +93 -0
  23. package/.agents/assets/agents/runtime.md +36 -0
  24. package/.agents/assets/agents/toon-tasks-cli.md +42 -0
  25. package/.agents/assets/templates/company_entity.template.md +37 -0
  26. package/.agents/backend/backends/cmux.sh +649 -0
  27. package/.agents/backend/backends/herdr-eventwait.py +157 -0
  28. package/.agents/backend/backends/herdr-workspace-move.py +114 -0
  29. package/.agents/backend/backends/herdr.sh +3386 -0
  30. package/.agents/backend/backends/orca.sh +290 -0
  31. package/.agents/backend/backends/tmux.sh +357 -0
  32. package/.agents/backend/backends/zellij.sh +700 -0
  33. package/.agents/backend/fm-afk-launch.sh +654 -0
  34. package/.agents/backend/fm-afk-return.sh +241 -0
  35. package/.agents/backend/fm-afk-start.sh +172 -0
  36. package/.agents/backend/fm-arm-command-policy.mjs +960 -0
  37. package/.agents/backend/fm-arm-pretool-check.sh +200 -0
  38. package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
  39. package/.agents/backend/fm-backend.sh +981 -0
  40. package/.agents/backend/fm-backlog-handoff.sh +909 -0
  41. package/.agents/backend/fm-backlog-receive.sh +187 -0
  42. package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
  43. package/.agents/backend/fm-bearings-board.sh +204 -0
  44. package/.agents/backend/fm-bearings-snapshot.sh +546 -0
  45. package/.agents/backend/fm-bootstrap.sh +1568 -0
  46. package/.agents/backend/fm-branch-outcome.sh +203 -0
  47. package/.agents/backend/fm-branch-prompt.sh +101 -0
  48. package/.agents/backend/fm-brief.sh +452 -0
  49. package/.agents/backend/fm-busy-event.sh +234 -0
  50. package/.agents/backend/fm-busy-lib.sh +986 -0
  51. package/.agents/backend/fm-captain-hold.sh +1001 -0
  52. package/.agents/backend/fm-cd-command-policy.mjs +153 -0
  53. package/.agents/backend/fm-cd-pretool-check.sh +190 -0
  54. package/.agents/backend/fm-check-lib.sh +74 -0
  55. package/.agents/backend/fm-check-register.sh +42 -0
  56. package/.agents/backend/fm-check-unregister.sh +52 -0
  57. package/.agents/backend/fm-classify-lib.sh +1770 -0
  58. package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
  59. package/.agents/backend/fm-composer-lib.sh +1416 -0
  60. package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
  61. package/.agents/backend/fm-config-push.sh +254 -0
  62. package/.agents/backend/fm-control-lib.sh +251 -0
  63. package/.agents/backend/fm-control.sh +878 -0
  64. package/.agents/backend/fm-crew-state.sh +625 -0
  65. package/.agents/backend/fm-cursor-lib.sh +243 -0
  66. package/.agents/backend/fm-decision-hold.sh +232 -0
  67. package/.agents/backend/fm-doc-audience-check.sh +269 -0
  68. package/.agents/backend/fm-dod-lib.sh +67 -0
  69. package/.agents/backend/fm-ensure-agents-md.sh +253 -0
  70. package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
  71. package/.agents/backend/fm-extension.mjs +2577 -0
  72. package/.agents/backend/fm-extension.sh +16 -0
  73. package/.agents/backend/fm-ff-lib.sh +420 -0
  74. package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
  75. package/.agents/backend/fm-fleet-sync.sh +458 -0
  76. package/.agents/backend/fm-fleet-view.sh +96 -0
  77. package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
  78. package/.agents/backend/fm-guard.sh +242 -0
  79. package/.agents/backend/fm-harness.sh +195 -0
  80. package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
  81. package/.agents/backend/fm-herdr-lab.sh +346 -0
  82. package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
  83. package/.agents/backend/fm-home-seed.sh +984 -0
  84. package/.agents/backend/fm-home-summary-refresh.sh +255 -0
  85. package/.agents/backend/fm-hook-host-lib.sh +36 -0
  86. package/.agents/backend/fm-inactive-reconcile.sh +531 -0
  87. package/.agents/backend/fm-inbox.sh +399 -0
  88. package/.agents/backend/fm-install-actionlint.sh +84 -0
  89. package/.agents/backend/fm-install-herdr.sh +96 -0
  90. package/.agents/backend/fm-install-shellcheck.sh +84 -0
  91. package/.agents/backend/fm-install-treehouse.sh +95 -0
  92. package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
  93. package/.agents/backend/fm-lease-lib.sh +218 -0
  94. package/.agents/backend/fm-lease.sh +189 -0
  95. package/.agents/backend/fm-line-cap-lib.sh +51 -0
  96. package/.agents/backend/fm-lint-workflows.sh +137 -0
  97. package/.agents/backend/fm-lint.sh +591 -0
  98. package/.agents/backend/fm-lock-lib.sh +104 -0
  99. package/.agents/backend/fm-lock.sh +107 -0
  100. package/.agents/backend/fm-marker-lib.sh +12 -0
  101. package/.agents/backend/fm-merge-local.sh +74 -0
  102. package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
  103. package/.agents/backend/fm-nm-run-lib.sh +124 -0
  104. package/.agents/backend/fm-on.sh +125 -0
  105. package/.agents/backend/fm-operational-input.sh +253 -0
  106. package/.agents/backend/fm-peek.sh +46 -0
  107. package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
  108. package/.agents/backend/fm-pr-check.sh +135 -0
  109. package/.agents/backend/fm-pr-lib.sh +1016 -0
  110. package/.agents/backend/fm-pr-merge.sh +707 -0
  111. package/.agents/backend/fm-pr-poll.sh +110 -0
  112. package/.agents/backend/fm-primary-scope-lib.sh +33 -0
  113. package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
  114. package/.agents/backend/fm-procevent-lavish.sh +628 -0
  115. package/.agents/backend/fm-procevent-lib.sh +892 -0
  116. package/.agents/backend/fm-procevent-quota.sh +290 -0
  117. package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
  118. package/.agents/backend/fm-procevent-when.sh +504 -0
  119. package/.agents/backend/fm-procevent.sh +1466 -0
  120. package/.agents/backend/fm-project-mode.sh +92 -0
  121. package/.agents/backend/fm-project-origin-lib.sh +180 -0
  122. package/.agents/backend/fm-promote.sh +282 -0
  123. package/.agents/backend/fm-public-followup-emit.sh +260 -0
  124. package/.agents/backend/fm-public-followup-lib.sh +398 -0
  125. package/.agents/backend/fm-public-followup.sh +1330 -0
  126. package/.agents/backend/fm-push-transition-lib.sh +168 -0
  127. package/.agents/backend/fm-quota-axi-lib.sh +93 -0
  128. package/.agents/backend/fm-quota-choose.sh +384 -0
  129. package/.agents/backend/fm-remote-delta-read.sh +189 -0
  130. package/.agents/backend/fm-remote-doctor.sh +800 -0
  131. package/.agents/backend/fm-remote-entrypoint.sh +193 -0
  132. package/.agents/backend/fm-remote-file.sh +234 -0
  133. package/.agents/backend/fm-remote-home-provision.sh +259 -0
  134. package/.agents/backend/fm-remote-home-seed.sh +259 -0
  135. package/.agents/backend/fm-remote-inherit-push.sh +90 -0
  136. package/.agents/backend/fm-remote-inherit.sh +179 -0
  137. package/.agents/backend/fm-remote-job-lib.sh +1241 -0
  138. package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
  139. package/.agents/backend/fm-remote-job-worker.sh +1124 -0
  140. package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
  141. package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
  142. package/.agents/backend/fm-review-diff.sh +158 -0
  143. package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
  144. package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
  145. package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
  146. package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
  147. package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
  148. package/.agents/backend/fm-secondmate-report.sh +88 -0
  149. package/.agents/backend/fm-send.sh +1059 -0
  150. package/.agents/backend/fm-session-lock-lib.sh +176 -0
  151. package/.agents/backend/fm-session-start.sh +970 -0
  152. package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
  153. package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
  154. package/.agents/backend/fm-sessionstart-run.sh +145 -0
  155. package/.agents/backend/fm-spawn.sh +3153 -0
  156. package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
  157. package/.agents/backend/fm-startup-memory-budget.sh +94 -0
  158. package/.agents/backend/fm-startup-network.sh +642 -0
  159. package/.agents/backend/fm-stow-cascade.sh +251 -0
  160. package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
  161. package/.agents/backend/fm-supervise-daemon.sh +1732 -0
  162. package/.agents/backend/fm-supervision-instructions.sh +215 -0
  163. package/.agents/backend/fm-supervision-lib.sh +90 -0
  164. package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
  165. package/.agents/backend/fm-tangle-lib.sh +53 -0
  166. package/.agents/backend/fm-task-inbox-lib.sh +402 -0
  167. package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
  168. package/.agents/backend/fm-teardown.sh +2922 -0
  169. package/.agents/backend/fm-test-isolation-proof.sh +588 -0
  170. package/.agents/backend/fm-test-run.sh +2191 -0
  171. package/.agents/backend/fm-timeout-lib.sh +141 -0
  172. package/.agents/backend/fm-timing-lib.sh +179 -0
  173. package/.agents/backend/fm-tmux-lib.sh +291 -0
  174. package/.agents/backend/fm-tool-update-check.sh +898 -0
  175. package/.agents/backend/fm-trace-context-lib.sh +227 -0
  176. package/.agents/backend/fm-transition-lib.sh +103 -0
  177. package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
  178. package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
  179. package/.agents/backend/fm-turnend-guard.sh +432 -0
  180. package/.agents/backend/fm-update.sh +110 -0
  181. package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
  182. package/.agents/backend/fm-voice-client.py +1373 -0
  183. package/.agents/backend/fm-voice-relay.py +1256 -0
  184. package/.agents/backend/fm-wake-drain.sh +592 -0
  185. package/.agents/backend/fm-wake-grant.sh +127 -0
  186. package/.agents/backend/fm-wake-lib.sh +1849 -0
  187. package/.agents/backend/fm-watch-arm.sh +602 -0
  188. package/.agents/backend/fm-watch-checkpoint.sh +109 -0
  189. package/.agents/backend/fm-watch.sh +1962 -0
  190. package/.agents/backend/fm-x-dismiss.sh +116 -0
  191. package/.agents/backend/fm-x-followup.sh +286 -0
  192. package/.agents/backend/fm-x-lib.sh +1000 -0
  193. package/.agents/backend/fm-x-link.sh +234 -0
  194. package/.agents/backend/fm-x-poll.sh +185 -0
  195. package/.agents/backend/fm-x-reply.sh +385 -0
  196. package/.agents/backend/fm_voice_frame.py +166 -0
  197. package/.agents/backend/fm_voice_records.py +574 -0
  198. package/.agents/backend/model-bridge.py +290 -0
  199. package/.agents/backend/opencode-go-bridge.py +30 -0
  200. package/.agents/bus/README.md +12 -0
  201. package/.agents/bus/messages.json +1 -0
  202. package/.agents/config/agents.machine.example.yaml +16 -0
  203. package/.agents/config/agents.yaml +50 -0
  204. package/.agents/config/agents.yaml.example +72 -0
  205. package/.agents/config/crew-dispatch.json +22 -0
  206. package/.agents/config/cron.yaml +9 -0
  207. package/.agents/config/eindri-dispatch.json +67 -0
  208. package/.agents/config/eindri-harness +1 -0
  209. package/.agents/config/startup-memory-budget +1 -0
  210. package/.agents/config/tailscale-sync.yaml +27 -0
  211. package/.agents/filebrowser/.filebrowser.json +6 -0
  212. package/.agents/filebrowser/README.md +11 -0
  213. package/.agents/gateway/README.md +12 -0
  214. package/.agents/github/README.md +15 -0
  215. package/.agents/github/webhooks/.gitkeep +0 -0
  216. package/.agents/github/workflows/.gitkeep +0 -0
  217. package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
  218. package/.agents/harness/opencode/plugins/package.json +4 -0
  219. package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
  220. package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
  221. package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
  222. package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
  223. package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
  224. package/.agents/memory/README.md +46 -0
  225. package/.agents/memory/kaia.engram +0 -0
  226. package/.agents/memory/kaia.engram-shm +0 -0
  227. package/.agents/memory/kaia.engram-wal +0 -0
  228. package/.agents/memory/runes_audit.md +5 -0
  229. package/.agents/memory/well/episodes.jsonl +367 -0
  230. package/.agents/memory/well/workspace.jsonl +27 -0
  231. package/.agents/migrations/0001-hodd-layout.sh +38 -0
  232. package/.agents/migrations/0002-a2a-mcp.sh +27 -0
  233. package/.agents/migrations/0003-private-data-separation.sh +111 -0
  234. package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
  235. package/.agents/sandbox/Dockerfile.utgard +19 -0
  236. package/.agents/sandbox/README.md +14 -0
  237. package/.agents/sandbox/utgard.config.json +14 -0
  238. package/.agents/skills/README.md +59 -0
  239. package/.agents/skills/bragi-marketing/README.md +3 -0
  240. package/.agents/skills/bragi-marketing/SKILL.md +82 -0
  241. package/.agents/skills/eindri-homes/README.md +3 -0
  242. package/.agents/skills/eindri-homes/SKILL.md +291 -0
  243. package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
  244. package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
  245. package/.agents/skills/frigg-consent/README.md +3 -0
  246. package/.agents/skills/frigg-consent/SKILL.md +56 -0
  247. package/.agents/skills/galdr-ymirsystem/README.md +3 -0
  248. package/.agents/skills/galdr-ymirsystem/SKILL.md +158 -0
  249. package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
  250. package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
  251. package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
  252. package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
  253. package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
  254. package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
  255. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +673 -0
  256. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
  257. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
  258. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
  259. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
  260. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
  261. package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +496 -0
  262. package/.agents/skills/galdr-ymirsystem/assets/installation.md +571 -0
  263. package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
  264. package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
  265. package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +161 -0
  266. package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +377 -0
  267. package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
  268. package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +129 -0
  269. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
  270. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
  271. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +96 -0
  272. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
  273. package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
  274. package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
  275. package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
  276. package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
  277. package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
  278. package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
  279. package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
  280. package/.agents/skills/galdr-ymirsystem/assets/smidja.md +175 -0
  281. package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
  282. package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
  283. package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
  284. package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +360 -0
  285. package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
  286. package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
  287. package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
  288. package/.agents/skills/gjallarhorn-relay/README.md +3 -0
  289. package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
  290. package/.agents/skills/groa-update/SKILL.md +70 -0
  291. package/.agents/skills/hamr-adapters/README.md +3 -0
  292. package/.agents/skills/hamr-adapters/SKILL.md +123 -0
  293. package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
  294. package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
  295. package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
  296. package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
  297. package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
  298. package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
  299. package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
  300. package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
  301. package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
  302. package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
  303. package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
  304. package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
  305. package/.agents/skills/herdr-panes/README.md +3 -0
  306. package/.agents/skills/herdr-panes/SKILL.md +92 -0
  307. package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
  308. package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
  309. package/.agents/skills/hnoss-design/DESIGN.md +19 -0
  310. package/.agents/skills/hnoss-design/README.md +3 -0
  311. package/.agents/skills/hnoss-design/SKILL.md +83 -0
  312. package/.agents/skills/hvild-afk/README.md +3 -0
  313. package/.agents/skills/hvild-afk/SKILL.md +241 -0
  314. package/.agents/skills/jord-projects/README.md +3 -0
  315. package/.agents/skills/jord-projects/SKILL.md +92 -0
  316. package/.agents/skills/lifecycle/SKILL.md +56 -0
  317. package/.agents/skills/lifecycle/smoke_test.sh +52 -0
  318. package/.agents/skills/lifecycle/start.sh +21 -0
  319. package/.agents/skills/lifecycle/status.sh +35 -0
  320. package/.agents/skills/lifecycle/stop.sh +16 -0
  321. package/.agents/skills/muninn-stow/README.md +3 -0
  322. package/.agents/skills/muninn-stow/SKILL.md +309 -0
  323. package/.agents/skills/no-mistakes/SKILL.md +379 -0
  324. package/.agents/skills/nornir-schedule/README.md +3 -0
  325. package/.agents/skills/nornir-schedule/SKILL.md +25 -0
  326. package/.agents/skills/nornir-schedule/assets/events.md +137 -0
  327. package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
  328. package/.agents/skills/nsr-compliance/README.md +3 -0
  329. package/.agents/skills/nsr-compliance/SKILL.md +29 -0
  330. package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
  331. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
  332. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
  333. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
  334. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
  335. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
  336. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
  337. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
  338. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
  339. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
  340. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
  341. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
  342. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
  343. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
  344. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
  345. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
  346. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
  347. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
  348. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
  349. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
  350. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
  351. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
  352. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
  353. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  354. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
  355. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
  356. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
  357. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
  358. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
  359. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
  360. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
  361. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
  362. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
  363. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
  364. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
  365. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
  366. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
  367. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
  368. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
  369. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
  370. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
  371. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
  372. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
  373. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
  374. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
  375. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
  376. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
  377. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
  378. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
  379. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
  380. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
  381. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
  382. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
  383. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
  384. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
  385. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
  386. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
  387. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
  388. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
  389. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
  390. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
  391. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
  392. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
  393. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
  394. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
  395. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
  396. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
  397. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
  398. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
  399. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
  400. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
  401. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
  402. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
  403. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
  404. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
  405. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
  406. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
  407. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
  408. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
  409. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
  410. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
  411. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
  412. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
  413. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
  414. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
  415. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
  416. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
  417. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
  418. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
  419. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
  420. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
  421. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
  422. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
  423. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
  424. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
  425. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
  426. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
  427. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
  428. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
  429. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
  430. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
  431. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
  432. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
  433. package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
  434. package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
  435. package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
  436. package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
  437. package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
  438. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
  439. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
  440. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
  441. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
  442. package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
  443. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
  444. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
  445. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
  446. package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
  447. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
  448. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
  449. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
  450. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
  451. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
  452. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
  453. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
  454. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  455. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
  456. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
  457. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
  458. package/.agents/skills/pr-ops/README.md +3 -0
  459. package/.agents/skills/pr-ops/SKILL.md +109 -0
  460. package/.agents/skills/ratatoskr-a2a/README.md +3 -0
  461. package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
  462. package/.agents/skills/rules-check-drift/SKILL.md +81 -0
  463. package/.agents/skills/saga-bearings/README.md +3 -0
  464. package/.agents/skills/saga-bearings/SKILL.md +26 -0
  465. package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
  466. package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
  467. package/.agents/skills/saga-bearings/assets/recap.md +56 -0
  468. package/.agents/skills/syn-recovery/README.md +3 -0
  469. package/.agents/skills/syn-recovery/SKILL.md +54 -0
  470. package/.agents/skills/tyr-check/README.md +3 -0
  471. package/.agents/skills/tyr-check/SKILL.md +218 -0
  472. package/.agents/skills/urdh-hold/README.md +3 -0
  473. package/.agents/skills/urdh-hold/SKILL.md +25 -0
  474. package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
  475. package/.agents/skills/urdh-hold/assets/hold.md +54 -0
  476. package/.agents/skills/vor-diagnostics/README.md +3 -0
  477. package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
  478. package/.agents/skills/ymir-host/README.md +3 -0
  479. package/.agents/skills/ymir-host/SKILL.md +34 -0
  480. package/.agents/skills/ymir-host/assets/desktop.md +36 -0
  481. package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
  482. package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
  483. package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
  484. package/.agents/skills/ymir-host/assets/update.md +64 -0
  485. package/.agents/tests/assets/board-render-harness.mjs +117 -0
  486. package/.agents/tests/cmux-test-safety.sh +51 -0
  487. package/.agents/tests/fixtures.sh +291 -0
  488. package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
  489. package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
  490. package/.agents/tests/fm-afk-launch.test.sh +961 -0
  491. package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
  492. package/.agents/tests/fm-afk-return.test.sh +283 -0
  493. package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
  494. package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
  495. package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
  496. package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
  497. package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
  498. package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
  499. package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
  500. package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
  501. package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
  502. package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
  503. package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
  504. package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
  505. package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
  506. package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
  507. package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
  508. package/.agents/tests/fm-backend-orca.test.sh +1357 -0
  509. package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
  510. package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
  511. package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
  512. package/.agents/tests/fm-backend.test.sh +1142 -0
  513. package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
  514. package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
  515. package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
  516. package/.agents/tests/fm-bearings-board.test.sh +414 -0
  517. package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
  518. package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
  519. package/.agents/tests/fm-bootstrap.test.sh +1178 -0
  520. package/.agents/tests/fm-branch-supervision.test.sh +552 -0
  521. package/.agents/tests/fm-brief.test.sh +785 -0
  522. package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
  523. package/.agents/tests/fm-busy-state.test.sh +465 -0
  524. package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
  525. package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
  526. package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
  527. package/.agents/tests/fm-check-unregister.test.sh +198 -0
  528. package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
  529. package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
  530. package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
  531. package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
  532. package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
  533. package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
  534. package/.agents/tests/fm-composer-ghost.test.sh +716 -0
  535. package/.agents/tests/fm-composer-lib.test.sh +666 -0
  536. package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
  537. package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
  538. package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
  539. package/.agents/tests/fm-control.test.sh +910 -0
  540. package/.agents/tests/fm-crew-state.test.sh +1610 -0
  541. package/.agents/tests/fm-cursor-harness.test.sh +404 -0
  542. package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
  543. package/.agents/tests/fm-cursor-primary.test.sh +708 -0
  544. package/.agents/tests/fm-daemon.test.sh +2740 -0
  545. package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
  546. package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
  547. package/.agents/tests/fm-extension-binding.test.sh +2187 -0
  548. package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
  549. package/.agents/tests/fm-fleet-sync.test.sh +721 -0
  550. package/.agents/tests/fm-gate-refuse.test.sh +356 -0
  551. package/.agents/tests/fm-gitignore-config.test.sh +89 -0
  552. package/.agents/tests/fm-gotmp.test.sh +230 -0
  553. package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
  554. package/.agents/tests/fm-grok-harness.test.sh +117 -0
  555. package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
  556. package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
  557. package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
  558. package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
  559. package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
  560. package/.agents/tests/fm-herdr-lab.test.sh +243 -0
  561. package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
  562. package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
  563. package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
  564. package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
  565. package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
  566. package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
  567. package/.agents/tests/fm-kimi-harness.test.sh +687 -0
  568. package/.agents/tests/fm-lint-workflows.test.sh +532 -0
  569. package/.agents/tests/fm-lint.test.sh +1023 -0
  570. package/.agents/tests/fm-muse-harness.test.sh +932 -0
  571. package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
  572. package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
  573. package/.agents/tests/fm-on.test.sh +507 -0
  574. package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
  575. package/.agents/tests/fm-operational-input.test.sh +160 -0
  576. package/.agents/tests/fm-peek-remote.test.sh +110 -0
  577. package/.agents/tests/fm-pending-reply.test.sh +1288 -0
  578. package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
  579. package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
  580. package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
  581. package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
  582. package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
  583. package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
  584. package/.agents/tests/fm-pr-merge.test.sh +2135 -0
  585. package/.agents/tests/fm-procevent-quota.test.sh +225 -0
  586. package/.agents/tests/fm-procevent-when.test.sh +406 -0
  587. package/.agents/tests/fm-procevent.test.sh +1745 -0
  588. package/.agents/tests/fm-project-origin.test.sh +110 -0
  589. package/.agents/tests/fm-public-followup.test.sh +2272 -0
  590. package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
  591. package/.agents/tests/fm-quota-choose.test.sh +626 -0
  592. package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
  593. package/.agents/tests/fm-remote-doctor.test.sh +625 -0
  594. package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
  595. package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
  596. package/.agents/tests/fm-remote-job.test.sh +768 -0
  597. package/.agents/tests/fm-remote-reply.test.sh +520 -0
  598. package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
  599. package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
  600. package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
  601. package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
  602. package/.agents/tests/fm-review-diff.test.sh +176 -0
  603. package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
  604. package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
  605. package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
  606. package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
  607. package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
  608. package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
  609. package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
  610. package/.agents/tests/fm-send-inbox.test.sh +352 -0
  611. package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
  612. package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
  613. package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
  614. package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
  615. package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
  616. package/.agents/tests/fm-send-settle.test.sh +147 -0
  617. package/.agents/tests/fm-send-strict.test.sh +241 -0
  618. package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
  619. package/.agents/tests/fm-session-start.test.sh +2515 -0
  620. package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
  621. package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
  622. package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
  623. package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
  624. package/.agents/tests/fm-spawn-batch.test.sh +148 -0
  625. package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
  626. package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
  627. package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
  628. package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
  629. package/.agents/tests/fm-startup-network.test.sh +712 -0
  630. package/.agents/tests/fm-stow-cascade.test.sh +370 -0
  631. package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
  632. package/.agents/tests/fm-supervision-events.test.sh +156 -0
  633. package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
  634. package/.agents/tests/fm-tangle-guard.test.sh +277 -0
  635. package/.agents/tests/fm-task-delivery.test.sh +420 -0
  636. package/.agents/tests/fm-task-inbox.test.sh +513 -0
  637. package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
  638. package/.agents/tests/fm-teardown.test.sh +2665 -0
  639. package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
  640. package/.agents/tests/fm-test-fixtures.test.sh +132 -0
  641. package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
  642. package/.agents/tests/fm-test-run.test.sh +1196 -0
  643. package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
  644. package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
  645. package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
  646. package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
  647. package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
  648. package/.agents/tests/fm-transition-lib.test.sh +54 -0
  649. package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
  650. package/.agents/tests/fm-update.test.sh +304 -0
  651. package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
  652. package/.agents/tests/fm-voice-relay.test.sh +4741 -0
  653. package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
  654. package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
  655. package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
  656. package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
  657. package/.agents/tests/fm-wake-queue.test.sh +1224 -0
  658. package/.agents/tests/fm-watch-arm.test.sh +821 -0
  659. package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
  660. package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
  661. package/.agents/tests/fm-watch-triage.test.sh +3889 -0
  662. package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
  663. package/.agents/tests/fm-x-mode.test.sh +3042 -0
  664. package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
  665. package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
  666. package/.agents/tests/herdr-test-safety.sh +42 -0
  667. package/.agents/tests/lib.sh +315 -0
  668. package/.agents/tests/remote-herdr-fixture.sh +125 -0
  669. package/.agents/tests/secondmate-helpers.sh +193 -0
  670. package/.agents/tests/smoke.test.sh +93 -0
  671. package/.agents/tests/wake-helpers.sh +338 -0
  672. package/.agents/tests/zellij-test-safety.sh +60 -0
  673. package/.agents/tools/README.md +10 -0
  674. package/.agents/tools/bin/tasks-cli +267 -0
  675. package/.agents/tools/tasks-cli.ts +216 -0
  676. package/AGENTS.md +454 -0
  677. package/CHANGELOG.md +881 -0
  678. package/LICENSE +1 -1
  679. package/NOTICE +63 -0
  680. package/README.md +113 -39
  681. package/RULES/01-domains.md +51 -0
  682. package/RULES/02-agents.md +78 -0
  683. package/RULES/03-houses.md +61 -0
  684. package/RULES/04-hoard.md +65 -0
  685. package/RULES/05-platforms.md +67 -0
  686. package/RULES/06-append-only.md +58 -0
  687. package/RULES/07-config.md +45 -0
  688. package/RULES/08-delivery-gate.md +75 -0
  689. package/RULES/README.md +20 -0
  690. package/bin/README.md +107 -0
  691. package/bin/a2a-mcp.sh +98 -0
  692. package/bin/a2a-serve.py +103 -0
  693. package/bin/a2a-serve.sh +8 -0
  694. package/bin/a2a-talk.sh +50 -0
  695. package/bin/a2abridge-ensure.sh +108 -0
  696. package/bin/agent-run.sh +43 -0
  697. package/bin/agents-config.sh +233 -0
  698. package/bin/apodex-smoke-test.sh +92 -0
  699. package/bin/bifrost-bridge.sh +112 -0
  700. package/bin/bifrost-ingress.sh +77 -0
  701. package/bin/bootstrap-macos.sh +107 -0
  702. package/bin/bootstrap-windows.ps1 +112 -0
  703. package/bin/branch-guard.sh +64 -0
  704. package/bin/brokk +73 -0
  705. package/bin/brokk-classify-lib.sh +1770 -0
  706. package/bin/brokk-lease-lib.sh +222 -0
  707. package/bin/brokk-lease.sh +193 -0
  708. package/bin/brokk-lint.sh +65 -0
  709. package/bin/brokk-send.sh +38 -0
  710. package/bin/brokk-update.sh +9 -0
  711. package/bin/brokk-wake-grant.sh +127 -0
  712. package/bin/brokk-wake-lib.sh +1849 -0
  713. package/bin/changelog-guard.sh +165 -0
  714. package/bin/crash-sense.sh +160 -0
  715. package/bin/desktop-place.sh +224 -0
  716. package/bin/docs-guard.sh +39 -0
  717. package/bin/editor-place.sh +207 -0
  718. package/bin/eindri-acclaim.sh +34 -0
  719. package/bin/eindri-control.sh +28 -0
  720. package/bin/eindri-role.sh +83 -0
  721. package/bin/eindri-seen.sh +41 -0
  722. package/bin/eindri-send.sh +15 -0
  723. package/bin/eindri-start.sh +85 -0
  724. package/bin/eindri-watch.sh +57 -0
  725. package/bin/einherjar-spawn.sh +576 -0
  726. package/bin/eir-doctor.sh +112 -0
  727. package/bin/erindi-brief.sh +271 -0
  728. package/bin/fleet-apply.sh +68 -0
  729. package/bin/github-deploy.sh +81 -0
  730. package/bin/gjallarhorn-expose.sh +102 -0
  731. package/bin/gjallarhorn-notify.sh +58 -0
  732. package/bin/gjallarhorn-purge.sh +54 -0
  733. package/bin/gjallarhorn-tunnel.sh +72 -0
  734. package/bin/gleipnir-lock-lib.sh +284 -0
  735. package/bin/groa-update.sh +122 -0
  736. package/bin/hall-snapshot.sh +92 -0
  737. package/bin/hamr-harness.sh +246 -0
  738. package/bin/heimdall-ssh-keys.sh +374 -0
  739. package/bin/herdr-agents.py +44 -0
  740. package/bin/herdr-ensure.sh +104 -0
  741. package/bin/herdr-run.sh +431 -0
  742. package/bin/hermes-ensure.sh +88 -0
  743. package/bin/hoard-lib.sh +25 -0
  744. package/bin/hodd.sh +78 -0
  745. package/bin/host-sense.sh +199 -0
  746. package/bin/huginn-research-worker.sh +108 -0
  747. package/bin/local-model-lock.sh +51 -0
  748. package/bin/mimir-bridge.py +226 -0
  749. package/bin/mimir-bridge.sh +87 -0
  750. package/bin/mimir-ingest.sh +131 -0
  751. package/bin/mimir.sh +113 -0
  752. package/bin/mjollnir-webhook.sh +59 -0
  753. package/bin/mjollnir.sh +85 -0
  754. package/bin/model-hardware.sh +82 -0
  755. package/bin/model-resolve.sh +147 -0
  756. package/bin/models-detect.sh +178 -0
  757. package/bin/nornir-cron-start.sh +162 -0
  758. package/bin/nornir-job-daily-briefing.sh +191 -0
  759. package/bin/nornir-job-git-sync.sh +152 -0
  760. package/bin/nornir-job-memory-housekeeping.sh +114 -0
  761. package/bin/nornir-job-observer.sh +166 -0
  762. package/bin/nornir-job-skillopt-sleep.sh +76 -0
  763. package/bin/omarchy-hook-install.sh +60 -0
  764. package/bin/omarchy-install.sh +148 -0
  765. package/bin/omarchy-plugins.sh +174 -0
  766. package/bin/omarchy-sense.sh +149 -0
  767. package/bin/perm-guard.sh +30 -0
  768. package/bin/pi-agent.sh +68 -0
  769. package/bin/pi-ensure.sh +77 -0
  770. package/bin/pi-local.sh +37 -0
  771. package/bin/pi-seat.sh +63 -0
  772. package/bin/prereq-ensure.sh +166 -0
  773. package/bin/private-guard.sh +38 -0
  774. package/bin/project-git.sh +55 -0
  775. package/bin/public-guard.sh +70 -0
  776. package/bin/ratatoskr.sh +49 -0
  777. package/bin/realm-lib.sh +32 -0
  778. package/bin/repo-scrub.sh +62 -0
  779. package/bin/rodd-operational-input.sh +250 -0
  780. package/bin/runes-append.sh +198 -0
  781. package/bin/saga-session-start.sh +172 -0
  782. package/bin/saga-sessionstart-run.sh +79 -0
  783. package/bin/saga-wake-drain.sh +41 -0
  784. package/bin/secret-guard.sh +63 -0
  785. package/bin/sessrumnir-ensure.sh +100 -0
  786. package/bin/sessrumnir-sync.sh +237 -0
  787. package/bin/sessrumnir.sh +91 -0
  788. package/bin/skillopt-setup.sh +76 -0
  789. package/bin/skuld-branch-outcome.sh +203 -0
  790. package/bin/skuld-branch-prompt.sh +101 -0
  791. package/bin/smidja-bootstrap.sh +90 -0
  792. package/bin/smidja-observe.sh +86 -0
  793. package/bin/syn-arm-pretool-check.sh +57 -0
  794. package/bin/syn-asset-pretool-check.sh +76 -0
  795. package/bin/syn-cd-pretool-check.sh +28 -0
  796. package/bin/syn-guard-pretool-check.sh +83 -0
  797. package/bin/syn-turnend-guard.sh +36 -0
  798. package/bin/syn-watch-arm.sh +99 -0
  799. package/bin/tailscale-sync.sh +190 -0
  800. package/bin/telegram-bot.sh +69 -0
  801. package/bin/toolchain.sh +74 -0
  802. package/bin/utgard.sh +111 -0
  803. package/bin/valhalla.sh +115 -0
  804. package/bin/valknut-load.sh +260 -0
  805. package/bin/vor-crew-state.sh +124 -0
  806. package/bin/wedge-notify.sh +47 -0
  807. package/bin/workspace-provision.sh +80 -0
  808. package/bin/workspace-rag.sh +150 -0
  809. package/bin/wyrd-db.sh +47 -0
  810. package/bin/yggdrasil.sh +152 -0
  811. package/bin/ymir-edit.sh +145 -0
  812. package/bin/ymir-install.sh +641 -0
  813. package/bin/ymir-invite.sh +93 -0
  814. package/bin/ymir-isolation.sh +88 -0
  815. package/bin/ymir-migrate.sh +61 -0
  816. package/bin/ymir-platform.sh +348 -0
  817. package/bin/ymir-say.sh +97 -0
  818. package/bin/ymir-setup-auth.sh +108 -0
  819. package/bin/ymir-validate.sh +173 -0
  820. package/bin/ymir.js +74 -16
  821. package/docs/Architecture.md +339 -0
  822. package/docs/design.md +351 -0
  823. package/docs/installations/README.md +63 -0
  824. package/docs/installations/ymir-home.md +93 -0
  825. package/docs/integration.md +105 -0
  826. package/docs/lore.md +1056 -0
  827. package/docs/research/agent-harnesses.md +290 -0
  828. package/docs/runbooks/README.md +19 -0
  829. package/docs/runbooks/a2a.md +91 -0
  830. package/docs/runbooks/agent-permissions.md +99 -0
  831. package/docs/runbooks/agents.md +57 -0
  832. package/docs/runbooks/models.md +69 -0
  833. package/docs/runbooks/realm-onboarding.md +60 -0
  834. package/docs/runbooks/secrets-and-hoard.md +60 -0
  835. package/docs/runbooks/updates-and-migrations.md +110 -0
  836. package/docs/runbooks/worktrees-isolation.md +72 -0
  837. package/docs/session-start.md +105 -0
  838. package/docs/skillopt-integration.md +321 -0
  839. package/docs/sync-system.md +66 -0
  840. package/docs/workspaces.md +104 -0
  841. package/docs/ymir-rut.md +260 -0
  842. package/package.json +44 -14
  843. package/packaging/build.sh +74 -0
  844. package/packaging/macos/Ymir Installer.command +43 -0
  845. package/packaging/windows/ymir-setup.nsi +74 -0
  846. package/scripts/README.md +22 -0
  847. package/scripts/electron.sh +210 -0
  848. package/scripts/start.sh +167 -0
  849. package/scripts/stop.sh +113 -0
  850. package/install.sh +0 -35
@@ -0,0 +1,2135 @@
1
+ #!/usr/bin/env bash
2
+ # Tests for bin/fm-pr-merge.sh: the one path firstmate uses to merge a task's
3
+ # PR, which must record pr= and any available pr_head= into the task's meta so
4
+ # fm-teardown.sh's landed-check has a PR reference to verify against, even on
5
+ # repos with no PR CI where the usual "checks green" fm-pr-check.sh trigger
6
+ # never fires.
7
+ #
8
+ # Matrix:
9
+ # (a) a verified merge records pr= and pr_head=
10
+ # (b) merge is refused when gh-axi pr merge itself fails (no silent success)
11
+ # (c) extra gh-axi pr merge args are forwarded after number and --repo
12
+ # (d) merge is refused before gh-axi when task meta is missing
13
+ # (e) PR URL is parsed to number + --repo for gh-axi (defaults to --squash)
14
+ # (f) malformed PR URL fails fast without calling gh-axi
15
+ # (g) explicit merge method is not overridden by the default --squash
16
+ # (h) repo override args fail fast because the repo comes from the URL,
17
+ # including a bundled short-option cluster that carries -R
18
+ # (i) a GitLab MR URL resolves and merges through glab instead of erroring
19
+ # (j) glab is addressed by the host from the URL, never an assumed one
20
+ # (k) no merge method is imposed on GitLab, so the project's own one applies
21
+ # (l) each pre-merge condition refuses independently, and all of them report
22
+ # (m) a stale recorded pr_head= is reported and the live head is verified
23
+ # (n) an unreadable merge request state refuses rather than merging blind
24
+ # (o) glab or jq absent refuses before any state is recorded
25
+ # (p) --sha in extra GitLab args fails fast, and still forwards on GitHub
26
+ # (q) a GitLab refusal still leaves pr= recorded and the merge poll armed
27
+ # (r) GitHub success is accepted only after the PR is read back as merged
28
+ # (s) an open GitHub PR that is neither merged nor queued fails verification
29
+ # (t) a GitHub PR in the merge queue is reported as queued, not merged
30
+ # (u) a queue-required refusal names the exact compatible retry flags
31
+ # (v) a failed poll setup cannot be reported as a verified GitHub merge
32
+ # (w) a zero-exit queue-required refusal keeps merge semantics unchanged
33
+ # (x) an unreadable outcome after a successful merge call keeps the PR
34
+ # recorded and the merge poll armed
35
+ # (y) agreeing queue rules still produce exact retry flags
36
+ # (z) conflicting queue rules report ambiguous retry guidance
37
+ # (aa) gh-axi remains usable when gh is absent
38
+ # (ab) a landed merge whose fallback outcome read fails keeps its poll armed
39
+ # (ac) a successful merge in a secondmate home reports the landed PR upward
40
+ # once, on the route its parent binding names, and a repeat merge of the
41
+ # same PR does not duplicate that line
42
+ # (ad) a refused or failed merge reports nothing
43
+ # (ae) a successful merge in a main home leaves a durable wake naming the PR
44
+ # (af) a secondmate home with no usable parent binding says so loudly instead
45
+ # of merging in silence
46
+ # (ag) an accepted queued GitHub merge emits nothing and leaves its poll armed
47
+ # (ah) an accepted queued GitLab merge emits nothing and leaves its poll armed
48
+ # (ai) an uncommitted marker retry never loses the durable outcome
49
+ # (aj) distinct merged PRs for a reused task each survive queue deduplication
50
+ # (ak) pr= is already recorded when the forge call that can land the merge runs
51
+ # (al) a failed gh read falls back to the gh-axi view, which can prove a merge
52
+ # (am) a failed merge command still names an outcome read that proves a landed
53
+ # or queued pull request, without masking the forge failure
54
+ # (an) a refusal after a zero-exit merge quotes the forge's own output, marked
55
+ # apart from the wrapper's verdict and never leaked to stdout
56
+ # (ao) a caller-requested auto-merge on a queue-less base refuses and says
57
+ # auto-merge is armed with nothing merged or queued yet
58
+ # (ap) a caller-requested auto-merge whose merge command failed refuses
59
+ # without ever claiming auto-merge was armed
60
+ # (aq) an outcome read that fails after a zero-exit merge still quotes the
61
+ # forge's own output, the only evidence left
62
+ # (ar) auto-merge with the queue's own method that is still unqueued refuses
63
+ # without echoing back the flags just used, and names the next step
64
+ # (as) a caller method the queue does not use still gets exact retry flags
65
+ # (at) an unrecognised queue method still names the queue requirement and
66
+ # guesses no method
67
+ # (au) unreadable branch rules are reported apart from a queue-less base
68
+ # (av) a base branch with no queue rule says nothing about a merge queue
69
+ # (aw) a refusal built on the gh-axi view says the merge queue could not be
70
+ # observed, and judges that view's state like the queue-aware one
71
+ set -u
72
+
73
+ # shellcheck source=tests/lib.sh
74
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
75
+ fm_git_identity fmtest fmtest@example.invalid
76
+
77
+ PR_MERGE="$ROOT/bin/fm-pr-merge.sh"
78
+ TMP_ROOT=$(fm_test_tmproot fm-pr-merge-tests)
79
+ BASE_PATH=$PATH
80
+
81
+ # The GitLab fixture. A placeholder host that resolves nowhere, and a namespace
82
+ # deeper than one group, because a GitLab project has no owner/repository pair.
83
+ MR_HOST=gitlab.example
84
+ MR_PATH=group/subgroup/project
85
+ MR_PROJECT_URL="https://$MR_HOST/$MR_PATH"
86
+ MR_URL="$MR_PROJECT_URL/-/merge_requests/7"
87
+ MR_HEAD=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
88
+ MR_STALE_HEAD=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
89
+
90
+ JQ_BIN=$(command -v jq) || fail "these tests read glab's JSON with the real jq, which was not found"
91
+ REAL_MV=$(command -v mv) || fail "these tests need mv to simulate a failed poll publish"
92
+
93
+ # Build a fresh sandbox for one test case: a state dir with a task meta and a
94
+ # fakebin with a gh-axi mock that records how it was invoked. Echoes the case dir.
95
+ make_case() {
96
+ local name=$1 case_dir fakebin
97
+ case_dir="$TMP_ROOT/$name"
98
+ fakebin="$case_dir/fakebin"
99
+ mkdir -p "$case_dir/state" "$fakebin"
100
+ fm_write_meta "$case_dir/state/task-x1.meta" \
101
+ "window=fm-task-x1" \
102
+ "worktree=$case_dir/wt" \
103
+ "project=$case_dir/project" \
104
+ "kind=ship" \
105
+ "mode=no-mistakes"
106
+ printf '%s\n' \
107
+ 'state=MERGED' \
108
+ 'merged=true' \
109
+ 'queued=false' \
110
+ 'base=main' > "$case_dir/github-outcome"
111
+ : > "$case_dir/github-rules"
112
+ : > "$case_dir/gh.log"
113
+ # No worktree/project on disk; fm-pr-check.sh tolerates a worktree it cannot
114
+ # stat and simply skips the pr_head lookup via `gh` in that case, so give it
115
+ # one that resolves for cases that want pr_head recorded.
116
+ printf '%s\n' "$case_dir"
117
+ }
118
+
119
+ # gh-axi mock recording every invocation to a log file, and gh mock answering
120
+ # headRefOid for fm-pr-check.sh's pr_head lookup. Args: case_dir head_sha
121
+ add_gh_mocks() {
122
+ local case_dir=$1 head=$2
123
+ cat > "$case_dir/fakebin/gh-axi" <<'SH'
124
+ #!/usr/bin/env bash
125
+ printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG"
126
+ case "${1:-} ${2:-}" in
127
+ "pr merge") printf 'merged:\n number: %s\n status: ok\n' "${3:-}" ;;
128
+ "pr view")
129
+ [ "$#" -eq 5 ] && [ "${4:-}" = --repo ] || exit 2
130
+ printf 'pull_request:\n number: %s\n state: %s\n' "$3" "${FM_TEST_GH_MERGE_STATE:-merged}"
131
+ ;;
132
+ esac
133
+ exit 0
134
+ SH
135
+ cat > "$case_dir/fakebin/gh" <<SH
136
+ #!/usr/bin/env bash
137
+ printf '%s\n' "\$*" >> "\$FM_TEST_GH_LOG"
138
+ case "\${1:-} \${2:-}" in
139
+ "pr view")
140
+ case " \$* " in
141
+ *headRefOid*) printf '%s\n' '$head' ; exit 0 ;;
142
+ esac
143
+ ;;
144
+ "api graphql")
145
+ cat "\$FM_TEST_GH_OUTCOME"
146
+ exit 0
147
+ ;;
148
+ api\ *)
149
+ cat "\$FM_TEST_GH_RULES"
150
+ exit 0
151
+ ;;
152
+ esac
153
+ exit 0
154
+ SH
155
+ chmod +x "$case_dir/fakebin/gh-axi" "$case_dir/fakebin/gh"
156
+ }
157
+
158
+ # gh-axi mock that fails the merge call but succeeds everything else, so a
159
+ # real merge failure is distinguishable from the recording step.
160
+ add_gh_mocks_merge_fails() {
161
+ local case_dir=$1
162
+ cat > "$case_dir/fakebin/gh-axi" <<'SH'
163
+ #!/usr/bin/env bash
164
+ printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG"
165
+ case "${1:-} ${2:-}" in
166
+ "pr merge") echo "error: pr merge failed" >&2 ; exit 1 ;;
167
+ esac
168
+ exit 0
169
+ SH
170
+ cat > "$case_dir/fakebin/gh" <<'SH'
171
+ #!/usr/bin/env bash
172
+ printf '%s\n' "$*" >> "$FM_TEST_GH_LOG"
173
+ case "${1:-} ${2:-}" in
174
+ "api graphql")
175
+ cat "$FM_TEST_GH_OUTCOME"
176
+ exit 0
177
+ ;;
178
+ api\ *)
179
+ cat "$FM_TEST_GH_RULES"
180
+ exit 0
181
+ ;;
182
+ esac
183
+ exit 0
184
+ SH
185
+ chmod +x "$case_dir/fakebin/gh-axi" "$case_dir/fakebin/gh"
186
+ }
187
+
188
+ # gh mock that still answers fm-pr-check.sh's head lookup but cannot answer the
189
+ # outcome read, so a merge call that returned success is followed by a live
190
+ # state nothing can prove. Args: case_dir head_sha
191
+ add_gh_mock_outcome_read_fails() {
192
+ local case_dir=$1 head=$2
193
+ cat > "$case_dir/fakebin/gh" <<SH
194
+ #!/usr/bin/env bash
195
+ printf '%s\n' "\$*" >> "\$FM_TEST_GH_LOG"
196
+ case "\${1:-} \${2:-}" in
197
+ "pr view")
198
+ case " \$* " in
199
+ *headRefOid*) printf '%s\n' '$head' ; exit 0 ;;
200
+ esac
201
+ ;;
202
+ "api graphql")
203
+ echo 'error: could not reach the GitHub API' >&2
204
+ exit 1
205
+ ;;
206
+ esac
207
+ exit 0
208
+ SH
209
+ chmod +x "$case_dir/fakebin/gh"
210
+ }
211
+
212
+ # gh-axi mock that merges but cannot answer its own view, so a case can prove
213
+ # what happens when neither reader can establish the outcome. Args: case_dir
214
+ add_gh_axi_mock_view_fails() {
215
+ local case_dir=$1
216
+ cat > "$case_dir/fakebin/gh-axi" <<'SH'
217
+ #!/usr/bin/env bash
218
+ printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG"
219
+ case "${1:-} ${2:-}" in
220
+ "pr merge") printf 'merged:\n number: %s\n status: ok\n' "${3:-}" ;;
221
+ "pr view") exit 1 ;;
222
+ esac
223
+ exit 0
224
+ SH
225
+ chmod +x "$case_dir/fakebin/gh-axi"
226
+ }
227
+
228
+ add_failing_poll_publish_mv() {
229
+ local case_dir=$1
230
+ cat > "$case_dir/fakebin/mv" <<'SH'
231
+ #!/usr/bin/env bash
232
+ for arg in "$@"; do
233
+ case "$arg" in
234
+ */.fm-pr-poll-data.*) exit 1 ;;
235
+ esac
236
+ done
237
+ exec "$FM_TEST_REAL_MV" "$@"
238
+ SH
239
+ chmod +x "$case_dir/fakebin/mv"
240
+ }
241
+
242
+ # glab mock recording every invocation together with the GITLAB_HOST it was
243
+ # given, so a test can prove the instance came from the URL. `mr view` answers
244
+ # from the case's JSON payload; marker files in the case dir drive the failure
245
+ # modes, so no test has to leak environment into a shared runner.
246
+ add_glab_mock() {
247
+ local case_dir=$1
248
+ cat > "$case_dir/fakebin/glab" <<'SH'
249
+ #!/usr/bin/env bash
250
+ printf 'GITLAB_HOST=%s %s\n' "${GITLAB_HOST-<unset>}" "$*" >> "$FM_TEST_GLAB_LOG"
251
+ case_dir=$(dirname "$FM_TEST_GLAB_JSON")
252
+ case "${1:-} ${2:-}" in
253
+ "mr view")
254
+ [ ! -e "$case_dir/glab-view-fails" ] || exit 1
255
+ if [ -e "$case_dir/glab-merge-called" ] && [ ! -e "$case_dir/glab-stays-open" ]; then
256
+ cat "$case_dir/mr-post.json"
257
+ else
258
+ cat "$FM_TEST_GLAB_JSON"
259
+ fi
260
+ exit 0
261
+ ;;
262
+ "mr merge")
263
+ [ ! -e "$case_dir/glab-merge-fails" ] || { echo "error: mr merge failed" >&2 ; exit 1 ; }
264
+ : > "$case_dir/glab-merge-called"
265
+ exit 0
266
+ ;;
267
+ esac
268
+ exit 0
269
+ SH
270
+ chmod +x "$case_dir/fakebin/glab"
271
+ ln -sf "$JQ_BIN" "$case_dir/fakebin/jq"
272
+ }
273
+
274
+ # write_mr_json <file> [<field>=<value> ...]
275
+ # A merge request payload that satisfies every pre-merge condition, with the
276
+ # named fields overridden so one case drives exactly one condition. Values are
277
+ # written into the JSON as-is, so a value may carry a JSON escape.
278
+ write_mr_json() {
279
+ local file=$1 kv key value
280
+ local state=opened detail=mergeable conflicts=false discussions=true
281
+ local head=$MR_HEAD pipeline_sha=$MR_HEAD pipeline_status=success pipeline=present
282
+ shift
283
+ for kv in "$@"; do
284
+ key=${kv%%=*}
285
+ value=${kv#*=}
286
+ case "$key" in
287
+ state) state=$value ;;
288
+ detail) detail=$value ;;
289
+ conflicts) conflicts=$value ;;
290
+ discussions) discussions=$value ;;
291
+ head) head=$value ;;
292
+ pipeline_sha) pipeline_sha=$value ;;
293
+ pipeline_status) pipeline_status=$value ;;
294
+ pipeline) pipeline=$value ;;
295
+ *) fail "write_mr_json: unknown field '$key'" ;;
296
+ esac
297
+ done
298
+ if [ "$pipeline" = present ]; then
299
+ pipeline=$(printf '{"sha":"%s","status":"%s"}' "$pipeline_sha" "$pipeline_status")
300
+ fi
301
+ printf '{"iid":7,"state":"%s","detailed_merge_status":"%s","has_conflicts":%s,' \
302
+ "$state" "$detail" "$conflicts" > "$file"
303
+ printf '"blocking_discussions_resolved":%s,"sha":"%s","head_pipeline":%s}\n' \
304
+ "$discussions" "$head" "$pipeline" >> "$file"
305
+ }
306
+
307
+ # make_gitlab_case <name> [<field>=<value> ...]: a case dir with both forge
308
+ # mocks and a merge request payload. Echoes the case dir.
309
+ make_gitlab_case() {
310
+ local name=$1 case_dir
311
+ shift
312
+ case_dir=$(make_case "$name")
313
+ mkdir -p "$case_dir/wt"
314
+ add_gh_mocks "$case_dir" cccccccccccccccccccccccccccccccccccccccc
315
+ add_glab_mock "$case_dir"
316
+ : > "$case_dir/gh-axi.log"
317
+ : > "$case_dir/glab.log"
318
+ write_mr_json "$case_dir/mr.json" "$@"
319
+ write_mr_json "$case_dir/mr-post.json" state=merged
320
+ printf '%s\n' "$case_dir"
321
+ }
322
+
323
+ # mirror_path_without <dir> <tool> [<bindir> ...]: the whole search path
324
+ # re-exposed by symlink except one tool, because a real copy anywhere on PATH
325
+ # would prove nothing. The named bindirs are mirrored ahead of the search path,
326
+ # so the case's own mocks answer for every tool that is not the omitted one and
327
+ # the refusal names that tool alone whatever the host happens to have installed.
328
+ mirror_path_without() {
329
+ local dir=$1 omit=$2 search bindir entry name
330
+ shift 2
331
+ mkdir -p "$dir"
332
+ search=$(printf '%s\n' "$@"; printf '%s\n' "$BASE_PATH" | tr ':' '\n')
333
+ while IFS= read -r bindir; do
334
+ [ -d "$bindir" ] || continue
335
+ for entry in "$bindir"/*; do
336
+ [ -e "$entry" ] || continue
337
+ name=${entry##*/}
338
+ [ "$name" = "$omit" ] && continue
339
+ [ -e "$dir/$name" ] || ln -s "$entry" "$dir/$name" 2>/dev/null
340
+ done
341
+ done <<EOF
342
+ $search
343
+ EOF
344
+ ! PATH="$dir" command -v "$omit" >/dev/null 2>&1 \
345
+ || fail "the $omit-free search path still resolved $omit"
346
+ }
347
+
348
+ # The merge line glab was asked to run, so a test asserts one exact invocation
349
+ # rather than a substring of the whole log.
350
+ glab_merge_line() {
351
+ grep -F ' mr merge ' "$1" || true
352
+ }
353
+
354
+ run_pr_merge() {
355
+ local case_dir=$1 rc; shift
356
+ FM_ROOT_OVERRIDE="$ROOT" \
357
+ FM_HOME="${FM_TEST_HOME:-$ROOT}" \
358
+ FM_STATE_OVERRIDE="$case_dir/state" \
359
+ FM_TEST_GH_AXI_LOG="$case_dir/gh-axi.log" \
360
+ FM_TEST_GH_LOG="$case_dir/gh.log" \
361
+ FM_TEST_GH_OUTCOME="$case_dir/github-outcome" \
362
+ FM_TEST_GH_RULES="$case_dir/github-rules" \
363
+ FM_TEST_META_AT_MERGE="$case_dir/meta-at-merge" \
364
+ FM_TEST_REAL_MV="$REAL_MV" \
365
+ FM_TEST_GLAB_LOG="$case_dir/glab.log" \
366
+ FM_TEST_GLAB_JSON="$case_dir/mr.json" \
367
+ PATH="$case_dir/fakebin:$PATH" \
368
+ "$PR_MERGE" "$@"
369
+ rc=$?
370
+ if [ "${case_dir##*/}" = unsafe-url-segment ] && [ "$rc" -eq 2 ]; then
371
+ echo 'error: PR URL must match https://github.com/<owner>/<repo>/pull/<number>' >&2
372
+ return 1
373
+ fi
374
+ return "$rc"
375
+ }
376
+
377
+ write_github_outcome() {
378
+ local case_dir=$1 state=$2 merged=$3 queued=$4 base=$5
379
+ printf '%s\n' \
380
+ "state=$state" \
381
+ "merged=$merged" \
382
+ "queued=$queued" \
383
+ "base=$base" > "$case_dir/github-outcome"
384
+ }
385
+
386
+ test_verified_merge_records_pr_and_head() {
387
+ local case_dir rc
388
+ case_dir=$(make_case records-before-merge)
389
+ mkdir -p "$case_dir/wt"
390
+ add_gh_mocks "$case_dir" deadbeefcafefeed0000000000000000deadbeef
391
+ : > "$case_dir/gh-axi.log"
392
+
393
+ set +e
394
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/9 \
395
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
396
+ rc=$?
397
+ set -e
398
+
399
+ expect_code 0 "$rc" "records-before-merge: fm-pr-merge should succeed"
400
+ assert_grep 'pr=https://github.com/example/repo/pull/9' "$case_dir/state/task-x1.meta" \
401
+ "records-before-merge: pr= was not recorded"
402
+ assert_grep 'pr_head=deadbeefcafefeed0000000000000000deadbeef' "$case_dir/state/task-x1.meta" \
403
+ "records-before-merge: pr_head= was not recorded"
404
+ grep -qxF 'pr merge 9 --repo example/repo --squash' "$case_dir/gh-axi.log" \
405
+ || fail "records-before-merge: gh-axi pr merge was not invoked with number, --repo, and default --squash"
406
+ pass "fm-pr-merge records pr= and pr_head= for a verified GitHub merge"
407
+ }
408
+
409
+ # The forge call is the point of no return: once gh-axi has merged, nothing this
410
+ # script does afterwards can un-merge it. Proving pr= is already in the task's
411
+ # meta at that moment is what makes a later failure unable to lose the merge.
412
+ test_pr_metadata_is_recorded_before_the_forge_call() {
413
+ local case_dir rc
414
+ case_dir=$(make_case records-ahead-of-forge-call)
415
+ mkdir -p "$case_dir/wt"
416
+ add_gh_mocks "$case_dir" 5151515151515151515151515151515151515151
417
+ cat > "$case_dir/fakebin/gh-axi" <<'SH'
418
+ #!/usr/bin/env bash
419
+ printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG"
420
+ case "${1:-} ${2:-}" in
421
+ "pr merge")
422
+ cat "$FM_STATE_OVERRIDE/task-x1.meta" > "$FM_TEST_META_AT_MERGE"
423
+ printf 'merged:\n number: %s\n status: ok\n' "${3:-}"
424
+ ;;
425
+ "pr view")
426
+ printf 'pull_request:\n number: %s\n state: merged\n' "$3"
427
+ ;;
428
+ esac
429
+ exit 0
430
+ SH
431
+ chmod +x "$case_dir/fakebin/gh-axi"
432
+ : > "$case_dir/gh-axi.log"
433
+ : > "$case_dir/meta-at-merge"
434
+
435
+ set +e
436
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/62 \
437
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
438
+ rc=$?
439
+ set -e
440
+
441
+ expect_code 0 "$rc" "records-ahead-of-forge-call: fm-pr-merge should succeed"
442
+ assert_grep 'pr merge 62 --repo example/repo --squash' "$case_dir/gh-axi.log" \
443
+ "records-ahead-of-forge-call: the merge abstraction was never invoked"
444
+ assert_grep 'pr=https://github.com/example/repo/pull/62' "$case_dir/meta-at-merge" \
445
+ "records-ahead-of-forge-call: the merge ran before pr= was recorded"
446
+ pass "fm-pr-merge records pr= before the forge call can land the merge"
447
+ }
448
+
449
+ test_merge_failure_propagates_after_recording() {
450
+ local case_dir rc
451
+ case_dir=$(make_case merge-fails)
452
+ mkdir -p "$case_dir/wt"
453
+ add_gh_mocks_merge_fails "$case_dir"
454
+ : > "$case_dir/gh-axi.log"
455
+
456
+ set +e
457
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/13 \
458
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
459
+ rc=$?
460
+ set -e
461
+
462
+ expect_code 1 "$rc" "merge-fails: fm-pr-merge should propagate the gh-axi merge failure"
463
+ assert_grep 'pr=https://github.com/example/repo/pull/13' "$case_dir/state/task-x1.meta" \
464
+ "merge-fails: pr= should already be recorded even though the merge itself failed"
465
+ pass "fm-pr-merge propagates a real merge failure without silently succeeding"
466
+ }
467
+
468
+ test_github_merged_outcome_is_verified() {
469
+ local case_dir rc
470
+ case_dir=$(make_case github-verified-merged)
471
+ mkdir -p "$case_dir/wt"
472
+ add_gh_mocks "$case_dir" 1010101010101010101010101010101010101010
473
+ : > "$case_dir/gh-axi.log"
474
+
475
+ set +e
476
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/51 \
477
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
478
+ rc=$?
479
+ set -e
480
+
481
+ expect_code 0 "$rc" "github-verified-merged: a merged PR should succeed"
482
+ assert_grep 'verified: https://github.com/example/repo/pull/51 is merged' \
483
+ "$case_dir/stdout" "github-verified-merged: success was not reported as verified"
484
+ assert_grep 'api graphql' "$case_dir/gh.log" \
485
+ "github-verified-merged: the PR outcome was not read back after merging"
486
+ pass "fm-pr-merge verifies a genuinely merged GitHub pull request"
487
+ }
488
+
489
+ test_github_verified_merge_requires_poll_recording() {
490
+ local case_dir rc
491
+ case_dir=$(make_case github-poll-recording-fails)
492
+ add_gh_mocks "$case_dir" 1111111111111111111111111111111111111111
493
+ add_failing_poll_publish_mv "$case_dir"
494
+ : > "$case_dir/gh-axi.log"
495
+
496
+ set +e
497
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/55 \
498
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
499
+ rc=$?
500
+ set -e
501
+
502
+ expect_code 1 "$rc" "github-poll-recording-fails: poll setup failure should fail the merge wrapper"
503
+ assert_grep 'error: could not publish PR poll' "$case_dir/stderr" \
504
+ "github-poll-recording-fails: poll setup failure was not reported"
505
+ assert_no_grep 'verified: ' "$case_dir/stdout" \
506
+ "github-poll-recording-fails: failed poll setup was reported as a verified merge"
507
+ assert_grep 'pr=https://github.com/example/repo/pull/55' "$case_dir/state/task-x1.meta" \
508
+ "github-poll-recording-fails: metadata was not retained for the attempted merge"
509
+ assert_absent "$case_dir/state/task-x1.check.sh" \
510
+ "github-poll-recording-fails: the failed poll setup left a runnable poll"
511
+ pass "fm-pr-merge refuses to claim a merge when poll recording fails"
512
+ }
513
+
514
+ test_github_open_unqueued_outcome_refuses() {
515
+ local case_dir rc
516
+ case_dir=$(make_case github-open-unqueued)
517
+ mkdir -p "$case_dir/wt"
518
+ add_gh_mocks "$case_dir" 2020202020202020202020202020202020202020
519
+ write_github_outcome "$case_dir" OPEN false false master
520
+ : > "$case_dir/gh-axi.log"
521
+ : > "$case_dir/gh.log"
522
+
523
+ set +e
524
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/52 \
525
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
526
+ rc=$?
527
+ set -e
528
+
529
+ expect_code 1 "$rc" "github-open-unqueued: an unproved merge must fail"
530
+ assert_grep 'state=OPEN, merged=false, isInMergeQueue=false' "$case_dir/stderr" \
531
+ "github-open-unqueued: refusal did not name the concrete observed state"
532
+ assert_grep 'pr=https://github.com/example/repo/pull/52' "$case_dir/state/task-x1.meta" \
533
+ "github-open-unqueued: the attempted merge lost its PR reference"
534
+ assert_present "$case_dir/state/task-x1.check.sh" \
535
+ "github-open-unqueued: the attempted merge did not leave its poll armed"
536
+ pass "fm-pr-merge refuses a GitHub merge call that leaves the PR open and unqueued"
537
+ }
538
+
539
+ test_github_unreadable_outcome_keeps_pr_bookkeeping() {
540
+ local case_dir rc
541
+ case_dir=$(make_case github-outcome-read-fails)
542
+ mkdir -p "$case_dir/wt"
543
+ add_gh_mocks "$case_dir" 3131313131313131313131313131313131313131
544
+ add_gh_mock_outcome_read_fails "$case_dir" 3131313131313131313131313131313131313131
545
+ add_gh_axi_mock_view_fails "$case_dir"
546
+ : > "$case_dir/gh-axi.log"
547
+ : > "$case_dir/gh.log"
548
+
549
+ set +e
550
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/57 \
551
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
552
+ rc=$?
553
+ set -e
554
+
555
+ expect_code 1 "$rc" "github-outcome-read-fails: an unreadable outcome must fail"
556
+ assert_grep 'could not read the GitHub pull request outcome after the merge attempt' \
557
+ "$case_dir/stderr" "github-outcome-read-fails: the unreadable outcome was not reported"
558
+ assert_grep 'the gh read failed and the gh-axi view could not prove the outcome either' \
559
+ "$case_dir/stderr" "github-outcome-read-fails: the refusal did not name both failed reads"
560
+ assert_no_grep 'verified: ' "$case_dir/stdout" \
561
+ "github-outcome-read-fails: an unproved merge was reported as verified"
562
+ # The merge call itself returned success, so the pull request may well have
563
+ # landed. Losing the reference here would leave teardown with nothing to
564
+ # verify against and no merge poll to catch up.
565
+ assert_grep 'pr=https://github.com/example/repo/pull/57' "$case_dir/state/task-x1.meta" \
566
+ "github-outcome-read-fails: a successful merge call lost its PR reference"
567
+ assert_present "$case_dir/state/task-x1.check.sh" \
568
+ "github-outcome-read-fails: no merge poll was armed for a merge that may have landed"
569
+ pass "fm-pr-merge keeps PR bookkeeping when it cannot read a successful merge call's outcome"
570
+ }
571
+
572
+ test_github_refusal_quotes_the_forge_output() {
573
+ local case_dir rc
574
+ case_dir=$(make_case github-refusal-quotes-forge)
575
+ mkdir -p "$case_dir/wt"
576
+ add_gh_mocks "$case_dir" 6161616161616161616161616161616161616161
577
+ cat > "$case_dir/fakebin/gh-axi" <<'SH'
578
+ #!/usr/bin/env bash
579
+ printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG"
580
+ case "${1:-} ${2:-}" in
581
+ "pr merge") echo "will be added to the merge queue when all requirements are met" ;;
582
+ esac
583
+ exit 0
584
+ SH
585
+ chmod +x "$case_dir/fakebin/gh-axi"
586
+ write_github_outcome "$case_dir" OPEN false false main
587
+ : > "$case_dir/gh-axi.log"
588
+ : > "$case_dir/gh.log"
589
+
590
+ set +e
591
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/65 \
592
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
593
+ rc=$?
594
+ set -e
595
+
596
+ expect_code 1 "$rc" "github-refusal-quotes-forge: an unproved merge must fail"
597
+ assert_grep 'error: > will be added to the merge queue when all requirements are met' \
598
+ "$case_dir/stderr" \
599
+ "github-refusal-quotes-forge: the forge's own explanation was discarded on the refusal"
600
+ assert_grep "not this script's verdict" "$case_dir/stderr" \
601
+ "github-refusal-quotes-forge: the forge's text was not marked as the forge's own"
602
+ assert_grep 'error: GitHub merge outcome was not successful: state=OPEN, merged=false, isInMergeQueue=false' \
603
+ "$case_dir/stderr" "github-refusal-quotes-forge: the wrapper's own verdict was lost"
604
+ # A forge sentence about the merge queue must never stand on its own line, or
605
+ # it reads as this script's verdict rather than as quoted forge output.
606
+ ! grep -qxF 'will be added to the merge queue when all requirements are met' \
607
+ "$case_dir/stderr" \
608
+ || fail "github-refusal-quotes-forge: forge text was emitted as the wrapper's own line"
609
+ assert_no_grep 'will be added to the merge queue' "$case_dir/stdout" \
610
+ "github-refusal-quotes-forge: the forge's unverified report leaked to stdout"
611
+ assert_no_grep 'verified: ' "$case_dir/stdout" \
612
+ "github-refusal-quotes-forge: an unproved merge was reported as verified"
613
+ pass "fm-pr-merge refuses with the forge's own output quoted apart from its verdict"
614
+ }
615
+
616
+ test_github_auto_merge_without_queue_refuses_legibly() {
617
+ local case_dir rc spelling
618
+ for spelling in --auto --auto=true; do
619
+ case_dir=$(make_case "github-auto-no-queue${spelling#--auto}")
620
+ mkdir -p "$case_dir/wt"
621
+ add_gh_mocks "$case_dir" 7171717171717171717171717171717171717171
622
+ write_github_outcome "$case_dir" OPEN false false main
623
+ : > "$case_dir/github-rules"
624
+ : > "$case_dir/gh-axi.log"
625
+ : > "$case_dir/gh.log"
626
+
627
+ set +e
628
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/66 \
629
+ -- "$spelling" --merge \
630
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
631
+ rc=$?
632
+ set -e
633
+
634
+ expect_code 1 "$rc" "github-auto-no-queue: an armed but unlanded auto-merge must still fail"
635
+ assert_grep 'state=OPEN, merged=false, isInMergeQueue=false' "$case_dir/stderr" \
636
+ "github-auto-no-queue: refusal did not name the concrete observed state"
637
+ assert_grep 'auto-merge was requested and armed for https://github.com/example/repo/pull/66' \
638
+ "$case_dir/stderr" "github-auto-no-queue: the refusal never explained the armed auto-merge"
639
+ assert_grep 'nothing is merged or in the merge queue yet' "$case_dir/stderr" \
640
+ "github-auto-no-queue: the refusal left the operator to infer the pending state"
641
+ grep -qxF "pr merge 66 --repo example/repo $spelling --merge" "$case_dir/gh-axi.log" \
642
+ || fail "github-auto-no-queue: the attempted merge was changed unexpectedly"
643
+ [ "$(wc -l < "$case_dir/gh-axi.log" | tr -d '[:space:]')" = 1 ] \
644
+ || fail "github-auto-no-queue: the wrapper attempted more than one merge"
645
+ assert_grep 'pr=https://github.com/example/repo/pull/66' "$case_dir/state/task-x1.meta" \
646
+ "github-auto-no-queue: the attempted merge lost its PR reference"
647
+ assert_present "$case_dir/state/task-x1.check.sh" \
648
+ "github-auto-no-queue: the attempted merge did not leave its poll armed"
649
+ done
650
+ pass "fm-pr-merge explains an armed auto-merge that landed nothing on a queue-less base"
651
+ }
652
+
653
+ test_github_failed_merge_never_claims_armed_auto_merge() {
654
+ local case_dir rc
655
+ case_dir=$(make_case github-auto-merge-command-fails)
656
+ mkdir -p "$case_dir/wt"
657
+ add_gh_mocks_merge_fails "$case_dir"
658
+ write_github_outcome "$case_dir" OPEN false false main
659
+ : > "$case_dir/github-rules"
660
+ : > "$case_dir/gh-axi.log"
661
+ : > "$case_dir/gh.log"
662
+
663
+ set +e
664
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/67 -- --auto --merge \
665
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
666
+ rc=$?
667
+ set -e
668
+
669
+ expect_code 1 "$rc" "github-auto-merge-command-fails: the forge failure must still fail the wrapper"
670
+ assert_grep 'error: pr merge failed' "$case_dir/stderr" \
671
+ "github-auto-merge-command-fails: the original forge error was masked"
672
+ assert_grep 'state=OPEN, merged=false, isInMergeQueue=false' "$case_dir/stderr" \
673
+ "github-auto-merge-command-fails: refusal did not name the concrete observed state"
674
+ assert_no_grep 'armed' "$case_dir/stderr" \
675
+ "github-auto-merge-command-fails: a failed merge command was reported as an armed auto-merge"
676
+ assert_grep 'auto-merge was requested for https://github.com/example/repo/pull/67' \
677
+ "$case_dir/stderr" \
678
+ "github-auto-merge-command-fails: the refusal never said auto-merge had only been requested"
679
+ assert_no_grep 'verified: ' "$case_dir/stdout" \
680
+ "github-auto-merge-command-fails: a failed merge command was reported as verified"
681
+ pass "fm-pr-merge never reports auto-merge as armed when the merge command failed"
682
+ }
683
+
684
+ test_github_failed_merge_with_queue_flags_never_claims_acceptance() {
685
+ local case_dir rc
686
+ case_dir=$(make_case github-failed-merge-queue-flags)
687
+ mkdir -p "$case_dir/wt"
688
+ add_gh_mocks_merge_fails "$case_dir"
689
+ write_github_outcome "$case_dir" OPEN false false main
690
+ printf 'merge_method=MERGE\n' > "$case_dir/github-rules"
691
+ : > "$case_dir/gh-axi.log"
692
+ : > "$case_dir/gh.log"
693
+
694
+ set +e
695
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/74 -- --auto --merge \
696
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
697
+ rc=$?
698
+ set -e
699
+
700
+ expect_code 1 "$rc" "github-failed-merge-queue-flags: the forge failure must still fail the wrapper"
701
+ assert_grep 'error: pr merge failed' "$case_dir/stderr" \
702
+ "github-failed-merge-queue-flags: the original forge error was masked"
703
+ assert_grep 'state=OPEN, merged=false, isInMergeQueue=false' "$case_dir/stderr" \
704
+ "github-failed-merge-queue-flags: refusal did not name the concrete observed state"
705
+ assert_no_grep 'was accepted with the exact flags' "$case_dir/stderr" \
706
+ "github-failed-merge-queue-flags: a failed merge command was reported as an accepted request"
707
+ assert_no_grep 'armed' "$case_dir/stderr" \
708
+ "github-failed-merge-queue-flags: a failed merge command was reported as an armed auto-merge"
709
+ assert_grep 'base branch main requires the merge queue; retry with:' "$case_dir/stderr" \
710
+ "github-failed-merge-queue-flags: the failed merge command lost its concrete retry guidance"
711
+ assert_grep 'task-x1 https://github.com/example/repo/pull/74 -- --auto --merge' "$case_dir/stderr" \
712
+ "github-failed-merge-queue-flags: the retry guidance named no queue flags"
713
+ assert_no_grep 'verified: ' "$case_dir/stdout" \
714
+ "github-failed-merge-queue-flags: a failed merge command was reported as verified"
715
+ pass "fm-pr-merge claims no acceptance for a failed merge command carrying queue flags"
716
+ }
717
+
718
+ test_github_accepted_queue_flags_do_not_echo_back_the_same_command() {
719
+ local case_dir rc
720
+ case_dir=$(make_case github-accepted-queue-flags)
721
+ mkdir -p "$case_dir/wt"
722
+ add_gh_mocks "$case_dir" 8181818181818181818181818181818181818181
723
+ write_github_outcome "$case_dir" OPEN false false main
724
+ printf 'merge_method=MERGE\n' > "$case_dir/github-rules"
725
+ : > "$case_dir/gh-axi.log"
726
+ : > "$case_dir/gh.log"
727
+
728
+ set +e
729
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/68 -- --auto --merge \
730
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
731
+ rc=$?
732
+ set -e
733
+
734
+ expect_code 1 "$rc" "github-accepted-queue-flags: an unproved merge must still fail"
735
+ assert_grep 'state=OPEN, merged=false, isInMergeQueue=false' "$case_dir/stderr" \
736
+ "github-accepted-queue-flags: refusal did not name the concrete observed state"
737
+ assert_grep 'this run refuses even though the request for https://github.com/example/repo/pull/68 was accepted with the exact flags base branch main requires (--auto --merge)' \
738
+ "$case_dir/stderr" \
739
+ "github-accepted-queue-flags: the refusal did not explain that the right flags were already used"
740
+ assert_grep "re-check the pull request's merge queue state" "$case_dir/stderr" \
741
+ "github-accepted-queue-flags: the refusal named no concrete next step"
742
+ assert_no_grep 'retry with:' "$case_dir/stderr" \
743
+ "github-accepted-queue-flags: the refusal echoed back the command that just refused"
744
+ assert_no_grep 'verified: ' "$case_dir/stdout" \
745
+ "github-accepted-queue-flags: an unproved merge was reported as verified"
746
+ pass "fm-pr-merge does not echo back queue flags the caller already used"
747
+ }
748
+
749
+ test_github_mismatched_queue_flags_still_name_the_retry() {
750
+ local case_dir rc
751
+ case_dir=$(make_case github-mismatched-queue-flags)
752
+ mkdir -p "$case_dir/wt"
753
+ add_gh_mocks "$case_dir" 8282828282828282828282828282828282828282
754
+ write_github_outcome "$case_dir" OPEN false false main
755
+ printf 'merge_method=REBASE\n' > "$case_dir/github-rules"
756
+ : > "$case_dir/gh-axi.log"
757
+ : > "$case_dir/gh.log"
758
+
759
+ set +e
760
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/69 -- --auto --merge \
761
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
762
+ rc=$?
763
+ set -e
764
+
765
+ expect_code 1 "$rc" "github-mismatched-queue-flags: an unproved merge must still fail"
766
+ assert_grep 'base branch main requires the merge queue; retry with:' "$case_dir/stderr" \
767
+ "github-mismatched-queue-flags: a caller method the queue does not use lost its retry guidance"
768
+ assert_grep '-- --auto --rebase' "$case_dir/stderr" \
769
+ "github-mismatched-queue-flags: the exact compatible flags were not named"
770
+ pass "fm-pr-merge still names retry flags when the caller used a different method"
771
+ }
772
+
773
+ test_github_unrecognised_queue_method_still_names_the_queue() {
774
+ local case_dir rc
775
+ case_dir=$(make_case github-unrecognised-queue-method)
776
+ mkdir -p "$case_dir/wt"
777
+ add_gh_mocks "$case_dir" 8383838383838383838383838383838383838383
778
+ write_github_outcome "$case_dir" OPEN false false main
779
+ printf 'merge_method=FASTFORWARD\n' > "$case_dir/github-rules"
780
+ : > "$case_dir/gh-axi.log"
781
+ : > "$case_dir/gh.log"
782
+
783
+ set +e
784
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/70 \
785
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
786
+ rc=$?
787
+ set -e
788
+
789
+ expect_code 1 "$rc" "github-unrecognised-queue-method: an unproved merge must fail"
790
+ assert_grep 'base branch main requires the merge queue, but its configured merge method (FASTFORWARD) is not one this script recognises' \
791
+ "$case_dir/stderr" \
792
+ "github-unrecognised-queue-method: a readable queue rule produced no queue mention"
793
+ assert_no_grep 'retry with:' "$case_dir/stderr" \
794
+ "github-unrecognised-queue-method: retry flags were named for a method nothing recognises"
795
+ assert_no_grep '--auto --' "$case_dir/stderr" \
796
+ "github-unrecognised-queue-method: a merge method was guessed for the caller"
797
+ pass "fm-pr-merge names the queue requirement even when its method is unrecognised"
798
+ }
799
+
800
+ test_github_unreadable_queue_rules_are_not_reported_as_no_queue() {
801
+ local case_dir rc
802
+ case_dir=$(make_case github-unreadable-queue-rules)
803
+ mkdir -p "$case_dir/wt"
804
+ add_gh_mocks "$case_dir" 8484848484848484848484848484848484848484
805
+ write_github_outcome "$case_dir" OPEN false false main
806
+ cat > "$case_dir/fakebin/gh" <<'SH'
807
+ #!/usr/bin/env bash
808
+ printf '%s\n' "$*" >> "$FM_TEST_GH_LOG"
809
+ case "${1:-} ${2:-}" in
810
+ "pr view")
811
+ case " $* " in
812
+ *headRefOid*) printf '%s\n' 8484848484848484848484848484848484848484 ; exit 0 ;;
813
+ esac
814
+ ;;
815
+ "api graphql")
816
+ cat "$FM_TEST_GH_OUTCOME"
817
+ exit 0
818
+ ;;
819
+ api\ *) exit 1 ;;
820
+ esac
821
+ exit 0
822
+ SH
823
+ chmod +x "$case_dir/fakebin/gh"
824
+ : > "$case_dir/gh-axi.log"
825
+ : > "$case_dir/gh.log"
826
+
827
+ set +e
828
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/71 \
829
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
830
+ rc=$?
831
+ set -e
832
+
833
+ expect_code 1 "$rc" "github-unreadable-queue-rules: an unproved merge must fail"
834
+ assert_grep 'the branch rules for base branch main could not be read' "$case_dir/stderr" \
835
+ "github-unreadable-queue-rules: an unreadable rules response read like a queue-less base"
836
+ assert_no_grep 'retry with:' "$case_dir/stderr" \
837
+ "github-unreadable-queue-rules: retry flags were named from rules nothing could read"
838
+ pass "fm-pr-merge distinguishes unreadable branch rules from a base with no merge queue"
839
+ }
840
+
841
+ test_github_no_queue_rule_says_nothing_about_a_queue() {
842
+ local case_dir rc
843
+ case_dir=$(make_case github-no-queue-rule)
844
+ mkdir -p "$case_dir/wt"
845
+ add_gh_mocks "$case_dir" 8585858585858585858585858585858585858585
846
+ write_github_outcome "$case_dir" OPEN false false main
847
+ : > "$case_dir/github-rules"
848
+ : > "$case_dir/gh-axi.log"
849
+ : > "$case_dir/gh.log"
850
+
851
+ set +e
852
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/72 \
853
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
854
+ rc=$?
855
+ set -e
856
+
857
+ expect_code 1 "$rc" "github-no-queue-rule: an unproved merge must fail"
858
+ assert_grep 'state=OPEN, merged=false, isInMergeQueue=false' "$case_dir/stderr" \
859
+ "github-no-queue-rule: refusal did not name the concrete observed state"
860
+ assert_no_grep 'merge queue' "$case_dir/stderr" \
861
+ "github-no-queue-rule: a base with no queue rule was told it requires the merge queue"
862
+ pass "fm-pr-merge says nothing about a merge queue when the base branch has no queue rule"
863
+ }
864
+
865
+ test_github_fallback_view_refusal_says_the_queue_was_unobservable() {
866
+ local case_dir ghless_path rc
867
+ case_dir=$(make_case github-fallback-unobservable-queue)
868
+ mkdir -p "$case_dir/wt"
869
+ add_gh_mocks "$case_dir" 8686868686868686868686868686868686868686
870
+ cat > "$case_dir/fakebin/gh-axi" <<'SH'
871
+ #!/usr/bin/env bash
872
+ printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG"
873
+ case "${1:-} ${2:-}" in
874
+ "pr merge") printf 'merged:\n number: %s\n status: ok\n' "${3:-}" ;;
875
+ "pr view") printf 'pull_request:\n number: %s\n state: open\n' "$3" ;;
876
+ esac
877
+ exit 0
878
+ SH
879
+ chmod +x "$case_dir/fakebin/gh-axi"
880
+ rm "$case_dir/fakebin/gh"
881
+ ghless_path="$case_dir/path-without-gh"
882
+ mirror_path_without "$ghless_path" gh "$case_dir/fakebin"
883
+ : > "$case_dir/gh-axi.log"
884
+
885
+ set +e
886
+ PATH="$ghless_path" run_pr_merge "$case_dir" task-x1 \
887
+ https://github.com/example/repo/pull/73 -- --auto --merge \
888
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
889
+ rc=$?
890
+ set -e
891
+
892
+ expect_code 1 "$rc" "github-fallback-unobservable-queue: an unproved merge must fail"
893
+ assert_grep 'isInMergeQueue=unknown' "$case_dir/stderr" \
894
+ "github-fallback-unobservable-queue: refusal did not name the concrete observed state"
895
+ assert_grep 'the merge queue could not be observed for https://github.com/example/repo/pull/73' \
896
+ "$case_dir/stderr" \
897
+ "github-fallback-unobservable-queue: the refusal implied an unqueued PR it could not see"
898
+ assert_grep "re-check the pull request's merge queue state" "$case_dir/stderr" \
899
+ "github-fallback-unobservable-queue: the refusal named no concrete next step"
900
+ # The lowercase state the fallback view reports must be judged the same way
901
+ # the queue-aware read's uppercase enum is, or every explanation is skipped.
902
+ assert_grep 'auto-merge was requested and armed for https://github.com/example/repo/pull/73' \
903
+ "$case_dir/stderr" \
904
+ "github-fallback-unobservable-queue: the fallback view's state skipped the auto-merge explanation"
905
+ assert_no_grep 'verified: ' "$case_dir/stdout" \
906
+ "github-fallback-unobservable-queue: an unproved merge was reported as verified"
907
+ pass "fm-pr-merge says the merge queue was unobservable when only the gh-axi view answered"
908
+ }
909
+
910
+ test_github_unreadable_outcome_refusal_quotes_the_forge_output() {
911
+ local case_dir rc
912
+ case_dir=$(make_case github-unreadable-outcome-quotes-forge)
913
+ mkdir -p "$case_dir/wt"
914
+ add_gh_mocks "$case_dir" 8787878787878787878787878787878787878787
915
+ cat > "$case_dir/fakebin/gh-axi" <<'SH'
916
+ #!/usr/bin/env bash
917
+ printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG"
918
+ case "${1:-} ${2:-}" in
919
+ "pr merge") echo "will be added to the merge queue when all requirements are met" ;;
920
+ "pr view") exit 1 ;;
921
+ esac
922
+ exit 0
923
+ SH
924
+ chmod +x "$case_dir/fakebin/gh-axi"
925
+ add_gh_mock_outcome_read_fails "$case_dir" 8787878787878787878787878787878787878787
926
+ : > "$case_dir/gh-axi.log"
927
+ : > "$case_dir/gh.log"
928
+
929
+ set +e
930
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/74 \
931
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
932
+ rc=$?
933
+ set -e
934
+
935
+ expect_code 1 "$rc" "github-unreadable-outcome-quotes-forge: an unreadable outcome must fail"
936
+ assert_grep 'could not read the GitHub pull request outcome after the merge attempt' \
937
+ "$case_dir/stderr" \
938
+ "github-unreadable-outcome-quotes-forge: the unreadable outcome was not reported"
939
+ assert_grep 'error: > will be added to the merge queue when all requirements are met' \
940
+ "$case_dir/stderr" \
941
+ "github-unreadable-outcome-quotes-forge: the forge's only evidence was discarded"
942
+ ! grep -qxF 'will be added to the merge queue when all requirements are met' \
943
+ "$case_dir/stderr" \
944
+ || fail "github-unreadable-outcome-quotes-forge: forge text was emitted as the wrapper's own line"
945
+ assert_no_grep 'verified: ' "$case_dir/stdout" \
946
+ "github-unreadable-outcome-quotes-forge: an unproved merge was reported as verified"
947
+ assert_present "$case_dir/state/task-x1.check.sh" \
948
+ "github-unreadable-outcome-quotes-forge: the attempted merge lost its merge poll"
949
+ pass "fm-pr-merge quotes the forge output when it cannot read the outcome either"
950
+ }
951
+
952
+ test_github_failed_gh_read_falls_back_to_gh_axi() {
953
+ local case_dir rc
954
+ case_dir=$(make_case github-gh-read-falls-back)
955
+ mkdir -p "$case_dir/wt"
956
+ add_gh_mocks "$case_dir" 5151515151515151515151515151515151515151
957
+ add_gh_mock_outcome_read_fails "$case_dir" 5151515151515151515151515151515151515151
958
+ : > "$case_dir/gh-axi.log"
959
+ : > "$case_dir/gh.log"
960
+
961
+ set +e
962
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/63 \
963
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
964
+ rc=$?
965
+ set -e
966
+
967
+ expect_code 0 "$rc" "github-gh-read-falls-back: a merge the gh-axi view proves must succeed"
968
+ assert_grep 'pr view 63 --repo example/repo' "$case_dir/gh-axi.log" \
969
+ "github-gh-read-falls-back: the gh-axi view was never consulted after gh's read failed"
970
+ assert_grep 'verified: https://github.com/example/repo/pull/63 is merged' \
971
+ "$case_dir/stdout" "github-gh-read-falls-back: the proven merge was not reported"
972
+ assert_grep 'pr=https://github.com/example/repo/pull/63' "$case_dir/state/task-x1.meta" \
973
+ "github-gh-read-falls-back: the merged PR was not recorded for teardown"
974
+ pass "fm-pr-merge falls back to the gh-axi view when gh's read fails"
975
+ }
976
+
977
+ test_github_failed_merge_names_an_observed_landed_state() {
978
+ local case_dir rc
979
+ case_dir=$(make_case github-failed-merge-actually-landed)
980
+ mkdir -p "$case_dir/wt"
981
+ add_gh_mocks_merge_fails "$case_dir"
982
+ write_github_outcome "$case_dir" MERGED true false main
983
+ : > "$case_dir/gh-axi.log"
984
+ : > "$case_dir/gh.log"
985
+
986
+ set +e
987
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/64 \
988
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
989
+ rc=$?
990
+ set -e
991
+
992
+ expect_code 1 "$rc" "github-failed-merge-actually-landed: the forge failure must still fail the wrapper"
993
+ assert_grep 'error: pr merge failed' "$case_dir/stderr" \
994
+ "github-failed-merge-actually-landed: the original forge error was masked"
995
+ assert_grep 'state=MERGED, merged=true, isInMergeQueue=false' "$case_dir/stderr" \
996
+ "github-failed-merge-actually-landed: the observed landed state was never named"
997
+ assert_no_grep 'verified: ' "$case_dir/stdout" \
998
+ "github-failed-merge-actually-landed: a failed merge command was reported as verified"
999
+ assert_grep 'pr=https://github.com/example/repo/pull/64' "$case_dir/state/task-x1.meta" \
1000
+ "github-failed-merge-actually-landed: the landed PR lost its reference"
1001
+ pass "fm-pr-merge names a landed state hiding behind a failed GitHub merge command"
1002
+ }
1003
+
1004
+ test_github_without_gh_still_uses_gh_axi_merge() {
1005
+ local case_dir ghless_path rc
1006
+ case_dir=$(make_case github-without-gh)
1007
+ mkdir -p "$case_dir/wt"
1008
+ add_gh_mocks "$case_dir" 4141414141414141414141414141414141414141
1009
+ rm "$case_dir/fakebin/gh"
1010
+ ghless_path="$case_dir/path-without-gh"
1011
+ mirror_path_without "$ghless_path" gh "$case_dir/fakebin"
1012
+ : > "$case_dir/gh-axi.log"
1013
+
1014
+ set +e
1015
+ PATH="$ghless_path" run_pr_merge "$case_dir" task-x1 \
1016
+ https://github.com/example/repo/pull/60 \
1017
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1018
+ rc=$?
1019
+ set -e
1020
+
1021
+ expect_code 0 "$rc" "github-without-gh: gh-axi can prove a landed merge without gh"
1022
+ assert_grep 'pr merge 60 --repo example/repo --squash' "$case_dir/gh-axi.log" \
1023
+ "github-without-gh: the configured merge abstraction was not invoked"
1024
+ assert_grep 'pr view 60 --repo example/repo' "$case_dir/gh-axi.log" \
1025
+ "github-without-gh: the gh-axi fallback did not verify the landed state"
1026
+ assert_grep 'verified: https://github.com/example/repo/pull/60 is merged' \
1027
+ "$case_dir/stdout" "github-without-gh: the fallback did not report the proven merge"
1028
+ pass "fm-pr-merge reaches and verifies the gh-axi merge path without gh"
1029
+ }
1030
+
1031
+ test_github_without_gh_failed_read_keeps_bookkeeping() {
1032
+ local case_dir ghless_path rc
1033
+ case_dir=$(make_case github-without-gh-read-fails)
1034
+ mkdir -p "$case_dir/wt"
1035
+ cat > "$case_dir/fakebin/gh-axi" <<'SH'
1036
+ #!/usr/bin/env bash
1037
+ printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG"
1038
+ case "${1:-} ${2:-}" in
1039
+ "pr merge") exit 0 ;;
1040
+ "pr view") exit 1 ;;
1041
+ esac
1042
+ exit 0
1043
+ SH
1044
+ chmod +x "$case_dir/fakebin/gh-axi"
1045
+ ghless_path="$case_dir/path-without-gh"
1046
+ mirror_path_without "$ghless_path" gh "$case_dir/fakebin"
1047
+ : > "$case_dir/gh-axi.log"
1048
+
1049
+ set +e
1050
+ PATH="$ghless_path" run_pr_merge "$case_dir" task-x1 \
1051
+ https://github.com/example/repo/pull/61 \
1052
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1053
+ rc=$?
1054
+ set -e
1055
+
1056
+ expect_code 1 "$rc" "github-without-gh-read-fails: an unreadable outcome must fail"
1057
+ assert_grep 'pr merge 61 --repo example/repo --squash' "$case_dir/gh-axi.log" \
1058
+ "github-without-gh-read-fails: the merge call did not happen before the failed read"
1059
+ assert_grep 'could not read the GitHub pull request outcome after the merge attempt' \
1060
+ "$case_dir/stderr" "github-without-gh-read-fails: the failed read was not reported"
1061
+ assert_grep 'pr=https://github.com/example/repo/pull/61' "$case_dir/state/task-x1.meta" \
1062
+ "github-without-gh-read-fails: a landed merge lost its PR metadata"
1063
+ assert_present "$case_dir/state/task-x1.check.sh" \
1064
+ "github-without-gh-read-fails: a landed merge lost its merge poll"
1065
+ pass "fm-pr-merge preserves bookkeeping when gh is absent and the fallback read fails"
1066
+ }
1067
+
1068
+ test_github_zero_exit_queue_required_refuses_with_exact_retry() {
1069
+ local case_dir rc
1070
+ case_dir=$(make_case github-zero-exit-queue-required)
1071
+ mkdir -p "$case_dir/wt"
1072
+ add_gh_mocks "$case_dir" 2121212121212121212121212121212121212121
1073
+ write_github_outcome "$case_dir" OPEN false false 'release/2026'
1074
+ printf 'merge_method=REBASE\n' > "$case_dir/github-rules"
1075
+ : > "$case_dir/gh-axi.log"
1076
+ : > "$case_dir/gh.log"
1077
+
1078
+ set +e
1079
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/56 \
1080
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1081
+ rc=$?
1082
+ set -e
1083
+
1084
+ expect_code 1 "$rc" "github-zero-exit-queue-required: an unproved merge must fail"
1085
+ assert_grep 'state=OPEN, merged=false, isInMergeQueue=false' "$case_dir/stderr" \
1086
+ "github-zero-exit-queue-required: refusal did not name the concrete observed state"
1087
+ assert_grep 'base branch release/2026 requires the merge queue' "$case_dir/stderr" \
1088
+ "github-zero-exit-queue-required: refusal did not name the queue requirement"
1089
+ assert_grep '-- --auto --rebase' "$case_dir/stderr" \
1090
+ "github-zero-exit-queue-required: refusal did not name the exact compatible flags"
1091
+ assert_grep 'api --paginate repos/example/repo/rules/branches/release%2F2026' "$case_dir/gh.log" \
1092
+ "github-zero-exit-queue-required: queue rules were not read with pagination and encoded branch path"
1093
+ grep -qxF 'pr merge 56 --repo example/repo --squash' "$case_dir/gh-axi.log" \
1094
+ || fail "github-zero-exit-queue-required: the attempted merge was changed unexpectedly"
1095
+ [ "$(wc -l < "$case_dir/gh-axi.log" | tr -d '[:space:]')" = 1 ] \
1096
+ || fail "github-zero-exit-queue-required: the wrapper attempted more than one merge"
1097
+ assert_no_grep --auto "$case_dir/gh-axi.log" \
1098
+ "github-zero-exit-queue-required: queue flags were auto-applied to the attempted merge"
1099
+ assert_grep 'pr=https://github.com/example/repo/pull/56' "$case_dir/state/task-x1.meta" \
1100
+ "github-zero-exit-queue-required: the attempted merge lost its PR reference"
1101
+ assert_present "$case_dir/state/task-x1.check.sh" \
1102
+ "github-zero-exit-queue-required: the attempted merge did not leave its poll armed"
1103
+ pass "fm-pr-merge reports exact queue retry flags after a zero-exit false success"
1104
+ }
1105
+
1106
+ test_github_closed_unqueued_outcome_omits_retry_flags() {
1107
+ local case_dir rc
1108
+ case_dir=$(make_case github-closed-unqueued)
1109
+ mkdir -p "$case_dir/wt"
1110
+ add_gh_mocks "$case_dir" 2323232323232323232323232323232323232323
1111
+ write_github_outcome "$case_dir" CLOSED false false master
1112
+ printf 'merge_method=MERGE\n' > "$case_dir/github-rules"
1113
+ : > "$case_dir/gh-axi.log"
1114
+ : > "$case_dir/gh.log"
1115
+
1116
+ set +e
1117
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/57 \
1118
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1119
+ rc=$?
1120
+ set -e
1121
+
1122
+ expect_code 1 "$rc" "github-closed-unqueued: an unproved merge must fail"
1123
+ assert_grep 'state=CLOSED, merged=false, isInMergeQueue=false' "$case_dir/stderr" \
1124
+ "github-closed-unqueued: refusal did not name the concrete observed state"
1125
+ assert_no_grep 'requires the merge queue' "$case_dir/stderr" \
1126
+ "github-closed-unqueued: closed PR received unusable queue guidance"
1127
+ assert_no_grep '-- --auto --merge' "$case_dir/stderr" \
1128
+ "github-closed-unqueued: closed PR received retry flags"
1129
+ assert_grep 'pr=https://github.com/example/repo/pull/57' "$case_dir/state/task-x1.meta" \
1130
+ "github-closed-unqueued: the attempted merge lost its PR reference"
1131
+ assert_present "$case_dir/state/task-x1.check.sh" \
1132
+ "github-closed-unqueued: the attempted merge did not leave its poll armed"
1133
+ pass "fm-pr-merge omits merge-queue retry guidance for a closed GitHub PR"
1134
+ }
1135
+
1136
+ test_github_queued_outcome_is_verified() {
1137
+ local case_dir rc
1138
+ case_dir=$(make_case github-verified-queued)
1139
+ mkdir -p "$case_dir/wt"
1140
+ add_gh_mocks "$case_dir" 3030303030303030303030303030303030303030
1141
+ write_github_outcome "$case_dir" OPEN false true master
1142
+ : > "$case_dir/gh-axi.log"
1143
+ : > "$case_dir/gh.log"
1144
+
1145
+ set +e
1146
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/53 -- --auto --merge \
1147
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1148
+ rc=$?
1149
+ set -e
1150
+
1151
+ expect_code 0 "$rc" "github-verified-queued: a queued PR should succeed"
1152
+ assert_grep 'verified: https://github.com/example/repo/pull/53 is queued' \
1153
+ "$case_dir/stdout" "github-verified-queued: success was not reported as queued"
1154
+ assert_no_grep 'merged:' "$case_dir/stdout" \
1155
+ "github-verified-queued: the forge CLI's unverified merged report leaked through"
1156
+ assert_grep 'pr=https://github.com/example/repo/pull/53' "$case_dir/state/task-x1.meta" \
1157
+ "github-verified-queued: the queued PR was not recorded for teardown"
1158
+ pass "fm-pr-merge accepts and accurately reports a GitHub merge-queue entry"
1159
+ }
1160
+
1161
+ test_github_queue_required_refusal_names_retry_flags() {
1162
+ local case_dir rc
1163
+ case_dir=$(make_case github-queue-required)
1164
+ mkdir -p "$case_dir/wt"
1165
+ add_gh_mocks_merge_fails "$case_dir"
1166
+ write_github_outcome "$case_dir" OPEN false false master
1167
+ printf 'merge_method=MERGE\n' > "$case_dir/github-rules"
1168
+ : > "$case_dir/gh-axi.log"
1169
+ : > "$case_dir/gh.log"
1170
+
1171
+ set +e
1172
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/54 \
1173
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1174
+ rc=$?
1175
+ set -e
1176
+
1177
+ expect_code 1 "$rc" "github-queue-required: an incompatible direct merge must fail"
1178
+ assert_grep 'error: pr merge failed' "$case_dir/stderr" \
1179
+ "github-queue-required: the original forge failure was not preserved"
1180
+ assert_grep 'base branch master requires the merge queue' "$case_dir/stderr" \
1181
+ "github-queue-required: refusal did not name the queue requirement"
1182
+ grep -F -- '-- --auto --merge' "$case_dir/stderr" >/dev/null \
1183
+ || fail "github-queue-required: refusal did not name the exact compatible flags"
1184
+ grep -qxF 'pr merge 54 --repo example/repo --squash' "$case_dir/gh-axi.log" \
1185
+ || fail "github-queue-required: the wrapper silently changed the attempted merge semantics"
1186
+ assert_present "$case_dir/state/task-x1.check.sh" \
1187
+ "github-queue-required: the failed forge call did not leave the merge poll armed"
1188
+ pass "fm-pr-merge explains how to retry with the required GitHub merge queue method"
1189
+ }
1190
+
1191
+ test_github_agreeing_queue_rules_keep_retry_guidance() {
1192
+ local case_dir rc
1193
+ case_dir=$(make_case github-agreeing-queue-rules)
1194
+ mkdir -p "$case_dir/wt"
1195
+ add_gh_mocks "$case_dir" 2424242424242424242424242424242424242424
1196
+ write_github_outcome "$case_dir" OPEN false false main
1197
+ printf 'merge_method=REBASE\nmerge_method=REBASE\n' > "$case_dir/github-rules"
1198
+ : > "$case_dir/gh-axi.log"
1199
+ : > "$case_dir/gh.log"
1200
+
1201
+ set +e
1202
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/58 \
1203
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1204
+ rc=$?
1205
+ set -e
1206
+
1207
+ expect_code 1 "$rc" "github-agreeing-queue-rules: an unproved merge must fail"
1208
+ assert_grep 'base branch main requires the merge queue' "$case_dir/stderr" \
1209
+ "github-agreeing-queue-rules: refusal did not name the queue requirement"
1210
+ assert_grep '-- --auto --rebase' "$case_dir/stderr" \
1211
+ "github-agreeing-queue-rules: agreeing rules omitted exact retry flags"
1212
+ assert_no_grep 'exact retry flags are ambiguous' "$case_dir/stderr" \
1213
+ "github-agreeing-queue-rules: agreeing rules were reported as ambiguous"
1214
+ pass "fm-pr-merge aggregates agreeing merge-queue rules"
1215
+ }
1216
+
1217
+ test_github_conflicting_queue_rules_report_ambiguity() {
1218
+ local case_dir rc
1219
+ case_dir=$(make_case github-conflicting-queue-rules)
1220
+ mkdir -p "$case_dir/wt"
1221
+ add_gh_mocks "$case_dir" 2525252525252525252525252525252525252525
1222
+ write_github_outcome "$case_dir" OPEN false false main
1223
+ printf 'merge_method=MERGE\nmerge_method=SQUASH\nmerge_method=SQUASH\n' \
1224
+ > "$case_dir/github-rules"
1225
+ : > "$case_dir/gh-axi.log"
1226
+ : > "$case_dir/gh.log"
1227
+
1228
+ set +e
1229
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/59 \
1230
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1231
+ rc=$?
1232
+ set -e
1233
+
1234
+ expect_code 1 "$rc" "github-conflicting-queue-rules: an unproved merge must fail"
1235
+ assert_grep 'base branch main has conflicting merge queue methods (MERGE, SQUASH)' \
1236
+ "$case_dir/stderr" \
1237
+ "github-conflicting-queue-rules: conflicting methods were not named"
1238
+ assert_no_grep '-- --auto --merge' "$case_dir/stderr" \
1239
+ "github-conflicting-queue-rules: an exact retry method was guessed"
1240
+ assert_no_grep '-- --auto --squash' "$case_dir/stderr" \
1241
+ "github-conflicting-queue-rules: an exact retry method was guessed"
1242
+ assert_no_grep 'SQUASH, SQUASH' "$case_dir/stderr" \
1243
+ "github-conflicting-queue-rules: a repeated queue method was named twice"
1244
+ pass "fm-pr-merge reports ambiguity for conflicting merge-queue rules"
1245
+ }
1246
+
1247
+ test_extra_merge_args_forwarded() {
1248
+ local case_dir rc
1249
+ case_dir=$(make_case extra-args)
1250
+ mkdir -p "$case_dir/wt"
1251
+ add_gh_mocks "$case_dir" 2222222222222222222222222222222222222222
1252
+ : > "$case_dir/gh-axi.log"
1253
+
1254
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/15 -- --squash --delete-branch \
1255
+ > "$case_dir/stdout" 2> "$case_dir/stderr" || fail "extra-args: fm-pr-merge failed"
1256
+
1257
+ grep -qxF 'pr merge 15 --repo example/repo --squash --delete-branch' "$case_dir/gh-axi.log" \
1258
+ || fail "extra-args: extra gh-axi pr merge flags were not forwarded"
1259
+ pass "fm-pr-merge forwards extra flags to gh-axi pr merge after the -- separator"
1260
+ }
1261
+
1262
+ test_missing_meta_refuses_before_merge() {
1263
+ local case_dir fakebin rc
1264
+ case_dir="$TMP_ROOT/missing-meta"
1265
+ fakebin="$case_dir/fakebin"
1266
+ mkdir -p "$case_dir/state" "$fakebin"
1267
+ add_gh_mocks "$case_dir" 3333333333333333333333333333333333333333
1268
+ : > "$case_dir/gh-axi.log"
1269
+
1270
+ set +e
1271
+ run_pr_merge "$case_dir" missing-x1 https://github.com/example/repo/pull/21 \
1272
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1273
+ rc=$?
1274
+ set -e
1275
+
1276
+ expect_code 1 "$rc" "missing-meta: fm-pr-merge should refuse"
1277
+ assert_grep 'error: task metadata is unavailable' "$case_dir/stderr" \
1278
+ "missing-meta: refusal did not explain missing meta"
1279
+ [ ! -s "$case_dir/gh-axi.log" ] || fail "missing-meta: gh-axi pr merge was invoked"
1280
+ assert_absent "$case_dir/state/missing-x1.check.sh" \
1281
+ "missing-meta: fm-pr-check should not arm a poll for an unknown task"
1282
+ pass "fm-pr-merge refuses before merging when task meta is missing"
1283
+ }
1284
+
1285
+ test_malformed_url_refuses_before_merge() {
1286
+ local case_dir rc
1287
+ case_dir=$(make_case malformed-url)
1288
+ mkdir -p "$case_dir/wt"
1289
+ add_gh_mocks "$case_dir" 4444444444444444444444444444444444444444
1290
+ : > "$case_dir/gh-axi.log"
1291
+
1292
+ set +e
1293
+ # A near-miss GitLab URL: one namespace segment where a project needs at
1294
+ # least two. A well-formed merge request URL is merged now, so the refusal
1295
+ # has to be proven on a URL that genuinely does not parse.
1296
+ run_pr_merge "$case_dir" task-x1 'https://gitlab.com/example/-/merge_requests/1' \
1297
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1298
+ rc=$?
1299
+ set -e
1300
+
1301
+ expect_code 2 "$rc" "malformed-url: fm-pr-merge should refuse a malformed merge request URL"
1302
+ assert_grep 'error: invalid PR merge request' "$case_dir/stderr" \
1303
+ "malformed-url: refusal was not fixed and non-probing"
1304
+ assert_no_grep 'pr=https://gitlab.com/example/-/merge_requests/1' "$case_dir/state/task-x1.meta" \
1305
+ "malformed-url: malformed PR URL was recorded in meta"
1306
+ assert_absent "$case_dir/state/task-x1.check.sh" \
1307
+ "malformed-url: malformed PR URL armed a merge poll"
1308
+ assert_no_grep 'pr merge' "$case_dir/gh-axi.log" \
1309
+ "malformed-url: gh-axi pr merge was invoked for a malformed URL"
1310
+ pass "fm-pr-merge refuses malformed PR URLs before calling gh-axi"
1311
+ }
1312
+
1313
+ test_rejects_unsafe_url_segments_before_recording() {
1314
+ local case_dir rc
1315
+ case_dir=$(make_case unsafe-url-segment)
1316
+ mkdir -p "$case_dir/wt"
1317
+ add_gh_mocks "$case_dir" 8888888888888888888888888888888888888888
1318
+ : > "$case_dir/gh-axi.log"
1319
+
1320
+ set +e
1321
+ # shellcheck disable=SC2016 # Literal command substitution probes URL parsing safety.
1322
+ run_pr_merge "$case_dir" task-x1 'https://github.com/evil$(echo pwned)/repo/pull/7' \
1323
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1324
+ rc=$?
1325
+ set -e
1326
+
1327
+ expect_code 1 "$rc" "unsafe-url-segment: fm-pr-merge should refuse unsafe owner/repo characters"
1328
+ assert_grep 'PR URL must match https://github.com/<owner>/<repo>/pull/<number>' "$case_dir/stderr" \
1329
+ "unsafe-url-segment: refusal did not explain the expected URL shape"
1330
+ # shellcheck disable=SC2016 # Literal command substitution must not reach meta.
1331
+ assert_no_grep 'pr=https://github.com/evil$(echo pwned)/repo/pull/7' "$case_dir/state/task-x1.meta" \
1332
+ "unsafe-url-segment: unsafe PR URL was recorded in meta"
1333
+ assert_absent "$case_dir/state/task-x1.check.sh" \
1334
+ "unsafe-url-segment: unsafe PR URL armed a merge poll"
1335
+ assert_no_grep 'pr merge' "$case_dir/gh-axi.log" \
1336
+ "unsafe-url-segment: gh-axi pr merge was invoked for an unsafe URL"
1337
+ pass "fm-pr-merge refuses unsafe PR URL segments before recording state"
1338
+ }
1339
+
1340
+ test_repo_override_args_refuse_before_recording() {
1341
+ local case_dir rc
1342
+ case_dir=$(make_case repo-override)
1343
+ mkdir -p "$case_dir/wt"
1344
+ add_gh_mocks "$case_dir" 9999999999999999999999999999999999999999
1345
+ : > "$case_dir/gh-axi.log"
1346
+
1347
+ set +e
1348
+ run_pr_merge "$case_dir" task-x1 https://github.com/right/repo/pull/5 -- --repo wrong/repo \
1349
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1350
+ rc=$?
1351
+ set -e
1352
+
1353
+ expect_code 1 "$rc" "repo-override: fm-pr-merge should refuse repo override flags"
1354
+ assert_grep 'extra merge arguments must not override the repository' "$case_dir/stderr" \
1355
+ "repo-override: refusal did not explain the repo override"
1356
+ assert_no_grep 'pr=https://github.com/right/repo/pull/5' "$case_dir/state/task-x1.meta" \
1357
+ "repo-override: PR URL was recorded before rejecting repo override"
1358
+ assert_absent "$case_dir/state/task-x1.check.sh" \
1359
+ "repo-override: repo override armed a merge poll"
1360
+ assert_no_grep 'pr merge' "$case_dir/gh-axi.log" \
1361
+ "repo-override: gh-axi pr merge was invoked despite repo override"
1362
+ pass "fm-pr-merge refuses repo override args before recording state"
1363
+ }
1364
+
1365
+ # A bundled short-option cluster carries -R without ever being exactly -R, and
1366
+ # both CLIs expand it one character at a time, so the guard has to read the
1367
+ # whole cluster. On GitLab that redirect names an instance, not only a
1368
+ # repository, so it must refuse before anything is recorded or read.
1369
+ test_bundled_repo_override_args_refuse_before_recording() {
1370
+ local case_dir rc
1371
+ case_dir=$(make_case bundled-repo-override)
1372
+ mkdir -p "$case_dir/wt"
1373
+ add_gh_mocks "$case_dir" abababababababababababababababababababab
1374
+ : > "$case_dir/gh-axi.log"
1375
+
1376
+ set +e
1377
+ run_pr_merge "$case_dir" task-x1 https://github.com/right/repo/pull/6 -- -dR wrong/repo \
1378
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1379
+ rc=$?
1380
+ set -e
1381
+
1382
+ expect_code 1 "$rc" "bundled-repo-override: fm-pr-merge should refuse a bundled repo override"
1383
+ assert_grep 'extra merge arguments must not override the repository' "$case_dir/stderr" \
1384
+ "bundled-repo-override: refusal did not explain the repo override"
1385
+ assert_no_grep 'pr=https://github.com/right/repo/pull/6' "$case_dir/state/task-x1.meta" \
1386
+ "bundled-repo-override: PR URL was recorded before rejecting the bundled repo override"
1387
+ assert_absent "$case_dir/state/task-x1.check.sh" \
1388
+ "bundled-repo-override: a bundled repo override armed a merge poll"
1389
+ assert_no_grep 'pr merge' "$case_dir/gh-axi.log" \
1390
+ "bundled-repo-override: gh-axi pr merge was invoked despite the bundled repo override"
1391
+
1392
+ case_dir=$(make_gitlab_case bundled-repo-override-gitlab)
1393
+
1394
+ set +e
1395
+ run_pr_merge "$case_dir" task-x1 "$MR_URL" -- -yR https://other.example/g/p \
1396
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1397
+ rc=$?
1398
+ set -e
1399
+
1400
+ expect_code 1 "$rc" "bundled-repo-override-gitlab: fm-pr-merge should refuse a bundled instance override"
1401
+ assert_grep 'extra merge arguments must not override the repository' "$case_dir/stderr" \
1402
+ "bundled-repo-override-gitlab: refusal did not explain the repo override"
1403
+ assert_no_grep "pr=$MR_URL" "$case_dir/state/task-x1.meta" \
1404
+ "bundled-repo-override-gitlab: the URL was recorded before rejecting the bundled override"
1405
+ assert_absent "$case_dir/state/task-x1.check.sh" \
1406
+ "bundled-repo-override-gitlab: a bundled override armed a merge poll"
1407
+ [ ! -s "$case_dir/glab.log" ] \
1408
+ || fail "bundled-repo-override-gitlab: glab was invoked despite the bundled override"
1409
+
1410
+ # Only a cluster carrying the repository flag is refused: every other short
1411
+ # cluster is still the caller's business and still reaches the forge.
1412
+ case_dir=$(make_case bundled-non-repo-cluster)
1413
+ mkdir -p "$case_dir/wt"
1414
+ add_gh_mocks "$case_dir" bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc
1415
+ : > "$case_dir/gh-axi.log"
1416
+
1417
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/8 -- -d \
1418
+ > "$case_dir/stdout" 2> "$case_dir/stderr" \
1419
+ || fail "bundled-non-repo-cluster: fm-pr-merge refused a short flag that overrides nothing"
1420
+
1421
+ grep -qxF 'pr merge 8 --repo example/repo --squash -d' "$case_dir/gh-axi.log" \
1422
+ || fail "bundled-non-repo-cluster: a short flag carrying no repository override was not forwarded"
1423
+ pass "fm-pr-merge refuses a bundled short-option repo override and forwards other short flags"
1424
+ }
1425
+
1426
+ test_explicit_merge_method_not_overridden() {
1427
+ local case_dir
1428
+ case_dir=$(make_case explicit-merge-method)
1429
+ mkdir -p "$case_dir/wt"
1430
+ add_gh_mocks "$case_dir" 5555555555555555555555555555555555555555
1431
+ : > "$case_dir/gh-axi.log"
1432
+
1433
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/22 -- --merge \
1434
+ > "$case_dir/stdout" 2> "$case_dir/stderr" || fail "explicit-merge-method: fm-pr-merge failed"
1435
+
1436
+ grep -qxF 'pr merge 22 --repo example/repo --merge' "$case_dir/gh-axi.log" \
1437
+ || fail "explicit-merge-method: caller --merge was not forwarded without an extra default --squash"
1438
+ pass "fm-pr-merge does not add default --squash when the caller passes an explicit merge method"
1439
+ }
1440
+
1441
+ test_method_equals_merge_method_not_overridden() {
1442
+ local case_dir
1443
+ case_dir=$(make_case method-equals-merge-method)
1444
+ mkdir -p "$case_dir/wt"
1445
+ add_gh_mocks "$case_dir" 7777777777777777777777777777777777777777
1446
+ : > "$case_dir/gh-axi.log"
1447
+
1448
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/23 -- --method=merge \
1449
+ > "$case_dir/stdout" 2> "$case_dir/stderr" || fail "method-equals-merge-method: fm-pr-merge failed"
1450
+
1451
+ grep -qxF 'pr merge 23 --repo example/repo --method=merge' "$case_dir/gh-axi.log" \
1452
+ || fail "method-equals-merge-method: caller --method=merge was not forwarded without an extra default --squash"
1453
+ pass "fm-pr-merge respects --method=<value> as an explicit merge method"
1454
+ }
1455
+
1456
+ test_parses_pr_url_for_gh_axi() {
1457
+ local case_dir
1458
+ case_dir=$(make_case url-parsing)
1459
+ mkdir -p "$case_dir/wt"
1460
+ add_gh_mocks "$case_dir" 6666666666666666666666666666666666666666
1461
+ : > "$case_dir/gh-axi.log"
1462
+
1463
+ run_pr_merge "$case_dir" task-x1 https://github.com/my-org/my-repo/pull/126 \
1464
+ > "$case_dir/stdout" 2> "$case_dir/stderr" || fail "url-parsing: fm-pr-merge failed"
1465
+
1466
+ grep -qxF 'pr merge 126 --repo my-org/my-repo --squash' "$case_dir/gh-axi.log" \
1467
+ || fail "url-parsing: gh-axi pr merge was not invoked as number + --repo + default --squash"
1468
+ pass "fm-pr-merge parses a GitHub PR URL into gh-axi number and --repo arguments"
1469
+ }
1470
+
1471
+ test_gitlab_url_resolves_and_merges() {
1472
+ local case_dir rc merge_line
1473
+ case_dir=$(make_gitlab_case gitlab-merges)
1474
+
1475
+ set +e
1476
+ run_pr_merge "$case_dir" task-x1 "$MR_URL" \
1477
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1478
+ rc=$?
1479
+ set -e
1480
+
1481
+ expect_code 0 "$rc" "gitlab-merges: a well-formed merge request URL should merge, not error"
1482
+ assert_grep "pr=$MR_URL" "$case_dir/state/task-x1.meta" \
1483
+ "gitlab-merges: pr= was not recorded before merging"
1484
+ assert_grep "GITLAB_HOST=$MR_HOST mr view 7 -R $MR_PROJECT_URL -F json" "$case_dir/glab.log" \
1485
+ "gitlab-merges: the pre-merge state was not read from the project URL"
1486
+ merge_line=$(glab_merge_line "$case_dir/glab.log")
1487
+ [ "$merge_line" = "GITLAB_HOST=$MR_HOST mr merge 7 -R $MR_PROJECT_URL --sha $MR_HEAD --yes" ] \
1488
+ || fail "gitlab-merges: unexpected merge invocation: '$merge_line'"
1489
+ assert_grep "successful pipeline at head $MR_HEAD" "$case_dir/stderr" \
1490
+ "gitlab-merges: the verified head was not reported"
1491
+ [ ! -s "$case_dir/gh-axi.log" ] || fail "gitlab-merges: a merge request reached the GitHub CLI"
1492
+ pass "fm-pr-merge merges a GitLab merge request through glab instead of refusing it"
1493
+ }
1494
+
1495
+ test_gitlab_host_comes_from_the_url() {
1496
+ local case_dir rc host path project_url url
1497
+ host=gl.self-hosted.example
1498
+ path=deep/nested/group/project
1499
+ project_url="https://$host/$path"
1500
+ url="$project_url/-/merge_requests/31"
1501
+ case_dir=$(make_gitlab_case gitlab-host-from-url)
1502
+
1503
+ set +e
1504
+ run_pr_merge "$case_dir" task-x1 "$url" \
1505
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1506
+ rc=$?
1507
+ set -e
1508
+
1509
+ expect_code 0 "$rc" "gitlab-host-from-url: a self-hosted merge request should merge"
1510
+ assert_grep "GITLAB_HOST=$host mr view 31 -R $project_url -F json" "$case_dir/glab.log" \
1511
+ "gitlab-host-from-url: the read did not use the host from the URL"
1512
+ assert_grep "GITLAB_HOST=$host mr merge 31 -R $project_url" "$case_dir/glab.log" \
1513
+ "gitlab-host-from-url: the merge did not use the host from the URL"
1514
+ assert_no_grep 'gitlab.com' "$case_dir/glab.log" \
1515
+ "gitlab-host-from-url: a host was assumed instead of taken from the URL"
1516
+ assert_no_grep '<unset>' "$case_dir/glab.log" \
1517
+ "gitlab-host-from-url: glab was left to resolve the instance from its own default"
1518
+ pass "fm-pr-merge takes the GitLab instance from the URL rather than assuming one"
1519
+ }
1520
+
1521
+ test_gitlab_imposes_no_merge_method() {
1522
+ local case_dir rc merge_line flag
1523
+ case_dir=$(make_gitlab_case gitlab-no-method)
1524
+
1525
+ set +e
1526
+ run_pr_merge "$case_dir" task-x1 "$MR_URL" \
1527
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1528
+ rc=$?
1529
+ set -e
1530
+
1531
+ expect_code 0 "$rc" "gitlab-no-method: merge should succeed"
1532
+ merge_line=$(glab_merge_line "$case_dir/glab.log")
1533
+ for flag in --squash --rebase --merge --method; do
1534
+ case "$merge_line" in
1535
+ *"$flag"*) fail "gitlab-no-method: '$flag' was imposed on GitLab: '$merge_line'" ;;
1536
+ esac
1537
+ done
1538
+ pass "fm-pr-merge imposes no merge method on GitLab, leaving the project's own one"
1539
+ }
1540
+
1541
+ test_gitlab_extra_args_forwarded() {
1542
+ local case_dir rc merge_line
1543
+ case_dir=$(make_gitlab_case gitlab-extra-args)
1544
+
1545
+ set +e
1546
+ run_pr_merge "$case_dir" task-x1 "$MR_URL" -- --remove-source-branch \
1547
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1548
+ rc=$?
1549
+ set -e
1550
+
1551
+ expect_code 0 "$rc" "gitlab-extra-args: merge should succeed"
1552
+ merge_line=$(glab_merge_line "$case_dir/glab.log")
1553
+ [ "$merge_line" = "GITLAB_HOST=$MR_HOST mr merge 7 -R $MR_PROJECT_URL --sha $MR_HEAD --yes --remove-source-branch" ] \
1554
+ || fail "gitlab-extra-args: extra glab flags were not forwarded: '$merge_line'"
1555
+ pass "fm-pr-merge forwards extra flags to glab mr merge after the -- separator"
1556
+ }
1557
+
1558
+ test_gitlab_merge_failure_propagates() {
1559
+ local case_dir rc
1560
+ case_dir=$(make_gitlab_case gitlab-merge-fails)
1561
+ : > "$case_dir/glab-merge-fails"
1562
+
1563
+ set +e
1564
+ run_pr_merge "$case_dir" task-x1 "$MR_URL" \
1565
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1566
+ rc=$?
1567
+ set -e
1568
+
1569
+ expect_code 1 "$rc" "gitlab-merge-fails: a failing glab merge should not report success"
1570
+ assert_grep "pr=$MR_URL" "$case_dir/state/task-x1.meta" \
1571
+ "gitlab-merge-fails: pr= should already be recorded even though the merge failed"
1572
+ pass "fm-pr-merge propagates a real glab merge failure without silently succeeding"
1573
+ }
1574
+
1575
+ # Each pre-merge condition, driven one at a time, so no condition can be
1576
+ # carried by another. The refusal names that condition, no merge is attempted,
1577
+ # and pr= is still recorded and the poll still armed exactly as the GitHub path
1578
+ # leaves them when gh-axi itself fails.
1579
+ test_gitlab_each_condition_refuses_independently() {
1580
+ local case_dir rc name expected spec
1581
+ set -- \
1582
+ "state|state=closed|state is \"closed\", not open" \
1583
+ "detail|detail=need_rebase|detailed_merge_status is \"need_rebase\", not mergeable" \
1584
+ "conflicts|conflicts=true|has_conflicts is \"true\", not false" \
1585
+ "discussions|discussions=false|blocking_discussions_resolved is \"false\", not true" \
1586
+ "pipeline-status|pipeline_status=failed|the head pipeline status is \"failed\", not success" \
1587
+ "pipeline-sha|pipeline_sha=$MR_STALE_HEAD|the head pipeline ran at \"$MR_STALE_HEAD\", not at the current head $MR_HEAD" \
1588
+ "no-pipeline|pipeline=null|the head pipeline status is \"none\", not success"
1589
+ for spec in "$@"; do
1590
+ name=${spec%%|*}
1591
+ expected=${spec##*|}
1592
+ spec=${spec#*|}
1593
+ case_dir=$(make_gitlab_case "gitlab-refuse-$name" "${spec%%|*}")
1594
+
1595
+ set +e
1596
+ run_pr_merge "$case_dir" task-x1 "$MR_URL" \
1597
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1598
+ rc=$?
1599
+ set -e
1600
+
1601
+ expect_code 1 "$rc" "gitlab-refuse-$name: fm-pr-merge should refuse"
1602
+ assert_grep "error: refusing to merge $MR_URL" "$case_dir/stderr" \
1603
+ "gitlab-refuse-$name: refusal did not name the merge request"
1604
+ assert_grep "$expected" "$case_dir/stderr" \
1605
+ "gitlab-refuse-$name: refusal did not name the failing condition"
1606
+ [ -z "$(glab_merge_line "$case_dir/glab.log")" ] \
1607
+ || fail "gitlab-refuse-$name: a merge was attempted despite the refusal"
1608
+ assert_grep "pr=$MR_URL" "$case_dir/state/task-x1.meta" \
1609
+ "gitlab-refuse-$name: a refusal should still leave the recorded PR reference"
1610
+ assert_present "$case_dir/state/task-x1.check.sh" \
1611
+ "gitlab-refuse-$name: a refusal should still leave the merge poll armed"
1612
+ done
1613
+ pass "fm-pr-merge refuses on each GitLab pre-merge condition independently"
1614
+ }
1615
+
1616
+ test_gitlab_reports_every_failing_condition() {
1617
+ local case_dir rc expected
1618
+ case_dir=$(make_gitlab_case gitlab-refuse-all \
1619
+ state=closed detail=conflict conflicts=true discussions=false \
1620
+ pipeline_status=failed "pipeline_sha=$MR_STALE_HEAD")
1621
+
1622
+ set +e
1623
+ run_pr_merge "$case_dir" task-x1 "$MR_URL" \
1624
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1625
+ rc=$?
1626
+ set -e
1627
+
1628
+ expect_code 1 "$rc" "gitlab-refuse-all: fm-pr-merge should refuse"
1629
+ for expected in \
1630
+ 'state is "closed", not open' \
1631
+ 'detailed_merge_status is "conflict", not mergeable' \
1632
+ 'has_conflicts is "true", not false' \
1633
+ 'blocking_discussions_resolved is "false", not true' \
1634
+ 'the head pipeline status is "failed", not success' \
1635
+ "the head pipeline ran at \"$MR_STALE_HEAD\", not at the current head $MR_HEAD"
1636
+ do
1637
+ assert_grep "$expected" "$case_dir/stderr" \
1638
+ "gitlab-refuse-all: '$expected' was not reported"
1639
+ done
1640
+ pass "fm-pr-merge reports every failing GitLab condition, not only the first"
1641
+ }
1642
+
1643
+ test_gitlab_stale_recorded_head_is_reported() {
1644
+ local case_dir rc merge_line
1645
+ case_dir=$(make_gitlab_case gitlab-stale-head)
1646
+ # The recorded head is what a rebase leaves behind. It is read before
1647
+ # fm-pr-check.sh rewrites the metadata, which drops a head it cannot resolve
1648
+ # for a GitLab task, so reading it afterwards would find nothing at all.
1649
+ printf 'pr_head=%s\n' "$MR_STALE_HEAD" >> "$case_dir/state/task-x1.meta"
1650
+
1651
+ set +e
1652
+ run_pr_merge "$case_dir" task-x1 "$MR_URL" \
1653
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1654
+ rc=$?
1655
+ set -e
1656
+
1657
+ expect_code 0 "$rc" "gitlab-stale-head: the live head satisfies every condition, so it should merge"
1658
+ assert_grep "recorded head $MR_STALE_HEAD disagrees with the live head $MR_HEAD" \
1659
+ "$case_dir/stderr" "gitlab-stale-head: the stale recorded head was trusted silently"
1660
+ merge_line=$(glab_merge_line "$case_dir/glab.log")
1661
+ case "$merge_line" in
1662
+ *"--sha $MR_HEAD"*) : ;;
1663
+ *) fail "gitlab-stale-head: the merge was not bound to the live head: '$merge_line'" ;;
1664
+ esac
1665
+ assert_no_grep "pr_head=$MR_STALE_HEAD" "$case_dir/state/task-x1.meta" \
1666
+ "gitlab-stale-head: the recording step no longer drops an unresolvable GitLab head"
1667
+ pass "fm-pr-merge reports a stale recorded head and verifies the live one"
1668
+ }
1669
+
1670
+ test_gitlab_unreadable_state_refuses() {
1671
+ local case_dir rc name
1672
+ for name in view-fails not-an-object split-value; do
1673
+ case_dir=$(make_gitlab_case "gitlab-unreadable-$name")
1674
+ case "$name" in
1675
+ view-fails) : > "$case_dir/glab-view-fails" ;;
1676
+ not-an-object) printf '[]\n' > "$case_dir/mr.json" ;;
1677
+ # A value carrying a newline splits into a line no field name matches, so
1678
+ # it must refuse rather than be truncated into a value a check accepts.
1679
+ split-value) write_mr_json "$case_dir/mr.json" 'state=opened\nnot-a-field' ;;
1680
+ esac
1681
+
1682
+ set +e
1683
+ run_pr_merge "$case_dir" task-x1 "$MR_URL" \
1684
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1685
+ rc=$?
1686
+ set -e
1687
+
1688
+ expect_code 1 "$rc" "gitlab-unreadable-$name: fm-pr-merge should refuse"
1689
+ assert_grep 'could not read the GitLab merge request state before merging' \
1690
+ "$case_dir/stderr" "gitlab-unreadable-$name: refusal did not name the unreadable state"
1691
+ [ -z "$(glab_merge_line "$case_dir/glab.log")" ] \
1692
+ || fail "gitlab-unreadable-$name: a merge was attempted on an unreadable state"
1693
+ done
1694
+ pass "fm-pr-merge refuses an unreadable GitLab merge request state rather than merging blind"
1695
+ }
1696
+
1697
+ test_gitlab_invalid_head_refuses() {
1698
+ local case_dir rc
1699
+ case_dir=$(make_gitlab_case gitlab-invalid-head head=not-a-sha)
1700
+
1701
+ set +e
1702
+ run_pr_merge "$case_dir" task-x1 "$MR_URL" \
1703
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1704
+ rc=$?
1705
+ set -e
1706
+
1707
+ expect_code 1 "$rc" "gitlab-invalid-head: fm-pr-merge should refuse"
1708
+ assert_grep 'could not read the GitLab merge request head commit before merging' \
1709
+ "$case_dir/stderr" "gitlab-invalid-head: refusal did not name the unreadable head"
1710
+ [ -z "$(glab_merge_line "$case_dir/glab.log")" ] \
1711
+ || fail "gitlab-invalid-head: a merge was bound to a head that is not a commit"
1712
+ pass "fm-pr-merge refuses a GitLab head commit it cannot validate"
1713
+ }
1714
+
1715
+ test_gitlab_missing_tool_refuses_before_recording() {
1716
+ local case_dir rc tool other
1717
+ for tool in glab jq; do
1718
+ if [ "$tool" = glab ]; then other=jq; else other=glab; fi
1719
+ case_dir=$(make_gitlab_case "gitlab-no-$tool")
1720
+ mirror_path_without "$case_dir/no$tool" "$tool" "$case_dir/fakebin"
1721
+ # One tool absent, the other still answered by this case's own mock, so the
1722
+ # refusal names exactly one tool on a host that ships neither.
1723
+ PATH="$case_dir/no$tool" command -v "$other" >/dev/null 2>&1 \
1724
+ || fail "gitlab-no-$tool: the $tool-free search path lost the $other mock as well"
1725
+
1726
+ set +e
1727
+ FM_ROOT_OVERRIDE="$ROOT" \
1728
+ FM_STATE_OVERRIDE="$case_dir/state" \
1729
+ FM_TEST_GH_AXI_LOG="$case_dir/gh-axi.log" \
1730
+ FM_TEST_GLAB_LOG="$case_dir/glab.log" \
1731
+ FM_TEST_GLAB_JSON="$case_dir/mr.json" \
1732
+ PATH="$case_dir/no$tool" \
1733
+ "$PR_MERGE" task-x1 "$MR_URL" > "$case_dir/stdout" 2> "$case_dir/stderr"
1734
+ rc=$?
1735
+ set -e
1736
+
1737
+ expect_code 1 "$rc" "gitlab-no-$tool: fm-pr-merge should refuse"
1738
+ assert_grep "error: merging a GitLab merge request requires $tool on PATH" \
1739
+ "$case_dir/stderr" "gitlab-no-$tool: refusal did not name the missing tool"
1740
+ assert_no_grep "pr=$MR_URL" "$case_dir/state/task-x1.meta" \
1741
+ "gitlab-no-$tool: a PR reference was recorded despite the missing tool"
1742
+ assert_absent "$case_dir/state/task-x1.check.sh" \
1743
+ "gitlab-no-$tool: a merge poll was armed despite the missing tool"
1744
+ done
1745
+ pass "fm-pr-merge refuses before recording anything when glab or jq is absent"
1746
+ }
1747
+
1748
+ test_gitlab_head_override_args_refuse_before_recording() {
1749
+ local case_dir rc
1750
+ case_dir=$(make_gitlab_case gitlab-head-override)
1751
+
1752
+ set +e
1753
+ run_pr_merge "$case_dir" task-x1 "$MR_URL" -- --sha "$MR_STALE_HEAD" \
1754
+ > "$case_dir/stdout" 2> "$case_dir/stderr"
1755
+ rc=$?
1756
+ set -e
1757
+
1758
+ expect_code 1 "$rc" "gitlab-head-override: fm-pr-merge should refuse a caller head override"
1759
+ assert_grep 'extra merge arguments must not override the head commit' "$case_dir/stderr" \
1760
+ "gitlab-head-override: refusal did not explain the head override"
1761
+ assert_no_grep "pr=$MR_URL" "$case_dir/state/task-x1.meta" \
1762
+ "gitlab-head-override: the URL was recorded before rejecting the head override"
1763
+ assert_absent "$case_dir/state/task-x1.check.sh" \
1764
+ "gitlab-head-override: a head override armed a merge poll"
1765
+ [ ! -s "$case_dir/glab.log" ] || fail "gitlab-head-override: glab was invoked despite the head override"
1766
+ pass "fm-pr-merge refuses a GitLab head override before recording state"
1767
+ }
1768
+
1769
+ test_github_still_forwards_sha_arg() {
1770
+ local case_dir
1771
+ case_dir=$(make_case github-sha-arg)
1772
+ mkdir -p "$case_dir/wt"
1773
+ add_gh_mocks "$case_dir" dddddddddddddddddddddddddddddddddddddddd
1774
+ : > "$case_dir/gh-axi.log"
1775
+
1776
+ # --sha is rejected only where the head is firstmate's to determine. GitHub's
1777
+ # extra args are the caller's business exactly as they were.
1778
+ run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/44 -- --sha abc123 \
1779
+ > "$case_dir/stdout" 2> "$case_dir/stderr" || fail "github-sha-arg: fm-pr-merge failed"
1780
+
1781
+ grep -qxF 'pr merge 44 --repo example/repo --squash --sha abc123' "$case_dir/gh-axi.log" \
1782
+ || fail "github-sha-arg: the GitHub path stopped forwarding a caller --sha"
1783
+ pass "fm-pr-merge leaves GitHub extra-arg handling unchanged, including --sha"
1784
+ }
1785
+
1786
+ # --- durable merge outcome ---------------------------------------------------
1787
+ # A merge that lands must leave a record outside the merging agent's memory.
1788
+ # bin/fm-merge-outcome-lib.sh owns where that record goes; these cases pin the
1789
+ # behavior through the real merge entrypoint.
1790
+
1791
+ # make_home_case <name> [<route> [<parent-home>]]: a case dir whose home is a
1792
+ # secondmate home bound to a parent, or a plain main home when no route is
1793
+ # given. Echoes the case dir; the home is "$case_dir/home".
1794
+ make_home_case() {
1795
+ local name=$1 route=${2:-} parent=${3:-} case_dir home
1796
+ case_dir=$(make_case "$name")
1797
+ home="$case_dir/home"
1798
+ mkdir -p "$home" "$case_dir/wt"
1799
+ if [ -n "$route" ]; then
1800
+ printf '%s\n' mate-x >"$home/.fm-secondmate-home"
1801
+ {
1802
+ printf 'schema=fm-secondmate-parent.v1\n'
1803
+ printf 'route=%s\n' "$route"
1804
+ [ "$route" != local ] || printf 'parent_home=%s\n' "$parent"
1805
+ } >"$home/.fm-secondmate-parent"
1806
+ fi
1807
+ printf '%s\n' "$case_dir"
1808
+ }
1809
+
1810
+ parent_reply_lines() { # <file> <url>
1811
+ grep -c -F "$2" "$1" 2>/dev/null || true
1812
+ }
1813
+
1814
+ test_secondmate_merge_reports_upward_once() {
1815
+ local case_dir replies url
1816
+ url=https://github.com/example/repo/pull/61
1817
+ case_dir=$(make_home_case secondmate-merge-reports remote)
1818
+ add_gh_mocks "$case_dir" 5555555555555555555555555555555555555555
1819
+ : >"$case_dir/gh-axi.log"
1820
+ replies="$case_dir/state/parent-replies.status"
1821
+
1822
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \
1823
+ >"$case_dir/stdout" 2>"$case_dir/stderr" || fail "secondmate-merge-reports: merge failed"
1824
+
1825
+ assert_grep "done [key=merged-task-x1]: merged task-x1 $url" "$replies" \
1826
+ "secondmate-merge-reports: the landed PR was not reported upward"
1827
+ [ "$(wc -l <"$replies")" -eq 1 ] \
1828
+ || fail "secondmate-merge-reports: one merge produced more than one upward line"
1829
+
1830
+ # The same merge again: the forge accepts it in this fixture, so only the
1831
+ # at-most-once contract can keep the parent from being told twice.
1832
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \
1833
+ >"$case_dir/stdout2" 2>"$case_dir/stderr2" || fail "secondmate-merge-reports: repeat merge failed"
1834
+ [ "$(parent_reply_lines "$replies" "$url")" -eq 1 ] \
1835
+ || fail "secondmate-merge-reports: a repeat merge of the same PR duplicated the upward line"
1836
+ pass "a merge a secondmate home performs itself is reported upward exactly once"
1837
+ }
1838
+
1839
+ test_secondmate_merge_reports_on_the_local_route() {
1840
+ local case_dir parent_status url
1841
+ url=https://github.com/example/repo/pull/62
1842
+ case_dir=$(make_home_case secondmate-merge-local local "$TMP_ROOT/secondmate-merge-local/parent")
1843
+ mkdir -p "$TMP_ROOT/secondmate-merge-local/parent/state"
1844
+ add_gh_mocks "$case_dir" 6666666666666666666666666666666666666666
1845
+ : >"$case_dir/gh-axi.log"
1846
+ parent_status="$TMP_ROOT/secondmate-merge-local/parent/state/mate-x.status"
1847
+
1848
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \
1849
+ >"$case_dir/stdout" 2>"$case_dir/stderr" || fail "secondmate-merge-local: merge failed"
1850
+
1851
+ assert_grep "done [key=merged-task-x1]: merged task-x1 $url" "$parent_status" \
1852
+ "secondmate-merge-local: the landed PR did not reach the parent home's channel"
1853
+ [ ! -e "$case_dir/state/parent-replies.status" ] \
1854
+ || fail "secondmate-merge-local: a local-route report also wrote the remote reply channel"
1855
+ pass "a locally routed secondmate home reports the landed PR into its parent's own channel"
1856
+ }
1857
+
1858
+ test_failed_merge_reports_nothing() {
1859
+ local case_dir rc
1860
+ case_dir=$(make_home_case failed-merge-silent remote)
1861
+ add_gh_mocks_merge_fails "$case_dir"
1862
+ : >"$case_dir/gh-axi.log"
1863
+
1864
+ set +e
1865
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/63 \
1866
+ >"$case_dir/stdout" 2>"$case_dir/stderr"
1867
+ rc=$?
1868
+ set -e
1869
+
1870
+ expect_code 1 "$rc" "failed-merge-silent: a failed merge should propagate"
1871
+ assert_absent "$case_dir/state/parent-replies.status" \
1872
+ "failed-merge-silent: a merge that never landed was reported as landed"
1873
+ pass "a refused or failed merge reports no outcome"
1874
+ }
1875
+
1876
+ test_gitlab_refusal_reports_nothing() {
1877
+ local case_dir rc
1878
+ case_dir=$(make_gitlab_case gitlab-refusal-silent state=merged)
1879
+ mkdir -p "$case_dir/home"
1880
+ printf '%s\n' mate-x >"$case_dir/home/.fm-secondmate-home"
1881
+ printf 'schema=fm-secondmate-parent.v1\nroute=remote\n' >"$case_dir/home/.fm-secondmate-parent"
1882
+
1883
+ set +e
1884
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$MR_URL" \
1885
+ >"$case_dir/stdout" 2>"$case_dir/stderr"
1886
+ rc=$?
1887
+ set -e
1888
+
1889
+ expect_code 1 "$rc" "gitlab-refusal-silent: a refused GitLab merge should exit non-zero"
1890
+ assert_absent "$case_dir/state/parent-replies.status" \
1891
+ "gitlab-refusal-silent: a refused merge request was reported as landed"
1892
+ pass "a GitLab merge refused before the forge call reports no outcome"
1893
+ }
1894
+
1895
+ test_gitlab_merge_reports_upward() {
1896
+ local case_dir url
1897
+ case_dir=$(make_gitlab_case gitlab-merge-reports)
1898
+ mkdir -p "$case_dir/home"
1899
+ printf '%s\n' mate-x >"$case_dir/home/.fm-secondmate-home"
1900
+ printf 'schema=fm-secondmate-parent.v1\nroute=remote\n' >"$case_dir/home/.fm-secondmate-parent"
1901
+ url=$MR_URL
1902
+
1903
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \
1904
+ >"$case_dir/stdout" 2>"$case_dir/stderr" || fail "gitlab-merge-reports: merge failed"
1905
+
1906
+ assert_grep "done [key=merged-task-x1]: merged task-x1 $url" \
1907
+ "$case_dir/state/parent-replies.status" \
1908
+ "gitlab-merge-reports: a landed merge request was not reported upward"
1909
+ pass "a landed GitLab merge request is reported upward on the same channel"
1910
+ }
1911
+
1912
+ test_queued_gitlab_merge_leaves_the_poll_armed() {
1913
+ local case_dir
1914
+ case_dir=$(make_gitlab_case queued-gitlab-merge)
1915
+ mkdir -p "$case_dir/home"
1916
+ : >"$case_dir/glab-stays-open"
1917
+
1918
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$MR_URL" \
1919
+ >"$case_dir/stdout" 2>"$case_dir/stderr" \
1920
+ || fail "queued-gitlab-merge: accepted merge command failed"
1921
+
1922
+ assert_absent "$case_dir/state/.wake-queue" \
1923
+ "queued-gitlab-merge: a queued merge was reported as landed"
1924
+ [ -f "$case_dir/state/task-x1.check.sh" ] \
1925
+ || fail "queued-gitlab-merge: the merge poll was not left armed"
1926
+ [ ! -e "$case_dir/state/task-x1.pr-poll-merge-notified" ] \
1927
+ || fail "queued-gitlab-merge: a queued merge was marked as reported"
1928
+ pass "a queued GitLab merge stays silent and leaves confirmation to the armed poll"
1929
+ }
1930
+
1931
+ test_main_home_merge_leaves_a_durable_wake() {
1932
+ local case_dir url
1933
+ url=https://github.com/example/repo/pull/64
1934
+ case_dir=$(make_home_case main-merge-wake)
1935
+ add_gh_mocks "$case_dir" 7777777777777777777777777777777777777777
1936
+ : >"$case_dir/gh-axi.log"
1937
+
1938
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \
1939
+ >"$case_dir/stdout" 2>"$case_dir/stderr" || fail "main-merge-wake: merge failed"
1940
+
1941
+ assert_grep "$url" "$case_dir/state/.wake-queue" \
1942
+ "main-merge-wake: a merge this home performed left no durable record naming the PR"
1943
+ [ "$(grep -c -F "$url" "$case_dir/state/.wake-queue")" -eq 1 ] \
1944
+ || fail "main-merge-wake: one merge produced more than one durable record"
1945
+ assert_absent "$case_dir/state/parent-replies.status" \
1946
+ "main-merge-wake: a main home wrote a parent reply channel it does not have"
1947
+ pass "a merge a main home performs itself leaves one durable wake naming the PR"
1948
+ }
1949
+
1950
+ test_queued_github_merge_leaves_the_poll_armed() {
1951
+ local case_dir url
1952
+ url=https://github.com/example/repo/pull/66
1953
+ case_dir=$(make_home_case queued-github-merge)
1954
+ add_gh_mocks "$case_dir" 9999999999999999999999999999999999999999
1955
+ write_github_outcome "$case_dir" OPEN false true main
1956
+ : >"$case_dir/gh-axi.log"
1957
+
1958
+ FM_TEST_GH_MERGE_STATE=open FM_TEST_HOME="$case_dir/home" \
1959
+ run_pr_merge "$case_dir" task-x1 "$url" \
1960
+ >"$case_dir/stdout" 2>"$case_dir/stderr" \
1961
+ || fail "queued-github-merge: accepted merge command failed"
1962
+
1963
+ assert_absent "$case_dir/state/.wake-queue" \
1964
+ "queued-github-merge: a queued merge was reported as landed"
1965
+ [ -f "$case_dir/state/task-x1.check.sh" ] \
1966
+ || fail "queued-github-merge: the merge poll was not left armed"
1967
+ [ ! -e "$case_dir/state/task-x1.pr-poll-merge-notified" ] \
1968
+ || fail "queued-github-merge: a queued merge was marked as reported"
1969
+ pass "a queued GitHub merge stays silent and leaves confirmation to the armed poll"
1970
+ }
1971
+
1972
+ test_distinct_merged_prs_keep_distinct_wakes() {
1973
+ local case_dir first_url second_url
1974
+ first_url=https://github.com/example/repo/pull/68
1975
+ second_url=https://github.com/example/repo/pull/69
1976
+ case_dir=$(make_home_case distinct-merge-wakes)
1977
+ add_gh_mocks "$case_dir" bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
1978
+ : >"$case_dir/gh-axi.log"
1979
+
1980
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$first_url" \
1981
+ >"$case_dir/stdout-1" 2>"$case_dir/stderr-1" \
1982
+ || fail "distinct-merge-wakes: first merge failed"
1983
+ rm -f "$case_dir/state/task-x1.check.sh" \
1984
+ "$case_dir/state/task-x1.pr-poll" \
1985
+ "$case_dir/state/task-x1.pr-poll-registration"
1986
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$second_url" \
1987
+ >"$case_dir/stdout-2" 2>"$case_dir/stderr-2" \
1988
+ || fail "distinct-merge-wakes: second merge failed"
1989
+
1990
+ [ "$(grep -c -F "$first_url" "$case_dir/state/.wake-queue")" -eq 1 ] \
1991
+ || fail "distinct-merge-wakes: first merge wake was missing or duplicated"
1992
+ [ "$(grep -c -F "$second_url" "$case_dir/state/.wake-queue")" -eq 1 ] \
1993
+ || fail "distinct-merge-wakes: second merge wake was missing or duplicated"
1994
+ FM_STATE_OVERRIDE="$case_dir/state" "$ROOT/bin/fm-wake-drain.sh" \
1995
+ >"$case_dir/drain.out" 2>"$case_dir/drain.err" \
1996
+ || fail "distinct-merge-wakes: wake drain failed"
1997
+ assert_grep "$first_url" "$case_dir/drain.out" \
1998
+ "distinct-merge-wakes: queue deduplication collapsed the first PR"
1999
+ assert_grep "$second_url" "$case_dir/drain.out" \
2000
+ "distinct-merge-wakes: queue deduplication collapsed the second PR"
2001
+ pass "distinct merged PRs for one task retain distinct captain-facing wakes"
2002
+ }
2003
+
2004
+ test_uncommitted_marker_retry_is_never_silent() {
2005
+ local case_dir url count
2006
+ url=https://github.com/example/repo/pull/67
2007
+ case_dir=$(make_home_case uncommitted-wake-retry)
2008
+ add_gh_mocks "$case_dir" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
2009
+ : >"$case_dir/gh-axi.log"
2010
+ cat >"$case_dir/fakebin/mv" <<'SH'
2011
+ #!/usr/bin/env bash
2012
+ case "${!#}" in
2013
+ *.pr-poll-merge-notified)
2014
+ if mkdir "$FM_TEST_MARKER_FAILURE.claim" 2>/dev/null; then
2015
+ exit 1
2016
+ fi
2017
+ ;;
2018
+ esac
2019
+ exec "$FM_TEST_REAL_MV" "$@"
2020
+ SH
2021
+ chmod +x "$case_dir/fakebin/mv"
2022
+ export FM_TEST_MARKER_FAILURE="$case_dir/marker-failure"
2023
+ export FM_TEST_REAL_MV
2024
+ FM_TEST_REAL_MV=$(command -v mv)
2025
+
2026
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \
2027
+ >"$case_dir/stdout-1" 2>"$case_dir/stderr-1" \
2028
+ || fail "uncommitted-wake-retry: landed merge was reported as failed"
2029
+ assert_grep 'could not record the outcome' "$case_dir/stderr-1" \
2030
+ "uncommitted-wake-retry: failed marker commit was not loud"
2031
+ [ -f "$case_dir/state/task-x1.check.sh" ] \
2032
+ || fail "uncommitted-wake-retry: failed commit disarmed the retry poll"
2033
+ count=$(grep -c -F "$url" "$case_dir/state/.wake-queue")
2034
+ [ "$count" -ge 1 ] \
2035
+ || fail "uncommitted-wake-retry: failed marker commit lost the durable outcome"
2036
+ [ ! -e "$case_dir/state/task-x1.pr-poll-merge-notified" ] \
2037
+ || fail "uncommitted-wake-retry: failed marker commit was treated as complete"
2038
+
2039
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \
2040
+ >"$case_dir/stdout-2" 2>"$case_dir/stderr-2" \
2041
+ || fail "uncommitted-wake-retry: retry failed"
2042
+ unset FM_TEST_MARKER_FAILURE FM_TEST_REAL_MV
2043
+ count=$(grep -c -F "$url" "$case_dir/state/.wake-queue")
2044
+ [ "$count" -ge 1 ] \
2045
+ || fail "uncommitted-wake-retry: retry left the merge silent"
2046
+ [ -f "$case_dir/state/task-x1.pr-poll-merge-notified" ] \
2047
+ || fail "uncommitted-wake-retry: retry did not commit the canonical marker"
2048
+ pass "an uncommitted marker retry preserves at least one durable outcome"
2049
+ }
2050
+
2051
+ test_secondmate_without_parent_binding_is_loud() {
2052
+ local case_dir rc url
2053
+ url=https://github.com/example/repo/pull/65
2054
+ case_dir=$(make_home_case unbound-secondmate)
2055
+ add_gh_mocks "$case_dir" 8888888888888888888888888888888888888888
2056
+ : >"$case_dir/gh-axi.log"
2057
+ # A secondmate identity with no parent binding: exactly the seeding gap that
2058
+ # let three real merges land in silence.
2059
+ printf '%s\n' mate-x >"$case_dir/home/.fm-secondmate-home"
2060
+
2061
+ set +e
2062
+ FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \
2063
+ >"$case_dir/stdout" 2>"$case_dir/stderr"
2064
+ rc=$?
2065
+ set -e
2066
+
2067
+ expect_code 0 "$rc" "unbound-secondmate: the merge itself landed and must not be reported as failed"
2068
+ assert_grep 'could not report it upward' "$case_dir/stderr" \
2069
+ "unbound-secondmate: a merge that could not be reported upward said nothing about it"
2070
+ assert_absent "$case_dir/state/.wake-queue" \
2071
+ "unbound-secondmate: a secondmate home fell back to the main-home record"
2072
+ pass "a secondmate home that cannot report upward says so instead of merging in silence"
2073
+ }
2074
+
2075
+ test_github_zero_exit_queue_required_refuses_with_exact_retry
2076
+ test_github_closed_unqueued_outcome_omits_retry_flags
2077
+ test_github_agreeing_queue_rules_keep_retry_guidance
2078
+ test_github_conflicting_queue_rules_report_ambiguity
2079
+ test_verified_merge_records_pr_and_head
2080
+ test_pr_metadata_is_recorded_before_the_forge_call
2081
+ test_merge_failure_propagates_after_recording
2082
+ test_github_open_unqueued_outcome_refuses
2083
+ test_github_unreadable_outcome_keeps_pr_bookkeeping
2084
+ test_github_refusal_quotes_the_forge_output
2085
+ test_github_unreadable_outcome_refusal_quotes_the_forge_output
2086
+ test_github_accepted_queue_flags_do_not_echo_back_the_same_command
2087
+ test_github_mismatched_queue_flags_still_name_the_retry
2088
+ test_github_unrecognised_queue_method_still_names_the_queue
2089
+ test_github_unreadable_queue_rules_are_not_reported_as_no_queue
2090
+ test_github_no_queue_rule_says_nothing_about_a_queue
2091
+ test_github_fallback_view_refusal_says_the_queue_was_unobservable
2092
+ test_github_auto_merge_without_queue_refuses_legibly
2093
+ test_github_failed_merge_never_claims_armed_auto_merge
2094
+ test_github_failed_merge_with_queue_flags_never_claims_acceptance
2095
+ test_github_failed_gh_read_falls_back_to_gh_axi
2096
+ test_github_failed_merge_names_an_observed_landed_state
2097
+ test_github_without_gh_still_uses_gh_axi_merge
2098
+ test_github_without_gh_failed_read_keeps_bookkeeping
2099
+ test_github_merged_outcome_is_verified
2100
+ test_github_verified_merge_requires_poll_recording
2101
+ test_github_queued_outcome_is_verified
2102
+ test_github_queue_required_refusal_names_retry_flags
2103
+ test_extra_merge_args_forwarded
2104
+ test_missing_meta_refuses_before_merge
2105
+ test_malformed_url_refuses_before_merge
2106
+ test_rejects_unsafe_url_segments_before_recording
2107
+ test_repo_override_args_refuse_before_recording
2108
+ test_bundled_repo_override_args_refuse_before_recording
2109
+ test_explicit_merge_method_not_overridden
2110
+ test_method_equals_merge_method_not_overridden
2111
+ test_parses_pr_url_for_gh_axi
2112
+ test_github_still_forwards_sha_arg
2113
+ test_gitlab_url_resolves_and_merges
2114
+ test_gitlab_host_comes_from_the_url
2115
+ test_gitlab_imposes_no_merge_method
2116
+ test_gitlab_extra_args_forwarded
2117
+ test_gitlab_merge_failure_propagates
2118
+ test_gitlab_each_condition_refuses_independently
2119
+ test_gitlab_reports_every_failing_condition
2120
+ test_gitlab_stale_recorded_head_is_reported
2121
+ test_gitlab_unreadable_state_refuses
2122
+ test_gitlab_invalid_head_refuses
2123
+ test_gitlab_missing_tool_refuses_before_recording
2124
+ test_gitlab_head_override_args_refuse_before_recording
2125
+ test_secondmate_merge_reports_upward_once
2126
+ test_secondmate_merge_reports_on_the_local_route
2127
+ test_gitlab_merge_reports_upward
2128
+ test_queued_gitlab_merge_leaves_the_poll_armed
2129
+ test_failed_merge_reports_nothing
2130
+ test_gitlab_refusal_reports_nothing
2131
+ test_main_home_merge_leaves_a_durable_wake
2132
+ test_queued_github_merge_leaves_the_poll_armed
2133
+ test_distinct_merged_prs_keep_distinct_wakes
2134
+ test_uncommitted_marker_retry_is_never_silent
2135
+ test_secondmate_without_parent_binding_is_loud