@zerwiz/ymir 0.1.4 → 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 -59
  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,2140 @@
1
+ #!/usr/bin/env bash
2
+ # Security and regression tests for canonical PR parsing, static merge polls,
3
+ # private atomic artifacts, authenticated custom checks, and teardown cleanup.
4
+ set -u
5
+
6
+ # shellcheck source=tests/lib.sh disable=SC1091
7
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
8
+ # shellcheck source=/dev/null
9
+ . "$ROOT/bin/fm-pr-lib.sh"
10
+ # shellcheck source=/dev/null
11
+ . "$ROOT/bin/fm-check-lib.sh"
12
+
13
+ PR_CHECK="$ROOT/bin/fm-pr-check.sh"
14
+ PR_MERGE="$ROOT/bin/fm-pr-merge.sh"
15
+ POLL="$ROOT/bin/fm-pr-poll.sh"
16
+ WATCH="$ROOT/bin/fm-watch.sh"
17
+ TEARDOWN="$ROOT/bin/fm-teardown.sh"
18
+ REGISTER="$ROOT/bin/fm-check-register.sh"
19
+ TMP_ROOT=$(fm_test_tmproot fm-pr-check-security)
20
+ BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin}
21
+ REAL_CP=$(command -v cp)
22
+ REAL_MV=$(command -v mv)
23
+ REAL_STAT=$(command -v stat)
24
+ REAL_CHMOD=$(command -v chmod)
25
+ # The merge path reads a merge request's JSON with the real jq, and BASE_PATH is
26
+ # deliberately restricted, so a case that needs jq exposes this one rather than
27
+ # depending on the host keeping jq in one of those four directories.
28
+ REAL_JQ=$(command -v jq) || fail "these tests read glab's JSON with the real jq, which was not found"
29
+
30
+ ack_watcher_cycle() { # <state>
31
+ local state=$1 err sequence generation
32
+ err="$state/.test-wake-drain.err"
33
+ FM_STATE_OVERRIDE="$state" "$ROOT/bin/fm-wake-drain.sh" >/dev/null 2> "$err" || return 1
34
+ sequence=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through \([0-9][0-9]*\) --recovery-generation [A-Za-z0-9._-][A-Za-z0-9._-]*$/\1/p' "$err")
35
+ generation=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through [0-9][0-9]* --recovery-generation \([A-Za-z0-9._-][A-Za-z0-9._-]*\)$/\1/p' "$err")
36
+ rm -f "$err"
37
+ [ -n "$sequence" ] && [ -n "$generation" ] || return 1
38
+ FM_STATE_OVERRIDE="$state" "$ROOT/bin/fm-wake-drain.sh" --ack-through "$sequence" \
39
+ --recovery-generation "$generation"
40
+ }
41
+
42
+ file_mode() {
43
+ if [ "$(uname)" = Darwin ]; then
44
+ stat -f %Lp "$1"
45
+ else
46
+ stat -c %a "$1"
47
+ fi
48
+ }
49
+
50
+ LINK_KIND=
51
+ LINK_TARGET=
52
+ LINK_CONTENT=
53
+ LINK_MODE=
54
+ make_private_symlink() {
55
+ local base=$1 destination=$2 kind=$3
56
+ LINK_KIND=$kind
57
+ LINK_TARGET="$base/target-$kind"
58
+ LINK_CONTENT=
59
+ LINK_MODE=
60
+ case "$kind" in
61
+ regular)
62
+ LINK_CONTENT='external sentinel'
63
+ printf '%s\n' "$LINK_CONTENT" > "$LINK_TARGET"
64
+ chmod 0644 "$LINK_TARGET"
65
+ LINK_MODE=644
66
+ ;;
67
+ dangling)
68
+ rm -f "$LINK_TARGET"
69
+ ;;
70
+ directory)
71
+ mkdir "$LINK_TARGET"
72
+ printf 'outside\n' > "$LINK_TARGET/keep"
73
+ chmod 0755 "$LINK_TARGET"
74
+ LINK_MODE=755
75
+ ;;
76
+ *) fail "unknown symlink fixture kind" ;;
77
+ esac
78
+ ln -s "$LINK_TARGET" "$destination"
79
+ }
80
+
81
+ assert_private_symlink_unchanged() {
82
+ local link=$1
83
+ [ -L "$link" ] || fail "private destination symlink was replaced"
84
+ case "$LINK_KIND" in
85
+ regular)
86
+ [ "$(cat "$LINK_TARGET")" = "$LINK_CONTENT" ] || fail "external regular target content changed"
87
+ [ "$(file_mode "$LINK_TARGET")" = "$LINK_MODE" ] || fail "external regular target mode changed"
88
+ ;;
89
+ dangling)
90
+ [ ! -e "$LINK_TARGET" ] || fail "dangling target was created"
91
+ ;;
92
+ directory)
93
+ [ -f "$LINK_TARGET/keep" ] || fail "external directory target contents changed"
94
+ [ "$(file_mode "$LINK_TARGET")" = "$LINK_MODE" ] || fail "external directory target mode changed"
95
+ ;;
96
+ esac
97
+ }
98
+
99
+ state_snapshot() {
100
+ local state=$1 file
101
+ (
102
+ cd "$state" || exit 1
103
+ find . \( -type f -o -type l \) -print | LC_ALL=C sort | while IFS= read -r file; do
104
+ if [ -L "$file" ]; then
105
+ printf 'link %s %s\n' "$file" "$(readlink "$file")"
106
+ else
107
+ printf 'file %s %s ' "$file" "$(file_mode "$file")"
108
+ shasum -a 256 "$file" | awk '{print $1}'
109
+ fi
110
+ done
111
+ )
112
+ }
113
+
114
+ make_case() {
115
+ local name=$1 dir fakebin fake_root
116
+ dir="$TMP_ROOT/$name"
117
+ fakebin="$dir/fakebin"
118
+ fake_root="$dir/root"
119
+ mkdir -p "$dir/home/state" "$dir/home/data" "$dir/home/config" "$dir/wt" "$fakebin" "$fake_root/bin"
120
+ cat > "$fake_root/bin/fm-guard.sh" <<'SH'
121
+ #!/usr/bin/env bash
122
+ printf 'guard\n' >> "$FM_TEST_GUARD_LOG"
123
+ SH
124
+ chmod +x "$fake_root/bin/fm-guard.sh"
125
+ cat > "$fakebin/gh" <<'SH'
126
+ #!/usr/bin/env bash
127
+ printf '%s\n' "$*" >> "$FM_TEST_GH_LOG"
128
+ case "${1:-} ${2:-}" in
129
+ "api graphql")
130
+ printf '%s\n' \
131
+ 'state=MERGED' \
132
+ 'merged=true' \
133
+ 'queued=false' \
134
+ 'base=main'
135
+ exit 0
136
+ ;;
137
+ esac
138
+ case " $* " in
139
+ *" headRefOid "*) printf '%s\n' "${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}" ;;
140
+ *" state "*)
141
+ [ "${FM_TEST_GH_FAIL:-0}" = 0 ] || exit 1
142
+ [ "${FM_TEST_GH_SLEEP:-0}" = 0 ] || sleep "$FM_TEST_GH_SLEEP"
143
+ printf '%s\n' "${FM_TEST_GH_STATE:-OPEN}"
144
+ ;;
145
+ esac
146
+ SH
147
+ cat > "$fakebin/gh-axi" <<'SH'
148
+ #!/usr/bin/env bash
149
+ printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG"
150
+ case "${1:-} ${2:-}" in
151
+ "pr view")
152
+ [ "$#" -eq 5 ] && [ "${4:-}" = --repo ] || exit 2
153
+ printf 'pull_request:\n number: %s\n state: %s\n' "$3" "${FM_TEST_GH_MERGE_STATE:-merged}"
154
+ ;;
155
+ esac
156
+ exit "${FM_TEST_GH_AXI_RC:-0}"
157
+ SH
158
+ # Plain glab, reproducing the real CLI's contract: its field output on stdout
159
+ # and exit 0 on success, and a non-zero exit with no stdout on any failure.
160
+ cat > "$fakebin/glab" <<'SH'
161
+ #!/usr/bin/env bash
162
+ printf '%s\n' "$*" >> "$FM_TEST_GLAB_LOG"
163
+ [ "${FM_TEST_GLAB_FAIL:-0}" = 0 ] || exit 1
164
+ [ "${FM_TEST_GLAB_SLEEP:-0}" = 0 ] || sleep "$FM_TEST_GLAB_SLEEP"
165
+ printf 'title:\tfixture merge request\nstate:\t%s\nauthor:\tsomeone\n' "${FM_TEST_GLAB_STATE:-opened}"
166
+ SH
167
+ chmod +x "$fakebin/gh" "$fakebin/gh-axi" "$fakebin/glab"
168
+ : > "$dir/gh.log"
169
+ : > "$dir/gh-axi.log"
170
+ : > "$dir/glab.log"
171
+ : > "$dir/guard.log"
172
+ printf '%s\n' "$dir"
173
+ }
174
+
175
+ write_task_meta() {
176
+ local dir=$1 id=${2:-task-a}
177
+ fm_write_meta "$dir/home/state/$id.meta" \
178
+ "window=firstmate:fm-$id" \
179
+ "endpoint_task_id=$id" \
180
+ "worktree=$dir/wt" \
181
+ "project=$dir/project" \
182
+ "kind=ship" \
183
+ "mode=no-mistakes"
184
+ }
185
+
186
+ write_poll_meta() {
187
+ local state=$1 id=$2 url=$3
188
+ fm_write_meta "$state/$id.meta" \
189
+ "window=fm-$id" \
190
+ "pr=$url"
191
+ }
192
+
193
+
194
+ run_check_entry() {
195
+ local dir=$1
196
+ shift
197
+ FM_ROOT_OVERRIDE="$dir/root" FM_HOME="$dir/home" \
198
+ FM_TEST_GUARD_LOG="$dir/guard.log" FM_TEST_GH_LOG="$dir/gh.log" \
199
+ FM_TEST_GH_AXI_LOG="$dir/gh-axi.log" FM_TEST_GLAB_LOG="$dir/glab.log" \
200
+ PATH="$dir/fakebin:$BASE_PATH" \
201
+ "$PR_CHECK" "$@"
202
+ }
203
+
204
+ run_merge_entry() {
205
+ local dir=$1
206
+ shift
207
+ FM_ROOT_OVERRIDE="$dir/root" FM_HOME="$dir/home" \
208
+ FM_TEST_GUARD_LOG="$dir/guard.log" FM_TEST_GH_LOG="$dir/gh.log" \
209
+ FM_TEST_GH_AXI_LOG="$dir/gh-axi.log" FM_TEST_GLAB_LOG="$dir/glab.log" \
210
+ PATH="$dir/fakebin:$BASE_PATH" \
211
+ "$PR_MERGE" "$@"
212
+ }
213
+
214
+ # shellcheck disable=SC2016 # Literal rejected URL bytes are parser test data.
215
+ INVALID_URLS=(
216
+ 'https://gitlab.com/single/-/merge_requests/1'
217
+ 'https://gitlab.com/g/p/-/merge_requests/0'
218
+ 'https://gitlab.com/g/p/-/merge_requests/01'
219
+ 'https://GitLab.com/g/p/-/merge_requests/1'
220
+ 'https://gitlab.com:443/g/p/-/merge_requests/1'
221
+ 'https://user@gitlab.com/g/p/-/merge_requests/1'
222
+ 'https://gitlab.com/g/p/-/merge_requests/1/'
223
+ 'https://gitlab.com/-/p/-/merge_requests/1'
224
+ 'https://gitlab.com/g/p.git/-/merge_requests/1'
225
+ 'https://gitlab.com/g/p.atom/-/merge_requests/1'
226
+ 'https://gitlab.com/g/p/-/merge_requests/1?x=1'
227
+ 'https://gitlab.com/g/p/-/merge_requests/1#note'
228
+ 'https://gitlab.com/g/p/-/issues/1'
229
+ 'https://gitlab.com//p/-/merge_requests/1'
230
+ 'https://.gitlab.com/g/p/-/merge_requests/1'
231
+ 'https://gitlab.com./g/p/-/merge_requests/1'
232
+ 'http://gitlab.com/g/p/-/merge_requests/1'
233
+ 'https://github.com/o/r/pull/1/'
234
+ ' https://github.com/o/r/pull/1'
235
+ 'https://github.com/o/r/pull/1 '
236
+ 'https://github.com/o /r/pull/1'
237
+ $'https://github.com/o/r/pull/1\t'
238
+ $'https://github.com/o/r/pull/1\r'
239
+ $'https://github.com/o/r/pull/1\nnext'
240
+ $'https://github.com/o/r/pull/1\r\nnext'
241
+ $'https://github.com/o/r/pull/1\001'
242
+ $'https://github.com/o/r/pull/1\033'
243
+ $'https://github.com/o/r/pull/1\177'
244
+ 'https://user@github.com/o/r/pull/1'
245
+ 'https://user:pass@github.com/o/r/pull/1'
246
+ 'https://github.com:443/o/r/pull/1'
247
+ 'https://github.com/o%2Fr/pull/1'
248
+ 'https://github.com/o/r%2Fz/pull/1'
249
+ 'https://github.com/o/r/pull/1%3Fq'
250
+ 'https://github.com/o/r/pull/1%23f'
251
+ 'https://github.com/o/r/pull/1%24x'
252
+ 'https://github.com/o/r/pull/1%28x%29'
253
+ 'https://github.com/o/r/pull/1%60x'
254
+ 'https://github.com/o/r/pull/1%0D'
255
+ 'https://github.com/o/r/pull/1%0A'
256
+ 'https://github.com/o/r/pull/1%252Fz'
257
+ 'https://github.com//r/pull/1'
258
+ 'https://github.com/o//pull/1'
259
+ 'https://github.com/o/r//1'
260
+ 'https://github.com/o/r/1'
261
+ 'https://github.com/o/r/pull/'
262
+ 'https://github.com/-owner/r/pull/1'
263
+ 'https://github.com/owner-/r/pull/1'
264
+ 'https://github.com/owner--name/r/pull/1'
265
+ 'https://github.com/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/r/pull/1'
266
+ 'https://github.com/o/./pull/1'
267
+ 'https://github.com/o/../pull/1'
268
+ 'https://github.com/o/r+z/pull/1'
269
+ 'https://github.com/o/r/pull/+1'
270
+ 'https://github.com/o/r/pull/0'
271
+ 'https://github.com/o/r/pull/-1'
272
+ 'https://github.com/o/r/pull/01'
273
+ 'https://github.com/o/r/pull/0x1'
274
+ 'https://github.com/o/r/pull/1e2'
275
+ 'https://github.com/o/r/pull/1.0'
276
+ 'https://github.com/o/r/issues/1'
277
+ 'https://github.com/o/r/x/pull/1'
278
+ 'https://github.com/o/r/pull/1/files'
279
+ 'https://github.com/o/r/pull/1?q=x'
280
+ 'https://github.com/o/r/pull/1#f'
281
+ 'https://github.com.evil/o/r/pull/1'
282
+ 'https://evilgithub.com/o/r/pull/1'
283
+ 'https://gıthub.com/o/r/pull/1'
284
+ 'https://xn--gthub-3va.com/o/r/pull/1'
285
+ 'http://github.com/o/r/pull/1'
286
+ 'ssh://github.com/o/r/pull/1'
287
+ 'git://github.com/o/r/pull/1'
288
+ 'file://github.com/o/r/pull/1'
289
+ '//github.com/o/r/pull/1'
290
+ 'HTTPS://github.com/o/r/pull/1'
291
+ 'https://GitHub.com/o/r/pull/1'
292
+ 'https://github.com/o$/r/pull/1'
293
+ 'https://github.com/o(/r/pull/1'
294
+ 'https://github.com/o)/r/pull/1'
295
+ 'https://github.com/o`/r/pull/1'
296
+ 'https://github.com/o/r`/pull/1'
297
+ 'https://github.com/o/r/pull/1`'
298
+ "https://github.com/o/'r'/pull/1"
299
+ 'https://github.com/o/"r"/pull/1'
300
+ 'https://github.com/o/'\''"r"'\''/pull/1'
301
+ "https://github.com/o/r/pull/1'"
302
+ 'https://github.com/o/r/pull/1"'
303
+ )
304
+
305
+ # shellcheck disable=SC2016 # Literal shell syntax is task-ID test data.
306
+ INVALID_IDS=(
307
+ '../escape'
308
+ 'a/b'
309
+ '.'
310
+ '..'
311
+ '.task'
312
+ 'task a'
313
+ $'task\ta'
314
+ $'task\na'
315
+ 'task*'
316
+ "task'a"
317
+ 'task"a'
318
+ 'task;a'
319
+ 'task$a'
320
+ )
321
+
322
+ # shellcheck disable=SC2016 # Literal shell syntax is task-ID test data.
323
+ UNSAFE_LIFECYCLE_IDS=(
324
+ '../escape'
325
+ 'a/b'
326
+ '.'
327
+ '..'
328
+ '.task'
329
+ 'task a'
330
+ $'task\ta'
331
+ $'task\na'
332
+ 'task*'
333
+ "task'a"
334
+ 'task"a'
335
+ 'task;a'
336
+ 'task$a'
337
+ )
338
+
339
+ test_parser_matrix() {
340
+ local id row url owner repo number
341
+ while IFS='|' read -r url owner repo number; do
342
+ [ -n "$url" ] || continue
343
+ fm_pr_url_parse "$url" || fail "parser rejected canonical URL"
344
+ [ "$FM_PR_URL" = "$url" ] || fail "parser changed canonical URL"
345
+ [ "$FM_PR_OWNER" = "$owner" ] || fail "parser returned wrong owner"
346
+ [ "$FM_PR_REPO" = "$repo" ] || fail "parser returned wrong repository"
347
+ [ "$FM_PR_NUMBER" = "$number" ] || fail "parser returned wrong PR number"
348
+ done <<'EOF'
349
+ https://github.com/a/b/pull/1|a|b|1
350
+ https://github.com/my-org/repo/pull/42|my-org|repo|42
351
+ https://github.com/Owner/repo-name_with.parts/pull/123456|Owner|repo-name_with.parts|123456
352
+ EOF
353
+ while IFS='|' read -r url host path number; do
354
+ [ -n "$url" ] || continue
355
+ fm_pr_url_parse "$url" || fail "parser rejected a canonical merge request URL"
356
+ [ "$FM_PR_PROVIDER" = gitlab ] || fail "parser did not tag a merge request URL as gitlab"
357
+ [ "$FM_PR_URL" = "$url" ] || fail "parser changed a canonical merge request URL"
358
+ [ "$FM_PR_HOST" = "$host" ] || fail "parser returned wrong GitLab host"
359
+ [ "$FM_PR_PATH" = "$path" ] || fail "parser returned wrong GitLab project path"
360
+ [ "$FM_PR_NUMBER" = "$number" ] || fail "parser returned wrong merge request number"
361
+ [ -z "$FM_PR_OWNER" ] && [ -z "$FM_PR_REPO" ] \
362
+ || fail "parser set GitHub owner/repository for a merge request URL"
363
+ done <<'EOF'
364
+ https://gitlab.com/group/project/-/merge_requests/1|gitlab.com|group/project|1
365
+ https://gitlab.com/group/sub/deep/project/-/merge_requests/42|gitlab.com|group/sub/deep/project|42
366
+ https://gitlab.example.co.uk/g/p/-/merge_requests/7|gitlab.example.co.uk|g/p|7
367
+ https://code.internal/team/tools/ci-runner/-/merge_requests/123456|code.internal|team/tools/ci-runner|123456
368
+ EOF
369
+ fm_pr_url_parse https://github.com/a/b/pull/1 || fail "parser rejected canonical URL"
370
+ [ "$FM_PR_PROVIDER" = github ] || fail "parser did not tag a pull request URL as github"
371
+ [ "$FM_PR_HOST" = github.com ] || fail "parser returned wrong GitHub host"
372
+ [ "$FM_PR_PATH" = a/b ] || fail "parser returned wrong GitHub project path"
373
+ for row in "${INVALID_URLS[@]}"; do
374
+ ! fm_pr_url_parse "$row" || fail "parser accepted a rejected raw-byte URL class"
375
+ done
376
+ for id in -task task- task--a Task-a task_a task.a; do
377
+ fm_pr_task_id_valid "$id" || fail "task ID validator rejected a safe lifecycle-compatible slug"
378
+ done
379
+ fm_task_id_creation_valid _noncanonical \
380
+ || fail "creation validator rejected a task ID after its reserved namespace moved"
381
+ id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
382
+ fm_pr_task_id_valid "$id" || fail "operational validator rejected a path-safe legacy task ID"
383
+ ! fm_task_id_creation_valid "$id" || fail "creation validator accepted an overlong task ID"
384
+ pass "raw-byte parser accepts canonical URLs and rejects the complete adversarial matrix"
385
+ }
386
+
387
+ test_invalid_entrypoints_have_zero_side_effects() {
388
+ local dir before after value rc
389
+ dir=$(make_case invalid-entrypoints)
390
+ write_task_meta "$dir"
391
+ printf 'existing-check\n' > "$dir/home/state/task-a.check.sh"
392
+ printf 'existing-data\n' > "$dir/home/state/task-a.pr-poll"
393
+ chmod 0600 "$dir/home/state/task-a.check.sh" "$dir/home/state/task-a.pr-poll"
394
+
395
+ for value in "${INVALID_URLS[@]}"; do
396
+ before=$(state_snapshot "$dir/home/state")
397
+ set +e
398
+ run_check_entry "$dir" task-a "$value" > "$dir/stdout" 2> "$dir/stderr"
399
+ rc=$?
400
+ set -e
401
+ [ "$rc" -ne 0 ] || fail "direct entrypoint accepted invalid URL"
402
+ [ "$(cat "$dir/stderr")" = 'error: invalid PR check request' ] || fail "direct invalid URL diagnostic was not fixed"
403
+ after=$(state_snapshot "$dir/home/state")
404
+ [ "$after" = "$before" ] || fail "direct invalid URL changed prior state"
405
+ done
406
+
407
+ for value in "${INVALID_IDS[@]}"; do
408
+ before=$(state_snapshot "$dir/home/state")
409
+ set +e
410
+ run_check_entry "$dir" "$value" https://github.com/o/r/pull/1 > "$dir/stdout" 2> "$dir/stderr"
411
+ rc=$?
412
+ set -e
413
+ [ "$rc" -ne 0 ] || fail "direct entrypoint accepted invalid task ID"
414
+ after=$(state_snapshot "$dir/home/state")
415
+ [ "$after" = "$before" ] || fail "invalid task ID changed state or traversed a path"
416
+ done
417
+
418
+ for value in "${INVALID_URLS[@]}"; do
419
+ before=$(state_snapshot "$dir/home/state")
420
+ set +e
421
+ run_merge_entry "$dir" task-a "$value" > "$dir/stdout" 2> "$dir/stderr"
422
+ rc=$?
423
+ set -e
424
+ [ "$rc" -ne 0 ] || fail "merge entrypoint accepted invalid URL"
425
+ [ "$(cat "$dir/stderr")" = 'error: invalid PR merge request' ] || fail "merge invalid URL diagnostic was not fixed"
426
+ after=$(state_snapshot "$dir/home/state")
427
+ [ "$after" = "$before" ] || fail "merge invalid URL changed prior state"
428
+ done
429
+
430
+ for value in "${INVALID_IDS[@]}"; do
431
+ before=$(state_snapshot "$dir/home/state")
432
+ set +e
433
+ run_merge_entry "$dir" "$value" https://github.com/o/r/pull/1 > "$dir/stdout" 2> "$dir/stderr"
434
+ rc=$?
435
+ set -e
436
+ [ "$rc" -ne 0 ] || fail "merge entrypoint accepted invalid task ID"
437
+ after=$(state_snapshot "$dir/home/state")
438
+ [ "$after" = "$before" ] || fail "merge invalid task ID changed state"
439
+ done
440
+
441
+ for value in "${UNSAFE_LIFECYCLE_IDS[@]}"; do
442
+ before=$(state_snapshot "$dir/home/state")
443
+ set +e
444
+ FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$dir/root" FM_TEST_GUARD_LOG="$dir/guard.log" \
445
+ "$TEARDOWN" "$value" --force > "$dir/stdout" 2> "$dir/stderr"
446
+ rc=$?
447
+ set -e
448
+ [ "$rc" -ne 0 ] || fail "teardown accepted invalid task ID"
449
+ [ "$(cat "$dir/stderr")" = 'error: invalid teardown request' ] \
450
+ || fail "teardown invalid task ID diagnostic was not fixed"
451
+ after=$(state_snapshot "$dir/home/state")
452
+ [ "$after" = "$before" ] || fail "teardown invalid task ID changed state"
453
+ done
454
+
455
+ set +e
456
+ run_check_entry "$dir" > /dev/null 2> "$dir/stderr"; rc=$?
457
+ set -e
458
+ [ "$rc" -ne 0 ] || fail "direct entrypoint accepted zero arguments"
459
+ set +e
460
+ run_check_entry "$dir" task-a https://github.com/o/r/pull/1 extra > /dev/null 2> "$dir/stderr"; rc=$?
461
+ set -e
462
+ [ "$rc" -ne 0 ] || fail "direct entrypoint accepted extra arguments"
463
+ set +e
464
+ run_merge_entry "$dir" > /dev/null 2> "$dir/stderr"; rc=$?
465
+ set -e
466
+ [ "$rc" -ne 0 ] || fail "merge entrypoint accepted zero arguments"
467
+
468
+ [ ! -s "$dir/gh.log" ] || fail "invalid direct or merge data called gh"
469
+ [ ! -s "$dir/gh-axi.log" ] || fail "invalid direct or merge data called gh-axi"
470
+ [ ! -s "$dir/guard.log" ] || fail "invalid direct or merge data called the guard"
471
+ [ ! -e "$TMP_ROOT/escape.check.sh" ] || fail "task traversal wrote outside state"
472
+ pass "PR and teardown entrypoints reject invalid arguments before every side effect"
473
+ }
474
+
475
+ test_valid_recording_and_merge_derivation() {
476
+ local dir expected sidecar count rc
477
+ dir=$(make_case valid-recording)
478
+ write_task_meta "$dir"
479
+ expected=0123456789abcdef0123456789abcdef01234567
480
+ FM_TEST_GH_HEAD=$expected run_check_entry "$dir" task-a https://github.com/my-org/repo_name.with-dots/pull/37 \
481
+ > "$dir/stdout" 2> "$dir/stderr" || fail "valid direct check failed"
482
+
483
+ grep -qxF 'pr=https://github.com/my-org/repo_name.with-dots/pull/37' "$dir/home/state/task-a.meta" \
484
+ || fail "canonical pr metadata was not exact"
485
+ grep -qxF "pr_head=$expected" "$dir/home/state/task-a.meta" || fail "PR head metadata was not exact"
486
+ cmp -s "$POLL" "$dir/home/state/task-a.check.sh" || fail "published check was not byte-for-byte static"
487
+ [ "$(file_mode "$dir/home/state/task-a.check.sh")" = 600 ] || fail "published check mode was not 0600"
488
+ [ "$(file_mode "$dir/home/state/task-a.pr-poll")" = 600 ] || fail "published sidecar mode was not 0600"
489
+ [ "$(file_mode "$dir/home/state/task-a.pr-poll-registration")" = 600 ] \
490
+ || fail "published registration mode was not 0600"
491
+ [ "$(fm_pr_file_link_count "$dir/home/state/task-a.check.sh")" = 1 ] \
492
+ && [ "$(fm_pr_file_link_count "$dir/home/state/task-a.pr-poll")" = 1 ] \
493
+ && [ "$(fm_pr_file_link_count "$dir/home/state/task-a.pr-poll-registration")" = 1 ] \
494
+ || fail "published poll artifacts were not single-link files"
495
+ fm_pr_poll_artifacts_valid "$dir/home/state" task-a "$POLL" \
496
+ || fail "published poll provenance or metadata binding was invalid"
497
+ sidecar=$(cat "$dir/home/state/task-a.pr-poll")
498
+ [ "$sidecar" = $'github\nhttps://github.com/my-org/repo_name.with-dots/pull/37\ngithub.com\nmy-org/repo_name.with-dots\n37' ] \
499
+ || fail "published sidecar bytes were not exact"
500
+
501
+ FM_TEST_GH_HEAD=$expected run_check_entry "$dir" task-a https://github.com/my-org/repo_name.with-dots/pull/37 \
502
+ >/dev/null 2>/dev/null || fail "valid duplicate check failed"
503
+ count=$(grep -c '^pr=' "$dir/home/state/task-a.meta")
504
+ [ "$count" -eq 1 ] || fail "duplicate pr metadata was appended"
505
+ count=$(grep -c '^pr_head=' "$dir/home/state/task-a.meta")
506
+ [ "$count" -eq 1 ] || fail "duplicate pr_head metadata was appended"
507
+
508
+ : > "$dir/gh-axi.log"
509
+ run_merge_entry "$dir" task-a https://github.com/my-org/repo_name.with-dots/pull/37 -- --merge \
510
+ >/dev/null 2>/dev/null || fail "valid merge wrapper failed"
511
+ grep -qxF 'pr merge 37 --repo my-org/repo_name.with-dots --merge' "$dir/gh-axi.log" \
512
+ || fail "merge wrapper did not preserve repository derivation and method"
513
+ # A merge this home performed leaves its own durable outcome, so the poll's
514
+ # confirmation is no longer the first the captain hears of it. Acknowledge that
515
+ # record before the watcher cycle below, which is what still retires the poll.
516
+ assert_grep 'https://github.com/my-org/repo_name.with-dots/pull/37' "$dir/home/state/.wake-queue" \
517
+ "a merge this home performed left no durable outcome"
518
+ ack_watcher_cycle "$dir/home/state" || fail "merge outcome acknowledgement failed"
519
+ # With the merge already reported, the poll's own detection is a duplicate the
520
+ # watcher absorbs, so this cycle needs its own reason to end.
521
+ add_stop_custom_check "$dir"
522
+ set +e
523
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/merged-watch.out" 2> "$dir/merged-watch.err"
524
+ rc=$?
525
+ set -e
526
+ [ "$rc" -eq 0 ] || fail "guarded merge poll retirement failed: $(cat "$dir/merged-watch.err")"
527
+ assert_poll_absent "$dir/home/state" task-a
528
+ assert_no_grep "merged-task-a" "$dir/home/state/.wake-queue" \
529
+ "the drained self-merge outcome was republished by its poll"
530
+ grep -qxF 'pr=https://github.com/my-org/repo_name.with-dots/pull/37' "$dir/home/state/task-a.meta" \
531
+ || fail "guarded merge retirement removed pr metadata"
532
+ grep -qxF "pr_head=$expected" "$dir/home/state/task-a.meta" \
533
+ || fail "guarded merge retirement removed pr_head metadata"
534
+
535
+ dir=$(make_case newline-head)
536
+ write_task_meta "$dir"
537
+ FM_TEST_GH_HEAD=$'0123456789abcdef0123456789abcdef01234567\nwindow=unexpected' \
538
+ run_check_entry "$dir" task-a https://github.com/o/r/pull/2 >/dev/null 2>/dev/null \
539
+ || fail "valid check with malformed remote head failed"
540
+ assert_no_grep 'pr_head=' "$dir/home/state/task-a.meta" "multiline PR head reached metadata"
541
+ assert_no_grep 'window=unexpected' "$dir/home/state/task-a.meta" "newline metadata key was injected"
542
+
543
+ dir=$(make_case lifecycle-compatible-id)
544
+ write_task_meta "$dir" Task_A.1
545
+ run_merge_entry "$dir" Task_A.1 https://github.com/o/r/pull/3 \
546
+ > "$dir/stdout" 2> "$dir/stderr" \
547
+ || fail "safe lifecycle-compatible task ID could not use the PR merge flow"
548
+ fm_pr_poll_artifacts_valid "$dir/home/state" Task_A.1 "$POLL" \
549
+ || fail "safe lifecycle-compatible task ID did not publish an authenticated poll"
550
+ rm -rf "$dir/wt"
551
+ cat > "$dir/fakebin/tmux" <<'SH'
552
+ #!/usr/bin/env bash
553
+ exit 0
554
+ SH
555
+ chmod 0700 "$dir/fakebin/tmux"
556
+ touch "$dir/home/state/.last-watcher-beat"
557
+ FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \
558
+ "$TEARDOWN" Task_A.1 --force > "$dir/teardown.out" 2> "$dir/teardown.err" \
559
+ || fail "safe lifecycle-compatible task ID could not be torn down"
560
+ [ ! -e "$dir/home/state/Task_A.1.meta" ] \
561
+ || fail "safe lifecycle-compatible task teardown retained metadata"
562
+
563
+ for id in _noncanonical aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do
564
+ dir=$(make_case "legacy-teardown-${id:0:12}")
565
+ fm_write_meta "$dir/home/state/$id.meta" \
566
+ "window=firstmate:fm-$id" \
567
+ "endpoint_task_id=$id" \
568
+ "worktree=$dir/missing-worktree" \
569
+ "project=$dir/project" \
570
+ 'kind=ship' \
571
+ 'mode=local-only'
572
+ cat > "$dir/fakebin/tmux" <<'SH'
573
+ #!/usr/bin/env bash
574
+ exit 0
575
+ SH
576
+ chmod 0700 "$dir/fakebin/tmux"
577
+ touch "$dir/home/state/.last-watcher-beat"
578
+ mkdir "$dir/home/state/$id.check.sh"
579
+ set +e
580
+ FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \
581
+ "$TEARDOWN" "$id" --force > "$dir/unsafe-teardown.out" 2> "$dir/unsafe-teardown.err"
582
+ rc=$?
583
+ set -e
584
+ [ "$rc" -ne 0 ] || fail "legacy task teardown accepted an unsafe direct artifact"
585
+ [ -e "$dir/home/state/$id.meta" ] \
586
+ || fail "legacy task teardown mutated lifecycle state before artifact refusal"
587
+ [ -d "$dir/home/state/$id.check.sh" ] \
588
+ || fail "legacy task teardown changed the unsafe direct artifact"
589
+ rmdir "$dir/home/state/$id.check.sh"
590
+ FM_HOME="$dir/home" "$ROOT/bin/fm-x-link.sh" "$id" req-legacy \
591
+ --carry-count 0 --carry-ts 1700000000 --carry-platform x --carry-max 280 \
592
+ > "$dir/x-link.out" 2> "$dir/x-link.err" \
593
+ || fail "path-safe legacy task ID could not link an X request"
594
+ run_merge_entry "$dir" "$id" https://github.com/o/r/pull/4 \
595
+ > "$dir/merge.out" 2> "$dir/merge.err" \
596
+ || fail "path-safe legacy task ID could not use the PR merge flow"
597
+ fm_pr_poll_artifacts_valid "$dir/home/state" "$id" "$POLL" \
598
+ || fail "path-safe legacy task ID did not publish an authenticated poll"
599
+ FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \
600
+ "$TEARDOWN" "$id" --force > "$dir/teardown.out" 2> "$dir/teardown.err" \
601
+ || fail "legacy path-safe task ID could not be torn down"
602
+ [ ! -e "$dir/home/state/$id.meta" ] || fail "legacy task teardown retained metadata"
603
+ done
604
+ pass "valid direct and merge flows record exact metadata and reject multiline head metadata"
605
+ }
606
+
607
+ run_watcher_bounded() {
608
+ local home=$1 fakebin=$2 check_interval=${FM_TEST_CHECK_INTERVAL:-0} watch_root=${FM_TEST_WATCH_ROOT:-$ROOT}
609
+ shift 2
610
+ perl -e 'my $pid=fork; die unless defined $pid; if (!$pid) { exec @ARGV } local $SIG{ALRM}=sub { kill "TERM", $pid; waitpid $pid, 0; exit 124 }; alarm 10; waitpid $pid, 0; alarm 0; exit($? >> 8)' \
611
+ env FM_HOME="$home" FM_ROOT_OVERRIDE="$watch_root" FM_CHECK_INTERVAL="$check_interval" FM_CHECK_TIMEOUT=1 \
612
+ FM_POLL=0.02 FM_HEARTBEAT=999999 FM_SIGNAL_GRACE=0 PATH="$fakebin:$BASE_PATH" "$WATCH" "$@"
613
+ }
614
+
615
+ test_rejected_metacharacter_bytes_are_inert() {
616
+ local dir family rc before after
617
+ dir=$(make_case rejected-metacharacters)
618
+ write_task_meta "$dir"
619
+ write_poll_meta "$dir/home/state" safe-check https://github.com/o/r/pull/99
620
+ families=(
621
+ 'https://github.com/o$/r/pull/1'
622
+ 'https://github.com/o(/r/pull/1'
623
+ 'https://github.com/o)/r/pull/1'
624
+ 'https://github.com/o`/r/pull/1'
625
+ )
626
+ for family in "${families[@]}"; do
627
+ rm -f "$dir/home/state/task-a.check.sh" "$dir/home/state/task-a.pr-poll"
628
+ set +e
629
+ run_check_entry "$dir" task-a "$family" > /dev/null 2> "$dir/stderr"
630
+ rc=$?
631
+ set -e
632
+ [ "$rc" -ne 0 ] || fail "rejected metacharacter byte was accepted"
633
+ [ ! -e "$dir/home/state/task-a.check.sh" ] || fail "rejected input left a runnable task check"
634
+ [ ! -e "$dir/home/state/task-a.pr-poll" ] || fail "rejected input left a sidecar"
635
+ fm_pr_poll_prepare "$dir/home/state" safe-check github https://github.com/o/r/pull/99 github.com o/r 99 "$POLL" \
636
+ || fail "could not prepare bounded watcher poll"
637
+ fm_pr_poll_publish_prepared || fail "could not publish bounded watcher poll"
638
+
639
+ set +e
640
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch.out" 2> "$dir/watch.err"
641
+ rc=$?
642
+ set -e
643
+ [ "$rc" -eq 0 ] || fail "bounded watcher did not complete through the authenticated poll"
644
+ rm -f "$dir/home/state/.last-check"
645
+ done
646
+
647
+ FM_TEST_GH_STATE=OPEN run_check_entry "$dir" task-a https://github.com/o/r/pull/1 >/dev/null 2>/dev/null \
648
+ || fail "could not seed a prior valid static poll"
649
+ before=$(state_snapshot "$dir/home/state")
650
+ set +e
651
+ run_check_entry "$dir" task-a "${families[0]}" >/dev/null 2>/dev/null
652
+ rc=$?
653
+ set -e
654
+ [ "$rc" -ne 0 ] || fail "rejected replacement was accepted"
655
+ after=$(state_snapshot "$dir/home/state")
656
+ [ "$after" = "$before" ] || fail "rejected replacement changed a prior valid static poll"
657
+ pass "rejected metacharacter bytes remain inert at generation and watcher time"
658
+ }
659
+
660
+ make_poll_fixture() {
661
+ local dir=$1
662
+ cp "$POLL" "$dir/home/state/task-a.check.sh"
663
+ printf '%s\n%s\n%s\n%s\n%s\n' \
664
+ github https://github.com/o/r/pull/1 github.com o/r 1 > "$dir/home/state/task-a.pr-poll"
665
+ chmod 0600 "$dir/home/state/task-a.check.sh" "$dir/home/state/task-a.pr-poll"
666
+ }
667
+
668
+ run_poll() {
669
+ local dir=$1
670
+ FM_TEST_GH_LOG="$dir/gh.log" FM_TEST_GLAB_LOG="$dir/glab.log" \
671
+ PATH="$dir/fakebin:$BASE_PATH" \
672
+ bash "$dir/home/state/task-a.check.sh"
673
+ }
674
+
675
+ test_static_poll_contract() {
676
+ local dir state out rc
677
+ dir=$(make_case poll-contract)
678
+ make_poll_fixture "$dir"
679
+
680
+ for state in OPEN CLOSED EMPTY MALFORMED; do
681
+ case "$state" in
682
+ EMPTY) value= ;;
683
+ MALFORMED) value='not-a-state' ;;
684
+ *) value=$state ;;
685
+ esac
686
+ out=$(FM_TEST_GH_STATE="$value" run_poll "$dir")
687
+ [ -z "$out" ] || fail "static poll emitted for non-merged state"
688
+ done
689
+ out=$(FM_TEST_GH_STATE=MERGED run_poll "$dir")
690
+ [ "$out" = merged ] || fail "static poll did not emit exactly one merged line"
691
+ out=$(FM_TEST_GH_FAIL=1 run_poll "$dir")
692
+ [ -z "$out" ] || fail "static poll emitted after gh failure"
693
+
694
+ mv "$dir/home/state/task-a.pr-poll" "$dir/home/state/task-a.pr-poll.missing"
695
+ out=$(run_poll "$dir")
696
+ [ -z "$out" ] || fail "static poll emitted with missing sidecar"
697
+ mv "$dir/home/state/task-a.pr-poll.missing" "$dir/home/state/task-a.pr-poll"
698
+ printf '%s\n%s\n%s\n%s\n%s\n%s\n' github https://github.com/o/r/pull/1 github.com o/r 1 extra > "$dir/home/state/task-a.pr-poll"
699
+ out=$(FM_TEST_GH_STATE=MERGED run_poll "$dir")
700
+ [ -z "$out" ] || fail "static poll emitted with multiline sidecar"
701
+ printf '%s\n%s\n%s\n%s\n%s\n' github https://github.com/o/r/pull/1x github.com o/r 1x > "$dir/home/state/task-a.pr-poll"
702
+ out=$(FM_TEST_GH_STATE=MERGED run_poll "$dir")
703
+ [ -z "$out" ] || fail "static poll emitted with malformed numeric data"
704
+
705
+ make_poll_fixture "$dir"
706
+ set +e
707
+ out=$(FM_STATE_OVERRIDE="$dir/home/state" FM_CHECK_TIMEOUT=1 FM_TEST_GH_LOG="$dir/gh.log" \
708
+ FM_TEST_GH_SLEEP=3 PATH="$dir/fakebin:$BASE_PATH" \
709
+ bash -c '. "$1"; run_check "$2"' bash "$WATCH" "$dir/home/state/task-a.check.sh")
710
+ rc=$?
711
+ set -e
712
+ [ "$rc" -eq 0 ] || fail "watcher run_check timeout wrapper failed"
713
+ [ -z "$out" ] || fail "timed-out static poll emitted output"
714
+
715
+ write_poll_meta "$dir/home/state" task-a https://github.com/o/r/pull/1
716
+ fm_pr_poll_prepare "$dir/home/state" task-a github https://github.com/o/r/pull/1 github.com o/r 1 "$POLL" \
717
+ || fail "could not prepare authenticated watcher poll"
718
+ fm_pr_poll_publish_prepared || fail "could not publish authenticated watcher poll"
719
+ rm -f "$dir/home/state/.last-check"
720
+ set +e
721
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch.out" 2> "$dir/watch.err"
722
+ rc=$?
723
+ set -e
724
+ [ "$rc" -eq 0 ] || fail "watcher did not surface merged poll"
725
+ [ "$(grep -c '^check: .*: merged$' "$dir/watch.out")" -eq 1 ] || fail "watcher did not convert merged output into exactly one wake"
726
+ pass "static poll is silent except for one merged line and remains watcher-bounded"
727
+ }
728
+
729
+ test_atomic_interruption_leaves_no_partial_artifact() {
730
+ local dir rc
731
+ dir=$(make_case interrupted-write)
732
+ write_task_meta "$dir"
733
+ cat > "$dir/fakebin/cp" <<SH
734
+ #!/usr/bin/env bash
735
+ '$REAL_CP' "\$@" || exit 1
736
+ kill -TERM "\$PPID"
737
+ exit 0
738
+ SH
739
+ chmod +x "$dir/fakebin/cp"
740
+
741
+ set +e
742
+ run_check_entry "$dir" task-a https://github.com/o/r/pull/1 > "$dir/stdout" 2> "$dir/stderr"
743
+ rc=$?
744
+ set -e
745
+ [ "$rc" -ne 0 ] || fail "interrupted publication unexpectedly succeeded"
746
+ [ ! -e "$dir/home/state/task-a.check.sh" ] || fail "interrupted publication left a runnable check"
747
+ [ ! -e "$dir/home/state/task-a.pr-poll" ] || fail "interrupted publication left a sidecar"
748
+ [ ! -e "$dir/home/state/task-a.pr-poll-registration" ] \
749
+ || fail "interrupted publication left a registration"
750
+ ! find "$dir/home/state" -name '.fm-pr-poll-*' -print | grep . >/dev/null \
751
+ || fail "interrupted publication left temporary files"
752
+ assert_no_grep 'pr=' "$dir/home/state/task-a.meta" "interrupted preparation changed metadata"
753
+ pass "interrupted atomic preparation cleans private temporaries and publishes nothing"
754
+ }
755
+
756
+ test_concurrent_watcher_sees_only_complete_publication() {
757
+ local n dir direct_pid rc i
758
+ n=1
759
+ while [ "$n" -le 3 ]; do
760
+ dir=$(make_case "concurrent-$n")
761
+ write_task_meta "$dir"
762
+ cat > "$dir/fakebin/cp" <<SH
763
+ #!/usr/bin/env bash
764
+ '$REAL_CP' "\$@" || exit 1
765
+ sleep 0.3
766
+ SH
767
+ chmod +x "$dir/fakebin/cp"
768
+
769
+ FM_TEST_GH_HEAD=0123456789abcdef0123456789abcdef01234567 \
770
+ run_check_entry "$dir" task-a https://github.com/o/r/pull/1 > "$dir/direct.out" 2> "$dir/direct.err" &
771
+ direct_pid=$!
772
+ i=0
773
+ while [ "$i" -lt 100 ] && ! find "$dir/home/state" -name '.fm-pr-poll-check.*' -print | grep . >/dev/null; do
774
+ sleep 0.01
775
+ i=$((i + 1))
776
+ done
777
+ [ "$i" -lt 100 ] || fail "atomic publication did not reach staged check"
778
+
779
+ set +e
780
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch.out" 2> "$dir/watch.err"
781
+ rc=$?
782
+ set -e
783
+ wait "$direct_pid" || fail "concurrent direct arming failed"
784
+ [ "$rc" -eq 0 ] || fail "concurrent watcher did not complete"
785
+ grep -q '^check: .*: merged$' "$dir/watch.out" || fail "concurrent watcher never saw complete poll"
786
+ [ ! -s "$dir/watch.err" ] || fail "concurrent watcher observed a partial artifact error"
787
+ if [ -e "$dir/home/state/task-a.check.sh" ]; then
788
+ cmp -s "$POLL" "$dir/home/state/task-a.check.sh" || fail "concurrent publication check bytes changed"
789
+ [ "$(file_mode "$dir/home/state/task-a.check.sh")" = 600 ] || fail "concurrent check mode was not private"
790
+ [ "$(file_mode "$dir/home/state/task-a.pr-poll")" = 600 ] || fail "concurrent sidecar mode was not private"
791
+ [ "$(file_mode "$dir/home/state/task-a.pr-poll-registration")" = 600 ] \
792
+ || fail "concurrent registration mode was not private"
793
+ fm_pr_poll_artifacts_valid "$dir/home/state" task-a "$POLL" \
794
+ || fail "concurrent publication did not leave canonical provenance"
795
+ else
796
+ assert_poll_absent "$dir/home/state" task-a
797
+ fi
798
+ n=$((n + 1))
799
+ done
800
+ pass "concurrent watchers observe only complete private poll publications"
801
+ }
802
+
803
+ test_poll_publication_refuses_unsafe_destinations() {
804
+ local artifact kind dir state destination
805
+ for artifact in task-a.pr-poll task-a.pr-poll-registration task-a.check.sh; do
806
+ for kind in regular dangling directory; do
807
+ dir=$(make_case "poll-path-${artifact//./-}-$kind")
808
+ state="$dir/home/state"
809
+ fm_pr_poll_prepare "$state" task-a github https://github.com/o/r/pull/1 github.com o/r 1 "$POLL" \
810
+ || fail "could not stage poll symlink refusal fixture"
811
+ destination="$state/$artifact"
812
+ make_private_symlink "$dir" "$destination" "$kind"
813
+ if fm_pr_poll_publish_prepared; then
814
+ fail "poll publication accepted a private destination symlink"
815
+ fi
816
+ fm_pr_poll_cleanup
817
+ assert_private_symlink_unchanged "$destination"
818
+ [ ! -e "$state/task-a.pr-poll" ] || [ "$artifact" = task-a.pr-poll ] \
819
+ || fail "check destination refusal published the sidecar"
820
+ done
821
+
822
+ dir=$(make_case "poll-path-${artifact//./-}-direct-directory")
823
+ state="$dir/home/state"
824
+ fm_pr_poll_prepare "$state" task-a github https://github.com/o/r/pull/1 github.com o/r 1 "$POLL" \
825
+ || fail "could not stage poll directory refusal fixture"
826
+ destination="$state/$artifact"
827
+ mkdir "$destination"
828
+ if fm_pr_poll_publish_prepared; then
829
+ fail "poll publication accepted a directory destination"
830
+ fi
831
+ fm_pr_poll_cleanup
832
+ [ -d "$destination" ] || fail "poll publication replaced a directory destination"
833
+ [ -z "$(find "$destination" -mindepth 1 -maxdepth 1 -print)" ] \
834
+ || fail "poll publication wrote inside a directory destination"
835
+ done
836
+ pass "poll publication paths refuse symlinks and directories"
837
+ }
838
+
839
+ test_live_artifact_single_link_and_privacy_validation() {
840
+ local artifact dir state alias rc
841
+ for artifact in check.sh pr-poll pr-poll-registration; do
842
+ dir=$(make_case "single-link-live-${artifact//./-}")
843
+ state="$dir/home/state"
844
+ write_task_meta "$dir"
845
+ run_check_entry "$dir" task-a https://github.com/o/r/pull/10 >/dev/null 2>/dev/null \
846
+ || fail "could not publish $artifact hard-link fixture"
847
+ fm_pr_poll_artifacts_valid "$state" task-a "$POLL" \
848
+ || fail "$artifact fixture was not initially authenticated"
849
+ alias="$dir/$artifact.alias"
850
+ ln "$state/task-a.$artifact" "$alias"
851
+ if [ "$artifact" = pr-poll ]; then
852
+ printf '%s\n%s\n%s\n%s\n' https://github.com/o/r/pull/11 o r 11 > "$alias"
853
+ fi
854
+ ! fm_pr_poll_artifacts_valid "$state" task-a "$POLL" \
855
+ || fail "$artifact hard link remained authenticated"
856
+ [ -e "$alias" ] || fail "$artifact hard-link refusal removed the external alias"
857
+ done
858
+
859
+ dir=$(make_case single-link-custom-check-registration)
860
+ state="$dir/home/state"
861
+ printf '#!/usr/bin/env bash\nprintf "custom-ready\\n"\n' > "$state/custom.check.sh"
862
+ chmod 0700 "$state/custom.check.sh"
863
+ alias="$dir/custom-check.alias"
864
+ ln "$state/custom.check.sh" "$alias"
865
+ set +e
866
+ FM_HOME="$dir/home" "$REGISTER" custom > "$dir/register.out" 2> "$dir/register.err"
867
+ rc=$?
868
+ set -e
869
+ [ "$rc" -ne 0 ] || fail "custom check registration accepted a hard-linked source"
870
+ [ ! -e "$state/custom.check-trust" ] || fail "rejected hard-linked custom check received a trust record"
871
+ rm -f "$alias"
872
+ FM_HOME="$dir/home" "$REGISTER" custom >/dev/null \
873
+ || fail "could not register the custom check single-link fixture"
874
+ ln "$state/custom.check.sh" "$alias"
875
+ ! fm_custom_check_registered "$state" custom \
876
+ || fail "registered custom check remained authenticated after source hard-linking"
877
+ ! fm_custom_check_snapshot_prepare "$state" custom \
878
+ || fail "watcher snapshot accepted a hard-linked custom check source"
879
+ fm_custom_check_snapshot_cleanup
880
+ rm -f "$alias"
881
+ alias="$dir/custom-trust.alias"
882
+ ln "$state/custom.check-trust" "$alias"
883
+ ! fm_custom_check_registered "$state" custom \
884
+ || fail "hard-linked custom check trust remained authenticated"
885
+ ! fm_custom_check_snapshot_prepare "$state" custom \
886
+ || fail "watcher snapshot accepted a hard-linked custom check trust record"
887
+ fm_custom_check_snapshot_cleanup
888
+ [ -e "$alias" ] || fail "custom-check hard-link refusal removed the external alias"
889
+
890
+ dir=$(make_case private-custom-check-source)
891
+ state="$dir/home/state"
892
+ printf '#!/usr/bin/env bash\nprintf "custom-ready\\n"\n' > "$state/custom.check.sh"
893
+ chmod 0755 "$state/custom.check.sh"
894
+ set +e
895
+ FM_HOME="$dir/home" "$REGISTER" custom > "$dir/register.out" 2> "$dir/register.err"
896
+ rc=$?
897
+ set -e
898
+ [ "$rc" -ne 0 ] || fail "custom check registration accepted a non-private source"
899
+ [ ! -e "$state/custom.check-trust" ] || fail "non-private custom check received a trust record"
900
+ chmod 0700 "$state/custom.check.sh"
901
+ FM_HOME="$dir/home" "$REGISTER" custom >/dev/null \
902
+ || fail "could not register private custom check fixture"
903
+ chmod 0755 "$state/custom.check.sh"
904
+ ! fm_custom_check_registered "$state" custom \
905
+ || fail "registered custom check remained authenticated after becoming non-private"
906
+ ! fm_custom_check_snapshot_prepare "$state" custom \
907
+ || fail "watcher snapshot accepted a non-private custom check source"
908
+ fm_custom_check_snapshot_cleanup
909
+ pass "live poll and custom-check artifacts require private single-link files"
910
+ }
911
+
912
+ install_final_publication_fault() {
913
+ local dir=$1
914
+ cat > "$dir/fakebin/mv" <<'SH'
915
+ #!/usr/bin/env bash
916
+ last=${!#}
917
+ "${FM_TEST_REAL_MV:?}" "$@" || exit $?
918
+ [ "$last" = "${FM_TEST_FINAL_PATH:?}" ] || exit 0
919
+ case "${FM_TEST_FINAL_ACTION:?}" in
920
+ type)
921
+ rm -f -- "$last"
922
+ ln -s "${FM_TEST_FAULT_LINK_TARGET:?}" "$last"
923
+ ;;
924
+ mode) "${FM_TEST_REAL_CHMOD:?}" 0644 "$last" ;;
925
+ content) printf 'faulted final bytes\n' > "$last" ;;
926
+ device) : > "${FM_TEST_FAULT_GATE:?}" ;;
927
+ *) exit 2 ;;
928
+ esac
929
+ SH
930
+ cat > "$dir/fakebin/stat" <<'SH'
931
+ #!/usr/bin/env bash
932
+ last=${!#}
933
+ if [ "$last" = "${FM_TEST_FINAL_PATH:-}" ] && [ -e "${FM_TEST_FAULT_GATE:-/nonexistent}" ]; then
934
+ case " $* " in
935
+ *" %d "*) printf '%s\n' 999999; exit 0 ;;
936
+ esac
937
+ fi
938
+ exec "${FM_TEST_REAL_STAT:?}" "$@"
939
+ SH
940
+ chmod +x "$dir/fakebin/mv" "$dir/fakebin/stat"
941
+ }
942
+
943
+ assert_no_final_poll() {
944
+ local state=$1
945
+ [ ! -e "$state/task-a.check.sh" ] && [ ! -L "$state/task-a.check.sh" ] \
946
+ || fail "failed publication left a runnable check name"
947
+ [ ! -e "$state/task-a.pr-poll" ] && [ ! -L "$state/task-a.pr-poll" ] \
948
+ || fail "failed publication left a sidecar name"
949
+ [ ! -e "$state/task-a.pr-poll-registration" ] && [ ! -L "$state/task-a.pr-poll-registration" ] \
950
+ || fail "failed publication left a registration name"
951
+ }
952
+
953
+ test_postrename_poll_validation_revokes_and_retries() {
954
+ local artifact action dir state destination link_target gate
955
+ for artifact in data registration check; do
956
+ for action in type mode device content; do
957
+ dir=$(make_case "poll-final-$artifact-$action")
958
+ state="$dir/home/state"
959
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/1
960
+ fm_pr_poll_prepare "$state" task-a github https://github.com/o/r/pull/1 github.com o/r 1 "$POLL" \
961
+ || fail "could not prepare prior poll"
962
+ fm_pr_poll_publish_prepared || fail "could not publish prior poll"
963
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/2
964
+ fm_pr_poll_prepare "$state" task-a github https://github.com/o/r/pull/2 github.com o/r 2 "$POLL" \
965
+ || fail "could not stage replacement poll"
966
+ case "$artifact" in
967
+ data) destination="$state/task-a.pr-poll" ;;
968
+ registration) destination="$state/task-a.pr-poll-registration" ;;
969
+ check) destination="$state/task-a.check.sh" ;;
970
+ esac
971
+ link_target="$dir/external-sentinel"
972
+ gate="$dir/device-fault"
973
+ printf 'external sentinel\n' > "$link_target"
974
+ chmod 0644 "$link_target"
975
+ install_final_publication_fault "$dir"
976
+ if FM_TEST_FINAL_PATH="$destination" FM_TEST_FINAL_ACTION="$action" \
977
+ FM_TEST_FAULT_LINK_TARGET="$link_target" FM_TEST_FAULT_GATE="$gate" \
978
+ FM_TEST_REAL_MV="$REAL_MV" FM_TEST_REAL_STAT="$REAL_STAT" FM_TEST_REAL_CHMOD="$REAL_CHMOD" \
979
+ PATH="$dir/fakebin:$BASE_PATH" fm_pr_poll_publish_prepared; then
980
+ fail "post-rename $artifact $action fault was reported as success"
981
+ fi
982
+ fm_pr_poll_cleanup
983
+ assert_no_final_poll "$state"
984
+ [ "$(cat "$link_target")" = 'external sentinel' ] || fail "poll type fault changed an external target"
985
+ [ "$(file_mode "$link_target")" = 644 ] || fail "poll type fault changed an external target mode"
986
+
987
+ fm_pr_poll_prepare "$state" task-a github https://github.com/o/r/pull/2 github.com o/r 2 "$POLL" \
988
+ || fail "could not prepare poll retry"
989
+ PATH="$BASE_PATH" fm_pr_poll_publish_prepared || fail "poll retry did not recover after final validation fault"
990
+ fm_pr_poll_artifacts_valid "$state" task-a "$POLL" || fail "poll retry did not publish a valid pair"
991
+ done
992
+ done
993
+ pass "post-rename poll validation faults revoke both names and allow a clean retry"
994
+ }
995
+
996
+ test_bootstrap_leaves_unauthenticated_checks() {
997
+ local dir state
998
+ dir=$(make_case bootstrap-no-legacy-rewrite)
999
+ state="$dir/home/state"
1000
+ fm_write_meta "$state/task-a.meta" \
1001
+ 'window=fm-task-a' \
1002
+ 'pr=https://github.com/o/r/pull/11'
1003
+ printf 'legacy bytes\n' > "$state/task-a.check.sh"
1004
+ chmod 0700 "$state/task-a.check.sh"
1005
+
1006
+ mkdir -p "$dir/home/config"
1007
+ printf '%s\n' manual > "$dir/home/config/backlog-backend"
1008
+ FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" FM_BOOTSTRAP_NETWORK=skip \
1009
+ PATH="$dir/fakebin:$BASE_PATH" \
1010
+ "$ROOT/bin/fm-bootstrap.sh" > "$dir/bootstrap.out" 2> "$dir/bootstrap.err" \
1011
+ || fail "bootstrap failed after migration retirement"
1012
+ [ "$(cat "$state/task-a.check.sh")" = 'legacy bytes' ] \
1013
+ || fail "bootstrap rewrote an unauthenticated check after migration retirement"
1014
+ assert_no_grep 'PR_CHECK_MIGRATION' "$dir/bootstrap.out" \
1015
+ "bootstrap still emitted a retired migration diagnostic on stdout"
1016
+ assert_no_grep 'PR_CHECK_MIGRATION' "$dir/bootstrap.err" \
1017
+ "bootstrap still emitted a retired migration diagnostic on stderr"
1018
+ pass "bootstrap does not rewrite unauthenticated checks or emit retired migration diagnostics"
1019
+ }
1020
+
1021
+ test_custom_snapshot_cleanup_on_signal() {
1022
+ local dir state child_pid_file pid child_pid i rc
1023
+ dir=$(make_case custom-snapshot-signal)
1024
+ state="$dir/home/state"
1025
+ child_pid_file="$dir/custom-child.pid"
1026
+ # shellcheck disable=SC2016 # The generated child expands $$ when it runs.
1027
+ printf '%s\n' '#!/usr/bin/env bash' 'trap "" TERM' \
1028
+ 'printf "%s\n" "$$" > "$FM_TEST_CUSTOM_CHILD_PID"' 'while :; do sleep 1; done' \
1029
+ > "$state/custom.check.sh"
1030
+ chmod 0700 "$state/custom.check.sh"
1031
+ cat > "$dir/fakebin/timeout" <<'SH'
1032
+ #!/usr/bin/env bash
1033
+ shift
1034
+ "$@" &
1035
+ child=$!
1036
+ trap 'kill -TERM "$child" 2>/dev/null; exit 124' TERM
1037
+ wait "$child"
1038
+ SH
1039
+ chmod 0700 "$dir/fakebin/timeout"
1040
+ FM_HOME="$dir/home" "$REGISTER" custom >/dev/null \
1041
+ || fail "could not register signal cleanup custom check"
1042
+
1043
+ FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" FM_POLL=0 FM_CHECK_INTERVAL=0 \
1044
+ FM_SIGNAL_GRACE=0 FM_TEST_CUSTOM_CHILD_PID="$child_pid_file" \
1045
+ PATH="$dir/fakebin:$BASE_PATH" "$WATCH" \
1046
+ > "$dir/watch.out" 2> "$dir/watch.err" &
1047
+ pid=$!
1048
+ i=0
1049
+ while [ "$i" -lt 100 ]; do
1050
+ [ -s "$child_pid_file" ] && break
1051
+ kill -0 "$pid" 2>/dev/null || break
1052
+ sleep 0.02
1053
+ i=$((i + 1))
1054
+ done
1055
+ [ -s "$child_pid_file" ] || fail "watcher did not start the custom check child"
1056
+ find "$state" -maxdepth 1 -name '.fm-custom-check.*' -print | grep . >/dev/null \
1057
+ || fail "watcher did not create the custom check snapshot"
1058
+ child_pid=$(cat "$child_pid_file")
1059
+ kill -TERM "$pid" 2>/dev/null || fail "could not signal watcher during custom check"
1060
+ i=0
1061
+ while kill -0 "$pid" 2>/dev/null && [ "$i" -lt 100 ]; do
1062
+ sleep 0.02
1063
+ i=$((i + 1))
1064
+ done
1065
+ if kill -0 "$pid" 2>/dev/null; then
1066
+ kill -KILL "$pid" 2>/dev/null || true
1067
+ wait "$pid" 2>/dev/null || true
1068
+ fail "signaled watcher did not exit promptly"
1069
+ fi
1070
+ rc=0
1071
+ wait "$pid" || rc=$?
1072
+ [ "$rc" -ne 0 ] || fail "signaled watcher exited successfully"
1073
+ ! kill -0 "$child_pid" 2>/dev/null || fail "signaled watcher left the custom check child running"
1074
+ ! find "$state" -maxdepth 1 -name '.fm-custom-check.*' -print | grep . >/dev/null \
1075
+ || fail "signaled watcher left a private custom check snapshot"
1076
+ ! find "$state" -maxdepth 1 -name '.fm-check-output.*' -print | grep . >/dev/null \
1077
+ || fail "signaled watcher left a private check output file"
1078
+ [ ! -e "$state/.watch.lock/pid" ] || fail "signaled watcher left its singleton lock"
1079
+ pass "watcher signals promptly stop custom checks and clean private state"
1080
+ }
1081
+
1082
+ test_returned_custom_check_descendants_are_drained() {
1083
+ local backend dir state fakebin ready direct_done child_pid_file sentinel watcher_pid child_pid i rc alive force_fallback
1084
+ for backend in installed-timeout fallback-timeout; do
1085
+ dir=$(make_case "returned-custom-descendant-$backend")
1086
+ state="$dir/home/state"
1087
+ fakebin="$dir/fakebin"
1088
+ ready="$dir/descendant-ready"
1089
+ direct_done="$dir/direct-check-done"
1090
+ child_pid_file="$dir/descendant.pid"
1091
+ sentinel="$dir/descendant-sentinel"
1092
+ cat > "$state/custom.check.sh" <<'SH'
1093
+ #!/usr/bin/env bash
1094
+ perl -e '$SIG{TERM}="IGNORE"; open my $ready, ">", $ENV{FM_TEST_DESCENDANT_READY} or die $!; print {$ready} "ready\n"; close $ready; select undef, undef, undef, 4; open my $sentinel, ">", $ENV{FM_TEST_DESCENDANT_SENTINEL} or die $!; print {$sentinel} "late\n"; close $sentinel; select undef, undef, undef, 1' &
1095
+ printf '%s\n' "$!" > "$FM_TEST_DESCENDANT_PID"
1096
+ while [ ! -s "$FM_TEST_DESCENDANT_READY" ]; do sleep 0.01; done
1097
+ : > "$FM_TEST_DIRECT_DONE"
1098
+ SH
1099
+ chmod 0700 "$state/custom.check.sh"
1100
+ FM_HOME="$dir/home" "$REGISTER" custom >/dev/null \
1101
+ || fail "could not register $backend returned-descendant check"
1102
+ if [ "$backend" = installed-timeout ]; then
1103
+ cat > "$fakebin/timeout" <<'SH'
1104
+ #!/usr/bin/env bash
1105
+ shift
1106
+ exec "$@"
1107
+ SH
1108
+ chmod 0700 "$fakebin/timeout"
1109
+ force_fallback=0
1110
+ else
1111
+ rm -f "$fakebin/timeout" "$fakebin/gtimeout"
1112
+ force_fallback=1
1113
+ fi
1114
+
1115
+ FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" FM_POLL=0.1 FM_CHECK_INTERVAL=999999 \
1116
+ FM_CHECK_TIMEOUT=10 FM_HEARTBEAT=999999 FM_SIGNAL_GRACE=0 \
1117
+ FM_CHECK_FORCE_FALLBACK="$force_fallback" FM_TEST_DESCENDANT_READY="$ready" \
1118
+ FM_TEST_DESCENDANT_SENTINEL="$sentinel" FM_TEST_DESCENDANT_PID="$child_pid_file" \
1119
+ FM_TEST_DIRECT_DONE="$direct_done" PATH="$fakebin:$BASE_PATH" "$WATCH" \
1120
+ > "$dir/watch.out" 2> "$dir/watch.err" &
1121
+ watcher_pid=$!
1122
+ i=0
1123
+ while [ "$i" -lt 200 ]; do
1124
+ [ -s "$ready" ] && [ -s "$child_pid_file" ] && [ -e "$direct_done" ] \
1125
+ && [ -e "$state/.last-check" ] && break
1126
+ kill -0 "$watcher_pid" 2>/dev/null || break
1127
+ sleep 0.02
1128
+ i=$((i + 1))
1129
+ done
1130
+ [ -s "$ready" ] && [ -s "$child_pid_file" ] && [ -e "$direct_done" ] \
1131
+ && [ -e "$state/.last-check" ] \
1132
+ || fail "$backend watcher did not complete the direct custom check"
1133
+ child_pid=$(cat "$child_pid_file")
1134
+ kill -TERM "$watcher_pid" 2>/dev/null || fail "could not stop $backend watcher"
1135
+ i=0
1136
+ while kill -0 "$watcher_pid" 2>/dev/null && [ "$i" -lt 150 ]; do
1137
+ sleep 0.02
1138
+ i=$((i + 1))
1139
+ done
1140
+ if kill -0 "$watcher_pid" 2>/dev/null; then
1141
+ kill -KILL "$watcher_pid" 2>/dev/null || true
1142
+ wait "$watcher_pid" 2>/dev/null || true
1143
+ kill -KILL "$child_pid" 2>/dev/null || true
1144
+ fail "$backend watcher did not stop after the direct check returned"
1145
+ fi
1146
+ rc=0
1147
+ wait "$watcher_pid" || rc=$?
1148
+ [ "$rc" -ne 0 ] || fail "$backend signaled watcher exited successfully"
1149
+ alive=0
1150
+ kill -0 "$child_pid" 2>/dev/null && alive=1
1151
+ [ "$alive" -eq 0 ] || kill -KILL "$child_pid" 2>/dev/null || true
1152
+ wait "$child_pid" 2>/dev/null || true
1153
+ [ "$alive" -eq 0 ] || fail "$backend watcher left a returned check descendant alive"
1154
+ [ ! -e "$sentinel" ] || fail "$backend returned check descendant reached its sentinel"
1155
+ ! find "$state" -maxdepth 1 -name '.fm-custom-check.*' -print | grep . >/dev/null \
1156
+ || fail "$backend watcher left a private custom check snapshot"
1157
+ ! find "$state" -maxdepth 1 -name '.fm-check-output.*' -print | grep . >/dev/null \
1158
+ || fail "$backend watcher left a private check output file"
1159
+ [ ! -e "$state/.watch.lock/pid" ] || fail "$backend watcher left its singleton lock"
1160
+ done
1161
+ pass "returned custom check descendants are drained on installed and fallback timeout paths"
1162
+ }
1163
+
1164
+ test_teardown_removes_poll_artifacts() {
1165
+ local dir fakebin artifact counterpart rc
1166
+ dir=$(make_case teardown-cleanup)
1167
+ fakebin="$dir/fakebin"
1168
+ fm_write_meta "$dir/home/state/task-a.meta" \
1169
+ 'window=firstmate:fm-task-a' \
1170
+ 'endpoint_task_id=task-a' \
1171
+ "worktree=$dir/missing-worktree" \
1172
+ "project=$dir/project" \
1173
+ 'kind=ship' \
1174
+ 'mode=local-only'
1175
+ printf 'check\n' > "$dir/home/state/task-a.check.sh"
1176
+ printf 'data\n' > "$dir/home/state/task-a.pr-poll"
1177
+ printf 'registration\n' > "$dir/home/state/task-a.pr-poll-registration"
1178
+ printf 'trust\n' > "$dir/home/state/task-a.check-trust"
1179
+ cat > "$fakebin/tmux" <<'SH'
1180
+ #!/usr/bin/env bash
1181
+ exit 0
1182
+ SH
1183
+ chmod +x "$fakebin/tmux"
1184
+ touch "$dir/home/state/.last-watcher-beat"
1185
+
1186
+ FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$fakebin:$BASE_PATH" \
1187
+ "$TEARDOWN" task-a --force > "$dir/teardown.out" 2> "$dir/teardown.err" \
1188
+ || fail "teardown cleanup fixture failed"
1189
+ [ ! -e "$dir/home/state/task-a.check.sh" ] || fail "teardown left the runnable check"
1190
+ [ ! -e "$dir/home/state/task-a.pr-poll" ] || fail "teardown left the sidecar"
1191
+ [ ! -e "$dir/home/state/task-a.pr-poll-registration" ] || fail "teardown left the PR poll registration"
1192
+ [ ! -e "$dir/home/state/task-a.check-trust" ] || fail "teardown left the custom check registration"
1193
+
1194
+ dir=$(make_case teardown-retirement-receipt)
1195
+ fakebin="$dir/fakebin"
1196
+ fm_write_meta "$dir/home/state/task-a.meta" \
1197
+ 'window=firstmate:fm-task-a' \
1198
+ 'endpoint_task_id=task-a' \
1199
+ "worktree=$dir/missing-worktree" \
1200
+ "project=$dir/project" \
1201
+ 'kind=ship' \
1202
+ 'mode=local-only' \
1203
+ 'pr=https://github.com/o/r/pull/18'
1204
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/18
1205
+ fm_pr_poll_snapshot_capture "$dir/home/state" task-a "$POLL" \
1206
+ || fail "could not snapshot teardown receipt fixture"
1207
+ fm_pr_poll_retirement_publish "$dir/home/state" task-a "$POLL" merged \
1208
+ || fail "could not publish teardown receipt fixture"
1209
+ rm -f "$dir/home/state/task-a.check.sh"
1210
+ cat > "$fakebin/tmux" <<'SH'
1211
+ #!/usr/bin/env bash
1212
+ exit 0
1213
+ SH
1214
+ chmod +x "$fakebin/tmux"
1215
+ touch "$dir/home/state/.last-watcher-beat"
1216
+ FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$fakebin:$BASE_PATH" \
1217
+ "$TEARDOWN" task-a --force > "$dir/teardown.out" 2> "$dir/teardown.err" \
1218
+ || fail "teardown could not finish a valid crash-left retirement receipt"
1219
+ assert_poll_absent "$dir/home/state" task-a
1220
+ [ ! -e "$dir/home/state/task-a.meta" ] || fail "receipt-aware teardown left task metadata"
1221
+
1222
+ for artifact in check.sh pr-poll; do
1223
+ dir=$(make_case "teardown-final-directory-${artifact//./-}")
1224
+ fakebin="$dir/fakebin"
1225
+ fm_write_meta "$dir/home/state/task-a.meta" \
1226
+ 'window=firstmate:fm-task-a' \
1227
+ 'endpoint_task_id=task-a' \
1228
+ "worktree=$dir/missing-worktree" \
1229
+ "project=$dir/project" \
1230
+ 'kind=ship' \
1231
+ 'mode=local-only'
1232
+ if [ "$artifact" = check.sh ]; then
1233
+ counterpart=pr-poll
1234
+ else
1235
+ counterpart=check.sh
1236
+ fi
1237
+ mkdir "$dir/home/state/task-a.$artifact"
1238
+ printf 'directory sentinel\n' > "$dir/home/state/task-a.$artifact/sentinel"
1239
+ printf 'counterpart sentinel\n' > "$dir/home/state/task-a.$counterpart"
1240
+ cat > "$fakebin/tmux" <<'SH'
1241
+ #!/usr/bin/env bash
1242
+ printf '%s\n' "$*" >> "${FM_FAKE_TMUX_LOG:?}"
1243
+ exit 0
1244
+ SH
1245
+ chmod +x "$fakebin/tmux"
1246
+ touch "$dir/home/state/.last-watcher-beat"
1247
+ set +e
1248
+ FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" FM_FAKE_TMUX_LOG="$dir/tmux.log" \
1249
+ PATH="$fakebin:$BASE_PATH" "$TEARDOWN" task-a --force \
1250
+ > "$dir/teardown.out" 2> "$dir/teardown.err"
1251
+ rc=$?
1252
+ set -e
1253
+ [ "$rc" -ne 0 ] || fail "teardown accepted a directory-shaped $artifact"
1254
+ [ -e "$dir/home/state/task-a.meta" ] || fail "teardown removed metadata before $artifact refusal"
1255
+ [ "$(cat "$dir/home/state/task-a.$artifact/sentinel")" = 'directory sentinel' ] \
1256
+ || fail "teardown changed the directory-shaped $artifact"
1257
+ [ "$(cat "$dir/home/state/task-a.$counterpart")" = 'counterpart sentinel' ] \
1258
+ || fail "teardown removed the counterpart before $artifact refusal"
1259
+ grep -F 'kill-window' "$dir/tmux.log" >/dev/null 2>&1 \
1260
+ && fail "teardown killed the endpoint before $artifact refusal"
1261
+ done
1262
+
1263
+ pass "teardown removes safe poll artifacts and refuses directory-shaped check files without traversal"
1264
+ }
1265
+
1266
+ # The GitLab watch must follow a merge request exactly as the GitHub watch
1267
+ # follows a pull request, on any instance, and must never turn an unreadable
1268
+ # merge request into a merge. Its evidence against the public fixture project
1269
+ # https://gitlab.com/KarotKris/gitlab-merge-watch-fixture is in
1270
+ # docs/gitlab-merge-watch.md; this exercises the same paths hermetically.
1271
+ test_gitlab_merge_watch() {
1272
+ local dir state out rc url value noglab entry bindir name
1273
+ dir=$(make_case gitlab-merge-watch)
1274
+ state="$dir/home/state"
1275
+ url=https://gitlab.example/group/subgroup/project/-/merge_requests/7
1276
+
1277
+ write_poll_meta "$state" task-a "$url"
1278
+ fm_pr_poll_prepare "$state" task-a gitlab "$url" gitlab.example group/subgroup/project 7 "$POLL" \
1279
+ || fail "could not prepare a GitLab poll"
1280
+ fm_pr_poll_publish_prepared || fail "could not publish a GitLab poll"
1281
+ fm_pr_poll_artifacts_valid "$state" task-a "$POLL" \
1282
+ || fail "published GitLab poll provenance or metadata binding was invalid"
1283
+ [ "$(cat "$state/task-a.pr-poll")" = "gitlab
1284
+ $url
1285
+ gitlab.example
1286
+ group/subgroup/project
1287
+ 7" ] || fail "published GitLab sidecar bytes were not exact"
1288
+
1289
+ # Only an exact merged state wakes firstmate. Every other reading, including
1290
+ # an unreadable merge request and a changed output format, stays silent.
1291
+ for value in opened closed locked '' not-a-state MERGED merged-but-not; do
1292
+ out=$(FM_TEST_GLAB_STATE="$value" run_poll "$dir")
1293
+ [ -z "$out" ] || fail "GitLab poll emitted for a non-merged state"
1294
+ done
1295
+ out=$(FM_TEST_GLAB_STATE=merged run_poll "$dir")
1296
+ [ "$out" = merged ] || fail "GitLab poll did not emit exactly one merged line"
1297
+ out=$(FM_TEST_GLAB_FAIL=1 run_poll "$dir")
1298
+ [ -z "$out" ] || fail "GitLab poll emitted after a glab failure"
1299
+
1300
+ # glab is addressed by project URL and merge request number, never by the
1301
+ # merge request URL, which the real CLI resolves through the current git
1302
+ # repository the watcher does not have.
1303
+ grep -qF -- "mr view 7 -R https://gitlab.example/group/subgroup/project" "$dir/glab.log" \
1304
+ || fail "GitLab poll did not address glab by project URL and merge request number"
1305
+ ! grep -qF -- "$url" "$dir/glab.log" \
1306
+ || fail "GitLab poll passed a merge request URL to glab"
1307
+
1308
+ # An absent CLI must produce no wake rather than a false merge. The whole
1309
+ # search path is mirrored without glab, because a real glab anywhere on
1310
+ # PATH would make this prove nothing.
1311
+ noglab="$dir/noglab"
1312
+ mkdir -p "$noglab"
1313
+ while IFS= read -r bindir; do
1314
+ [ -d "$bindir" ] || continue
1315
+ for entry in "$bindir"/*; do
1316
+ [ -e "$entry" ] || continue
1317
+ name=$(basename "$entry")
1318
+ [ "$name" = glab ] && continue
1319
+ [ -e "$noglab/$name" ] || ln -s "$entry" "$noglab/$name" 2>/dev/null
1320
+ done
1321
+ done <<EOF
1322
+ $dir/fakebin
1323
+ $(printf '%s\n' "$BASE_PATH" | tr ':' '\n')
1324
+ EOF
1325
+ ! PATH="$noglab" command -v glab >/dev/null 2>&1 \
1326
+ || fail "the glab-free search path still resolved glab"
1327
+ out=$(FM_TEST_GLAB_STATE=merged FM_TEST_GH_LOG="$dir/gh.log" FM_TEST_GLAB_LOG="$dir/glab.log" \
1328
+ PATH="$noglab" \
1329
+ bash "$state/task-a.check.sh")
1330
+ [ -z "$out" ] || fail "GitLab poll emitted with glab absent from PATH"
1331
+
1332
+ # A doctored sidecar cannot redirect the poll: the stored parts must rebuild
1333
+ # the stored URL exactly.
1334
+ printf '%s\n%s\n%s\n%s\n%s\n' gitlab "$url" elsewhere.example group/subgroup/project 7 \
1335
+ > "$state/task-a.pr-poll"
1336
+ out=$(FM_TEST_GLAB_STATE=merged run_poll "$dir")
1337
+ [ -z "$out" ] || fail "GitLab poll emitted for a sidecar whose host was swapped"
1338
+ printf '%s\n%s\n%s\n%s\n%s\n' gitlab "$url" gitlab.example group/subgroup/other 7 \
1339
+ > "$state/task-a.pr-poll"
1340
+ out=$(FM_TEST_GLAB_STATE=merged run_poll "$dir")
1341
+ [ -z "$out" ] || fail "GitLab poll emitted for a sidecar whose project was swapped"
1342
+
1343
+ # Arming is where a missing CLI can still be reported, so it refuses there.
1344
+ write_task_meta "$dir" task-b
1345
+ set +e
1346
+ out=$(FM_ROOT_OVERRIDE="$dir/root" FM_HOME="$dir/home" \
1347
+ FM_TEST_GUARD_LOG="$dir/guard.log" PATH="$noglab" \
1348
+ "$PR_CHECK" task-b "$url" 2>&1)
1349
+ rc=$?
1350
+ set -e
1351
+ [ "$rc" -ne 0 ] || fail "arming a GitLab watch succeeded with glab absent"
1352
+ case "$out" in
1353
+ *"requires glab on PATH"*) ;;
1354
+ *) fail "arming a GitLab watch with glab absent did not report the missing CLI" ;;
1355
+ esac
1356
+ [ ! -e "$state/task-b.check.sh" ] || fail "refused GitLab arming left a poll armed"
1357
+
1358
+ # The merge path addresses the forge the URL names, and never the other one.
1359
+ # This fixture's glab answers with the field output the poll reads, so the
1360
+ # merge's JSON read cannot be parsed, which must refuse rather than merge on a
1361
+ # state it could not read.
1362
+ write_task_meta "$dir" task-c
1363
+ : > "$dir/glab.log"
1364
+ # The merge path needs jq before it reads anything, so this case supplies it
1365
+ # and the refusal below is the unreadable state rather than a missing tool.
1366
+ ln -sf "$REAL_JQ" "$dir/fakebin/jq"
1367
+ set +e
1368
+ run_merge_entry "$dir" task-c "$url" >/dev/null 2> "$dir/merge-c.err"
1369
+ rc=$?
1370
+ set -e
1371
+ [ "$rc" -ne 0 ] || fail "merge wrapper merged a GitLab merge request it could not read"
1372
+ grep -qF 'could not read the GitLab merge request state before merging' "$dir/merge-c.err" \
1373
+ || fail "merge wrapper refused for some reason other than the state it could not read"
1374
+ [ ! -s "$dir/gh-axi.log" ] || fail "merge wrapper reached the GitHub CLI for a GitLab URL"
1375
+ grep -qF "mr view 7 -R https://gitlab.example/group/subgroup/project" "$dir/glab.log" \
1376
+ || fail "merge wrapper did not read the merge request through glab at its own instance"
1377
+ ! grep -qF ' mr merge ' "$dir/glab.log" \
1378
+ || fail "merge wrapper merged despite an unreadable merge request state"
1379
+
1380
+ pass "GitLab merge requests are followed on any instance and never wake falsely"
1381
+ }
1382
+
1383
+ seed_canonical_poll() {
1384
+ local dir=$1 id=$2 url=$3 template=${4:-$POLL} state provider host path number
1385
+ state="$dir/home/state"
1386
+ fm_pr_url_parse "$url" || fail "retirement fixture URL was invalid"
1387
+ provider=$FM_PR_PROVIDER
1388
+ host=$FM_PR_HOST
1389
+ path=$FM_PR_PATH
1390
+ number=$FM_PR_NUMBER
1391
+ fm_pr_poll_prepare "$state" "$id" "$provider" "$url" "$host" "$path" "$number" "$template" \
1392
+ || fail "could not prepare retirement fixture"
1393
+ fm_pr_poll_publish_prepared || fail "could not publish retirement fixture"
1394
+ }
1395
+
1396
+ add_stop_custom_check() {
1397
+ local dir=$1 state
1398
+ state="$dir/home/state"
1399
+ printf '#!/usr/bin/env bash\nprintf "stop-cycle\\n"\n' > "$state/z-stop.check.sh"
1400
+ chmod 0700 "$state/z-stop.check.sh"
1401
+ FM_HOME="$dir/home" "$REGISTER" z-stop >/dev/null \
1402
+ || fail "could not register stop-cycle custom check"
1403
+ }
1404
+
1405
+ assert_poll_absent() {
1406
+ local state=$1 id=$2 suffix
1407
+ for suffix in check.sh pr-poll pr-poll-registration pr-poll-retirement; do
1408
+ [ ! -e "$state/$id.$suffix" ] && [ ! -L "$state/$id.$suffix" ] \
1409
+ || fail "retired poll left $id.$suffix"
1410
+ done
1411
+ }
1412
+
1413
+ poll_artifact_snapshot() {
1414
+ local state=$1 id=$2 suffix path
1415
+ for suffix in check.sh pr-poll pr-poll-registration pr-poll-retirement meta; do
1416
+ path="$state/$id.$suffix"
1417
+ [ -e "$path" ] || [ -L "$path" ] || continue
1418
+ if [ -L "$path" ]; then
1419
+ printf 'link %s %s\n' "$suffix" "$(readlink "$path")"
1420
+ elif [ -f "$path" ]; then
1421
+ printf 'file %s %s ' "$suffix" "$(file_mode "$path")"
1422
+ shasum -a 256 "$path" | awk '{print $1}'
1423
+ else
1424
+ printf 'other %s\n' "$suffix"
1425
+ fi
1426
+ done
1427
+ }
1428
+
1429
+ test_merged_poll_retires_once() {
1430
+ local dir state rc first second meta_before
1431
+ dir=$(make_case merged-retirement-once)
1432
+ state="$dir/home/state"
1433
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/1
1434
+ meta_before=$(cat "$state/task-a.meta")
1435
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/1
1436
+ add_stop_custom_check "$dir"
1437
+
1438
+ set +e
1439
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch-1.out" 2> "$dir/watch-1.err"
1440
+ rc=$?
1441
+ set -e
1442
+ [ "$rc" -eq 0 ] || fail "merged retirement watcher failed: $(cat "$dir/watch-1.err")"
1443
+ first=$(cat "$dir/watch-1.out")
1444
+ case "$first" in check:*task-a.check.sh:*merged) ;; *) fail "first merged notification was not preserved: $first" ;; esac
1445
+ ack_watcher_cycle "$state" || fail "first merged notification handling acknowledgement failed"
1446
+ assert_poll_absent "$state" task-a
1447
+ [ "$(cat "$state/task-a.meta")" = "$meta_before" ] || fail "merged retirement changed canonical metadata"
1448
+
1449
+ rm -f "$state/.last-check"
1450
+ set +e
1451
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch-2.out" 2> "$dir/watch-2.err"
1452
+ rc=$?
1453
+ set -e
1454
+ [ "$rc" -eq 0 ] || fail "second watcher cycle failed: $(cat "$dir/watch-2.err")"
1455
+ second=$(cat "$dir/watch-2.out")
1456
+ case "$second" in check:*z-stop.check.sh:*stop-cycle) ;; *) fail "second cycle did not reach the control check: $second" ;; esac
1457
+ ! grep -F 'task-a.check.sh: merged' "$dir/watch-2.out" >/dev/null \
1458
+ || fail "retired merged poll executed a second time"
1459
+ ! grep "$(printf '\tcheck\ttask-a.check.sh\t')" "$state/.wake-queue" >/dev/null 2>&1 \
1460
+ || fail "handled merged notification remained queued after acknowledgement"
1461
+ pass "validated merged polls notify once and retire before the next watcher cycle"
1462
+ }
1463
+
1464
+ # A poll's own retirement state is scoped to ONE registration, so it cannot by
1465
+ # itself catch a poll re-registered for a task whose merge was already
1466
+ # surfaced (e.g. bin/fm-pr-check.sh re-armed after the fact). The per-task
1467
+ # merge-notified marker (bin/fm-pr-lib.sh) is what stops that re-registration
1468
+ # from producing a second main-blocking wake for the identical merge, while a
1469
+ # genuinely first notification (test_merged_poll_retires_once above) still
1470
+ # reaches main.
1471
+ test_merged_poll_reregistration_after_notification_is_absorbed() {
1472
+ local dir state rc first
1473
+ dir=$(make_case merged-reregistration-absorbed)
1474
+ state="$dir/home/state"
1475
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/1
1476
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/1
1477
+ add_stop_custom_check "$dir"
1478
+
1479
+ set +e
1480
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch-1.out" 2> "$dir/watch-1.err"
1481
+ rc=$?
1482
+ set -e
1483
+ [ "$rc" -eq 0 ] || fail "first merged watcher cycle failed: $(cat "$dir/watch-1.err")"
1484
+ first=$(cat "$dir/watch-1.out")
1485
+ case "$first" in check:*task-a.check.sh:*merged) ;; *) fail "first merge confirmation was not delivered: $first" ;; esac
1486
+ ack_watcher_cycle "$state" || fail "first merge confirmation acknowledgement failed"
1487
+ assert_poll_absent "$state" task-a
1488
+ [ -f "$state/task-a.pr-poll-merge-notified" ] || fail "the merge-notified marker was not recorded"
1489
+
1490
+ # Re-registration: fm-pr-check.sh re-armed for a task whose PR is already
1491
+ # merged (a fresh check.sh/pr-poll/pr-poll-registration, a distinct
1492
+ # retirement identity from the one just retired).
1493
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/1
1494
+ rm -f "$state/.last-check"
1495
+
1496
+ set +e
1497
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch-2.out" 2> "$dir/watch-2.err"
1498
+ rc=$?
1499
+ set -e
1500
+ [ "$rc" -eq 0 ] || fail "second watcher cycle failed: $(cat "$dir/watch-2.err")"
1501
+ case "$(cat "$dir/watch-2.out")" in
1502
+ check:*z-stop.check.sh:*stop-cycle) ;;
1503
+ *) fail "the re-registered duplicate did not fall through to the next check: $(cat "$dir/watch-2.out")" ;;
1504
+ esac
1505
+ ! grep -F 'task-a.check.sh: merged' "$dir/watch-2.out" >/dev/null \
1506
+ || fail "a repeat identical merged poll opened a main-blocking row: $(cat "$dir/watch-2.out")"
1507
+ ! grep "$(printf '\tcheck\ttask-a.check.sh\t')" "$state/.wake-queue" >/dev/null 2>&1 \
1508
+ || fail "the absorbed duplicate merge notice was queued as a main-blocking row"
1509
+ assert_poll_absent "$state" task-a
1510
+ pass "a repeat identical merged poll for an already-notified task is absorbed, never queued as a main-blocking row"
1511
+ }
1512
+
1513
+ # The captain merging a PR himself on the forge is the same outcome as a merge
1514
+ # this home performed: bin/fm-merge-outcome-lib.sh carries both to the parent on
1515
+ # the one reply channel, so no second watch path exists for the captain's case.
1516
+ # The poll's own durable row still lands here, because the mate that owns the
1517
+ # task still has to act on it.
1518
+ seed_secondmate_home() { # <dir> [<route>]
1519
+ local dir=$1 route=${2:-remote}
1520
+ printf '%s\n' mate-x > "$dir/home/.fm-secondmate-home"
1521
+ printf 'schema=fm-secondmate-parent.v1\nroute=%s\n' "$route" \
1522
+ > "$dir/home/.fm-secondmate-parent"
1523
+ }
1524
+
1525
+ test_merged_poll_retries_a_failed_upward_report() {
1526
+ local dir state rc replies url
1527
+ url=https://github.com/o/r/pull/1
1528
+ dir=$(make_case merged-poll-upward-retry)
1529
+ state="$dir/home/state"
1530
+ replies="$state/parent-replies.status"
1531
+ printf '%s\n' mate-x > "$dir/home/.fm-secondmate-home"
1532
+ write_poll_meta "$state" task-a "$url"
1533
+ seed_canonical_poll "$dir" task-a "$url"
1534
+ add_stop_custom_check "$dir"
1535
+
1536
+ set +e
1537
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" \
1538
+ > "$dir/watch-1.out" 2> "$dir/watch-1.err"
1539
+ rc=$?
1540
+ set -e
1541
+ [ "$rc" -ne 0 ] || fail "merged-poll-upward-retry: failed report did not keep the watcher loud"
1542
+ [ -f "$state/task-a.check.sh" ] \
1543
+ || fail "merged-poll-upward-retry: failed report retired its retry poll"
1544
+ [ ! -e "$state/task-a.pr-poll-merge-notified" ] \
1545
+ || fail "merged-poll-upward-retry: failed report was marked notified"
1546
+ [ ! -e "$replies" ] \
1547
+ || fail "merged-poll-upward-retry: failed report wrote a parent reply"
1548
+
1549
+ printf 'schema=fm-secondmate-parent.v1\nroute=remote\n' \
1550
+ > "$dir/home/.fm-secondmate-parent"
1551
+ rm -f "$state/.last-check"
1552
+ set +e
1553
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" \
1554
+ > "$dir/watch-2.out" 2> "$dir/watch-2.err"
1555
+ rc=$?
1556
+ set -e
1557
+ [ "$rc" -eq 0 ] || fail "merged-poll-upward-retry: retry failed: $(cat "$dir/watch-2.err")"
1558
+ if [ ! -e "$replies" ]; then
1559
+ ack_watcher_cycle "$state" \
1560
+ || fail "merged-poll-upward-retry: recovery acknowledgement failed"
1561
+ rm -f "$state/.last-check"
1562
+ set +e
1563
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" \
1564
+ > "$dir/watch-3.out" 2> "$dir/watch-3.err"
1565
+ rc=$?
1566
+ set -e
1567
+ [ "$rc" -eq 0 ] || fail "merged-poll-upward-retry: post-recovery retry failed: $(cat "$dir/watch-3.err")"
1568
+ fi
1569
+ assert_grep "done [key=merged-task-a]: merged task-a $url" "$replies" \
1570
+ "merged-poll-upward-retry: repaired binding did not receive the retry"
1571
+ assert_poll_absent "$state" task-a
1572
+ pass "a failed upward merge report keeps its poll armed for repair and retry"
1573
+ }
1574
+
1575
+ test_self_merge_and_poll_publish_one_outcome() {
1576
+ local dir state replies url rc
1577
+ url=https://github.com/o/r/pull/1
1578
+
1579
+ # Interleaving one: self publication commits before the poll observes the
1580
+ # merge, so the poll absorbs the committed identity without reporting again.
1581
+ dir=$(make_case merge-outcome-committed)
1582
+ state="$dir/home/state"
1583
+ replies="$state/parent-replies.status"
1584
+ seed_secondmate_home "$dir"
1585
+ write_task_meta "$dir" task-a
1586
+ run_check_entry "$dir" task-a "$url" >/dev/null 2>"$dir/seed.err" \
1587
+ || fail "merge-outcome-committed: could not arm merge poll"
1588
+ run_merge_entry "$dir" task-a "$url" >"$dir/merge.out" 2>"$dir/merge.err" \
1589
+ || fail "merge-outcome-committed: merge entrypoint failed: $(cat "$dir/merge.err")"
1590
+ add_stop_custom_check "$dir"
1591
+ set +e
1592
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" \
1593
+ >"$dir/watch.out" 2>"$dir/watch.err"
1594
+ rc=$?
1595
+ set -e
1596
+ [ "$rc" -eq 0 ] \
1597
+ || fail "merge-outcome-committed: watcher failed: $(cat "$dir/watch.err")"
1598
+ [ "$(grep -c -F "$url" "$replies")" -eq 1 ] \
1599
+ || fail "merge-outcome-committed: self and poll reports produced duplicate outcomes"
1600
+ assert_no_grep "check: $state/task-a.check.sh: merged" "$state/.wake-queue" \
1601
+ "merge-outcome-committed: absorbed poll published a second outcome"
1602
+ assert_poll_absent "$state" task-a
1603
+
1604
+ # Interleaving two: self publication lands but its marker commit fails. After
1605
+ # that outcome is drained, the still-armed poll must publish it again rather
1606
+ # than treating the interrupted attempt as complete and going silent.
1607
+ dir=$(make_case merge-outcome-uncommitted)
1608
+ state="$dir/home/state"
1609
+ write_task_meta "$dir" task-a
1610
+ run_check_entry "$dir" task-a "$url" >/dev/null 2>"$dir/seed.err" \
1611
+ || fail "merge-outcome-uncommitted: could not arm merge poll"
1612
+ cat >"$dir/fakebin/mv" <<'SH'
1613
+ #!/usr/bin/env bash
1614
+ case " $* " in
1615
+ *pr-poll-merge-notified*) exit 1 ;;
1616
+ esac
1617
+ exec "$FM_TEST_REAL_MV" "$@"
1618
+ SH
1619
+ chmod +x "$dir/fakebin/mv"
1620
+ set +e
1621
+ FM_TEST_REAL_MV="$REAL_MV" run_merge_entry "$dir" task-a "$url" \
1622
+ >"$dir/merge.out" 2>"$dir/merge.err"
1623
+ rc=$?
1624
+ set -e
1625
+ [ "$rc" -eq 0 ] \
1626
+ || fail "merge-outcome-uncommitted: landed merge was reported as failed"
1627
+ assert_grep "$url" "$state/.wake-queue" \
1628
+ "merge-outcome-uncommitted: interrupted publication emitted no outcome"
1629
+ [ ! -e "$state/task-a.pr-poll-merge-notified" ] \
1630
+ || fail "merge-outcome-uncommitted: failed marker commit was treated as complete"
1631
+ ack_watcher_cycle "$state" \
1632
+ || fail "merge-outcome-uncommitted: could not drain the first outcome"
1633
+ assert_no_grep "$url" "$state/.wake-queue" \
1634
+ "merge-outcome-uncommitted: first outcome remained queued after its drain"
1635
+ rm -f "$dir/fakebin/mv" "$state/.last-check"
1636
+
1637
+ set +e
1638
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" \
1639
+ >"$dir/watch.out" 2>"$dir/watch.err"
1640
+ rc=$?
1641
+ set -e
1642
+ [ "$rc" -eq 0 ] \
1643
+ || fail "merge-outcome-uncommitted: poll retry failed: $(cat "$dir/watch.err")"
1644
+ case "$(cat "$dir/watch.out")" in
1645
+ check:*task-a.check.sh:*merged) ;;
1646
+ *) fail "merge-outcome-uncommitted: poll retry did not re-emit the outcome" ;;
1647
+ esac
1648
+ assert_grep "$url" "$state/.wake-queue" \
1649
+ "merge-outcome-uncommitted: drained outcome was not durably re-emitted"
1650
+ fm_pr_poll_merge_already_notified "$state" task-a github github.com o/r 1 \
1651
+ || fail "merge-outcome-uncommitted: successful retry did not commit the marker"
1652
+ assert_poll_absent "$state" task-a
1653
+ pass "staged self-merge and poll interleavings are never silent"
1654
+ }
1655
+
1656
+ test_merged_poll_reports_upward_from_a_secondmate_home_once() {
1657
+ local dir state rc replies url
1658
+ url=https://github.com/o/r/pull/1
1659
+ dir=$(make_case merged-poll-upward)
1660
+ state="$dir/home/state"
1661
+ replies="$state/parent-replies.status"
1662
+ seed_secondmate_home "$dir"
1663
+ write_poll_meta "$state" task-a "$url"
1664
+ seed_canonical_poll "$dir" task-a "$url"
1665
+ add_stop_custom_check "$dir"
1666
+
1667
+ set +e
1668
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch-1.out" 2> "$dir/watch-1.err"
1669
+ rc=$?
1670
+ set -e
1671
+ [ "$rc" -eq 0 ] || fail "merged-poll-upward: watcher failed: $(cat "$dir/watch-1.err")"
1672
+ case "$(cat "$dir/watch-1.out")" in
1673
+ check:*task-a.check.sh:*merged) ;;
1674
+ *) fail "merged-poll-upward: the poll's own row was lost: $(cat "$dir/watch-1.out")" ;;
1675
+ esac
1676
+ assert_grep "done [key=merged-task-a]: merged task-a $url" "$replies" \
1677
+ "merged-poll-upward: a merge this home did not perform was never reported upward"
1678
+ [ "$(grep -c -F "$url" "$replies")" -eq 1 ] \
1679
+ || fail "merged-poll-upward: one detected merge produced more than one upward line"
1680
+ ack_watcher_cycle "$state" || fail "merged-poll-upward: acknowledgement failed"
1681
+
1682
+ # Re-registered for the same, already-reported merge: the absorbed duplicate
1683
+ # must not tell the parent a second time either.
1684
+ seed_canonical_poll "$dir" task-a "$url"
1685
+ rm -f "$state/.last-check"
1686
+ set +e
1687
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch-2.out" 2> "$dir/watch-2.err"
1688
+ rc=$?
1689
+ set -e
1690
+ [ "$rc" -eq 0 ] || fail "merged-poll-upward: second watcher cycle failed: $(cat "$dir/watch-2.err")"
1691
+ [ "$(grep -c -F "$url" "$replies")" -eq 1 ] \
1692
+ || fail "merged-poll-upward: an absorbed duplicate detection reported the merge again"
1693
+ pass "a merge detected by the poll is reported upward from a secondmate home exactly once"
1694
+ }
1695
+
1696
+ test_different_merged_pr_for_same_task_is_not_absorbed() {
1697
+ local dir state rc
1698
+ dir=$(make_case different-merged-pr-not-absorbed)
1699
+ state="$dir/home/state"
1700
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/1
1701
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/1
1702
+
1703
+ set +e
1704
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch-1.out" 2> "$dir/watch-1.err"
1705
+ rc=$?
1706
+ set -e
1707
+ [ "$rc" -eq 0 ] || fail "first merged watcher cycle failed: $(cat "$dir/watch-1.err")"
1708
+ case "$(cat "$dir/watch-1.out")" in
1709
+ check:*task-a.check.sh:*merged) ;;
1710
+ *) fail "first PR merge confirmation was not delivered: $(cat "$dir/watch-1.out")" ;;
1711
+ esac
1712
+ ack_watcher_cycle "$state" || fail "first PR merge confirmation acknowledgement failed"
1713
+ assert_poll_absent "$state" task-a
1714
+
1715
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/2
1716
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/2
1717
+ rm -f "$state/.last-check"
1718
+
1719
+ set +e
1720
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch-2.out" 2> "$dir/watch-2.err"
1721
+ rc=$?
1722
+ set -e
1723
+ [ "$rc" -eq 0 ] || fail "different-PR watcher cycle failed: $(cat "$dir/watch-2.err")"
1724
+ case "$(cat "$dir/watch-2.out")" in
1725
+ check:*task-a.check.sh:*merged) ;;
1726
+ *) fail "a different PR merge was absorbed: $(cat "$dir/watch-2.out")" ;;
1727
+ esac
1728
+ grep -F "$(printf '\tcheck\tmerged-task-a-https://github.com/o/r/pull/2\t')" \
1729
+ "$state/.wake-queue" >/dev/null 2>&1 \
1730
+ || fail "the different PR merge did not create a main-blocking wake row"
1731
+ fm_pr_poll_merge_already_notified "$state" task-a github github.com o/r 2 \
1732
+ || fail "the marker was not advanced to the different PR identity"
1733
+ ! fm_pr_poll_merge_already_notified "$state" task-a github github.com o/r 1 \
1734
+ || fail "the marker still matched the superseded PR identity"
1735
+ assert_poll_absent "$state" task-a
1736
+ pass "a different merged PR for the same task gets its own first notification"
1737
+ }
1738
+
1739
+ test_persistent_secondmate_retirement_is_poll_only() {
1740
+ local dir state meta_before status_before registry_before endpoint_before rc
1741
+ dir=$(make_case merged-retirement-secondmate)
1742
+ state="$dir/home/state"
1743
+ fm_write_meta "$state/domain.meta" \
1744
+ 'window=session:fm-domain' \
1745
+ "worktree=$dir/secondmate-home" \
1746
+ "project=$dir/project" \
1747
+ 'kind=secondmate' \
1748
+ 'mode=secondmate' \
1749
+ 'backend=tmux' \
1750
+ "home=$dir/secondmate-home" \
1751
+ 'pr=https://github.com/o/r/pull/2' \
1752
+ 'pr_head=0123456789abcdef0123456789abcdef01234567'
1753
+ mkdir -p "$dir/secondmate-home"
1754
+ printf 'working: persistent endpoint remains healthy\n' > "$state/domain.status"
1755
+ printf -- '- domain | scope: test | home: %s\n' "$dir/secondmate-home" > "$dir/home/data/secondmates.md"
1756
+ printf 'endpoint-alive\n' > "$dir/endpoint-sentinel"
1757
+ meta_before=$(shasum -a 256 "$state/domain.meta")
1758
+ status_before=$(shasum -a 256 "$state/domain.status")
1759
+ registry_before=$(shasum -a 256 "$dir/home/data/secondmates.md")
1760
+ endpoint_before=$(shasum -a 256 "$dir/endpoint-sentinel")
1761
+ seed_canonical_poll "$dir" domain https://github.com/o/r/pull/2
1762
+
1763
+ set +e
1764
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch.out" 2> "$dir/watch.err"
1765
+ rc=$?
1766
+ set -e
1767
+ [ "$rc" -eq 0 ] || fail "persistent secondmate merged watcher failed: $(cat "$dir/watch.err")"
1768
+ assert_poll_absent "$state" domain
1769
+ [ "$(shasum -a 256 "$state/domain.meta")" = "$meta_before" ] || fail "retirement changed secondmate metadata"
1770
+ [ "$(shasum -a 256 "$state/domain.status")" = "$status_before" ] || fail "retirement changed secondmate status"
1771
+ [ "$(shasum -a 256 "$dir/home/data/secondmates.md")" = "$registry_before" ] || fail "retirement changed secondmate registry"
1772
+ [ "$(shasum -a 256 "$dir/endpoint-sentinel")" = "$endpoint_before" ] || fail "retirement changed secondmate endpoint evidence"
1773
+ [ -d "$dir/secondmate-home" ] || fail "retirement removed the persistent secondmate home"
1774
+ pass "merged poll retirement preserves every persistent secondmate lifecycle artifact"
1775
+ }
1776
+
1777
+ test_retirement_crash_recovery() {
1778
+ local dir state rc raw_count drain_count historical_poll
1779
+
1780
+ dir=$(make_case retirement-after-queue)
1781
+ state="$dir/home/state"
1782
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/3
1783
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/3
1784
+ FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_wake_append check "$2" "$3"' _ \
1785
+ "$ROOT/bin/fm-wake-lib.sh" "$state/task-a.check.sh" "check: $state/task-a.check.sh: merged" \
1786
+ || fail "could not seed post-queue crash"
1787
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/recovery.out" 2> "$dir/recovery.err" \
1788
+ || fail "post-queue crash recovery wake failed: $(cat "$dir/recovery.err")"
1789
+ grep -F 'check: rearm-resurface' "$dir/recovery.out" >/dev/null \
1790
+ || fail "post-queue crash did not surface its durable recovery first"
1791
+ ack_watcher_cycle "$state" || fail "post-queue crash recovery acknowledgement failed"
1792
+ set +e
1793
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch.out" 2> "$dir/watch.err"
1794
+ rc=$?
1795
+ set -e
1796
+ [ "$rc" -eq 0 ] || fail "post-queue retry watcher failed: $(cat "$dir/watch.err")"
1797
+ assert_poll_absent "$state" task-a
1798
+ raw_count=$(grep -cF "$(printf '\tcheck\tmerged-task-a-https://github.com/o/r/pull/3\t')" \
1799
+ "$state/.wake-queue" || true)
1800
+ [ "$raw_count" -eq 1 ] || fail "post-queue retry did not publish exactly one new terminal row"
1801
+ FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" "$ROOT/bin/fm-wake-drain.sh" > "$dir/drain.out" 2>/dev/null
1802
+ drain_count=$(grep -cF "$(printf '\tcheck\tmerged-task-a-https://github.com/o/r/pull/3\t')" \
1803
+ "$dir/drain.out" || true)
1804
+ [ "$drain_count" -eq 1 ] || fail "same-key crash retry rows did not deduplicate at drain"
1805
+
1806
+ dir=$(make_case retirement-after-receipt)
1807
+ state="$dir/home/state"
1808
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/4
1809
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/4
1810
+ fm_pr_poll_snapshot_capture "$state" task-a "$POLL" || fail "could not snapshot receipt crash fixture"
1811
+ fm_pr_poll_retirement_publish "$state" task-a "$POLL" merged || fail "could not publish crash receipt"
1812
+ add_stop_custom_check "$dir"
1813
+ set +e
1814
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/restart.out" 2> "$dir/restart.err"
1815
+ rc=$?
1816
+ set -e
1817
+ [ "$rc" -eq 0 ] || fail "receipt recovery watcher failed: $(cat "$dir/restart.err")"
1818
+ assert_poll_absent "$state" task-a
1819
+ ! grep -F 'task-a.check.sh: merged' "$dir/restart.out" >/dev/null || fail "receipt recovery duplicated the terminal wake"
1820
+
1821
+ dir=$(make_case retirement-after-check-removal)
1822
+ state="$dir/home/state"
1823
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/5
1824
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/5
1825
+ fm_pr_poll_snapshot_capture "$state" task-a "$POLL" || fail "could not snapshot partial removal fixture"
1826
+ fm_pr_poll_retirement_publish "$state" task-a "$POLL" merged || fail "could not publish partial removal receipt"
1827
+ rm -f "$state/task-a.check.sh"
1828
+ add_stop_custom_check "$dir"
1829
+ set +e
1830
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/restart.out" 2> "$dir/restart.err"
1831
+ rc=$?
1832
+ set -e
1833
+ [ "$rc" -eq 0 ] || fail "post-check-removal restart failed: $(cat "$dir/restart.err")"
1834
+ case "$(cat "$dir/restart.out")" in check:*z-stop.check.sh:*stop-cycle) ;; *) fail "post-check-removal restart did not reach the control check" ;; esac
1835
+ assert_poll_absent "$state" task-a
1836
+ fm_pr_poll_retirement_recover_one "$state" task-a "$POLL" || fail "completed retirement was not idempotent"
1837
+
1838
+ dir=$(make_case retirement-after-registration-removal)
1839
+ state="$dir/home/state"
1840
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/5
1841
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/5
1842
+ fm_pr_poll_snapshot_capture "$state" task-a "$POLL" || fail "could not snapshot sidecar-removal fixture"
1843
+ fm_pr_poll_retirement_publish "$state" task-a "$POLL" merged || fail "could not publish sidecar-removal receipt"
1844
+ rm -f "$state/task-a.check.sh" "$state/task-a.pr-poll-registration"
1845
+ add_stop_custom_check "$dir"
1846
+ set +e
1847
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/restart.out" 2> "$dir/restart.err"
1848
+ rc=$?
1849
+ set -e
1850
+ [ "$rc" -eq 0 ] || fail "post-registration-removal restart failed: $(cat "$dir/restart.err")"
1851
+ case "$(cat "$dir/restart.out")" in check:*z-stop.check.sh:*stop-cycle) ;; *) fail "post-registration-removal restart did not reach the control check" ;; esac
1852
+ assert_poll_absent "$state" task-a
1853
+
1854
+ dir=$(make_case retirement-before-receipt-removal)
1855
+ state="$dir/home/state"
1856
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/5
1857
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/5
1858
+ fm_pr_poll_snapshot_capture "$state" task-a "$POLL" || fail "could not snapshot receipt-only fixture"
1859
+ fm_pr_poll_retirement_publish "$state" task-a "$POLL" merged || fail "could not publish receipt-only fixture"
1860
+ rm -f "$state/task-a.check.sh" "$state/task-a.pr-poll-registration" "$state/task-a.pr-poll"
1861
+ add_stop_custom_check "$dir"
1862
+ set +e
1863
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/restart.out" 2> "$dir/restart.err"
1864
+ rc=$?
1865
+ set -e
1866
+ [ "$rc" -eq 0 ] || fail "receipt-only restart failed: $(cat "$dir/restart.err")"
1867
+ case "$(cat "$dir/restart.out")" in check:*z-stop.check.sh:*stop-cycle) ;; *) fail "receipt-only restart did not reach the control check" ;; esac
1868
+ assert_poll_absent "$state" task-a
1869
+
1870
+ dir=$(make_case retirement-after-template-update)
1871
+ state="$dir/home/state"
1872
+ historical_poll="$dir/historical-fm-pr-poll.sh"
1873
+ cp "$POLL" "$historical_poll"
1874
+ printf '\n' >> "$historical_poll"
1875
+ chmod 0600 "$historical_poll"
1876
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/22
1877
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/22 "$historical_poll"
1878
+ FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_wake_append check "$2" "$3"' _ \
1879
+ "$ROOT/bin/fm-wake-lib.sh" "$state/task-a.check.sh" "check: $state/task-a.check.sh: merged" \
1880
+ || fail "could not seed pre-update terminal wake"
1881
+ fm_pr_poll_snapshot_capture "$state" task-a "$historical_poll" \
1882
+ || fail "could not snapshot pre-update retirement fixture"
1883
+ fm_pr_poll_retirement_publish "$state" task-a "$historical_poll" merged \
1884
+ || fail "could not publish pre-update retirement receipt"
1885
+ add_stop_custom_check "$dir"
1886
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/template-recovery.out" 2> "$dir/template-recovery.err" \
1887
+ || fail "template-update recovery wake failed: $(cat "$dir/template-recovery.err")"
1888
+ grep -F 'check: rearm-resurface' "$dir/template-recovery.out" >/dev/null \
1889
+ || fail "template-update recovery did not surface its durable wake first"
1890
+ ack_watcher_cycle "$state" || fail "template-update recovery acknowledgement failed"
1891
+ set +e
1892
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/restart.out" 2> "$dir/restart.err"
1893
+ rc=$?
1894
+ set -e
1895
+ [ "$rc" -eq 0 ] || fail "template-update recovery watcher failed: $(cat "$dir/restart.err")"
1896
+ case "$(cat "$dir/restart.out")" in check:*z-stop.check.sh:*stop-cycle) ;; *) fail "template-update recovery did not reach the control check" ;; esac
1897
+ [ ! -s "$dir/gh.log" ] || fail "template-update migration rebuilt and queried the retired poll"
1898
+ ! grep "$(printf '\tcheck\ttask-a.check.sh\t')" "$state/.wake-queue" >/dev/null 2>&1 \
1899
+ || fail "template-update recovery left the handled terminal wake queued"
1900
+ assert_poll_absent "$state" task-a
1901
+ pass "queue, receipt, and every fixed-path removal crash point recover without loss or repeated execution"
1902
+ }
1903
+
1904
+ test_external_merge_transition_retires_only_terminal_poll() {
1905
+ local dir state before rc label
1906
+ dir=$(make_case external-merge-transition)
1907
+ state="$dir/home/state"
1908
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/19
1909
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/19
1910
+ add_stop_custom_check "$dir"
1911
+ before=$(poll_artifact_snapshot "$state" task-a)
1912
+
1913
+ for label in open-green open-red closed-unmerged forge-error malformed; do
1914
+ rm -f "$state/.last-check"
1915
+ set +e
1916
+ case "$label" in
1917
+ open-green|open-red)
1918
+ FM_TEST_GH_STATE=OPEN run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/$label.out" 2> "$dir/$label.err"
1919
+ ;;
1920
+ closed-unmerged)
1921
+ FM_TEST_GH_STATE=CLOSED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/$label.out" 2> "$dir/$label.err"
1922
+ ;;
1923
+ forge-error)
1924
+ FM_TEST_GH_FAIL=1 run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/$label.out" 2> "$dir/$label.err"
1925
+ ;;
1926
+ malformed)
1927
+ FM_TEST_GH_STATE=NOT_A_FORGE_STATE run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/$label.out" 2> "$dir/$label.err"
1928
+ ;;
1929
+ esac
1930
+ rc=$?
1931
+ set -e
1932
+ [ "$rc" -eq 0 ] || fail "$label watcher cycle failed: $(cat "$dir/$label.err")"
1933
+ case "$(cat "$dir/$label.out")" in check:*z-stop.check.sh:*stop-cycle) ;; *) fail "$label did not reach the control check" ;; esac
1934
+ [ "$(poll_artifact_snapshot "$state" task-a)" = "$before" ] || fail "$label changed the armed poll"
1935
+ ack_watcher_cycle "$state" || fail "$label control wake acknowledgement failed"
1936
+ done
1937
+
1938
+ rm -f "$state/z-stop.check.sh" "$state/z-stop.check-trust" "$state/.last-check"
1939
+ set +e
1940
+ FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/merged.out" 2> "$dir/merged.err"
1941
+ rc=$?
1942
+ set -e
1943
+ [ "$rc" -eq 0 ] || fail "external merged transition failed: $(cat "$dir/merged.err")"
1944
+ case "$(cat "$dir/merged.out")" in check:*task-a.check.sh:*merged) ;; *) fail "external merge did not preserve its notification" ;; esac
1945
+ assert_poll_absent "$state" task-a
1946
+ pass "open/red, closed-unmerged, malformed, and forge errors remain armed until an exact merged transition"
1947
+ }
1948
+
1949
+ test_retirement_refuses_replacement_and_nonterminal_results() {
1950
+ local dir state before rc replacement_check replacement_data replacement_registration replacement_meta
1951
+ local historical_poll current_poll
1952
+ dir=$(make_case retirement-replacement)
1953
+ state="$dir/home/state"
1954
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/6
1955
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/6
1956
+ fm_pr_poll_snapshot_capture "$state" task-a "$POLL" || fail "could not snapshot replacement fixture"
1957
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/7
1958
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/7
1959
+ before=$(state_snapshot "$state")
1960
+ fm_pr_poll_retirement_publish "$state" task-a "$POLL" merged \
1961
+ && fail "stale terminal snapshot retired a replacement poll"
1962
+ [ "$(state_snapshot "$state")" = "$before" ] || fail "snapshot mismatch changed replacement artifacts"
1963
+ fm_pr_poll_artifacts_valid "$state" task-a "$POLL" || fail "replacement poll was not left canonical"
1964
+
1965
+ fm_pr_poll_snapshot_capture "$state" task-a "$POLL" || fail "could not snapshot nonterminal fixture"
1966
+ for result in OPEN CLOSED ERROR 'merged extra'; do
1967
+ fm_pr_poll_retirement_publish "$state" task-a "$POLL" "$result" \
1968
+ && fail "nonterminal result '$result' received retirement authority"
1969
+ done
1970
+ [ "$(state_snapshot "$state")" = "$before" ] || fail "nonterminal result changed canonical artifacts"
1971
+
1972
+ printf '# tamper\n' >> "$state/task-a.check.sh"
1973
+ before=$(state_snapshot "$state")
1974
+ fm_pr_poll_retirement_publish "$state" task-a "$POLL" merged \
1975
+ && fail "tampered check received a retirement receipt"
1976
+ [ "$(state_snapshot "$state")" = "$before" ] || fail "tampered retirement attempt changed state"
1977
+
1978
+ dir=$(make_case retirement-rearm-race)
1979
+ state="$dir/home/state"
1980
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/20
1981
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/20
1982
+ fm_pr_poll_snapshot_capture "$state" task-a "$POLL" || fail "could not snapshot rearm-race fixture"
1983
+ fm_pr_poll_retirement_publish "$state" task-a "$POLL" merged || fail "could not publish rearm-race receipt"
1984
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/21
1985
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/21
1986
+ replacement_check=$(shasum -a 256 "$state/task-a.check.sh")
1987
+ replacement_data=$(shasum -a 256 "$state/task-a.pr-poll")
1988
+ replacement_registration=$(shasum -a 256 "$state/task-a.pr-poll-registration")
1989
+ replacement_meta=$(shasum -a 256 "$state/task-a.meta")
1990
+ fm_pr_poll_retirement_recover_one "$state" task-a "$POLL" || fail "stale receipt did not yield to a canonical replacement"
1991
+ [ ! -e "$state/task-a.pr-poll-retirement" ] || fail "stale receipt survived canonical replacement recovery"
1992
+ [ "$(shasum -a 256 "$state/task-a.check.sh")" = "$replacement_check" ] || fail "stale receipt changed replacement check"
1993
+ [ "$(shasum -a 256 "$state/task-a.pr-poll")" = "$replacement_data" ] || fail "stale receipt changed replacement data"
1994
+ [ "$(shasum -a 256 "$state/task-a.pr-poll-registration")" = "$replacement_registration" ] || fail "stale receipt changed replacement registration"
1995
+ [ "$(shasum -a 256 "$state/task-a.meta")" = "$replacement_meta" ] || fail "stale receipt changed replacement metadata"
1996
+ fm_pr_poll_artifacts_valid "$state" task-a "$POLL" || fail "replacement poll lost canonical provenance"
1997
+
1998
+ dir=$(make_case retirement-template-update-rearm-race)
1999
+ state="$dir/home/state"
2000
+ historical_poll="$dir/historical-fm-pr-poll.sh"
2001
+ current_poll="$dir/current-fm-pr-poll.sh"
2002
+ cp "$POLL" "$historical_poll"
2003
+ cp "$POLL" "$current_poll"
2004
+ printf '\n' >> "$current_poll"
2005
+ chmod 0600 "$historical_poll" "$current_poll"
2006
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/23
2007
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/23 "$historical_poll"
2008
+ fm_pr_poll_snapshot_capture "$state" task-a "$historical_poll" \
2009
+ || fail "could not snapshot pre-update rearm fixture"
2010
+ fm_pr_poll_retirement_publish "$state" task-a "$historical_poll" merged \
2011
+ || fail "could not publish pre-update rearm receipt"
2012
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/24
2013
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/24 "$current_poll"
2014
+ replacement_check=$(shasum -a 256 "$state/task-a.check.sh")
2015
+ replacement_data=$(shasum -a 256 "$state/task-a.pr-poll")
2016
+ replacement_registration=$(shasum -a 256 "$state/task-a.pr-poll-registration")
2017
+ replacement_meta=$(shasum -a 256 "$state/task-a.meta")
2018
+ fm_pr_poll_retirement_recover_one "$state" task-a "$current_poll" \
2019
+ || fail "template update blocked stale receipt recovery"
2020
+ [ ! -e "$state/task-a.pr-poll-retirement" ] || fail "pre-update receipt survived canonical replacement recovery"
2021
+ [ "$(shasum -a 256 "$state/task-a.check.sh")" = "$replacement_check" ] || fail "pre-update receipt changed replacement check"
2022
+ [ "$(shasum -a 256 "$state/task-a.pr-poll")" = "$replacement_data" ] || fail "pre-update receipt changed replacement data"
2023
+ [ "$(shasum -a 256 "$state/task-a.pr-poll-registration")" = "$replacement_registration" ] || fail "pre-update receipt changed replacement registration"
2024
+ [ "$(shasum -a 256 "$state/task-a.meta")" = "$replacement_meta" ] || fail "pre-update receipt changed replacement metadata"
2025
+ fm_pr_poll_artifacts_valid "$state" task-a "$current_poll" || fail "updated replacement poll lost canonical provenance"
2026
+
2027
+ dir=$(make_case custom-merged-not-retired)
2028
+ state="$dir/home/state"
2029
+ printf '#!/usr/bin/env bash\nprintf "merged\\n"\n' > "$state/custom.check.sh"
2030
+ chmod 0700 "$state/custom.check.sh"
2031
+ FM_HOME="$dir/home" "$REGISTER" custom >/dev/null || fail "could not register merged custom check"
2032
+ set +e
2033
+ run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/custom.out" 2> "$dir/custom.err"
2034
+ rc=$?
2035
+ set -e
2036
+ [ "$rc" -eq 0 ] || fail "merged custom watcher failed: $(cat "$dir/custom.err")"
2037
+ [ -f "$state/custom.check.sh" ] && [ -f "$state/custom.check-trust" ] \
2038
+ || fail "custom merged output was retired as a PR poll"
2039
+ [ ! -e "$state/custom.pr-poll-retirement" ] || fail "custom check received a PR receipt"
2040
+ pass "replacement, nonterminal, tampered, and custom results receive no deletion authority"
2041
+ }
2042
+
2043
+ test_retirement_queue_failure_and_receipt_tampering() {
2044
+ local dir state before rc external
2045
+ dir=$(make_case retirement-queue-failure)
2046
+ state="$dir/home/state"
2047
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/8
2048
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/8
2049
+ # Fail sequence publication without making the queue itself look non-empty:
2050
+ # a directory at .wake-queue would now (correctly) trigger re-arm recovery
2051
+ # before the poll runs, so it no longer exercises the terminal append path.
2052
+ mkdir "$state/.wake-queue.seq"
2053
+ before=$(poll_artifact_snapshot "$state" task-a)
2054
+ set +e
2055
+ FM_TEST_GH_LOG="$dir/gh.log" FM_TEST_GH_STATE=MERGED \
2056
+ run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch.out" 2> "$dir/watch.err"
2057
+ rc=$?
2058
+ set -e
2059
+ [ "$rc" -ne 0 ] || fail "watcher retired despite queue publication failure"
2060
+ [ -s "$dir/gh.log" ] || fail "queue failure fixture did not reach the authenticated poll"
2061
+ [ "$(poll_artifact_snapshot "$state" task-a)" = "$before" ] || fail "queue failure changed poll artifacts"
2062
+ [ ! -e "$state/task-a.pr-poll-retirement" ] || fail "queue failure published a receipt"
2063
+
2064
+ dir=$(make_case retirement-receipt-tamper)
2065
+ state="$dir/home/state"
2066
+ write_poll_meta "$state" task-a https://github.com/o/r/pull/9
2067
+ seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/9
2068
+ fm_pr_poll_snapshot_capture "$state" task-a "$POLL" || fail "could not snapshot receipt tamper fixture"
2069
+ fm_pr_poll_retirement_publish "$state" task-a "$POLL" merged || fail "could not publish receipt tamper fixture"
2070
+ printf 'extra\n' >> "$state/task-a.pr-poll-retirement"
2071
+ before=$(state_snapshot "$state")
2072
+ fm_pr_poll_retirement_recover_one "$state" task-a "$POLL" \
2073
+ && fail "malformed receipt authorized poll deletion"
2074
+ [ "$(state_snapshot "$state")" = "$before" ] || fail "malformed receipt changed poll state"
2075
+ set +e
2076
+ run_check_entry "$dir" task-a https://github.com/o/r/pull/10 > "$dir/rearm.out" 2> "$dir/rearm.err"
2077
+ rc=$?
2078
+ set -e
2079
+ [ "$rc" -ne 0 ] || fail "rearm accepted an invalid pending retirement receipt"
2080
+ [ "$(cat "$dir/rearm.err")" = 'error: pending PR poll retirement could not be validated' ] \
2081
+ || fail "rearm did not report the invalid pending receipt"
2082
+ [ "$(state_snapshot "$state")" = "$before" ] || fail "refused rearm changed poll state"
2083
+
2084
+ rm -f "$state/task-a.pr-poll-retirement"
2085
+ external="$dir/external-receipt"
2086
+ printf 'external\n' > "$external"
2087
+ ln -s "$external" "$state/task-a.pr-poll-retirement"
2088
+ before=$(state_snapshot "$state")
2089
+ fm_pr_poll_retirement_recover_one "$state" task-a "$POLL" \
2090
+ && fail "receipt symlink authorized poll deletion"
2091
+ [ "$(state_snapshot "$state")" = "$before" ] || fail "receipt symlink changed poll state"
2092
+ [ "$(cat "$external")" = external ] || fail "receipt symlink target was changed"
2093
+ pass "queue failure and untrusted receipts preserve canonical poll evidence"
2094
+ }
2095
+
2096
+ test_gitlab_merged_poll_retires() {
2097
+ local dir state url rc
2098
+ dir=$(make_case gitlab-merged-retirement)
2099
+ state="$dir/home/state"
2100
+ url=https://gitlab.example/group/subgroup/project/-/merge_requests/17
2101
+ write_poll_meta "$state" task-a "$url"
2102
+ seed_canonical_poll "$dir" task-a "$url"
2103
+ set +e
2104
+ FM_TEST_GLAB_STATE=merged run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch.out" 2> "$dir/watch.err"
2105
+ rc=$?
2106
+ set -e
2107
+ [ "$rc" -eq 0 ] || fail "GitLab merged retirement watcher failed: $(cat "$dir/watch.err")"
2108
+ case "$(cat "$dir/watch.out")" in check:*task-a.check.sh:*merged) ;; *) fail "GitLab merged wake was missing" ;; esac
2109
+ assert_poll_absent "$state" task-a
2110
+ grep -qxF "pr=$url" "$state/task-a.meta" || fail "GitLab retirement removed canonical metadata"
2111
+ pass "GitHub and GitLab exact merged results share one retirement path"
2112
+ }
2113
+
2114
+ test_parser_matrix
2115
+ test_gitlab_merge_watch
2116
+ test_merged_poll_retires_once
2117
+ test_merged_poll_reregistration_after_notification_is_absorbed
2118
+ test_merged_poll_retries_a_failed_upward_report
2119
+ test_self_merge_and_poll_publish_one_outcome
2120
+ test_merged_poll_reports_upward_from_a_secondmate_home_once
2121
+ test_different_merged_pr_for_same_task_is_not_absorbed
2122
+ test_persistent_secondmate_retirement_is_poll_only
2123
+ test_retirement_crash_recovery
2124
+ test_external_merge_transition_retires_only_terminal_poll
2125
+ test_retirement_refuses_replacement_and_nonterminal_results
2126
+ test_retirement_queue_failure_and_receipt_tampering
2127
+ test_gitlab_merged_poll_retires
2128
+ test_invalid_entrypoints_have_zero_side_effects
2129
+ test_valid_recording_and_merge_derivation
2130
+ test_rejected_metacharacter_bytes_are_inert
2131
+ test_static_poll_contract
2132
+ test_atomic_interruption_leaves_no_partial_artifact
2133
+ test_concurrent_watcher_sees_only_complete_publication
2134
+ test_poll_publication_refuses_unsafe_destinations
2135
+ test_live_artifact_single_link_and_privacy_validation
2136
+ test_postrename_poll_validation_revokes_and_retries
2137
+ test_bootstrap_leaves_unauthenticated_checks
2138
+ test_custom_snapshot_cleanup_on_signal
2139
+ test_returned_custom_check_descendants_are_drained
2140
+ test_teardown_removes_poll_artifacts