@zerwiz/ymir 0.1.4 → 0.1.7

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 (865) 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/README.md +74 -0
  27. package/.agents/backend/backends/cmux.sh +649 -0
  28. package/.agents/backend/backends/herdr-eventwait.py +157 -0
  29. package/.agents/backend/backends/herdr-workspace-move.py +114 -0
  30. package/.agents/backend/backends/herdr.sh +3386 -0
  31. package/.agents/backend/backends/orca.sh +290 -0
  32. package/.agents/backend/backends/tmux.sh +357 -0
  33. package/.agents/backend/backends/zellij.sh +700 -0
  34. package/.agents/backend/fm-afk-launch.sh +654 -0
  35. package/.agents/backend/fm-afk-return.sh +241 -0
  36. package/.agents/backend/fm-afk-start.sh +172 -0
  37. package/.agents/backend/fm-arm-command-policy.mjs +960 -0
  38. package/.agents/backend/fm-arm-pretool-check.sh +200 -0
  39. package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
  40. package/.agents/backend/fm-backend.sh +981 -0
  41. package/.agents/backend/fm-backlog-handoff.sh +909 -0
  42. package/.agents/backend/fm-backlog-receive.sh +187 -0
  43. package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
  44. package/.agents/backend/fm-bearings-board.sh +204 -0
  45. package/.agents/backend/fm-bearings-snapshot.sh +546 -0
  46. package/.agents/backend/fm-bootstrap.sh +1568 -0
  47. package/.agents/backend/fm-branch-outcome.sh +203 -0
  48. package/.agents/backend/fm-branch-prompt.sh +101 -0
  49. package/.agents/backend/fm-brief.sh +452 -0
  50. package/.agents/backend/fm-busy-event.sh +234 -0
  51. package/.agents/backend/fm-busy-lib.sh +986 -0
  52. package/.agents/backend/fm-captain-hold.sh +1001 -0
  53. package/.agents/backend/fm-cd-command-policy.mjs +153 -0
  54. package/.agents/backend/fm-cd-pretool-check.sh +190 -0
  55. package/.agents/backend/fm-check-lib.sh +74 -0
  56. package/.agents/backend/fm-check-register.sh +42 -0
  57. package/.agents/backend/fm-check-unregister.sh +52 -0
  58. package/.agents/backend/fm-classify-lib.sh +1770 -0
  59. package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
  60. package/.agents/backend/fm-composer-lib.sh +1416 -0
  61. package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
  62. package/.agents/backend/fm-config-push.sh +254 -0
  63. package/.agents/backend/fm-control-lib.sh +251 -0
  64. package/.agents/backend/fm-control.sh +878 -0
  65. package/.agents/backend/fm-crew-state.sh +625 -0
  66. package/.agents/backend/fm-cursor-lib.sh +243 -0
  67. package/.agents/backend/fm-decision-hold.sh +232 -0
  68. package/.agents/backend/fm-doc-audience-check.sh +269 -0
  69. package/.agents/backend/fm-dod-lib.sh +67 -0
  70. package/.agents/backend/fm-ensure-agents-md.sh +253 -0
  71. package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
  72. package/.agents/backend/fm-extension.mjs +2577 -0
  73. package/.agents/backend/fm-extension.sh +16 -0
  74. package/.agents/backend/fm-ff-lib.sh +420 -0
  75. package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
  76. package/.agents/backend/fm-fleet-sync.sh +458 -0
  77. package/.agents/backend/fm-fleet-view.sh +96 -0
  78. package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
  79. package/.agents/backend/fm-guard.sh +242 -0
  80. package/.agents/backend/fm-harness.sh +195 -0
  81. package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
  82. package/.agents/backend/fm-herdr-lab.sh +346 -0
  83. package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
  84. package/.agents/backend/fm-home-seed.sh +984 -0
  85. package/.agents/backend/fm-home-summary-refresh.sh +255 -0
  86. package/.agents/backend/fm-hook-host-lib.sh +36 -0
  87. package/.agents/backend/fm-inactive-reconcile.sh +531 -0
  88. package/.agents/backend/fm-inbox.sh +399 -0
  89. package/.agents/backend/fm-install-actionlint.sh +84 -0
  90. package/.agents/backend/fm-install-herdr.sh +96 -0
  91. package/.agents/backend/fm-install-shellcheck.sh +84 -0
  92. package/.agents/backend/fm-install-treehouse.sh +95 -0
  93. package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
  94. package/.agents/backend/fm-lease-lib.sh +218 -0
  95. package/.agents/backend/fm-lease.sh +189 -0
  96. package/.agents/backend/fm-line-cap-lib.sh +51 -0
  97. package/.agents/backend/fm-lint-workflows.sh +137 -0
  98. package/.agents/backend/fm-lint.sh +591 -0
  99. package/.agents/backend/fm-lock-lib.sh +104 -0
  100. package/.agents/backend/fm-lock.sh +107 -0
  101. package/.agents/backend/fm-marker-lib.sh +12 -0
  102. package/.agents/backend/fm-merge-local.sh +74 -0
  103. package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
  104. package/.agents/backend/fm-nm-run-lib.sh +124 -0
  105. package/.agents/backend/fm-on.sh +125 -0
  106. package/.agents/backend/fm-operational-input.sh +253 -0
  107. package/.agents/backend/fm-peek.sh +46 -0
  108. package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
  109. package/.agents/backend/fm-pr-check.sh +135 -0
  110. package/.agents/backend/fm-pr-lib.sh +1016 -0
  111. package/.agents/backend/fm-pr-merge.sh +707 -0
  112. package/.agents/backend/fm-pr-poll.sh +110 -0
  113. package/.agents/backend/fm-primary-scope-lib.sh +33 -0
  114. package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
  115. package/.agents/backend/fm-procevent-lavish.sh +628 -0
  116. package/.agents/backend/fm-procevent-lib.sh +892 -0
  117. package/.agents/backend/fm-procevent-quota.sh +290 -0
  118. package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
  119. package/.agents/backend/fm-procevent-when.sh +504 -0
  120. package/.agents/backend/fm-procevent.sh +1466 -0
  121. package/.agents/backend/fm-project-mode.sh +92 -0
  122. package/.agents/backend/fm-project-origin-lib.sh +180 -0
  123. package/.agents/backend/fm-promote.sh +282 -0
  124. package/.agents/backend/fm-public-followup-emit.sh +260 -0
  125. package/.agents/backend/fm-public-followup-lib.sh +398 -0
  126. package/.agents/backend/fm-public-followup.sh +1330 -0
  127. package/.agents/backend/fm-push-transition-lib.sh +168 -0
  128. package/.agents/backend/fm-quota-axi-lib.sh +93 -0
  129. package/.agents/backend/fm-quota-choose.sh +384 -0
  130. package/.agents/backend/fm-remote-delta-read.sh +189 -0
  131. package/.agents/backend/fm-remote-doctor.sh +800 -0
  132. package/.agents/backend/fm-remote-entrypoint.sh +193 -0
  133. package/.agents/backend/fm-remote-file.sh +234 -0
  134. package/.agents/backend/fm-remote-home-provision.sh +259 -0
  135. package/.agents/backend/fm-remote-home-seed.sh +259 -0
  136. package/.agents/backend/fm-remote-inherit-push.sh +90 -0
  137. package/.agents/backend/fm-remote-inherit.sh +179 -0
  138. package/.agents/backend/fm-remote-job-lib.sh +1241 -0
  139. package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
  140. package/.agents/backend/fm-remote-job-worker.sh +1124 -0
  141. package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
  142. package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
  143. package/.agents/backend/fm-review-diff.sh +158 -0
  144. package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
  145. package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
  146. package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
  147. package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
  148. package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
  149. package/.agents/backend/fm-secondmate-report.sh +88 -0
  150. package/.agents/backend/fm-send.sh +1059 -0
  151. package/.agents/backend/fm-session-lock-lib.sh +176 -0
  152. package/.agents/backend/fm-session-start.sh +970 -0
  153. package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
  154. package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
  155. package/.agents/backend/fm-sessionstart-run.sh +145 -0
  156. package/.agents/backend/fm-spawn.sh +3153 -0
  157. package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
  158. package/.agents/backend/fm-startup-memory-budget.sh +94 -0
  159. package/.agents/backend/fm-startup-network.sh +642 -0
  160. package/.agents/backend/fm-stow-cascade.sh +251 -0
  161. package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
  162. package/.agents/backend/fm-supervise-daemon.sh +1732 -0
  163. package/.agents/backend/fm-supervision-instructions.sh +215 -0
  164. package/.agents/backend/fm-supervision-lib.sh +90 -0
  165. package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
  166. package/.agents/backend/fm-tangle-lib.sh +53 -0
  167. package/.agents/backend/fm-task-inbox-lib.sh +402 -0
  168. package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
  169. package/.agents/backend/fm-teardown.sh +2922 -0
  170. package/.agents/backend/fm-test-isolation-proof.sh +588 -0
  171. package/.agents/backend/fm-test-run.sh +2191 -0
  172. package/.agents/backend/fm-timeout-lib.sh +141 -0
  173. package/.agents/backend/fm-timing-lib.sh +179 -0
  174. package/.agents/backend/fm-tmux-lib.sh +291 -0
  175. package/.agents/backend/fm-tool-update-check.sh +898 -0
  176. package/.agents/backend/fm-trace-context-lib.sh +227 -0
  177. package/.agents/backend/fm-transition-lib.sh +103 -0
  178. package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
  179. package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
  180. package/.agents/backend/fm-turnend-guard.sh +432 -0
  181. package/.agents/backend/fm-update.sh +110 -0
  182. package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
  183. package/.agents/backend/fm-voice-client.py +1373 -0
  184. package/.agents/backend/fm-voice-relay.py +1256 -0
  185. package/.agents/backend/fm-wake-drain.sh +592 -0
  186. package/.agents/backend/fm-wake-grant.sh +127 -0
  187. package/.agents/backend/fm-wake-lib.sh +1849 -0
  188. package/.agents/backend/fm-watch-arm.sh +602 -0
  189. package/.agents/backend/fm-watch-checkpoint.sh +109 -0
  190. package/.agents/backend/fm-watch.sh +1962 -0
  191. package/.agents/backend/fm-x-dismiss.sh +116 -0
  192. package/.agents/backend/fm-x-followup.sh +286 -0
  193. package/.agents/backend/fm-x-lib.sh +1000 -0
  194. package/.agents/backend/fm-x-link.sh +234 -0
  195. package/.agents/backend/fm-x-poll.sh +185 -0
  196. package/.agents/backend/fm-x-reply.sh +385 -0
  197. package/.agents/backend/fm_voice_frame.py +166 -0
  198. package/.agents/backend/fm_voice_records.py +574 -0
  199. package/.agents/backend/model-bridge.py +360 -0
  200. package/.agents/backend/opencode-go-bridge.py +30 -0
  201. package/.agents/bus/README.md +12 -0
  202. package/.agents/bus/messages.json +1 -0
  203. package/.agents/config/agents.machine.example.yaml +16 -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 +11 -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/filebrowser/.filebrowser.json +6 -0
  211. package/.agents/filebrowser/README.md +11 -0
  212. package/.agents/gateway/README.md +12 -0
  213. package/.agents/github/README.md +15 -0
  214. package/.agents/github/webhooks/.gitkeep +0 -0
  215. package/.agents/github/workflows/.gitkeep +0 -0
  216. package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
  217. package/.agents/harness/opencode/plugins/package.json +4 -0
  218. package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
  219. package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
  220. package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
  221. package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
  222. package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
  223. package/.agents/memory/README.md +46 -0
  224. package/.agents/memory/runes_audit.md +5 -0
  225. package/.agents/migrations/0001-hodd-layout.sh +38 -0
  226. package/.agents/migrations/0002-a2a-mcp.sh +27 -0
  227. package/.agents/migrations/0003-private-data-separation.sh +121 -0
  228. package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
  229. package/.agents/migrations/0005-roots-out-of-tree.sh +158 -0
  230. package/.agents/sandbox/Dockerfile.utgard +19 -0
  231. package/.agents/sandbox/README.md +14 -0
  232. package/.agents/sandbox/utgard.config.json +14 -0
  233. package/.agents/skills/README.md +59 -0
  234. package/.agents/skills/bragi-marketing/README.md +3 -0
  235. package/.agents/skills/bragi-marketing/SKILL.md +82 -0
  236. package/.agents/skills/eindri-homes/README.md +3 -0
  237. package/.agents/skills/eindri-homes/SKILL.md +291 -0
  238. package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
  239. package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
  240. package/.agents/skills/frigg-consent/README.md +3 -0
  241. package/.agents/skills/frigg-consent/SKILL.md +56 -0
  242. package/.agents/skills/galdr-ymirsystem/README.md +3 -0
  243. package/.agents/skills/galdr-ymirsystem/SKILL.md +159 -0
  244. package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
  245. package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
  246. package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
  247. package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
  248. package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
  249. package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
  250. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +736 -0
  251. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
  252. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
  253. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
  254. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
  255. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
  256. package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +539 -0
  257. package/.agents/skills/galdr-ymirsystem/assets/installation.md +763 -0
  258. package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
  259. package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
  260. package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +163 -0
  261. package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +415 -0
  262. package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
  263. package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +133 -0
  264. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
  265. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
  266. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +101 -0
  267. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
  268. package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
  269. package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
  270. package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
  271. package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
  272. package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
  273. package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
  274. package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
  275. package/.agents/skills/galdr-ymirsystem/assets/smidja.md +180 -0
  276. package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
  277. package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
  278. package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
  279. package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +503 -0
  280. package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
  281. package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
  282. package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
  283. package/.agents/skills/gjallarhorn-relay/README.md +3 -0
  284. package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
  285. package/.agents/skills/groa-update/SKILL.md +90 -0
  286. package/.agents/skills/hamr-adapters/README.md +3 -0
  287. package/.agents/skills/hamr-adapters/SKILL.md +123 -0
  288. package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
  289. package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
  290. package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
  291. package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
  292. package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
  293. package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
  294. package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
  295. package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
  296. package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
  297. package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
  298. package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
  299. package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
  300. package/.agents/skills/herdr-panes/README.md +3 -0
  301. package/.agents/skills/herdr-panes/SKILL.md +92 -0
  302. package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
  303. package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
  304. package/.agents/skills/hnoss-design/DESIGN.md +19 -0
  305. package/.agents/skills/hnoss-design/README.md +3 -0
  306. package/.agents/skills/hnoss-design/SKILL.md +83 -0
  307. package/.agents/skills/hvild-afk/README.md +3 -0
  308. package/.agents/skills/hvild-afk/SKILL.md +241 -0
  309. package/.agents/skills/jord-projects/README.md +3 -0
  310. package/.agents/skills/jord-projects/SKILL.md +92 -0
  311. package/.agents/skills/lifecycle/SKILL.md +56 -0
  312. package/.agents/skills/lifecycle/smoke_test.sh +57 -0
  313. package/.agents/skills/lifecycle/start.sh +21 -0
  314. package/.agents/skills/lifecycle/status.sh +35 -0
  315. package/.agents/skills/lifecycle/stop.sh +16 -0
  316. package/.agents/skills/muninn-stow/README.md +3 -0
  317. package/.agents/skills/muninn-stow/SKILL.md +309 -0
  318. package/.agents/skills/no-mistakes/SKILL.md +379 -0
  319. package/.agents/skills/nornir-schedule/README.md +3 -0
  320. package/.agents/skills/nornir-schedule/SKILL.md +25 -0
  321. package/.agents/skills/nornir-schedule/assets/events.md +137 -0
  322. package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
  323. package/.agents/skills/nsr-compliance/README.md +3 -0
  324. package/.agents/skills/nsr-compliance/SKILL.md +29 -0
  325. package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
  326. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
  327. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
  328. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
  329. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
  330. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
  331. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
  332. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
  333. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
  334. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
  335. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
  336. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
  337. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
  338. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
  339. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
  340. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
  341. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
  342. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
  343. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
  344. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
  345. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
  346. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
  347. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
  348. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  349. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
  350. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
  351. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
  352. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
  353. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
  354. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
  355. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
  356. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
  357. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
  358. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
  359. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
  360. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
  361. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
  362. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
  363. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
  364. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
  365. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
  366. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
  367. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
  368. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
  369. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
  370. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
  371. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
  372. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
  373. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
  374. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
  375. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
  376. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
  377. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
  378. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
  379. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
  380. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
  381. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
  382. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
  383. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
  384. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
  385. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
  386. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
  387. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
  388. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
  389. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
  390. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
  391. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
  392. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
  393. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
  394. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
  395. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
  396. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
  397. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
  398. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
  399. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
  400. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
  401. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
  402. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
  403. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
  404. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
  405. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
  406. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
  407. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
  408. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
  409. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
  410. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
  411. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
  412. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
  413. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
  414. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
  415. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
  416. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
  417. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
  418. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
  419. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
  420. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
  421. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
  422. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
  423. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
  424. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
  425. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
  426. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
  427. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
  428. package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
  429. package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
  430. package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
  431. package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
  432. package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
  433. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
  434. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
  435. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
  436. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
  437. package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
  438. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
  439. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
  440. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
  441. package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
  442. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
  443. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
  444. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
  445. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
  446. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
  447. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
  448. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
  449. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  450. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
  451. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
  452. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
  453. package/.agents/skills/pr-ops/README.md +3 -0
  454. package/.agents/skills/pr-ops/SKILL.md +109 -0
  455. package/.agents/skills/ratatoskr-a2a/README.md +3 -0
  456. package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
  457. package/.agents/skills/rules-check-drift/SKILL.md +81 -0
  458. package/.agents/skills/saga-bearings/README.md +3 -0
  459. package/.agents/skills/saga-bearings/SKILL.md +26 -0
  460. package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
  461. package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
  462. package/.agents/skills/saga-bearings/assets/recap.md +56 -0
  463. package/.agents/skills/syn-recovery/README.md +3 -0
  464. package/.agents/skills/syn-recovery/SKILL.md +54 -0
  465. package/.agents/skills/tyr-check/README.md +3 -0
  466. package/.agents/skills/tyr-check/SKILL.md +218 -0
  467. package/.agents/skills/urdh-hold/README.md +3 -0
  468. package/.agents/skills/urdh-hold/SKILL.md +25 -0
  469. package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
  470. package/.agents/skills/urdh-hold/assets/hold.md +54 -0
  471. package/.agents/skills/vor-diagnostics/README.md +3 -0
  472. package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
  473. package/.agents/skills/ymir-host/README.md +3 -0
  474. package/.agents/skills/ymir-host/SKILL.md +35 -0
  475. package/.agents/skills/ymir-host/assets/desktop.md +36 -0
  476. package/.agents/skills/ymir-host/assets/install.md +69 -0
  477. package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
  478. package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
  479. package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
  480. package/.agents/skills/ymir-host/assets/update.md +64 -0
  481. package/.agents/tests/assets/board-render-harness.mjs +117 -0
  482. package/.agents/tests/cmux-test-safety.sh +51 -0
  483. package/.agents/tests/fixtures.sh +291 -0
  484. package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
  485. package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
  486. package/.agents/tests/fm-afk-launch.test.sh +961 -0
  487. package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
  488. package/.agents/tests/fm-afk-return.test.sh +283 -0
  489. package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
  490. package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
  491. package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
  492. package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
  493. package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
  494. package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
  495. package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
  496. package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
  497. package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
  498. package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
  499. package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
  500. package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
  501. package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
  502. package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
  503. package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
  504. package/.agents/tests/fm-backend-orca.test.sh +1357 -0
  505. package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
  506. package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
  507. package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
  508. package/.agents/tests/fm-backend.test.sh +1142 -0
  509. package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
  510. package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
  511. package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
  512. package/.agents/tests/fm-bearings-board.test.sh +414 -0
  513. package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
  514. package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
  515. package/.agents/tests/fm-bootstrap.test.sh +1178 -0
  516. package/.agents/tests/fm-branch-supervision.test.sh +552 -0
  517. package/.agents/tests/fm-brief.test.sh +785 -0
  518. package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
  519. package/.agents/tests/fm-busy-state.test.sh +465 -0
  520. package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
  521. package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
  522. package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
  523. package/.agents/tests/fm-check-unregister.test.sh +198 -0
  524. package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
  525. package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
  526. package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
  527. package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
  528. package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
  529. package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
  530. package/.agents/tests/fm-composer-ghost.test.sh +716 -0
  531. package/.agents/tests/fm-composer-lib.test.sh +666 -0
  532. package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
  533. package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
  534. package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
  535. package/.agents/tests/fm-control.test.sh +910 -0
  536. package/.agents/tests/fm-crew-state.test.sh +1610 -0
  537. package/.agents/tests/fm-cursor-harness.test.sh +404 -0
  538. package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
  539. package/.agents/tests/fm-cursor-primary.test.sh +708 -0
  540. package/.agents/tests/fm-daemon.test.sh +2740 -0
  541. package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
  542. package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
  543. package/.agents/tests/fm-extension-binding.test.sh +2187 -0
  544. package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
  545. package/.agents/tests/fm-fleet-sync.test.sh +721 -0
  546. package/.agents/tests/fm-gate-refuse.test.sh +356 -0
  547. package/.agents/tests/fm-gitignore-config.test.sh +89 -0
  548. package/.agents/tests/fm-gotmp.test.sh +230 -0
  549. package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
  550. package/.agents/tests/fm-grok-harness.test.sh +117 -0
  551. package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
  552. package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
  553. package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
  554. package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
  555. package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
  556. package/.agents/tests/fm-herdr-lab.test.sh +243 -0
  557. package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
  558. package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
  559. package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
  560. package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
  561. package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
  562. package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
  563. package/.agents/tests/fm-kimi-harness.test.sh +687 -0
  564. package/.agents/tests/fm-lint-workflows.test.sh +532 -0
  565. package/.agents/tests/fm-lint.test.sh +1023 -0
  566. package/.agents/tests/fm-muse-harness.test.sh +932 -0
  567. package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
  568. package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
  569. package/.agents/tests/fm-on.test.sh +507 -0
  570. package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
  571. package/.agents/tests/fm-operational-input.test.sh +160 -0
  572. package/.agents/tests/fm-peek-remote.test.sh +110 -0
  573. package/.agents/tests/fm-pending-reply.test.sh +1288 -0
  574. package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
  575. package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
  576. package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
  577. package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
  578. package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
  579. package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
  580. package/.agents/tests/fm-pr-merge.test.sh +2135 -0
  581. package/.agents/tests/fm-procevent-quota.test.sh +225 -0
  582. package/.agents/tests/fm-procevent-when.test.sh +406 -0
  583. package/.agents/tests/fm-procevent.test.sh +1745 -0
  584. package/.agents/tests/fm-project-origin.test.sh +110 -0
  585. package/.agents/tests/fm-public-followup.test.sh +2272 -0
  586. package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
  587. package/.agents/tests/fm-quota-choose.test.sh +626 -0
  588. package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
  589. package/.agents/tests/fm-remote-doctor.test.sh +625 -0
  590. package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
  591. package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
  592. package/.agents/tests/fm-remote-job.test.sh +768 -0
  593. package/.agents/tests/fm-remote-reply.test.sh +520 -0
  594. package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
  595. package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
  596. package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
  597. package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
  598. package/.agents/tests/fm-review-diff.test.sh +176 -0
  599. package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
  600. package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
  601. package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
  602. package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
  603. package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
  604. package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
  605. package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
  606. package/.agents/tests/fm-send-inbox.test.sh +352 -0
  607. package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
  608. package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
  609. package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
  610. package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
  611. package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
  612. package/.agents/tests/fm-send-settle.test.sh +147 -0
  613. package/.agents/tests/fm-send-strict.test.sh +241 -0
  614. package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
  615. package/.agents/tests/fm-session-start.test.sh +2515 -0
  616. package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
  617. package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
  618. package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
  619. package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
  620. package/.agents/tests/fm-spawn-batch.test.sh +148 -0
  621. package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
  622. package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
  623. package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
  624. package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
  625. package/.agents/tests/fm-startup-network.test.sh +712 -0
  626. package/.agents/tests/fm-stow-cascade.test.sh +370 -0
  627. package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
  628. package/.agents/tests/fm-supervision-events.test.sh +156 -0
  629. package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
  630. package/.agents/tests/fm-tangle-guard.test.sh +277 -0
  631. package/.agents/tests/fm-task-delivery.test.sh +420 -0
  632. package/.agents/tests/fm-task-inbox.test.sh +513 -0
  633. package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
  634. package/.agents/tests/fm-teardown.test.sh +2665 -0
  635. package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
  636. package/.agents/tests/fm-test-fixtures.test.sh +132 -0
  637. package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
  638. package/.agents/tests/fm-test-run.test.sh +1196 -0
  639. package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
  640. package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
  641. package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
  642. package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
  643. package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
  644. package/.agents/tests/fm-transition-lib.test.sh +54 -0
  645. package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
  646. package/.agents/tests/fm-update.test.sh +304 -0
  647. package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
  648. package/.agents/tests/fm-voice-relay.test.sh +4741 -0
  649. package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
  650. package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
  651. package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
  652. package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
  653. package/.agents/tests/fm-wake-queue.test.sh +1224 -0
  654. package/.agents/tests/fm-watch-arm.test.sh +821 -0
  655. package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
  656. package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
  657. package/.agents/tests/fm-watch-triage.test.sh +3889 -0
  658. package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
  659. package/.agents/tests/fm-x-mode.test.sh +3042 -0
  660. package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
  661. package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
  662. package/.agents/tests/herdr-test-safety.sh +42 -0
  663. package/.agents/tests/lib.sh +315 -0
  664. package/.agents/tests/remote-herdr-fixture.sh +125 -0
  665. package/.agents/tests/secondmate-helpers.sh +193 -0
  666. package/.agents/tests/smoke.test.sh +93 -0
  667. package/.agents/tests/wake-helpers.sh +338 -0
  668. package/.agents/tests/zellij-test-safety.sh +60 -0
  669. package/.agents/tools/README.md +10 -0
  670. package/.agents/tools/bin/tasks-cli +267 -0
  671. package/.agents/tools/tasks-cli.ts +216 -0
  672. package/AGENTS.md +498 -0
  673. package/CHANGELOG.md +1913 -0
  674. package/LICENSE +1 -1
  675. package/NOTICE +63 -0
  676. package/README.md +114 -60
  677. package/RULES/01-domains.md +51 -0
  678. package/RULES/02-agents.md +78 -0
  679. package/RULES/03-houses.md +61 -0
  680. package/RULES/04-hoard.md +97 -0
  681. package/RULES/05-platforms.md +67 -0
  682. package/RULES/06-append-only.md +108 -0
  683. package/RULES/07-config.md +101 -0
  684. package/RULES/08-delivery-gate.md +75 -0
  685. package/RULES/09-electron.md +58 -0
  686. package/RULES/README.md +22 -0
  687. package/bin/README.md +107 -0
  688. package/bin/a2a-mcp.sh +133 -0
  689. package/bin/a2a-serve.py +103 -0
  690. package/bin/a2a-serve.sh +8 -0
  691. package/bin/a2a-talk.sh +50 -0
  692. package/bin/a2abridge-ensure.sh +108 -0
  693. package/bin/agent-run.sh +43 -0
  694. package/bin/agents-config.sh +256 -0
  695. package/bin/apodex-smoke-test.sh +105 -0
  696. package/bin/bifrost-bridge.sh +131 -0
  697. package/bin/bifrost-ingress.sh +77 -0
  698. package/bin/bootstrap-macos.sh +112 -0
  699. package/bin/bootstrap-windows.ps1 +112 -0
  700. package/bin/branch-guard.sh +64 -0
  701. package/bin/brokk +73 -0
  702. package/bin/brokk-classify-lib.sh +1770 -0
  703. package/bin/brokk-lease-lib.sh +222 -0
  704. package/bin/brokk-lease.sh +193 -0
  705. package/bin/brokk-lint.sh +65 -0
  706. package/bin/brokk-send.sh +51 -0
  707. package/bin/brokk-update.sh +9 -0
  708. package/bin/brokk-wake-grant.sh +127 -0
  709. package/bin/brokk-wake-lib.sh +1849 -0
  710. package/bin/changelog-assemble.sh +154 -0
  711. package/bin/changelog-guard.sh +197 -0
  712. package/bin/crash-sense.sh +173 -0
  713. package/bin/design-check.sh +80 -0
  714. package/bin/design-icon.sh +153 -0
  715. package/bin/desktop-place.sh +224 -0
  716. package/bin/docs-guard.sh +39 -0
  717. package/bin/editor-place.sh +225 -0
  718. package/bin/eindri-acclaim.sh +47 -0
  719. package/bin/eindri-control.sh +28 -0
  720. package/bin/eindri-role.sh +83 -0
  721. package/bin/eindri-seat.sh +83 -0
  722. package/bin/eindri-seen.sh +54 -0
  723. package/bin/eindri-send.sh +15 -0
  724. package/bin/eindri-start.sh +98 -0
  725. package/bin/eindri-watch.sh +57 -0
  726. package/bin/einherjar-spawn.sh +576 -0
  727. package/bin/eir-doctor.sh +158 -0
  728. package/bin/erindi-brief.sh +271 -0
  729. package/bin/feature-inventory.sh +73 -0
  730. package/bin/fleet-apply.sh +81 -0
  731. package/bin/github-deploy.sh +94 -0
  732. package/bin/gjallarhorn-expose.sh +136 -0
  733. package/bin/gjallarhorn-notify.sh +71 -0
  734. package/bin/gjallarhorn-purge.sh +67 -0
  735. package/bin/gjallarhorn-tunnel.sh +85 -0
  736. package/bin/gleipnir-lock-lib.sh +284 -0
  737. package/bin/groa-update.sh +130 -0
  738. package/bin/hall-snapshot.sh +111 -0
  739. package/bin/hamr-harness.sh +246 -0
  740. package/bin/heimdall-ssh-keys.sh +374 -0
  741. package/bin/herdr-agents.py +44 -0
  742. package/bin/herdr-ensure.sh +104 -0
  743. package/bin/herdr-run.sh +452 -0
  744. package/bin/hermes-ensure.sh +88 -0
  745. package/bin/hlidskjalf-agents.sh +172 -0
  746. package/bin/hlidskjalf-usage.sh +196 -0
  747. package/bin/hoard-guard.sh +135 -0
  748. package/bin/hoard-lib.sh +105 -0
  749. package/bin/hodd.sh +102 -0
  750. package/bin/host-sense.sh +199 -0
  751. package/bin/huginn-research-worker.sh +121 -0
  752. package/bin/local-model-lock.sh +70 -0
  753. package/bin/mimir-bridge.py +259 -0
  754. package/bin/mimir-bridge.sh +100 -0
  755. package/bin/mimir-ingest.sh +131 -0
  756. package/bin/mimir-reflect.sh +27 -0
  757. package/bin/mimir.sh +113 -0
  758. package/bin/mjollnir-webhook.sh +72 -0
  759. package/bin/mjollnir.sh +104 -0
  760. package/bin/model-hardware.sh +96 -0
  761. package/bin/model-resolve.sh +160 -0
  762. package/bin/models-detect.sh +178 -0
  763. package/bin/nornir-cron-start.sh +162 -0
  764. package/bin/nornir-job-daily-briefing.sh +191 -0
  765. package/bin/nornir-job-git-sync.sh +152 -0
  766. package/bin/nornir-job-hall-snapshot.sh +28 -0
  767. package/bin/nornir-job-memory-housekeeping.sh +114 -0
  768. package/bin/nornir-job-nsr-compliance.sh +54 -0
  769. package/bin/nornir-job-observer.sh +166 -0
  770. package/bin/nornir-job-skillopt-sleep.sh +76 -0
  771. package/bin/npm-publish.sh +136 -0
  772. package/bin/omarchy-hook-install.sh +60 -0
  773. package/bin/omarchy-install.sh +161 -0
  774. package/bin/omarchy-plugins.sh +187 -0
  775. package/bin/omarchy-sense.sh +162 -0
  776. package/bin/perm-guard.sh +30 -0
  777. package/bin/pi-agent.sh +68 -0
  778. package/bin/pi-ensure.sh +77 -0
  779. package/bin/pi-local.sh +37 -0
  780. package/bin/pi-model-agnostic.sh +71 -0
  781. package/bin/pi-seat.sh +63 -0
  782. package/bin/prereq-ensure.sh +166 -0
  783. package/bin/private-guard.sh +58 -0
  784. package/bin/project-git.sh +69 -0
  785. package/bin/public-guard.sh +71 -0
  786. package/bin/ratatoskr.sh +49 -0
  787. package/bin/realm-lib.sh +32 -0
  788. package/bin/repo-scrub.sh +62 -0
  789. package/bin/rodd-operational-input.sh +250 -0
  790. package/bin/runes-append.sh +198 -0
  791. package/bin/saga-session-start.sh +172 -0
  792. package/bin/saga-sessionstart-run.sh +79 -0
  793. package/bin/saga-wake-drain.sh +41 -0
  794. package/bin/secret-guard.sh +63 -0
  795. package/bin/sessrumnir-ensure.sh +100 -0
  796. package/bin/sessrumnir-sync.sh +237 -0
  797. package/bin/sessrumnir.sh +104 -0
  798. package/bin/skillopt-setup.sh +76 -0
  799. package/bin/skuld-branch-outcome.sh +203 -0
  800. package/bin/skuld-branch-prompt.sh +101 -0
  801. package/bin/smidja-bootstrap.sh +94 -0
  802. package/bin/smidja-observe.sh +86 -0
  803. package/bin/syn-arm-pretool-check.sh +57 -0
  804. package/bin/syn-asset-pretool-check.sh +89 -0
  805. package/bin/syn-cd-pretool-check.sh +28 -0
  806. package/bin/syn-guard-pretool-check.sh +83 -0
  807. package/bin/syn-spawn-pretool-check.sh +58 -0
  808. package/bin/syn-turnend-guard.sh +36 -0
  809. package/bin/syn-watch-arm.sh +99 -0
  810. package/bin/tailscale-sync.sh +203 -0
  811. package/bin/telegram-bot.sh +82 -0
  812. package/bin/toolchain.sh +74 -0
  813. package/bin/ui-truth-check.sh +75 -0
  814. package/bin/utgard.sh +111 -0
  815. package/bin/valhalla.sh +115 -0
  816. package/bin/valknut-load.sh +298 -0
  817. package/bin/vor-crew-state.sh +124 -0
  818. package/bin/wedge-notify.sh +60 -0
  819. package/bin/workspace-provision.sh +80 -0
  820. package/bin/workspace-rag.sh +163 -0
  821. package/bin/wyrd-db.sh +47 -0
  822. package/bin/yggdrasil.sh +152 -0
  823. package/bin/ymir-edit.sh +145 -0
  824. package/bin/ymir-install.sh +806 -0
  825. package/bin/ymir-invite.sh +93 -0
  826. package/bin/ymir-isolation.sh +88 -0
  827. package/bin/ymir-migrate.sh +61 -0
  828. package/bin/ymir-plan.sh +356 -0
  829. package/bin/ymir-platform.sh +348 -0
  830. package/bin/ymir-say.sh +110 -0
  831. package/bin/ymir-setup-auth.sh +119 -0
  832. package/bin/ymir-validate.sh +193 -0
  833. package/bin/ymir.js +74 -16
  834. package/docs/Architecture.md +339 -0
  835. package/docs/design.md +351 -0
  836. package/docs/installations/README.md +63 -0
  837. package/docs/installations/ymir-home.md +93 -0
  838. package/docs/integration.md +105 -0
  839. package/docs/lore.md +1060 -0
  840. package/docs/research/agent-harnesses.md +290 -0
  841. package/docs/runbooks/README.md +19 -0
  842. package/docs/runbooks/a2a.md +91 -0
  843. package/docs/runbooks/agent-permissions.md +99 -0
  844. package/docs/runbooks/agents.md +57 -0
  845. package/docs/runbooks/models.md +69 -0
  846. package/docs/runbooks/realm-onboarding.md +60 -0
  847. package/docs/runbooks/secrets-and-hoard.md +60 -0
  848. package/docs/runbooks/updates-and-migrations.md +110 -0
  849. package/docs/runbooks/worktrees-isolation.md +72 -0
  850. package/docs/session-start.md +105 -0
  851. package/docs/skillopt-integration.md +321 -0
  852. package/docs/sync-system.md +66 -0
  853. package/docs/workspaces.md +104 -0
  854. package/docs/ymir-rut.md +260 -0
  855. package/package.json +54 -14
  856. package/packaging/build.sh +74 -0
  857. package/packaging/macos/Ymir Installer.command +43 -0
  858. package/packaging/windows/ymir-setup.nsi +74 -0
  859. package/scripts/README.md +22 -0
  860. package/scripts/electron.sh +269 -0
  861. package/scripts/lower.sh +34 -0
  862. package/scripts/raise.sh +42 -0
  863. package/scripts/start.sh +217 -0
  864. package/scripts/stop.sh +113 -0
  865. package/install.sh +0 -35
@@ -0,0 +1,2272 @@
1
+ #!/usr/bin/env bash
2
+ # End-to-end and regression tests for the deterministic public-followup consumer.
3
+ #
4
+ # The failure this suite pins: firstmate promises a public final reply in an X or
5
+ # Discord thread, routes the work out, and then the session compacts or restarts.
6
+ # Nothing in memory survives. The promise is only kept if a terminal work result
7
+ # reconciles the typed obligation from DISK and the final reply lands in the
8
+ # ORIGINAL thread exactly once.
9
+ #
10
+ # Everything here is hermetic: the relay is a fakebin `curl`, so no port, no
11
+ # server, and no public post. tasks-axi and jq are the real tools, because
12
+ # tasks-axi owns the obligation state machine and stubbing it would test nothing.
13
+ set -u
14
+
15
+ # shellcheck source=tests/lib.sh
16
+ # shellcheck disable=SC1091
17
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
18
+
19
+ PF="$ROOT/bin/fm-public-followup.sh"
20
+ EMIT="$ROOT/bin/fm-public-followup-emit.sh"
21
+ POLL="$ROOT/bin/fm-x-poll.sh"
22
+ TEARDOWN="$ROOT/bin/fm-teardown.sh"
23
+ PROMOTE="$ROOT/bin/fm-promote.sh"
24
+ SESSION_START="$ROOT/bin/fm-session-start.sh"
25
+ TMP_ROOT=$(fm_test_tmproot fm-public-followup)
26
+ PF_TEST_NOW=1787539200
27
+
28
+ command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; }
29
+ command -v tasks-axi >/dev/null 2>&1 || { echo "skip: tasks-axi not found"; exit 0; }
30
+
31
+ # A fakebin `curl` standing in for the relay. It logs every call so a test can
32
+ # prove exactly how many public posts happened, and honours FAKE_FOLLOWUP_CODE so
33
+ # a transport failure can be simulated.
34
+ make_fake_curl() { # <home>
35
+ local fakebin
36
+ fakebin=$(fm_fakebin "$1")
37
+ cat > "$fakebin/curl" <<'SH'
38
+ #!/usr/bin/env bash
39
+ ofile="" url="" data=""
40
+ while [ $# -gt 0 ]; do
41
+ case "$1" in
42
+ -o) ofile=$2; shift 2 ;;
43
+ --data-binary)
44
+ case "$2" in
45
+ @-) data=$(cat) ;;
46
+ @*) data=$(cat -- "${2#@}") ;;
47
+ *) data=$2 ;;
48
+ esac
49
+ shift 2
50
+ ;;
51
+ -H|-m|-w|-X) shift 2 ;;
52
+ -s) shift ;;
53
+ http://*|https://*) url=$1; shift ;;
54
+ *) shift ;;
55
+ esac
56
+ done
57
+ if [ -n "${FAKE_CURL_LOG:-}" ]; then
58
+ { echo "url=$url"; echo "data=$data"; } >> "$FAKE_CURL_LOG"
59
+ fi
60
+ case "$url" in
61
+ */connector/followup) printf '%s' "${FAKE_FOLLOWUP_CODE:-200}" ;;
62
+ */connector/answer) printf '200' ;;
63
+ */connector/request-context)
64
+ [ -n "$ofile" ] && printf '%s' "${FAKE_REQCTX_BODY:-}" > "$ofile"
65
+ printf '%s' "${FAKE_REQCTX_CODE:-404}"
66
+ ;;
67
+ */connector/poll) printf '204' ;;
68
+ *) printf '204' ;;
69
+ esac
70
+ exit 0
71
+ SH
72
+ chmod +x "$fakebin/curl"
73
+ printf '%s\n' "$fakebin"
74
+ }
75
+
76
+ # make_home <name> [relay-on|relay-off]: a firstmate home with its own backlog.
77
+ # relay-off omits .env entirely, which is exactly what a home that never opted
78
+ # into the myfirstmate relay looks like.
79
+ make_home() { # <name> [relay-on|relay-off]
80
+ local home="$TMP_ROOT/$1" relay=${2:-relay-on}
81
+ mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects"
82
+ cp "$ROOT/.tasks.toml" "$home/.tasks.toml"
83
+ cat > "$home/data/backlog.md" <<'EOF'
84
+ ## In flight
85
+
86
+ ## Queued
87
+
88
+ ## Done
89
+ EOF
90
+ [ "$relay" = relay-off ] || printf 'FMX_PAIRING_TOKEN=test-token\n' > "$home/.env"
91
+ make_fake_curl "$home" >/dev/null
92
+ fm_fake_exit0 "$home/fakebin" tmux treehouse no-mistakes gh gh-axi
93
+ printf '%s\n' "$home"
94
+ }
95
+
96
+ run_pf() { # <home> <args...>
97
+ local home=$1
98
+ shift
99
+ PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \
100
+ FM_STATE_OVERRIDE="$home/state" FAKE_CURL_LOG="${FAKE_CURL_LOG:-}" \
101
+ FAKE_FOLLOWUP_CODE="${FAKE_FOLLOWUP_CODE:-200}" \
102
+ FMX_NOW_OVERRIDE="${FMX_NOW_OVERRIDE:-$PF_TEST_NOW}" "$PF" "$@"
103
+ }
104
+
105
+ tasks_in() { # <home> <tasks-axi args...>
106
+ local home=$1
107
+ shift
108
+ (cd "$home" && tasks-axi "$@")
109
+ }
110
+
111
+ # seed_commitment <home> <obligation> <request> <platform> <work-home> <work-id>
112
+ # Simulates the intake half that already works today: the relay mention arrives,
113
+ # the typed obligation is created with its opaque thread binding, the work is
114
+ # bound, and the private request context is retained.
115
+ seed_commitment() {
116
+ local home=$1 obligation=$2 request=$3 platform=$4 work_home=$5 work_id=$6
117
+ jq -n --arg r "$request" --arg p "$platform" \
118
+ '{request_id:$r, platform:$p,
119
+ context_binding:{version:"ctx1", value:("ctx1_" + $r)},
120
+ public_safe_summary:"fix worker placement when two spaces share a name",
121
+ received_at:"2026-07-30T10:00:00Z",
122
+ followup_expires_at:"2026-08-06T10:00:00Z",
123
+ reservation_expires_at:"2026-08-06T10:00:00Z"}' > "$home/request.json"
124
+ jq -n '{type:"pr-merged", project:"firstmate",
125
+ required_deliverables:["pr_url"], completion_policy:"all-required"}' \
126
+ > "$home/expected.json"
127
+ jq -n --arg h "$work_home" --arg w "$work_id" \
128
+ '{relation_id:"rel-code", work_ref:{home_id:$h, task_id:$w},
129
+ role:"fulfills", required:true, generation:1}' > "$home/relation.json"
130
+
131
+ tasks_in "$home" public-followup add "$obligation" \
132
+ --request-context-file "$home/request.json" --purpose promised-final \
133
+ --expected-final-file "$home/expected.json" --expires-at 2026-10-01T00:00:00Z >/dev/null \
134
+ || fail "could not create the public commitment"
135
+ tasks_in "$home" public-followup bind-work "$obligation" \
136
+ --relation-file "$home/relation.json" >/dev/null \
137
+ || fail "could not bind work to the public commitment"
138
+
139
+ # The mention payload and the durable per-request context, exactly as the relay
140
+ # poll records them at intake.
141
+ mkdir -p "$home/state/x-inbox"
142
+ jq -n --arg r "$request" --arg p "$platform" \
143
+ '{request_id:$r, platform:$p, text:"please fix worker placement"}' \
144
+ > "$home/state/x-inbox/$request.json"
145
+ chmod 700 "$home/state/x-inbox"
146
+ chmod 600 "$home/state/x-inbox/$request.json"
147
+ FM_HOME="$home" FMX_NOW_OVERRIDE="$PF_TEST_NOW" bash -c \
148
+ ". '$ROOT/bin/fm-x-lib.sh'; fmx_context_registry_set '$home/state' '$request' '$platform' 1900" \
149
+ || fail "could not retain the private request context"
150
+
151
+ run_pf "$home" register "$obligation" --relation rel-code \
152
+ --work-home "$work_home" --work-id "$work_id" --generation 1 >/dev/null \
153
+ || fail "could not register the public commitment"
154
+ }
155
+
156
+ # The pi-rearm shape: a report-ready promised-final bound to a secondmate.
157
+ seed_repro_commitment() { # <home> <obligation> <request> <work-home> <work-id>
158
+ local home=$1 obligation=$2 request=$3 work_home=$4 work_id=$5
159
+ jq -n --arg r "$request" \
160
+ '{request_id:$r, platform:"discord",
161
+ context_binding:{version:"ctx1", value:("ctx1_" + $r)},
162
+ public_safe_summary:"reproduce a Pi recovery notification loop",
163
+ received_at:"2026-08-21T01:12:00Z",
164
+ followup_expires_at:"2026-08-28T01:12:00Z",
165
+ reservation_expires_at:"2026-08-28T01:12:00Z"}' > "$home/request.json"
166
+ jq -n '{type:"report-ready", project:"firstmate",
167
+ required_deliverables:["report_path"], completion_policy:"all-required"}' \
168
+ > "$home/expected.json"
169
+ jq -n --arg h "$work_home" --arg w "$work_id" \
170
+ '{relation_id:"rel-code", work_ref:{home_id:$h, task_id:$w},
171
+ role:"fulfills", required:true, generation:1}' > "$home/relation.json"
172
+ tasks_in "$home" public-followup add "$obligation" --request-context-file "$home/request.json" \
173
+ --purpose promised-final --expected-final-file "$home/expected.json" \
174
+ --expires-at 2026-10-01T00:00:00Z >/dev/null || fail "add failed"
175
+ tasks_in "$home" public-followup bind-work "$obligation" --relation-file "$home/relation.json" >/dev/null \
176
+ || fail "bind-work failed"
177
+ FM_HOME="$home" FMX_NOW_OVERRIDE="$PF_TEST_NOW" bash -c \
178
+ ". '$ROOT/bin/fm-x-lib.sh'; fmx_context_registry_set '$home/state' '$request' discord 2000" \
179
+ || fail "context retain failed"
180
+ run_pf "$home" register "$obligation" --relation rel-code --work-home "$work_home" \
181
+ --work-id "$work_id" --generation 1 >/dev/null || fail "register failed"
182
+ }
183
+
184
+ emit_terminal() { # <child-run-dir> <owning-home> <obligation> <work-home> <work-id> [pr-url] [outcome]
185
+ local owning=$2 obligation=$3 work_home=$4 work_id=$5
186
+ local pr=${6:-https://github.com/example/repo/pull/7} outcome=${7:-pr-merged}
187
+ "$EMIT" --home "$owning" --obligation "$obligation" --relation rel-code \
188
+ --source-home "$work_home" --work-id "$work_id" --generation 1 \
189
+ --outcome "$outcome" --deliverable "pr_url=$pr" \
190
+ --outcome-text 'Fixed: workers now land in the launching workspace even when two spaces share a name.'
191
+ }
192
+
193
+ delivery_state() { # <home> <obligation>
194
+ tasks_in "$1" public-followup list --json 2>/dev/null \
195
+ | jq -r --arg id "$2" '(.public_followups // [])
196
+ | map(select(.id == $id)) | .[0].public_followup.delivery.state // "absent"'
197
+ }
198
+
199
+ task_state() { # <home> <obligation>
200
+ tasks_in "$1" public-followup list --json 2>/dev/null \
201
+ | jq -r --arg id "$2" '(.public_followups // [])
202
+ | map(select(.id == $id)) | .[0].state // "absent"'
203
+ }
204
+
205
+ followup_posts() { # <log>
206
+ local n
207
+ n=$(grep -c 'connector/followup' "$1" 2>/dev/null) || n=0
208
+ printf '%s' "${n:-0}"
209
+ }
210
+
211
+ # expect_failure <label> <command...>: run <command>, require a non-zero exit, and
212
+ # leave its combined output in EXPECT_OUT for the assertions that follow. Keeps
213
+ # refusal tests readable without toggling errexit around every case.
214
+ EXPECT_OUT=
215
+ expect_failure() {
216
+ local label=$1
217
+ shift
218
+ if EXPECT_OUT=$("$@" 2>&1); then
219
+ fail "$label (unexpectedly succeeded)"$'\n'"--- output ---"$'\n'"$EXPECT_OUT"
220
+ fi
221
+ }
222
+
223
+ # --- 0. bounded, single-line, character-safe outcome text -----------------------
224
+
225
+ # The outcome sentence becomes a public reply, so bounding it must not mangle
226
+ # non-ASCII characters, and control characters must never survive into the typed
227
+ # event or the thread.
228
+ test_outcome_text_is_bounded_without_corrupting_characters() {
229
+ local home event text long
230
+ home=$(make_home outcome-text)
231
+ seed_commitment "$home" pf-text req-text discord main work-text
232
+
233
+ "$EMIT" --home "$home" --obligation pf-text --relation rel-code \
234
+ --source-home main --work-id work-text --generation 1 --outcome pr-merged \
235
+ --deliverable pr_url=https://github.com/example/repo/pull/3 \
236
+ --outcome-text "$(printf 'Shipped\tthe caf\xc3\xa9 fix \xe2\x80\x94 \xf0\x9f\x9a\xa2\nsecond line')" >/dev/null \
237
+ || fail "emit failed for non-ASCII outcome text"
238
+ event=$(find "$home/state/public-followup/events" -name '*.json' | head -1)
239
+ text=$(jq -r '.public_safe_outcome' "$event") \
240
+ || fail "the typed event must remain valid JSON with non-ASCII text"
241
+ [ "$text" = 'Shipped the café fix — 🚢 second line' ] \
242
+ || fail "non-ASCII outcome text was corrupted or not collapsed: '$text'"
243
+
244
+ # A very long sentence is capped by codepoint, so the JSON stays valid.
245
+ rm -f "$event"
246
+ long=$(python3 -c 'print("é" * 5000, end="")')
247
+ "$EMIT" --home "$home" --obligation pf-text --relation rel-code \
248
+ --source-home main --work-id work-text --generation 1 --outcome pr-merged \
249
+ --deliverable pr_url=https://github.com/example/repo/pull/4 \
250
+ --outcome-text "$long" >/dev/null \
251
+ || fail "emit failed for an over-long outcome text"
252
+ event=$(find "$home/state/public-followup/events" -name '*.json' | head -1)
253
+ text=$(jq -r '.public_safe_outcome' "$event") \
254
+ || fail "an over-long outcome must still produce valid JSON"
255
+ [ "${#text}" -le 600 ] || fail "the outcome text was not bounded, got ${#text} characters"
256
+ case "$text" in
257
+ *[!é]*) fail "codepoint bounding split a multi-byte character" ;;
258
+ esac
259
+ pass "outcome text is collapsed to one line, bounded by codepoint, and never corrupts characters"
260
+ }
261
+
262
+ # --- 1. the restart end-to-end -------------------------------------------------
263
+
264
+ # The whole reported failure, start to finish, with no conversation memory
265
+ # anywhere: a Discord request becomes a typed commitment, a secondmate child
266
+ # lands the work and reports a TYPED terminal result, the session ends, and a
267
+ # cold reconciliation from disk delivers exactly one final reply into the
268
+ # original thread and closes the obligation.
269
+ test_restart_e2e_delivers_exactly_once() {
270
+ local home child log out posts receipt
271
+ home=$(make_home restart-e2e)
272
+ child=$(make_home restart-child relay-off)
273
+ log="$home/curl.log"; : > "$log"
274
+ printf '%s\n' fmdev > "$child/.fm-secondmate-home"
275
+ fm_write_meta "$home/state/fmdev.meta" "kind=secondmate" "home=$child"
276
+ seed_commitment "$home" pf-restart req-restart discord secondmate:fmdev work-code-q1
277
+ fm_write_meta "$child/state/work-code-q1.meta" \
278
+ "x_request=req-restart" "x_request_ts=1700000000" "x_followups=1"
279
+
280
+ # The reported failure, reproduced: with the work bound but no reconciled
281
+ # terminal result, the commitment is stranded at pending-work and nothing can
282
+ # be delivered - which is exactly how a promised final reply went unsent.
283
+ [ "$(delivery_state "$home" pf-restart)" = pending-work ] \
284
+ || fail "a freshly bound commitment must sit at pending-work"
285
+ FAKE_CURL_LOG="$log" expect_failure "a commitment still waiting on its work must not be deliverable" \
286
+ run_pf "$home" deliver pf-restart
287
+ assert_contains "$EXPECT_OUT" "still waiting on its bound work" \
288
+ "the stranded state must be reported, not silently skipped"
289
+ [ "$(followup_posts "$log")" -eq 0 ] || fail "the stranded state must post nothing"
290
+
291
+ # The child home reports its terminal result as typed data. This is the step
292
+ # whose absence left the obligation stranded at pending-work.
293
+ emit_terminal "$home" "$home" pf-restart secondmate:fmdev work-code-q1 >/dev/null \
294
+ || fail "the child could not report its typed terminal result"
295
+
296
+ # Simulate compaction/restart: nothing but disk survives, and the drained inbox
297
+ # is gone. The durable private request context is what keeps the thread binding
298
+ # resolvable.
299
+ rm -f "$home/state/x-inbox/req-restart.json"
300
+
301
+ out=$(FAKE_CURL_LOG="$log" run_pf "$home" consume) \
302
+ || fail "cold reconciliation failed"
303
+ assert_contains "$out" "ready pf-restart req-restart discord" \
304
+ "reconciliation must report the commitment as delivery-ready"
305
+ [ "$(delivery_state "$home" pf-restart)" = ready ] \
306
+ || fail "the typed terminal result must move the commitment to ready"
307
+ [ "$(followup_posts "$log")" -eq 0 ] \
308
+ || fail "reconciliation must not post anything by itself"
309
+
310
+ out=$(FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-restart) \
311
+ || fail "delivery failed"
312
+ assert_contains "$out" "delivered pf-restart request=req-restart platform=discord" \
313
+ "delivery must report the original request binding"
314
+
315
+ posts=$(followup_posts "$log")
316
+ [ "$posts" -eq 1 ] || fail "expected exactly one public reply, got $posts"
317
+ assert_grep 'connector/followup' "$log" "the reply must use the follow-up endpoint"
318
+ assert_grep '"request_id":"req-restart"' "$log" \
319
+ "the reply must target the ORIGINAL request binding"
320
+ assert_grep 'workers now land in the launching workspace' "$log" \
321
+ "the reply must reuse the accepted terminal outcome verbatim"
322
+
323
+ receipt=$(tasks_in "$home" public-followup list --json \
324
+ | jq -r '(.public_followups // [])
325
+ | map(select(.id == "pf-restart")) | .[0].public_followup.delivery.receipt.state // "none"')
326
+ [ "$receipt" = posted ] || fail "a validated posted receipt must be recorded, got '$receipt'"
327
+ [ "$(task_state "$home" pf-restart)" = 'done' ] \
328
+ || fail "the commitment must be Done only after the receipt"
329
+ assert_no_grep '^x_request=' "$child/state/work-code-q1.meta" \
330
+ "typed delivery must clear the secondmate's legacy X link"
331
+ pass "restart end-to-end: typed result reconciles from disk and delivers one reply to the original thread"
332
+ }
333
+
334
+ # --- 2. idempotency ------------------------------------------------------------
335
+
336
+ test_duplicate_event_and_replay_are_noops() {
337
+ local home log first second out posts
338
+ home=$(make_home idempotent)
339
+ log="$home/curl.log"; : > "$log"
340
+ seed_commitment "$home" pf-dup req-dup discord main work-dup
341
+
342
+ first=$(emit_terminal "$home" "$home" pf-dup main work-dup) || fail "first emit failed"
343
+ second=$(emit_terminal "$home" "$home" pf-dup main work-dup) || fail "second emit failed"
344
+ [ "$first" = "$second" ] \
345
+ || fail "the same terminal result must derive the same event identity"
346
+ [ "$(find "$home/state/public-followup/events" -name '*.json' | wc -l | tr -d ' ')" -eq 1 ] \
347
+ || fail "a duplicate emit must not create a second event file"
348
+
349
+ FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "first consume failed"
350
+ # Replay the identical event after the fact, exactly as a restarted child would.
351
+ emit_terminal "$home" "$home" pf-dup main work-dup >/dev/null || fail "replay emit failed"
352
+ out=$(FAKE_CURL_LOG="$log" run_pf "$home" consume) || fail "replay consume failed"
353
+ [ -z "$out" ] || fail "replaying an accepted event must be silent, got: $out"
354
+ [ "$(delivery_state "$home" pf-dup)" = ready ] \
355
+ || fail "replay must not disturb the delivery state"
356
+
357
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-dup >/dev/null || fail "delivery failed"
358
+ out=$(FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-dup) || fail "second deliver must succeed silently"
359
+ assert_contains "$out" "already delivered" "a second delivery must report the existing receipt"
360
+ posts=$(followup_posts "$log")
361
+ [ "$posts" -eq 1 ] || fail "a repeated delivery must never double-post, got $posts posts"
362
+ pass "duplicate terminal results, restart replay, and repeated delivery are all no-ops"
363
+ }
364
+
365
+ # --- 3. refusals ---------------------------------------------------------------
366
+
367
+ # Everything tasks-axi is the authority on - source home, work id, generation,
368
+ # schema, and permitted deliverables - must be refused rather than half-applied,
369
+ # and quarantined rather than retried forever.
370
+ test_invalid_events_are_refused_and_quarantined() {
371
+ local home out events rejected
372
+ home=$(make_home refusals)
373
+ seed_commitment "$home" pf-refuse req-refuse discord secondmate:fmdev work-real
374
+
375
+ # Wrong source home and wrong work id are caught at the edge by the emitter,
376
+ # because the owning home's own registration disagrees.
377
+ expect_failure "a wrong source home must be refused" \
378
+ "$EMIT" --home "$home" --obligation pf-refuse --relation rel-code \
379
+ --source-home secondmate:other --work-id work-real --generation 1 \
380
+ --outcome pr-merged --deliverable pr_url=https://example.invalid/1 \
381
+ --outcome-text 'x'
382
+ assert_contains "$EXPECT_OUT" "does not match this home's registration" \
383
+ "the refusal must name the mismatch"
384
+
385
+ expect_failure "a wrong work id must be refused" \
386
+ "$EMIT" --home "$home" --obligation pf-refuse --relation rel-code \
387
+ --source-home secondmate:fmdev --work-id work-other --generation 1 \
388
+ --outcome pr-merged --deliverable pr_url=https://example.invalid/1 \
389
+ --outcome-text 'x'
390
+ expect_failure "a stale generation must be refused" \
391
+ "$EMIT" --home "$home" --obligation pf-refuse --relation rel-code \
392
+ --source-home secondmate:fmdev --work-id work-real --generation 0 \
393
+ --outcome pr-merged --deliverable pr_url=https://example.invalid/1 \
394
+ --outcome-text 'x'
395
+
396
+ events="$home/state/public-followup/events"
397
+ rejected="$home/state/public-followup/rejected"
398
+
399
+ # A malformed event that bypassed the emitter entirely.
400
+ printf 'not json at all\n' > "$events/deadbeef.json"
401
+ out=$(run_pf "$home" consume) || fail "consume must survive a malformed event"
402
+ assert_contains "$out" "rejected deadbeef" "a malformed event must be refused"
403
+ assert_absent "$events/deadbeef.json" "a refused event must leave the pending inbox"
404
+ assert_present "$rejected/deadbeef.reason" "a refusal must keep an inspectable reason"
405
+
406
+ # A deliverable the expected-final type does not permit. The emitter accepts the
407
+ # shape; tasks-axi is the authority that refuses the semantics.
408
+ "$EMIT" --home "$home" --obligation pf-refuse --relation rel-code \
409
+ --source-home secondmate:fmdev --work-id work-real --generation 1 \
410
+ --outcome pr-merged --deliverable report_path=data/x/report.md \
411
+ --outcome-text 'wrong deliverable for a merged PR' >/dev/null \
412
+ || fail "the emitter should publish a shape-valid event"
413
+ out=$(run_pf "$home" consume) || fail "consume must survive an unsupported deliverable"
414
+ assert_contains "$out" "rejected " "an unsupported deliverable must be refused by tasks-axi"
415
+ [ "$(delivery_state "$home" pf-refuse)" = pending-work ] \
416
+ || fail "a refused event must leave the commitment untouched"
417
+
418
+ # A hand-edited event whose id no longer matches its own identity fields.
419
+ jq -n '{schema_version:1, event_id:"forged", obligation_id:"pf-refuse",
420
+ relation_id:"rel-code", work_id:"work-real", generation:1,
421
+ source_home_id:"secondmate:fmdev", outcome_type:"pr-merged",
422
+ deliverables:{pr_url:"https://example.invalid/9"},
423
+ public_safe_outcome:"forged", occurred_at:"2026-07-30T12:00:00Z",
424
+ successor:null}' > "$events/forged.json"
425
+ out=$(run_pf "$home" consume) || fail "consume must survive a forged event"
426
+ assert_contains "$out" "rejected forged" "a forged event identity must be refused"
427
+ pass "wrong source, wrong work id, stale generation, malformed, unsupported deliverable, and forged identity are all refused"
428
+ }
429
+
430
+ # --- 4. transport failure and late receipt -------------------------------------
431
+
432
+ test_relay_failure_holds_without_false_completion() {
433
+ local home log out posts
434
+ home=$(make_home retryable)
435
+ log="$home/curl.log"; : > "$log"
436
+ seed_commitment "$home" pf-retry req-retry discord main work-retry
437
+ emit_terminal "$home" "$home" pf-retry main work-retry >/dev/null || fail "emit failed"
438
+ FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed"
439
+
440
+ FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=500 \
441
+ expect_failure "a failed relay post must not report success" \
442
+ run_pf "$home" deliver pf-retry
443
+ assert_contains "$EXPECT_OUT" "recorded as retryable" "the failure must be typed as retryable"
444
+ [ "$(delivery_state "$home" pf-retry)" = retry-due ] \
445
+ || fail "a failed post must leave a retryable state, got $(delivery_state "$home" pf-retry)"
446
+ [ "$(task_state "$home" pf-retry)" != 'done' ] \
447
+ || fail "a failed post must never close the commitment"
448
+
449
+ # The retry succeeds and closes it, with exactly one successful post.
450
+ : > "$log"
451
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-retry >/dev/null || fail "the retry should deliver"
452
+ posts=$(followup_posts "$log")
453
+ [ "$posts" -eq 1 ] || fail "the retry must post exactly once, got $posts"
454
+ [ "$(task_state "$home" pf-retry)" = 'done' ] || fail "a successful retry must close the commitment"
455
+ pass "a relay transport failure is held as retryable with no false completion, and the retry posts once"
456
+ }
457
+
458
+ test_dry_run_does_not_close_commitment() {
459
+ local home log out posts
460
+ home=$(make_home dry-run)
461
+ log="$home/curl.log"; : > "$log"
462
+ seed_commitment "$home" pf-dry req-dry discord main work-dry
463
+ emit_terminal "$home" "$home" pf-dry main work-dry >/dev/null || fail "emit failed"
464
+ run_pf "$home" consume >/dev/null || fail "consume failed"
465
+
466
+ FMX_DRY_RUN=1 FAKE_CURL_LOG="$log" expect_failure \
467
+ "a dry-run must not close a public commitment" run_pf "$home" deliver pf-dry
468
+ assert_contains "$EXPECT_OUT" "recorded as retryable" \
469
+ "a dry-run must leave a retryable typed state"
470
+ [ "$(delivery_state "$home" pf-dry)" = retry-due ] \
471
+ || fail "a dry-run must leave the obligation retryable, got $(delivery_state "$home" pf-dry)"
472
+ [ "$(task_state "$home" pf-dry)" != 'done' ] \
473
+ || fail "a dry-run must never close the commitment"
474
+ posts=$(followup_posts "$log")
475
+ [ "$posts" -eq 0 ] || fail "a dry-run must not post to the relay, got $posts posts"
476
+ pass "a dry-run records no public delivery and leaves the commitment retryable"
477
+ }
478
+
479
+ test_late_receipt_closes_the_exact_attempt_without_reposting() {
480
+ local home log out posts attempt
481
+ home=$(make_home late-receipt)
482
+ log="$home/curl.log"; : > "$log"
483
+ seed_commitment "$home" pf-late req-late x main work-late
484
+ fm_write_meta "$home/state/work-late.meta" \
485
+ "x_request=req-late" "x_request_ts=1700000000" "x_followups=1"
486
+ emit_terminal "$home" "$home" pf-late main work-late >/dev/null || fail "emit failed"
487
+ FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed"
488
+
489
+ FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=503 run_pf "$home" deliver pf-late >/dev/null 2>&1 || true
490
+ attempt=$(tasks_in "$home" public-followup list --json \
491
+ | jq -r '(.public_followups // []) | map(select(.id == "pf-late"))
492
+ | .[0].public_followup.delivery.attempt_count')
493
+ [ "$attempt" = 1 ] || fail "the failed attempt must be recorded as attempt 1, got '$attempt'"
494
+
495
+ expect_failure "a late receipt must include its exact message count" \
496
+ run_pf "$home" record-posted pf-late --attempt 1
497
+ assert_contains "$EXPECT_OUT" "--chunks <n> is required" \
498
+ "a late receipt without a message count must be refused"
499
+
500
+ # The post actually landed; its receipt was simply lost. Close the exact attempt
501
+ # without sending anything else.
502
+ : > "$log"
503
+ out=$(FAKE_CURL_LOG="$log" run_pf "$home" record-posted pf-late --attempt 1 --chunks 1) \
504
+ || fail "recording a late receipt for the exact attempt must succeed"
505
+ assert_contains "$out" "recorded pf-late attempt=1" "the late receipt must name its attempt"
506
+ posts=$(followup_posts "$log")
507
+ [ "$posts" -eq 0 ] || fail "recording a late receipt must post nothing, got $posts posts"
508
+ [ "$(task_state "$home" pf-late)" = 'done' ] || fail "a validated late receipt must close the commitment"
509
+ assert_no_grep '^x_request=' "$home/state/work-late.meta" \
510
+ "a late receipt must clear the legacy X link"
511
+
512
+ FAKE_CURL_LOG="$log" expect_failure "a receipt for a different attempt must be refused" \
513
+ run_pf "$home" record-posted pf-late --attempt 9 --chunks 1
514
+ pass "a late success receipt closes the exact attempt with no second post, and a mismatched attempt is refused"
515
+ }
516
+
517
+ test_typed_terminal_clear_only_removes_legacy_link() {
518
+ local home meta out
519
+ home=$(make_home typed-clear)
520
+ meta="$home/state/work-clear.meta"
521
+ printf '%s\n' 'status=working' 'x_request=req-clear' 'x_request_ts=1700000000' \
522
+ 'x_followups=2' 'x_platform=discord' 'x_reply_max_chars=1900' > "$meta"
523
+
524
+ out=$(PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \
525
+ FM_STATE_OVERRIDE="$home/state" "$ROOT/bin/fm-x-followup.sh" --clear work-clear) \
526
+ || fail "the typed terminal clear transition must succeed"
527
+ [ "$out" = work-clear ] || fail "the clear-only transition must identify the task"
528
+ assert_grep 'status=working' "$meta" "clear-only transition must preserve unrelated task metadata"
529
+ assert_no_grep '^x_request=' "$meta" "clear-only transition must remove the request link"
530
+ assert_no_grep '^x_followups=' "$meta" "clear-only transition must remove the follow-up counter"
531
+ assert_no_grep '^x_platform=' "$meta" "clear-only transition must remove platform metadata"
532
+ pass "typed terminal cleanup clears the legacy link without posting"
533
+ }
534
+
535
+ # A crash between the post and its receipt is the one case where we cannot know
536
+ # whether the thread already got a reply. Delivery must refuse rather than guess.
537
+ test_interrupted_delivery_refuses_to_repost() {
538
+ local home log out posts
539
+ home=$(make_home interrupted)
540
+ log="$home/curl.log"; : > "$log"
541
+ seed_commitment "$home" pf-crash req-crash discord main work-crash
542
+ emit_terminal "$home" "$home" pf-crash main work-crash >/dev/null || fail "emit failed"
543
+ run_pf "$home" consume >/dev/null || fail "consume failed"
544
+
545
+ # Reproduce the crash window directly through the state machine.
546
+ tasks_in "$home" public-followup begin-delivery pf-crash \
547
+ --payload-hash 0000000000000000000000000000000000000000000000000000000000000000 >/dev/null \
548
+ || fail "could not stage the interrupted attempt"
549
+
550
+ FAKE_CURL_LOG="$log" expect_failure "an interrupted delivery must not silently post again" \
551
+ run_pf "$home" deliver pf-crash
552
+ assert_contains "$EXPECT_OUT" "mid-delivery" "the refusal must name the interrupted attempt"
553
+ posts=$(followup_posts "$log")
554
+ [ "$posts" -eq 0 ] || fail "an interrupted delivery must post nothing, got $posts posts"
555
+ pass "a delivery interrupted between post and receipt refuses to repost"
556
+ }
557
+
558
+ # --- 5. ownership --------------------------------------------------------------
559
+
560
+ # The outward post belongs to the home holding the relay consent and the thread
561
+ # binding. A child home has neither, and must not be able to acquire them.
562
+ test_outward_delivery_stays_with_the_owning_home() {
563
+ local owner child log out
564
+ owner=$(make_home owner)
565
+ child=$(make_home child relay-off)
566
+ log="$owner/curl.log"; : > "$log"
567
+ printf '%s\n' child > "$child/.fm-secondmate-home"
568
+ fm_write_meta "$owner/state/child.meta" "kind=secondmate" "home=$child"
569
+ seed_commitment "$owner" pf-own req-own discord secondmate:child work-child
570
+ fm_write_meta "$child/state/work-child.meta" \
571
+ "x_request=req-own" "x_request_ts=1700000000" "x_followups=1"
572
+
573
+ FAKE_CURL_LOG="$log" emit_terminal "$owner" "$owner" pf-own secondmate:child work-child >/dev/null \
574
+ || fail "the child could not report its typed result"
575
+ [ "$(followup_posts "$log")" -eq 0 ] \
576
+ || fail "reporting a terminal result must never post publicly"
577
+ run_pf "$owner" consume >/dev/null || fail "the owning home could not consume the child's typed result"
578
+
579
+ # The child home has no commitment of its own and no relay consent, so it can
580
+ # neither deliver nor even see one.
581
+ PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
582
+ FM_STATE_OVERRIDE="$child/state" FAKE_CURL_LOG="$log" \
583
+ expect_failure "a home without relay consent must not deliver a public reply" \
584
+ "$PF" deliver pf-own
585
+ assert_contains "$EXPECT_OUT" "has not opted into the myfirstmate relay" \
586
+ "the refusal must name the missing relay consent"
587
+ [ "$(followup_posts "$log")" -eq 0 ] || fail "the refused delivery must post nothing"
588
+ FAKE_CURL_LOG="$log" run_pf "$owner" deliver pf-own >/dev/null \
589
+ || fail "the owning home must deliver the typed public reply"
590
+ assert_no_grep '^x_request=' "$child/state/work-child.meta" \
591
+ "typed delivery must clear the child task's legacy X link"
592
+ pass "a child home reports typed results but can never become the outward-post owner"
593
+ }
594
+
595
+ test_delivery_requires_registration_before_posting() {
596
+ local home log out
597
+ home=$(make_home missing-registration)
598
+ log="$home/curl.log"; : > "$log"
599
+ seed_commitment "$home" pf-missing req-missing x main work-missing
600
+ fm_write_meta "$home/state/work-missing.meta" \
601
+ "x_request=req-missing" "x_request_ts=1700000000" "x_followups=1"
602
+ emit_terminal "$home" "$home" pf-missing main work-missing >/dev/null || fail "emit failed"
603
+ run_pf "$home" consume >/dev/null || fail "consume failed"
604
+ rm -f "$home/state/public-followup/registry/pf-missing"
605
+
606
+ FAKE_CURL_LOG="$log" expect_failure "delivery without a registration must refuse" \
607
+ run_pf "$home" deliver pf-missing
608
+ assert_contains "$EXPECT_OUT" "registration for 'pf-missing' is missing or invalid" \
609
+ "missing registration must be an actionable delivery refusal"
610
+ [ "$(followup_posts "$log")" -eq 0 ] || fail "missing registration must prevent any public post"
611
+ [ "$(task_state "$home" pf-missing)" != 'done' ] \
612
+ || fail "missing registration must not close the obligation"
613
+ assert_grep 'x_request=req-missing' "$home/state/work-missing.meta" \
614
+ "missing registration must leave the legacy link for reconciliation"
615
+ pass "typed delivery refuses to post when its cleanup registration is missing"
616
+ }
617
+
618
+ test_secondmate_teardown_requires_parent_binding() {
619
+ local parent child registry_before marker_before
620
+ parent=$(make_home teardown-parent)
621
+ child=$(make_home teardown-child)
622
+ printf '%s\n' mate > "$child/.fm-secondmate-home"
623
+ seed_commitment "$parent" pf-teardown req-teardown x secondmate:mate work-child
624
+ fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child"
625
+ fm_write_meta "$child/state/work-child.meta" \
626
+ "window=firstmate:fm-work-child" "endpoint_task_id=work-child" \
627
+ "worktree=$child" "project=$child" "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
628
+
629
+ PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
630
+ FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \
631
+ expect_failure "marked child teardown without a parent must refuse cleanup" \
632
+ "$TEARDOWN" work-child
633
+ assert_contains "$EXPECT_OUT" "cannot resolve the primary home for marked secondmate mate" \
634
+ "missing parent binding must be an actionable teardown refusal"
635
+ assert_present "$child/state/work-child.meta" \
636
+ "missing parent binding must preserve the child work metadata"
637
+
638
+ parent=$(make_home teardown-valid-parent)
639
+ child=$(make_home teardown-valid-child)
640
+ printf '%s\n' mate > "$child/.fm-secondmate-home"
641
+ printf -- '- mate - synthetic (id is legacy); preserve this (home: %s; scope: synthetic (child); semicolon remains meaningful; projects: ; added 2026-07-30)\n' \
642
+ "$child" > "$parent/data/secondmates.md"
643
+ FM_HOME="$parent" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null \
644
+ || fail "home-seed validation rejected a punctuation-bearing operational registry record"
645
+ registry_before=$(cat "$parent/data/secondmates.md")
646
+ marker_before=$(cat "$child/.fm-secondmate-home")
647
+ seed_commitment "$parent" pf-teardown-valid req-teardown-valid x secondmate:mate work-child
648
+ fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child"
649
+ fm_write_meta "$child/state/work-child.meta" \
650
+ "window=firstmate:fm-work-child" "endpoint_task_id=work-child" \
651
+ "worktree=$child" "project=$child" "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
652
+ assert_absent "$child/.fm-secondmate-parent" \
653
+ "the legacy env-only binding case must not gain a durable parent record"
654
+
655
+ PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
656
+ FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \
657
+ FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" \
658
+ expect_failure "marked child teardown with a valid parent must enforce the parent commitment" \
659
+ "$TEARDOWN" work-child
660
+ assert_contains "$EXPECT_OUT" "still owes a public reply" \
661
+ "valid parent binding must route cleanup through the parent commitment"
662
+ case "$EXPECT_OUT" in
663
+ *"cannot resolve the primary home"*) fail "valid parent binding was reported as unresolved" ;;
664
+ esac
665
+ assert_present "$child/state/work-child.meta" \
666
+ "an owed parent commitment must preserve the child work metadata"
667
+ [ "$registry_before" = "$(cat "$parent/data/secondmates.md")" ] \
668
+ || fail "guarded cleanup refusal changed the parent registry"
669
+ [ "$marker_before" = "$(cat "$child/.fm-secondmate-home")" ] \
670
+ || fail "guarded cleanup refusal changed the child identity marker"
671
+ pass "marked secondmate teardown resolves its parent and fails closed when unavailable"
672
+ }
673
+
674
+ # The three tests below exercise the durable .fm-secondmate-parent record a real
675
+ # bin/fm-home-seed.sh seed now writes next to .fm-secondmate-home (fm-remote-sm-
676
+ # cleanup-parent-binding-s1 report, section 7). Before this record existed, the
677
+ # marked-child gate above could only ever see the parent through the launch-time
678
+ # FM_PUBLIC_FOLLOWUP_PRIMARY_HOME env var: a restart that dropped that prefix made
679
+ # the guard silently treat an actually-active parent relay as off, which could
680
+ # drop a real public-reply obligation without anyone noticing. Each test drives
681
+ # the real bin/fm-teardown.sh cleanup path against a home real fm-home-seed.sh
682
+ # produced, never a hand-crafted marker.
683
+
684
+ assert_local_secondmate_parent_record() {
685
+ local child=$1 parent=$2
686
+ cmp -s "$child/.fm-secondmate-parent" <(
687
+ printf 'schema=fm-secondmate-parent.v1\nroute=local\nparent_home=%s\n' "$parent"
688
+ ) || fail "real secondmate seeding must write the exact durable local parent record"
689
+ }
690
+
691
+ test_local_secondmate_seed_publishes_parent_before_identity() {
692
+ local parent child parent_resolved fakebin entered release manifest_out real_mv seed_pid wait_count
693
+ parent=$(make_home seed-publication-parent relay-off)
694
+ child="$TMP_ROOT/seed-publication-child"
695
+ parent_resolved=$(cd "$parent" && pwd -P)
696
+ fakebin=$(fm_fakebin "$TMP_ROOT/seed-publication-fake")
697
+ entered="$TMP_ROOT/seed-publication-entered"
698
+ release="$TMP_ROOT/seed-publication-release"
699
+ manifest_out="$TMP_ROOT/seed-publication.out"
700
+ real_mv=$(command -v mv)
701
+ cat > "$fakebin/mv" <<'SH'
702
+ #!/usr/bin/env bash
703
+ destination=${!#}
704
+ case "$destination" in
705
+ */.fm-secondmate-home)
706
+ touch "$FM_TEST_PUBLISH_ENTERED"
707
+ wait_count=0
708
+ while [ ! -f "$FM_TEST_PUBLISH_RELEASE" ]; do
709
+ wait_count=$((wait_count + 1))
710
+ [ "$wait_count" -le 250 ] || exit 97
711
+ sleep 0.02
712
+ done
713
+ ;;
714
+ esac
715
+ exec "$FM_TEST_REAL_MV" "$@"
716
+ SH
717
+ chmod +x "$fakebin/mv"
718
+ PATH="$fakebin:$PATH" FM_HOME="$parent" \
719
+ FM_SECONDMATE_CHARTER='Local publication-order regression charter.' \
720
+ FM_TEST_REAL_MV="$real_mv" FM_TEST_PUBLISH_ENTERED="$entered" \
721
+ FM_TEST_PUBLISH_RELEASE="$release" \
722
+ "$ROOT/bin/fm-home-seed.sh" mate "$child" --no-projects > "$manifest_out" 2>&1 &
723
+ seed_pid=$!
724
+ wait_count=0
725
+ while [ ! -f "$entered" ]; do
726
+ kill -0 "$seed_pid" 2>/dev/null \
727
+ || fail "local seeding exited before its identity completion marker: $(cat "$manifest_out")"
728
+ wait_count=$((wait_count + 1))
729
+ [ "$wait_count" -le 250 ] || fail "local seeding never reached its identity completion marker"
730
+ sleep 0.02
731
+ done
732
+ assert_local_secondmate_parent_record "$child" "$parent_resolved"
733
+ assert_absent "$child/.fm-secondmate-home" \
734
+ "the local identity marker must remain absent until durable parent publication completes"
735
+ touch "$release"
736
+ wait "$seed_pid" || fail "local seeding failed after publishing durable parent state: $(cat "$manifest_out")"
737
+ assert_present "$child/.fm-secondmate-home" \
738
+ "local seeding must publish its identity marker as the completion point"
739
+ pass "local seeding publishes durable parent state before its identity marker"
740
+ }
741
+
742
+ test_secondmate_teardown_resolves_parent_from_durable_record_when_env_lost() {
743
+ local parent child parent_resolved
744
+ parent=$(make_home teardown-durable-parent)
745
+ child="$TMP_ROOT/teardown-durable-child"
746
+ FM_SECONDMATE_CHARTER='Durable-record regression charter.' \
747
+ FM_HOME="$parent" "$ROOT/bin/fm-home-seed.sh" mate "$child" --no-projects >/dev/null \
748
+ || fail "real secondmate seeding failed"
749
+ child=$(cd "$child" && pwd -P)
750
+ parent_resolved=$(cd "$parent" && pwd -P)
751
+ make_fake_curl "$child" >/dev/null
752
+ fm_fake_exit0 "$child/fakebin" tmux treehouse no-mistakes gh gh-axi
753
+
754
+ assert_local_secondmate_parent_record "$child" "$parent_resolved"
755
+
756
+ seed_commitment "$parent" pf-durable req-durable x secondmate:mate work-child
757
+ fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child"
758
+ fm_write_meta "$child/state/work-child.meta" \
759
+ "window=firstmate:fm-work-child" "endpoint_task_id=work-child" \
760
+ "worktree=$child" "project=$child" "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
761
+
762
+ # No FM_PUBLIC_FOLLOWUP_PRIMARY_HOME at all here: a restart of the secondmate
763
+ # agent that drops the launch-time prefix must still find the real parent
764
+ # through the durable record instead of silently treating the relay as off.
765
+ PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
766
+ FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \
767
+ expect_failure "teardown with a lost launch binding must still find the real parent" \
768
+ "$TEARDOWN" work-child
769
+ assert_contains "$EXPECT_OUT" "still owes a public reply" \
770
+ "the durable record must resolve to the real parent's owed commitment"
771
+ case "$EXPECT_OUT" in
772
+ *"cannot resolve the primary home"*) fail "the durable local record was not used to resolve the parent" ;;
773
+ esac
774
+ assert_present "$child/state/work-child.meta" \
775
+ "a durably-resolved owed commitment must preserve the child work metadata"
776
+ pass "a lost launch-time parent binding is recovered from the durable local record"
777
+ }
778
+
779
+ test_secondmate_teardown_durable_record_missing_parent_registration_still_refuses() {
780
+ local parent child parent_resolved
781
+ parent=$(make_home teardown-durable-missing-parent relay-off)
782
+ child="$TMP_ROOT/teardown-durable-missing-child"
783
+ FM_SECONDMATE_CHARTER='Durable-record missing-registration regression charter.' \
784
+ FM_HOME="$parent" "$ROOT/bin/fm-home-seed.sh" mate "$child" --no-projects >/dev/null \
785
+ || fail "real secondmate seeding failed"
786
+ child=$(cd "$child" && pwd -P)
787
+ parent_resolved=$(cd "$parent" && pwd -P)
788
+ make_fake_curl "$child" >/dev/null
789
+ fm_fake_exit0 "$child/fakebin" tmux treehouse no-mistakes gh gh-axi
790
+ assert_local_secondmate_parent_record "$child" "$parent_resolved"
791
+ fm_write_meta "$child/state/work-child.meta" \
792
+ "window=firstmate:fm-work-child" "endpoint_task_id=work-child" \
793
+ "worktree=$child" "project=$child" "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
794
+ # No parent/state/mate.meta at all: the parent never recorded this secondmate's
795
+ # own agent, so its side of the binding is genuinely missing. A durable LOCAL
796
+ # record naming the real parent path must not be enough on its own to bypass
797
+ # the check; the real protection this guard exists for must survive the fix.
798
+
799
+ PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
800
+ FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \
801
+ expect_failure "a durable local record with no parent-side registration must still refuse" \
802
+ "$TEARDOWN" work-child
803
+ assert_contains "$EXPECT_OUT" "cannot resolve the primary home for marked secondmate mate" \
804
+ "a genuinely missing parent-side registration must remain an actionable teardown refusal"
805
+ assert_present "$child/state/work-child.meta" \
806
+ "a genuinely missing parent binding must preserve the child work metadata"
807
+ pass "a durable local parent record does not bypass a genuinely missing parent-side registration"
808
+ }
809
+
810
+ test_secondmate_teardown_durable_record_with_unknown_field_succeeds() {
811
+ local parent parent_alias child parent_resolved rc out
812
+ parent=$(make_home teardown-durable-clean-parent relay-off)
813
+ child="$TMP_ROOT/teardown-durable-clean-child"
814
+ FM_SECONDMATE_CHARTER='Durable-record clean-cleanup regression charter.' \
815
+ FM_HOME="$parent" "$ROOT/bin/fm-home-seed.sh" mate "$child" --no-projects >/dev/null \
816
+ || fail "real secondmate seeding failed"
817
+ child=$(cd "$child" && pwd -P)
818
+ parent_resolved=$(cd "$parent" && pwd -P)
819
+ make_fake_curl "$child" >/dev/null
820
+ fm_fake_exit0 "$child/fakebin" tmux treehouse no-mistakes gh gh-axi
821
+ assert_local_secondmate_parent_record "$child" "$parent_resolved"
822
+ printf 'some_future_field=value\n' >> "$child/.fm-secondmate-parent"
823
+ parent_alias="$TMP_ROOT/teardown-durable-clean-parent-alias"
824
+ ln -s "$parent" "$parent_alias"
825
+ fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child"
826
+ fm_git_init_commit "$child/projects/worktree"
827
+ printf 'manual\n' > "$child/config/backlog-backend"
828
+ fm_write_meta "$child/state/work-clean.meta" \
829
+ "window=firstmate:fm-work-clean" "endpoint_task_id=work-clean" \
830
+ "worktree=$child/projects/worktree" "project=$child/projects/worktree" \
831
+ "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
832
+
833
+ rc=0
834
+ out=$(PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
835
+ FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \
836
+ FM_CONFIG_OVERRIDE="$child/config" FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent_alias" \
837
+ "$TEARDOWN" work-clean 2>&1) || rc=$?
838
+ [ "$rc" -eq 0 ] || fail "a resolved parent with no owed commitment must allow cleanup (rc=$rc): $out"
839
+ assert_not_contains "$out" "cannot resolve the primary home" \
840
+ "a real durable-record-backed parent must resolve cleanly"
841
+ pass "unknown durable parent fields remain forward-compatible"
842
+ }
843
+
844
+ test_secondmate_teardown_rejects_conflicting_live_and_durable_parent_bindings() {
845
+ local durable_parent live_parent child parent_resolved
846
+ durable_parent=$(make_home teardown-durable-conflict-recorded relay-off)
847
+ live_parent=$(make_home teardown-durable-conflict-live relay-off)
848
+ child="$TMP_ROOT/teardown-durable-conflict-child"
849
+ FM_SECONDMATE_CHARTER='Durable-record conflict regression charter.' \
850
+ FM_HOME="$durable_parent" "$ROOT/bin/fm-home-seed.sh" mate "$child" --no-projects >/dev/null \
851
+ || fail "real secondmate seeding failed"
852
+ child=$(cd "$child" && pwd -P)
853
+ parent_resolved=$(cd "$durable_parent" && pwd -P)
854
+ make_fake_curl "$child" >/dev/null
855
+ fm_fake_exit0 "$child/fakebin" tmux treehouse no-mistakes gh gh-axi
856
+ assert_local_secondmate_parent_record "$child" "$parent_resolved"
857
+ fm_write_meta "$durable_parent/state/mate.meta" "kind=secondmate" "home=$child"
858
+ fm_git_init_commit "$child/projects/worktree"
859
+ printf 'manual\n' > "$child/config/backlog-backend"
860
+ fm_write_meta "$child/state/work-conflict.meta" \
861
+ "window=firstmate:fm-work-conflict" "endpoint_task_id=work-conflict" \
862
+ "worktree=$child/projects/worktree" "project=$child/projects/worktree" \
863
+ "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
864
+
865
+ PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
866
+ FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \
867
+ FM_CONFIG_OVERRIDE="$child/config" FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$live_parent" \
868
+ expect_failure "conflicting live and durable parent bindings must refuse cleanup" \
869
+ "$TEARDOWN" work-conflict
870
+ assert_contains "$EXPECT_OUT" "cannot resolve the primary home for marked secondmate mate" \
871
+ "a conflicting live parent must produce the explicit durable-binding refusal"
872
+ assert_present "$child/state/work-conflict.meta" \
873
+ "a conflicting live parent must preserve child work metadata"
874
+ pass "conflicting live and durable parent bindings fail closed"
875
+ }
876
+
877
+ test_secondmate_teardown_rejects_unsafe_durable_parent_records() {
878
+ local case_name parent child parent_record
879
+ for case_name in symlink invalid-route duplicate-route remote-parent-home local-parent-host; do
880
+ parent=$(make_home "teardown-durable-$case_name-parent" relay-off)
881
+ child="$TMP_ROOT/teardown-durable-$case_name-child"
882
+ FM_SECONDMATE_CHARTER='Unsafe durable-record regression charter.' \
883
+ FM_HOME="$parent" "$ROOT/bin/fm-home-seed.sh" mate "$child" --no-projects >/dev/null \
884
+ || fail "real secondmate seeding failed for $case_name"
885
+ child=$(cd "$child" && pwd -P)
886
+ make_fake_curl "$child" >/dev/null
887
+ fm_fake_exit0 "$child/fakebin" tmux treehouse no-mistakes gh gh-axi
888
+ fm_write_meta "$child/state/work-child.meta" \
889
+ "window=firstmate:fm-work-child" "endpoint_task_id=work-child" \
890
+ "worktree=$child" "project=$child" "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
891
+ parent_record="$child/.fm-secondmate-parent"
892
+ case "$case_name" in
893
+ symlink)
894
+ mv "$parent_record" "$parent_record.valid"
895
+ ln -s .fm-secondmate-parent.valid "$parent_record"
896
+ ;;
897
+ invalid-route)
898
+ printf 'schema=fm-secondmate-parent.v1\nroute=garbage\n' > "$parent_record"
899
+ ;;
900
+ duplicate-route)
901
+ printf 'schema=fm-secondmate-parent.v1\nroute=local\nparent_home=%s\nroute=remote\n' \
902
+ "$parent" > "$parent_record"
903
+ ;;
904
+ remote-parent-home)
905
+ printf 'schema=fm-secondmate-parent.v1\nroute=remote\nparent_home=%s\n' \
906
+ "$parent" > "$parent_record"
907
+ ;;
908
+ local-parent-host)
909
+ printf 'schema=fm-secondmate-parent.v1\nroute=local\nparent_home=%s\nparent_host=remote-host\n' \
910
+ "$parent" > "$parent_record"
911
+ ;;
912
+ esac
913
+
914
+ PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
915
+ FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \
916
+ expect_failure "an unsafe $case_name durable parent record must refuse cleanup" \
917
+ "$TEARDOWN" work-child
918
+ assert_contains "$EXPECT_OUT" "cannot resolve the primary home for marked secondmate mate" \
919
+ "an unsafe $case_name durable parent record must produce the explicit binding refusal"
920
+ assert_present "$child/state/work-child.meta" \
921
+ "an unsafe $case_name durable parent record must preserve child work metadata"
922
+ done
923
+ pass "unsafe durable parent records fail closed before cleanup"
924
+ }
925
+
926
+ # A NUL byte inside the durable record must fail closed like every other
927
+ # malformed record. bash's read drops NUL bytes while parsing, and different
928
+ # bash generations disagree on the result (3.2 truncates the value at the NUL,
929
+ # 5.x splices the surrounding bytes together), so a NUL-bearing parent_home can
930
+ # resolve to a home the record's bytes never name contiguously - and teardown's
931
+ # parent reads (registration, registry, relay state) then land in that other
932
+ # home, with the promised-public-reply protection engaging or not depending on
933
+ # which interpreter ran the cleanup. The fixture is deliberately the proven
934
+ # clean-cleanup shape above (registered parent, landed worktree, quiet relay):
935
+ # with the NUL spliced mid-path the record reassembles the real registered
936
+ # parent under a NUL-dropping read, so before the parser rejected NUL this
937
+ # cleanup PROCEEDED - the refusal asserted here is the parser failing closed,
938
+ # not the fixture refusing for some unrelated reason.
939
+ test_secondmate_teardown_rejects_nul_bearing_durable_parent_record() {
940
+ local parent child parent_resolved pre suf record
941
+ parent=$(make_home teardown-durable-nul-parent relay-off)
942
+ child="$TMP_ROOT/teardown-durable-nul-child"
943
+ FM_SECONDMATE_CHARTER='Durable-record NUL regression charter.' \
944
+ FM_HOME="$parent" "$ROOT/bin/fm-home-seed.sh" mate "$child" --no-projects >/dev/null \
945
+ || fail "real secondmate seeding failed"
946
+ child=$(cd "$child" && pwd -P)
947
+ parent_resolved=$(cd "$parent" && pwd -P)
948
+ make_fake_curl "$child" >/dev/null
949
+ fm_fake_exit0 "$child/fakebin" tmux treehouse no-mistakes gh gh-axi
950
+ assert_local_secondmate_parent_record "$child" "$parent_resolved"
951
+ fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child"
952
+ fm_git_init_commit "$child/projects/worktree"
953
+ printf 'manual\n' > "$child/config/backlog-backend"
954
+ fm_write_meta "$child/state/work-child.meta" \
955
+ "window=firstmate:fm-work-child" "endpoint_task_id=work-child" \
956
+ "worktree=$child/projects/worktree" "project=$child/projects/worktree" \
957
+ "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
958
+ pre=${parent_resolved%??????}
959
+ suf=${parent_resolved#"$pre"}
960
+ record="$child/.fm-secondmate-parent"
961
+ {
962
+ printf 'schema=fm-secondmate-parent.v1\nroute=local\n'
963
+ printf 'parent_home=%s' "$pre"
964
+ printf '\0'
965
+ printf '%s\n' "$suf"
966
+ } > "$record"
967
+
968
+ PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
969
+ FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \
970
+ FM_CONFIG_OVERRIDE="$child/config" \
971
+ expect_failure "a NUL-bearing durable parent record must refuse cleanup" \
972
+ "$TEARDOWN" work-child
973
+ assert_contains "$EXPECT_OUT" "cannot resolve the primary home for marked secondmate mate" \
974
+ "a NUL-bearing durable parent record must produce the explicit binding refusal"
975
+ assert_present "$child/state/work-child.meta" \
976
+ "a NUL-bearing durable parent record must preserve child work metadata"
977
+ pass "a NUL-bearing durable parent record fails closed before cleanup"
978
+ }
979
+
980
+ test_relay_disabled_unmarked_teardown_skips_public_path() {
981
+ local home tasks_log out rc
982
+ home=$(make_home teardown-disabled-unmarked relay-off)
983
+ fm_git_init_commit "$home/projects/worktree"
984
+ tasks_log="$home/tasks-axi.log"; : > "$tasks_log"
985
+ printf 'manual\n' > "$home/config/backlog-backend"
986
+ cat > "$home/fakebin/tasks-axi" <<'SH'
987
+ #!/usr/bin/env bash
988
+ echo "$*" >> "$FAKE_TASKS_AXI_LOG"
989
+ exit 99
990
+ SH
991
+ chmod +x "$home/fakebin/tasks-axi"
992
+ fm_write_meta "$home/state/work-disabled.meta" \
993
+ "window=firstmate:fm-work-disabled" "endpoint_task_id=work-disabled" \
994
+ "worktree=$home/projects/worktree" "project=$home/projects/worktree" \
995
+ "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
996
+
997
+ rc=0
998
+ out=$(PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \
999
+ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \
1000
+ FM_CONFIG_OVERRIDE="$home/config" FAKE_TASKS_AXI_LOG="$tasks_log" \
1001
+ "$TEARDOWN" work-disabled 2>&1) || rc=$?
1002
+ [ "$rc" -eq 0 ] || fail "relay-disabled unmarked teardown must not refuse public-followup cleanup (rc=$rc): $out"
1003
+ [ ! -s "$tasks_log" ] || fail "relay-disabled unmarked teardown must not invoke tasks-axi: $(tr '\n' ';' < "$tasks_log")"
1004
+ assert_not_contains "$out" "still owes a public reply" \
1005
+ "relay-disabled unmarked teardown must not run the public commitment guard"
1006
+ assert_absent "$home/state/public-followup" \
1007
+ "relay-disabled unmarked teardown must not create a public-followup artifact"
1008
+ pass "relay-disabled unmarked teardown runs no public-followup work"
1009
+ }
1010
+
1011
+ test_relay_disabled_parent_allows_marked_child_teardown() {
1012
+ local parent child tasks_log out rc
1013
+ parent=$(make_home teardown-disabled-parent relay-off)
1014
+ child=$(make_home teardown-disabled-child relay-off)
1015
+ fm_git_init_commit "$child/projects/worktree"
1016
+ printf '%s\n' disabled-mate > "$child/.fm-secondmate-home"
1017
+ printf -- '- disabled-mate - synthetic (home: %s; scope: synthetic; projects: ; added 2026-07-30)\n' \
1018
+ "$child" > "$parent/data/secondmates.md"
1019
+ fm_write_meta "$parent/state/disabled-mate.meta" "kind=secondmate" "home=$child"
1020
+ tasks_log="$child/tasks-axi.log"; : > "$tasks_log"
1021
+ printf 'manual\n' > "$child/config/backlog-backend"
1022
+ cat > "$child/fakebin/tasks-axi" <<'SH'
1023
+ #!/usr/bin/env bash
1024
+ echo "$*" >> "$FAKE_TASKS_AXI_LOG"
1025
+ exit 99
1026
+ SH
1027
+ chmod +x "$child/fakebin/tasks-axi"
1028
+ fm_write_meta "$child/state/work-disabled.meta" \
1029
+ "window=firstmate:fm-work-disabled" "endpoint_task_id=work-disabled" \
1030
+ "worktree=$child/projects/worktree" "project=$child/projects/worktree" \
1031
+ "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
1032
+
1033
+ rc=0
1034
+ out=$(PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
1035
+ FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \
1036
+ FM_CONFIG_OVERRIDE="$child/config" \
1037
+ FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" FAKE_TASKS_AXI_LOG="$tasks_log" \
1038
+ "$TEARDOWN" work-disabled 2>&1) || rc=$?
1039
+ [ "$rc" -eq 0 ] || fail "relay-disabled parent must allow marked-child teardown (rc=$rc): $out"
1040
+ [ ! -s "$tasks_log" ] || fail "relay-disabled parent must not invoke tasks-axi for a marked child"
1041
+ assert_not_contains "$out" "still owes a public reply" \
1042
+ "relay-disabled parent must not run the public commitment guard"
1043
+ assert_absent "$child/state/public-followup" \
1044
+ "relay-disabled parent must not create a public-followup artifact"
1045
+ pass "a marked child proceeds without tasks-axi when its parent relay is disabled"
1046
+ }
1047
+
1048
+ test_secondmate_parent_binding_matches_literal_id() {
1049
+ local parent child
1050
+ parent=$(make_home teardown-literal-parent)
1051
+ child=$(make_home teardown-literal-child)
1052
+ printf '%s\n' 'mate.id' > "$child/.fm-secondmate-home"
1053
+ printf -- '- mateXid - synthetic (home: %s; scope: synthetic; projects: ; added 2026-07-30)\n' \
1054
+ "$child" > "$parent/data/secondmates.md"
1055
+ seed_commitment "$parent" pf-teardown-literal req-teardown-literal x secondmate:mate.id work-literal
1056
+ fm_write_meta "$parent/state/mate.id.meta" "kind=secondmate" "home=$child"
1057
+ fm_write_meta "$child/state/work-literal.meta" \
1058
+ "window=firstmate:fm-work-literal" "endpoint_task_id=work-literal" \
1059
+ "worktree=$child" "project=$child" "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
1060
+
1061
+ PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
1062
+ FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \
1063
+ FM_CONFIG_OVERRIDE="$child/config" FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" \
1064
+ expect_failure "a near-match registry id must not satisfy a dotted parent binding" \
1065
+ "$TEARDOWN" work-literal
1066
+ assert_contains "$EXPECT_OUT" "cannot resolve the primary home for marked secondmate mate.id" \
1067
+ "a dotted id must be matched as an exact registry field"
1068
+ assert_present "$child/state/work-literal.meta" \
1069
+ "a near-match parent binding must preserve the child work metadata"
1070
+ pass "secondmate parent resolution matches the durable registry id literally"
1071
+ }
1072
+
1073
+ test_traversal_registration_is_refused_before_delivery() {
1074
+ local home log out
1075
+ home=$(make_home traversal-registration)
1076
+ log="$home/curl.log"; : > "$log"
1077
+ seed_commitment "$home" pf-traversal req-traversal x main work-traversal
1078
+ emit_terminal "$home" "$home" pf-traversal main work-traversal >/dev/null \
1079
+ || fail "emit failed for traversal registration"
1080
+ sed -i.bak 's/^work_home=.*/work_home=secondmate:..\/..\/x/' \
1081
+ "$home/state/public-followup/registry/pf-traversal"
1082
+ rm -f "$home/state/public-followup/registry/pf-traversal.bak"
1083
+ run_pf "$home" consume >/dev/null || fail "consume failed for traversal registration"
1084
+
1085
+ out=$(FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-traversal 2>&1) && \
1086
+ fail "a traversal-shaped registration must not be deliverable"
1087
+ assert_contains "$out" "registration for 'pf-traversal' is missing or invalid" \
1088
+ "a traversal-shaped work home must be rejected before delivery"
1089
+ [ "$(followup_posts "$log")" -eq 0 ] || fail "an invalid work home must not post publicly"
1090
+ assert_present "$home/state/public-followup/registry/pf-traversal" \
1091
+ "an invalid work home must retain its registration for reconciliation"
1092
+ [ "$(task_state "$home" pf-traversal)" != 'done' ] \
1093
+ || fail "an invalid work home must not close the obligation"
1094
+ pass "traversal-shaped registrations are rejected before path construction or posting"
1095
+ }
1096
+
1097
+ test_pending_rejects_malformed_listing() {
1098
+ local home out
1099
+ home=$(make_home pending-malformed)
1100
+ seed_commitment "$home" pf-malformed req-malformed discord main work-malformed
1101
+ cat > "$home/fakebin/tasks-axi" <<'SH'
1102
+ #!/usr/bin/env bash
1103
+ printf '%s' '{"public_followups":['
1104
+ SH
1105
+ chmod +x "$home/fakebin/tasks-axi"
1106
+
1107
+ out=$(run_pf "$home" pending) || fail "pending must survive malformed tasks-axi output"
1108
+ assert_contains "$out" "cannot read this home's public commitments through tasks-axi" \
1109
+ "malformed backlog output must use the loud fallback"
1110
+ assert_present "$home/state/public-followup/registry/pf-malformed" \
1111
+ "malformed backlog output must retain the registration"
1112
+ pass "pending keeps registrations when tasks-axi returns malformed JSON"
1113
+ }
1114
+
1115
+ test_private_context_survives_inbox_cleanup() {
1116
+ local home log posts
1117
+ home=$(make_home context-retention)
1118
+ log="$home/curl.log"; : > "$log"
1119
+ seed_commitment "$home" pf-ctx req-ctx discord main work-ctx
1120
+ emit_terminal "$home" "$home" pf-ctx main work-ctx >/dev/null || fail "emit failed"
1121
+ run_pf "$home" consume >/dev/null || fail "consume failed"
1122
+
1123
+ # Drain the inbox exactly as answering the original mention does, and make any
1124
+ # relay fallback fail, so only the retained private context can resolve the
1125
+ # thread's platform and size budget.
1126
+ rm -f "$home/state/x-inbox/req-ctx.json"
1127
+ assert_present "$home/state/x-context/req-ctx.json" \
1128
+ "the private request context must outlive the inbox payload"
1129
+
1130
+ FAKE_CURL_LOG="$log" FAKE_REQCTX_CODE=500 run_pf "$home" deliver pf-ctx >/dev/null \
1131
+ || fail "delivery must still resolve the thread from retained private context"
1132
+ posts=$(followup_posts "$log")
1133
+ [ "$posts" -eq 1 ] || fail "expected one reply after inbox cleanup, got $posts"
1134
+ assert_grep '"request_id":"req-ctx"' "$log" "the reply must still target the original thread"
1135
+ pass "the retained private request context keeps the original thread deliverable after inbox cleanup"
1136
+ }
1137
+
1138
+ # --- 6. completion semantics ---------------------------------------------------
1139
+
1140
+ test_cleanup_refuses_while_a_public_reply_is_owed() {
1141
+ local home rc
1142
+ home=$(make_home cleanup-guard)
1143
+ seed_commitment "$home" pf-guard req-guard discord main ship-task
1144
+ tasks_in "$home" add ship-task "ship guarded by its public follow-up" --kind ship >/dev/null \
1145
+ || fail "could not add the guarded ship to its home's backlog"
1146
+ tasks_in "$home" start ship-task >/dev/null \
1147
+ || fail "could not mark the guarded ship In flight"
1148
+ fm_write_meta "$home/state/ship-task.meta" \
1149
+ "window=firstmate:fm-ship-task" \
1150
+ "worktree=$home/projects/gone" \
1151
+ "project=$home/projects/sample" \
1152
+ "harness=codex" \
1153
+ "kind=ship" \
1154
+ "mode=no-mistakes" \
1155
+ "spawn_gen=public-followup-guard"
1156
+
1157
+ rc=0
1158
+ PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \
1159
+ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \
1160
+ FM_CONFIG_OVERRIDE="$home/config" "$TEARDOWN" ship-task \
1161
+ > "$home/teardown.out" 2> "$home/teardown.err" || rc=$?
1162
+ [ "$rc" -ne 0 ] || fail "cleanup must refuse while a public reply is still owed"
1163
+ assert_grep "still owes a public reply" "$home/teardown.err" "the refusal must be explicit"
1164
+ assert_present "$home/state/ship-task.meta" "a refused cleanup must preserve the task record"
1165
+
1166
+ # Once the reply has landed, the same cleanup is allowed to proceed.
1167
+ emit_terminal "$home" "$home" pf-guard main ship-task >/dev/null || fail "emit failed"
1168
+ run_pf "$home" consume >/dev/null || fail "consume failed"
1169
+ FAKE_CURL_LOG="$home/curl.log" run_pf "$home" deliver pf-guard >/dev/null || fail "delivery failed"
1170
+ rc=0
1171
+ PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \
1172
+ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \
1173
+ FM_CONFIG_OVERRIDE="$home/config" "$TEARDOWN" ship-task >/dev/null 2>&1 || rc=$?
1174
+ [ "$rc" -eq 0 ] || fail "cleanup must proceed once the public reply has landed (rc=$rc)"
1175
+ pass "cleanup refuses while a public reply is owed and proceeds once it has landed"
1176
+ }
1177
+
1178
+ # --- 7. zero overhead for homes that do not use the relay ----------------------
1179
+
1180
+ # The hard acceptance criterion. A home that never opted into the myfirstmate
1181
+ # relay must see no process, no tasks-axi call, no scan, no output, and no file.
1182
+ test_relay_disabled_home_pays_nothing() {
1183
+ local home tasks_log out rc before after cmd
1184
+ home=$(make_home relay-disabled relay-off)
1185
+ tasks_log="$home/tasks-axi.log"; : > "$tasks_log"
1186
+ # Any tasks-axi invocation at all is a failure here, so make it loud.
1187
+ cat > "$home/fakebin/tasks-axi" <<'SH'
1188
+ #!/usr/bin/env bash
1189
+ echo "$*" >> "$FAKE_TASKS_AXI_LOG"
1190
+ exit 0
1191
+ SH
1192
+ chmod +x "$home/fakebin/tasks-axi"
1193
+
1194
+ before=$(find "$home/state" | LC_ALL=C sort)
1195
+ for cmd in "consume" "pending" "guard-work main any-task" "retire anything"; do
1196
+ rc=0
1197
+ # shellcheck disable=SC2086 # each cmd is a deliberate argument list
1198
+ out=$(PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \
1199
+ FM_STATE_OVERRIDE="$home/state" FAKE_TASKS_AXI_LOG="$tasks_log" "$PF" $cmd 2>&1) || rc=$?
1200
+ [ "$rc" -eq 0 ] || fail "'$cmd' must be a silent success in a relay-disabled home (rc=$rc)"
1201
+ [ -z "$out" ] || fail "'$cmd' must print nothing in a relay-disabled home, got: $out"
1202
+ done
1203
+
1204
+ # New fields and the open-loop gate must not create work in a disabled home.
1205
+ # shellcheck disable=SC1091
1206
+ . "$ROOT/bin/fm-public-followup-lib.sh"
1207
+ fm_pf_has_open_loops "$home/state" \
1208
+ && fail "a relay-disabled home must not grow an open-loop registry"
1209
+ fm_pf_has_delivered_open_loops "$home/state" \
1210
+ && fail "a relay-disabled home must not grow a delivered open-loop registry"
1211
+
1212
+ rc=0
1213
+ PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \
1214
+ FM_STATE_OVERRIDE="$home/state" FAKE_TASKS_AXI_LOG="$tasks_log" "$PF" active || rc=$?
1215
+ [ "$rc" -eq 1 ] || fail "'active' must report inactive in a relay-disabled home"
1216
+
1217
+ [ ! -s "$tasks_log" ] \
1218
+ || fail "a relay-disabled home must never invoke tasks-axi: $(cat "$tasks_log")"
1219
+ after=$(find "$home/state" | LC_ALL=C sort)
1220
+ [ "$before" = "$after" ] \
1221
+ || fail "a relay-disabled home must gain no public-followup artifact"
1222
+ assert_absent "$home/state/public-followup" \
1223
+ "a relay-disabled home must never get a public-followup directory"
1224
+
1225
+ # A child cannot force artifacts into a home that never opted in either.
1226
+ rc=0
1227
+ out=$("$EMIT" --home "$home" --obligation pf-x --relation rel-code \
1228
+ --source-home main --work-id w --generation 1 --outcome pr-merged \
1229
+ --outcome-text 'x' 2>&1) || rc=$?
1230
+ [ "$rc" -eq 0 ] || fail "emitting into a relay-disabled home must be a silent no-op (rc=$rc)"
1231
+ [ -z "$out" ] || fail "emitting into a relay-disabled home must produce no output: $out"
1232
+ assert_absent "$home/state/public-followup" \
1233
+ "a refused emit must not create a public-followup directory"
1234
+ pass "a relay-disabled home runs no tasks-axi call, prints nothing, and gains no artifact"
1235
+ }
1236
+
1237
+ # An opted-in home that has never made a public commitment must not start paying
1238
+ # either: the second gate is a directory presence check, not a backlog scan.
1239
+ test_relay_enabled_empty_state_makes_no_calls() {
1240
+ local home tasks_log out rc cmd
1241
+ home=$(make_home relay-enabled-empty)
1242
+ tasks_log="$home/tasks-axi.log"; : > "$tasks_log"
1243
+ cat > "$home/fakebin/tasks-axi" <<'SH'
1244
+ #!/usr/bin/env bash
1245
+ echo "$*" >> "$FAKE_TASKS_AXI_LOG"
1246
+ exit 0
1247
+ SH
1248
+ chmod +x "$home/fakebin/tasks-axi"
1249
+
1250
+ for cmd in "consume" "pending" "guard-work main any-task"; do
1251
+ rc=0
1252
+ # shellcheck disable=SC2086 # each cmd is a deliberate argument list
1253
+ out=$(PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \
1254
+ FM_STATE_OVERRIDE="$home/state" FAKE_TASKS_AXI_LOG="$tasks_log" "$PF" $cmd 2>&1) || rc=$?
1255
+ [ "$rc" -eq 0 ] || fail "'$cmd' must be a silent success with no commitments (rc=$rc)"
1256
+ [ -z "$out" ] || fail "'$cmd' must print nothing with no commitments, got: $out"
1257
+ done
1258
+ [ ! -s "$tasks_log" ] \
1259
+ || fail "an empty relay home must not query the backlog: $(cat "$tasks_log")"
1260
+ pass "a relay-enabled home with no commitments makes no backlog call and stays silent"
1261
+ }
1262
+
1263
+ # The relay's own refusal of an exhausted follow-up binding is a captain
1264
+ # decision, not something to retry into a public thread.
1265
+ test_exhausted_binding_is_not_retried() {
1266
+ local home log out posts
1267
+ home=$(make_home exhausted)
1268
+ log="$home/curl.log"; : > "$log"
1269
+ seed_commitment "$home" pf-gone req-gone x main work-gone
1270
+ emit_terminal "$home" "$home" pf-gone main work-gone >/dev/null || fail "emit failed"
1271
+ run_pf "$home" consume >/dev/null || fail "consume failed"
1272
+
1273
+ FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=409 \
1274
+ expect_failure "an exhausted binding must not be reported as delivered" \
1275
+ run_pf "$home" deliver pf-gone
1276
+ assert_contains "$EXPECT_OUT" "captain decision" "an exhausted binding must be escalated, not retried"
1277
+ [ "$(delivery_state "$home" pf-gone)" = expired-action-required ] \
1278
+ || fail "an exhausted binding must be recorded as needing action, got $(delivery_state "$home" pf-gone)"
1279
+ [ "$(task_state "$home" pf-gone)" != 'done' ] \
1280
+ || fail "an exhausted binding must never close the commitment"
1281
+ posts=$(followup_posts "$log")
1282
+ [ "$posts" -eq 1 ] || fail "the refused attempt is one relay call, got $posts"
1283
+ pass "a relay-exhausted follow-up binding is escalated rather than retried into the thread"
1284
+ }
1285
+
1286
+ # The relay poll is the only thing that runs on a cadence in an opted-in home, so
1287
+ # it must stay a hard no-op without a token, and must not start scanning when a
1288
+ # relay-enabled home has no public commitments at all.
1289
+ test_relay_poll_stays_inert_and_surfaces_once() {
1290
+ local off on out first second
1291
+ off=$(make_home poll-off relay-off)
1292
+ out=$(PATH="$off/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$off" \
1293
+ FM_STATE_OVERRIDE="$off/state" "$POLL" 2>&1)
1294
+ [ -z "$out" ] || fail "the relay poll must stay silent without a token, got: $out"
1295
+ assert_absent "$off/state/public-followup" "an inert poll must create nothing"
1296
+
1297
+ on=$(make_home poll-on)
1298
+ out=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \
1299
+ FM_STATE_OVERRIDE="$on/state" "$POLL" 2>&1)
1300
+ assert_not_contains "$out" "public-followup" \
1301
+ "a relay home with no public commitments must not mention public follow-ups"
1302
+
1303
+ seed_commitment "$on" pf-poll req-poll discord main work-poll
1304
+ out=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \
1305
+ FM_STATE_OVERRIDE="$on/state" "$POLL" 2>&1)
1306
+ assert_not_contains "$out" "public-followup" \
1307
+ "a registered commitment with no terminal result yet must not wake the poll"
1308
+
1309
+ emit_terminal "$on" "$on" pf-poll main work-poll >/dev/null || fail "emit failed"
1310
+ first=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \
1311
+ FM_STATE_OVERRIDE="$on/state" "$POLL" 2>&1)
1312
+ assert_contains "$first" "public-followup terminal results are waiting" \
1313
+ "a new terminal result must surface through the existing relay poll"
1314
+ second=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \
1315
+ FM_STATE_OVERRIDE="$on/state" "$POLL" 2>&1)
1316
+ assert_not_contains "$second" "public-followup" \
1317
+ "an unchanged pending set must not wake firstmate again every cycle"
1318
+ pass "the relay poll stays inert without a token, silent with no commitments, and surfaces a new result once"
1319
+ }
1320
+
1321
+ # --- 8. startup surfacing ------------------------------------------------------
1322
+
1323
+ test_session_start_surfaces_only_when_owed() {
1324
+ local off on out
1325
+ off=$(make_home startup-off relay-off)
1326
+ out=$(PATH="$off/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$off" \
1327
+ FM_STATE_OVERRIDE="$off/state" FM_DATA_OVERRIDE="$off/data" \
1328
+ FM_CONFIG_OVERRIDE="$off/config" "$SESSION_START" 2>&1)
1329
+ assert_not_contains "$out" "Public commitments" \
1330
+ "a relay-disabled home must not gain a public-commitments section at startup"
1331
+
1332
+ on=$(make_home startup-on)
1333
+ seed_commitment "$on" pf-start req-start discord main work-start
1334
+ out=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \
1335
+ FM_STATE_OVERRIDE="$on/state" FM_DATA_OVERRIDE="$on/data" \
1336
+ FM_CONFIG_OVERRIDE="$on/config" "$SESSION_START" 2>&1)
1337
+ assert_contains "$out" "Public commitments" \
1338
+ "an unresolved commitment must be surfaced at startup"
1339
+ assert_contains "$out" "unresolved pf-start state=pending-work platform=discord" \
1340
+ "the startup summary must be typed and actionable"
1341
+ assert_contains "$out" "fix worker placement when two spaces share a name" \
1342
+ "the startup summary must carry the public-safe summary"
1343
+ assert_not_contains "$out" "please fix worker placement" \
1344
+ "the startup summary must not carry raw request text"
1345
+ pass "startup surfaces unresolved public commitments only in a relay home that owes one"
1346
+ }
1347
+
1348
+ # --- 9. typed records stay public-safe ----------------------------------------
1349
+
1350
+ test_typed_records_exclude_raw_public_material() {
1351
+ local home backlog event
1352
+ home=$(make_home privacy)
1353
+ seed_commitment "$home" pf-priv req-priv discord main work-priv
1354
+ emit_terminal "$home" "$home" pf-priv main work-priv >/dev/null || fail "emit failed"
1355
+ event=$(find "$home/state/public-followup/events" -name '*.json' | head -1)
1356
+ assert_no_grep 'please fix worker placement' "$event" \
1357
+ "a terminal event must not carry raw request text"
1358
+ run_pf "$home" consume >/dev/null || fail "consume failed"
1359
+
1360
+ backlog="$home/data/backlog.md"
1361
+ assert_no_grep 'please fix worker placement' "$backlog" \
1362
+ "the backlog must never carry raw public message text"
1363
+ # The typed record is base64url canonical JSON, so check the decoded payload too.
1364
+ tasks_in "$home" public-followup list --json > "$home/typed.json"
1365
+ assert_no_grep 'please fix worker placement' "$home/typed.json" \
1366
+ "the typed obligation must never carry raw public message text"
1367
+ pass "typed public-followup records carry only public-safe summaries and deliverables"
1368
+ }
1369
+
1370
+ # --- 10. delivery does not close the public loop --------------------------------
1371
+
1372
+ test_dropped_baton_now_surfaces_open_loop() {
1373
+ local parent child log
1374
+ parent=$(make_home baton-parent)
1375
+ child="$TMP_ROOT/baton-child"
1376
+ FM_SECONDMATE_CHARTER='Baton repro charter.' FM_HOME="$parent" \
1377
+ "$ROOT/bin/fm-home-seed.sh" mate "$child" --no-projects >/dev/null || fail "seed failed"
1378
+ child=$(cd "$child" && pwd -P)
1379
+ make_fake_curl "$child" >/dev/null
1380
+ fm_fake_exit0 "$child/fakebin" tmux treehouse no-mistakes gh gh-axi
1381
+ log="$TMP_ROOT/curl.log"; : > "$log"
1382
+
1383
+ seed_repro_commitment "$parent" public-final-pi-rearm-repro req-pirearm \
1384
+ secondmate:mate pi-rearm-loop-repro-s1
1385
+ fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child"
1386
+
1387
+ "$EMIT" --home "$parent" --obligation public-final-pi-rearm-repro --relation rel-code \
1388
+ --source-home secondmate:mate --work-id pi-rearm-loop-repro-s1 --generation 1 \
1389
+ --outcome report-ready --deliverable report_path=data/pi-rearm-loop-repro-s1/report.md \
1390
+ --outcome-text 'Reproduced the loop. A bounded fix is scoped and waiting on you, captain.' >/dev/null \
1391
+ || fail "emit failed"
1392
+ FAKE_CURL_LOG="$log" run_pf "$parent" consume | grep -q '^ready ' || fail "consume not ready"
1393
+ FAKE_CURL_LOG="$log" run_pf "$parent" deliver public-final-pi-rearm-repro >/dev/null || fail "deliver failed"
1394
+ [ "$(followup_posts "$log")" = 1 ] || fail "expected exactly one closing post"
1395
+ assert_present "$parent/state/public-followup/registry/public-final-pi-rearm-repro" \
1396
+ "delivery must retain the registration"
1397
+
1398
+ fm_write_meta "$child/state/pi-rearm-loop-fix-r1.meta" \
1399
+ "window=firstmate:fm-pi-rearm-loop-fix-r1" "endpoint_task_id=pi-rearm-loop-fix-r1" \
1400
+ "worktree=$child" "project=$child" "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
1401
+
1402
+ PATH="$parent/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$parent" \
1403
+ FM_STATE_OVERRIDE="$parent/state" "$PF" guard-work secondmate:mate pi-rearm-loop-fix-r1 \
1404
+ || fail "guard-work unexpectedly blocked the unregistered follow-on"
1405
+
1406
+ run_pf "$parent" pending > "$TMP_ROOT/pending.out"
1407
+ grep -q '^open-loop public-final-pi-rearm-repro ' "$TMP_ROOT/pending.out" \
1408
+ || fail "pending must print the open-loop line after delivery"
1409
+ grep -q 'request=req-pirearm' "$TMP_ROOT/pending.out" \
1410
+ || fail "the open-loop line must name the original request"
1411
+
1412
+ PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
1413
+ FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" FAKE_CURL_LOG="$log" \
1414
+ "$TEARDOWN" pi-rearm-loop-fix-r1 > "$TMP_ROOT/td.out" 2>&1 || true
1415
+ case "$(cat "$TMP_ROOT/td.out")" in
1416
+ *"still owes a public reply"*) fail "teardown unexpectedly guarded the unregistered follow-on" ;;
1417
+ esac
1418
+ [ "$(followup_posts "$log")" = 1 ] || fail "unexpected extra post"
1419
+ pass "dropped-baton regression: delivery retains the loop and pending prints open-loop"
1420
+ }
1421
+
1422
+ test_control_registered_followon_is_guarded() {
1423
+ local parent child
1424
+ parent=$(make_home baton-control-parent)
1425
+ child="$TMP_ROOT/baton-control-child"
1426
+ FM_SECONDMATE_CHARTER='Baton control charter.' FM_HOME="$parent" \
1427
+ "$ROOT/bin/fm-home-seed.sh" mate "$child" --no-projects >/dev/null || fail "seed failed"
1428
+ child=$(cd "$child" && pwd -P)
1429
+ make_fake_curl "$child" >/dev/null
1430
+ fm_fake_exit0 "$child/fakebin" tmux treehouse no-mistakes gh gh-axi
1431
+ seed_repro_commitment "$parent" public-final-pi-rearm-ship req-pirearm2 \
1432
+ secondmate:mate pi-rearm-loop-fix-r1
1433
+ fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child"
1434
+ fm_write_meta "$child/state/pi-rearm-loop-fix-r1.meta" \
1435
+ "window=firstmate:fm-pi-rearm-loop-fix-r1" "endpoint_task_id=pi-rearm-loop-fix-r1" \
1436
+ "worktree=$child" "project=$child" "kind=ship" "mode=local-only" "spawn_gen=public-followup-fixture"
1437
+ PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
1438
+ FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \
1439
+ expect_failure "registered follow-on must be guarded" "$TEARDOWN" pi-rearm-loop-fix-r1
1440
+ assert_contains "$EXPECT_OUT" "still owes a public reply" "the guard fires only on presence"
1441
+ pass "CONTROL: the identical teardown REFUSES the moment a commitment is registered"
1442
+ }
1443
+
1444
+ test_rechain_delivers_second_post_on_same_thread() {
1445
+ local parent log out posts command command_log
1446
+ parent=$(make_home rechain-parent)
1447
+ log="$parent/curl.log"; : > "$log"
1448
+ seed_repro_commitment "$parent" public-final-a req-rechain main scout-a
1449
+ "$EMIT" --home "$parent" --obligation public-final-a --relation rel-code \
1450
+ --source-home main --work-id scout-a --generation 1 \
1451
+ --outcome report-ready --deliverable report_path=data/scout-a/report.md \
1452
+ --outcome-text 'Reproduced. A fix is waiting.' >/dev/null || fail "emit failed"
1453
+ FAKE_CURL_LOG="$log" run_pf "$parent" consume >/dev/null || fail "consume failed"
1454
+ FAKE_CURL_LOG="$log" run_pf "$parent" deliver public-final-a >/dev/null || fail "deliver failed"
1455
+ [ "$(followup_posts "$log")" = 1 ] || fail "expected the investigation post"
1456
+
1457
+ out=$(FAKE_CURL_LOG="$log" run_pf "$parent" rechain public-final-b --from public-final-a \
1458
+ --work-home main --work-id ship-b --expected pr-merged) \
1459
+ || fail "rechain failed: $out"
1460
+ assert_contains "$out" "retired public-final-a reason=handed on to public-final-b" \
1461
+ "rechain must retire the source loop"
1462
+ assert_contains "$out" "--deliverable pr_url=<value>" \
1463
+ "rechain brief must name the actual required deliverable key"
1464
+ command_log="$parent/brief-command.args"
1465
+ cat > "$parent/fakebin/record-emit" <<'SH'
1466
+ #!/usr/bin/env bash
1467
+ printf '%s\n' "$@" > "$RECORD_ARGS"
1468
+ SH
1469
+ chmod +x "$parent/fakebin/record-emit"
1470
+ command=$(printf '%s\n' "$out" | awk '
1471
+ index($0, "/bin/fm-public-followup-emit.sh") { capture=1 }
1472
+ capture { if ($0 == "") exit; print }
1473
+ ')
1474
+ assert_contains "$command" "--outcome-text" \
1475
+ "the exact rechain command must remain continuous through outcome text"
1476
+ command=${command/"$ROOT/bin/fm-public-followup-emit.sh"/"$parent/fakebin/record-emit"}
1477
+ command=${command//<value>/https://github.com/example/repo/pull/99}
1478
+ RECORD_ARGS="$command_log" bash -c "$command" \
1479
+ || fail "the exact rechain command must execute after filling its deliverable value"
1480
+ assert_grep '--deliverable' "$command_log" \
1481
+ "the executable rechain command must pass its deliverable option"
1482
+ assert_grep '--outcome-text' "$command_log" \
1483
+ "the executable rechain command must pass its outcome text option"
1484
+ assert_absent "$parent/state/public-followup/registry/public-final-a" \
1485
+ "the source registration must be gone after rechain"
1486
+ assert_present "$parent/state/public-followup/registry/public-final-b" \
1487
+ "the follow-on registration must exist"
1488
+
1489
+ "$EMIT" --home "$parent" --obligation public-final-b --relation rel-1 \
1490
+ --source-home main --work-id ship-b --generation 1 \
1491
+ --outcome pr-merged --deliverable pr_url=https://github.com/example/repo/pull/99 \
1492
+ --outcome-text 'Shipped: the Pi recovery loop is fixed.' >/dev/null || fail "follow-on emit failed"
1493
+ FAKE_CURL_LOG="$log" run_pf "$parent" consume | grep -q '^ready public-final-b ' \
1494
+ || fail "follow-on consume not ready"
1495
+ FAKE_CURL_LOG="$log" run_pf "$parent" deliver public-final-b >/dev/null || fail "follow-on deliver failed"
1496
+ posts=$(followup_posts "$log")
1497
+ [ "$posts" = 2 ] || fail "expected exactly two posts in the same thread, got $posts"
1498
+ assert_grep 'Shipped: the Pi recovery loop is fixed.' "$log" \
1499
+ "the second post must carry the shipped text"
1500
+ assert_grep '"request_id":"req-rechain"' "$log" \
1501
+ "both posts must target the original request"
1502
+ pass "rechain posts the shipped follow-on into the same thread"
1503
+ }
1504
+
1505
+ test_rechain_resumes_after_partial_add() {
1506
+ local home log real_tasks marker out count
1507
+ home=$(make_home rechain-resume)
1508
+ log="$home/curl.log"; : > "$log"
1509
+ seed_repro_commitment "$home" public-final-resume-a req-resume main scout-resume
1510
+ "$EMIT" --home "$home" --obligation public-final-resume-a --relation rel-code \
1511
+ --source-home main --work-id scout-resume --generation 1 \
1512
+ --outcome report-ready --deliverable report_path=data/scout-resume/report.md \
1513
+ --outcome-text 'Reproduced. A fix is waiting.' >/dev/null || fail "emit failed"
1514
+ FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed"
1515
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver public-final-resume-a >/dev/null \
1516
+ || fail "deliver failed"
1517
+
1518
+ real_tasks=$(command -v tasks-axi)
1519
+ marker="$home/rechain-bind-failed"
1520
+ cat > "$home/fakebin/tasks-axi" <<'SH'
1521
+ #!/usr/bin/env bash
1522
+ if [ "$1" = public-followup ] && [ "$2" = bind-work ] \
1523
+ && [ "$3" = public-final-resume-b ] && [ ! -e "$RECHAIN_FAIL_MARKER" ]; then
1524
+ : > "$RECHAIN_FAIL_MARKER"
1525
+ exit 73
1526
+ fi
1527
+ exec "$REAL_TASKS_AXI" "$@"
1528
+ SH
1529
+ chmod +x "$home/fakebin/tasks-axi"
1530
+
1531
+ REAL_TASKS_AXI="$real_tasks" RECHAIN_FAIL_MARKER="$marker" \
1532
+ expect_failure "rechain must expose a resumable partial add" \
1533
+ run_pf "$home" rechain public-final-resume-b --from public-final-resume-a \
1534
+ --work-home main --work-id ship-resume --expected pr-merged
1535
+ assert_contains "$EXPECT_OUT" "retry this same rechain command" \
1536
+ "a partial add must direct the caller to the resumable path"
1537
+ count=$(tasks_in "$home" public-followup list --json \
1538
+ | jq '[.public_followups[] | select(.id == "public-final-resume-b")] | length')
1539
+ [ "$count" = 1 ] || fail "the interrupted rechain must leave exactly one recoverable obligation"
1540
+ assert_present "$home/state/public-followup/registry/public-final-resume-a" \
1541
+ "a partial rechain must retain the source loop"
1542
+ assert_absent "$home/state/public-followup/registry/public-final-resume-b" \
1543
+ "a failed bind must not publish a registration"
1544
+
1545
+ out=$(REAL_TASKS_AXI="$real_tasks" RECHAIN_FAIL_MARKER="$marker" \
1546
+ run_pf "$home" rechain public-final-resume-b --from public-final-resume-a \
1547
+ --work-home main --work-id ship-resume --expected pr-merged) \
1548
+ || fail "retrying the same rechain command must resume: $out"
1549
+ assert_contains "$out" "retired public-final-resume-a" \
1550
+ "the resumed rechain must retire its source"
1551
+ count=$(tasks_in "$home" public-followup list --json \
1552
+ | jq '[.public_followups[] | select(.id == "public-final-resume-b")] | length')
1553
+ [ "$count" = 1 ] || fail "the resumed rechain must not duplicate the obligation"
1554
+ assert_present "$home/state/public-followup/registry/public-final-resume-b" \
1555
+ "the resumed rechain must publish the destination registration"
1556
+ assert_absent "$home/state/public-followup/registry/public-final-resume-a" \
1557
+ "the resumed rechain must close the source registration"
1558
+ pass "rechain resumes the same obligation after an interrupted bind"
1559
+ }
1560
+
1561
+ test_rechain_claims_delivered_source_once() {
1562
+ local home log pid_b pid_c rc_b=0 rc_c=0 registry_count
1563
+ home=$(make_home rechain-claim)
1564
+ log="$home/curl.log"; : > "$log"
1565
+ seed_repro_commitment "$home" public-final-claim-a req-claim main scout-claim
1566
+ "$EMIT" --home "$home" --obligation public-final-claim-a --relation rel-code \
1567
+ --source-home main --work-id scout-claim --generation 1 \
1568
+ --outcome report-ready --deliverable report_path=data/scout-claim/report.md \
1569
+ --outcome-text 'Reproduced. One follow-on may claim this thread.' >/dev/null || fail "emit failed"
1570
+ FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed"
1571
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver public-final-claim-a >/dev/null || fail "deliver failed"
1572
+
1573
+ FMX_NOW_OVERRIDE=1787539200 run_pf "$home" rechain public-final-claim-b \
1574
+ --from public-final-claim-a --work-home main --work-id ship-claim-b \
1575
+ --expected pr-merged > "$home/rechain-b.out" 2>&1 &
1576
+ pid_b=$!
1577
+ FMX_NOW_OVERRIDE=1787539200 run_pf "$home" rechain public-final-claim-c \
1578
+ --from public-final-claim-a --work-home main --work-id ship-claim-c \
1579
+ --expected pr-merged > "$home/rechain-c.out" 2>&1 &
1580
+ pid_c=$!
1581
+ wait "$pid_b" || rc_b=$?
1582
+ wait "$pid_c" || rc_c=$?
1583
+
1584
+ if [ "$rc_b" -eq 0 ]; then
1585
+ [ "$rc_c" -ne 0 ] || fail "two concurrent rechains must not both claim one source"
1586
+ else
1587
+ [ "$rc_c" -eq 0 ] || fail "exactly one concurrent rechain must succeed"
1588
+ fi
1589
+ registry_count=$(find "$home/state/public-followup/registry" -type f \
1590
+ \( -name 'public-final-claim-b' -o -name 'public-final-claim-c' \) | wc -l | tr -d ' ')
1591
+ [ "$registry_count" = 1 ] || fail "one source must produce exactly one registered destination"
1592
+ assert_absent "$home/state/public-followup/registry/public-final-claim-a" \
1593
+ "the successfully claimed source must be retired"
1594
+ pass "concurrent rechains cannot fork one delivered source"
1595
+ }
1596
+
1597
+ test_failed_rechain_retirement_keeps_source_claimed() {
1598
+ local home log registry_file out
1599
+ home=$(make_home rechain-retire-failure)
1600
+ log="$home/curl.log"; : > "$log"
1601
+ seed_repro_commitment "$home" public-final-retire-a req-retire-failure main scout-retire
1602
+ "$EMIT" --home "$home" --obligation public-final-retire-a --relation rel-code \
1603
+ --source-home main --work-id scout-retire --generation 1 \
1604
+ --outcome report-ready --deliverable report_path=data/scout-retire/report.md \
1605
+ --outcome-text 'Reproduced. A fix is waiting.' >/dev/null || fail "emit failed"
1606
+ FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed"
1607
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver public-final-retire-a >/dev/null \
1608
+ || fail "deliver failed"
1609
+
1610
+ registry_file="$home/state/public-followup/registry/public-final-retire-a"
1611
+ cat > "$home/fakebin/rm" <<EOF
1612
+ #!/usr/bin/env bash
1613
+ for arg in "\$@"; do
1614
+ [ "\$arg" != '$registry_file' ] || exit 1
1615
+ done
1616
+ exec /bin/rm "\$@"
1617
+ EOF
1618
+ chmod +x "$home/fakebin/rm"
1619
+ expect_failure "rechain must surface a failed source retirement" \
1620
+ run_pf "$home" rechain public-final-retire-b --from public-final-retire-a \
1621
+ --work-home main --work-id ship-retire-b --expected pr-merged
1622
+ assert_contains "$EXPECT_OUT" "public loop remains open" \
1623
+ "failed retirement must report that the source remains open"
1624
+
1625
+ expect_failure "a failed retirement must not leave the source claimable by another destination" \
1626
+ run_pf "$home" rechain public-final-retire-c --from public-final-retire-a \
1627
+ --work-home main --work-id ship-retire-c --expected pr-merged
1628
+ assert_contains "$EXPECT_OUT" "already claimed by rechain destination 'public-final-retire-b'" \
1629
+ "a second destination must be refused after the first destination is published"
1630
+ assert_absent "$home/state/public-followup/registry/public-final-retire-c" \
1631
+ "a refused competing destination must not be registered"
1632
+
1633
+ /bin/rm "$home/fakebin/rm"
1634
+ out=$(run_pf "$home" rechain public-final-retire-b --from public-final-retire-a \
1635
+ --work-home main --work-id ship-retire-b --expected pr-merged) \
1636
+ || fail "the claimed destination must remain resumable: $out"
1637
+ assert_contains "$out" "retired public-final-retire-a" \
1638
+ "resuming the claimed destination must finish source retirement"
1639
+ assert_absent "$registry_file" "successful retry must retire the source"
1640
+ assert_present "$home/state/public-followup/registry/public-final-retire-b" \
1641
+ "successful retry must retain the one claimed destination"
1642
+ pass "failed rechain retirement keeps the source claimed by one resumable destination"
1643
+ }
1644
+
1645
+ test_registration_replay_preserves_delivery_and_retirement() {
1646
+ local home log registry snapshot
1647
+ home=$(make_home register-replay)
1648
+ log="$home/curl.log"; : > "$log"
1649
+ seed_commitment "$home" pf-register-replay req-register-replay discord main work-register-replay
1650
+ jq -n '{relation_id:"rel-alternate", work_ref:{home_id:"main", task_id:"work-alternate"},
1651
+ role:"fulfills", required:false, generation:1}' > "$home/alternate-relation.json"
1652
+ tasks_in "$home" public-followup bind-work pf-register-replay \
1653
+ --relation-file "$home/alternate-relation.json" >/dev/null \
1654
+ || fail "could not add the alternate valid work binding"
1655
+ emit_terminal "$home" "$home" pf-register-replay main work-register-replay >/dev/null || fail "emit failed"
1656
+ run_pf "$home" consume >/dev/null || fail "initial consume failed"
1657
+ run_pf "$home" register pf-register-replay --relation rel-alternate --work-home main \
1658
+ --work-id work-alternate --generation 1 >/dev/null \
1659
+ || fail "could not register the alternate valid work binding"
1660
+ "$EMIT" --home "$home" --obligation pf-register-replay --relation rel-alternate \
1661
+ --source-home main --work-id work-alternate --generation 1 --outcome pr-merged \
1662
+ --deliverable pr_url=https://github.com/example/repo/pull/8 \
1663
+ --outcome-text 'The alternate bound work also reached its terminal outcome.' >/dev/null \
1664
+ || fail "alternate emit failed"
1665
+ run_pf "$home" consume >/dev/null || fail "alternate consume failed"
1666
+ run_pf "$home" register pf-register-replay --relation rel-code --work-home main \
1667
+ --work-id work-register-replay --generation 1 >/dev/null \
1668
+ || fail "could not restore the original open registration"
1669
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-register-replay >/dev/null || fail "deliver failed"
1670
+
1671
+ run_pf "$home" register pf-register-replay --relation rel-code --work-home main \
1672
+ --work-id work-register-replay --generation 1 >/dev/null || fail "registration replay failed"
1673
+ registry="$home/state/public-followup/registry/pf-register-replay"
1674
+ grep -q '^state=delivered$' "$registry" \
1675
+ || fail "registration replay must not downgrade a delivered loop"
1676
+
1677
+ snapshot="$home/registry-before-replay"
1678
+ cp "$registry" "$snapshot"
1679
+ run_pf "$home" register pf-register-replay --relation rel-alternate --work-home main \
1680
+ --work-id work-alternate --generation 1 --platform x --request req-alternate >/dev/null \
1681
+ || fail "delivered registration replay against another valid binding failed"
1682
+ cmp -s "$snapshot" "$registry" \
1683
+ || fail "delivered registration replay must preserve the complete retained baton"
1684
+
1685
+ run_pf "$home" retire pf-register-replay --reason "finished after replay" >/dev/null \
1686
+ || fail "retire after replay failed"
1687
+ expect_failure "registration replay must not reopen a retired loop" \
1688
+ run_pf "$home" register pf-register-replay --relation rel-code --work-home main \
1689
+ --work-id work-register-replay --generation 1
1690
+ assert_contains "$EXPECT_OUT" "already been retired" \
1691
+ "a retirement receipt must make registration fail closed"
1692
+ assert_absent "$home/state/public-followup/registry/pf-register-replay" \
1693
+ "registration replay must not recreate a retired loop"
1694
+ pass "registration replay preserves delivered and retired loop states"
1695
+ }
1696
+
1697
+ test_redelivery_does_not_report_retired_loop_open() {
1698
+ local home log out
1699
+ home=$(make_home redelivery-retired)
1700
+ log="$home/curl.log"; : > "$log"
1701
+ seed_commitment "$home" pf-redelivery-retired req-redelivery-retired discord main work-redelivery-retired
1702
+ emit_terminal "$home" "$home" pf-redelivery-retired main work-redelivery-retired >/dev/null \
1703
+ || fail "emit failed"
1704
+ run_pf "$home" consume >/dev/null || fail "consume failed"
1705
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-redelivery-retired >/dev/null \
1706
+ || fail "initial delivery failed"
1707
+ run_pf "$home" retire pf-redelivery-retired --reason "thread finished" >/dev/null \
1708
+ || fail "retire failed"
1709
+
1710
+ out=$(FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-redelivery-retired) \
1711
+ || fail "idempotent redelivery failed"
1712
+ assert_contains "$out" "already delivered pf-redelivery-retired" \
1713
+ "redelivery must remain idempotent"
1714
+ case "$out" in
1715
+ *"still OPEN"*) fail "redelivery must not report a retired loop as open: $out" ;;
1716
+ esac
1717
+ [ "$(grep -c '^url=.*connector/followup' "$log" || true)" -eq 1 ] \
1718
+ || fail "redelivery must not post a second public reply"
1719
+ pass "redelivery does not report a retired loop as open"
1720
+ }
1721
+
1722
+ test_retire_after_secondmate_home_removal() {
1723
+ local home child log out
1724
+ home=$(make_home retire-removed-secondmate)
1725
+ child="$home/removed-mate"
1726
+ mkdir -p "$child/state"
1727
+ printf 'mate\n' > "$child/.fm-secondmate-home"
1728
+ fm_write_meta "$home/state/mate.meta" "kind=secondmate" "home=$child"
1729
+ log="$home/curl.log"; : > "$log"
1730
+ seed_repro_commitment "$home" pf-removed-mate req-removed-mate secondmate:mate scout-removed
1731
+ "$EMIT" --home "$home" --obligation pf-removed-mate --relation rel-code \
1732
+ --source-home secondmate:mate --work-id scout-removed --generation 1 \
1733
+ --outcome report-ready --deliverable report_path=data/scout-removed/report.md \
1734
+ --outcome-text 'The removed child completed its investigation.' >/dev/null || fail "emit failed"
1735
+ run_pf "$home" consume >/dev/null || fail "consume failed"
1736
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-removed-mate >/dev/null || fail "deliver failed"
1737
+
1738
+ rm -rf "$child"
1739
+ rm -f "$home/state/mate.meta"
1740
+ out=$(run_pf "$home" retire pf-removed-mate --reason "child home was torn down") \
1741
+ || fail "retire must accept an already-absent child legacy link: $out"
1742
+ assert_contains "$out" "retired pf-removed-mate" \
1743
+ "retire must close a delivered loop after its secondmate home is removed"
1744
+ assert_absent "$home/state/public-followup/registry/pf-removed-mate" \
1745
+ "retire must remove the registration after child teardown"
1746
+ assert_present "$home/state/public-followup/retired/pf-removed-mate" \
1747
+ "retire must still record its receipt"
1748
+ pass "retire closes delivered loops after secondmate home removal"
1749
+ }
1750
+
1751
+ test_retire_refuses_unbound_existing_secondmate() {
1752
+ local home child log
1753
+ home=$(make_home retire-unbound-secondmate)
1754
+ child="$home/unbound-mate"
1755
+ mkdir -p "$child/state"
1756
+ child=$(cd "$child" && pwd -P)
1757
+ printf 'mate\n' > "$child/.fm-secondmate-home"
1758
+ fm_write_meta "$home/state/mate.meta" "kind=secondmate" "home=$child"
1759
+ log="$home/curl.log"; : > "$log"
1760
+ seed_repro_commitment "$home" pf-unbound-mate req-unbound-mate secondmate:mate scout-unbound
1761
+ "$EMIT" --home "$home" --obligation pf-unbound-mate --relation rel-code \
1762
+ --source-home secondmate:mate --work-id scout-unbound --generation 1 \
1763
+ --outcome report-ready --deliverable report_path=data/scout-unbound/report.md \
1764
+ --outcome-text 'The unbound child completed its investigation.' >/dev/null || fail "emit failed"
1765
+ run_pf "$home" consume >/dev/null || fail "consume failed"
1766
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-unbound-mate >/dev/null || fail "deliver failed"
1767
+ fm_write_meta "$child/state/scout-unbound.meta" "status=working" "x_request=req-unbound-mate"
1768
+ assert_grep "work_home_path=$child" \
1769
+ "$home/state/public-followup/registry/pf-unbound-mate" \
1770
+ "registration must retain the canonical secondmate path"
1771
+ rm -f "$home/state/mate.meta"
1772
+
1773
+ expect_failure "retire must not assume an unbound existing child link is cleared" \
1774
+ run_pf "$home" retire pf-unbound-mate --reason "child binding disappeared"
1775
+ assert_contains "$EXPECT_OUT" "could not clear the legacy X link" \
1776
+ "retire must report an unverifiable secondmate legacy link"
1777
+ assert_present "$home/state/public-followup/registry/pf-unbound-mate" \
1778
+ "an unverifiable child link must retain the registration"
1779
+ assert_absent "$home/state/public-followup/retired/pf-unbound-mate" \
1780
+ "an unverifiable child link must not create a retirement receipt"
1781
+ assert_grep 'x_request=req-unbound-mate' "$child/state/scout-unbound.meta" \
1782
+ "failed retirement must preserve the unresolved legacy link"
1783
+ pass "retire fails closed for an unbound existing secondmate"
1784
+ }
1785
+
1786
+ test_retire_refuses_reassigned_secondmate_home() {
1787
+ local home original replacement log
1788
+ home=$(make_home retire-reassigned-secondmate)
1789
+ original="$home/original-mate"
1790
+ replacement="$home/replacement-mate"
1791
+ mkdir -p "$original/state"
1792
+ printf 'mate\n' > "$original/.fm-secondmate-home"
1793
+ fm_write_meta "$home/state/mate.meta" "kind=secondmate" "home=$original"
1794
+ log="$home/curl.log"; : > "$log"
1795
+ seed_repro_commitment "$home" pf-reassigned-mate req-reassigned-mate secondmate:mate scout-reassigned
1796
+ "$EMIT" --home "$home" --obligation pf-reassigned-mate --relation rel-code \
1797
+ --source-home secondmate:mate --work-id scout-reassigned --generation 1 \
1798
+ --outcome report-ready --deliverable report_path=data/scout-reassigned/report.md \
1799
+ --outcome-text 'The original child completed its investigation.' >/dev/null || fail "emit failed"
1800
+ run_pf "$home" consume >/dev/null || fail "consume failed"
1801
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-reassigned-mate >/dev/null || fail "deliver failed"
1802
+
1803
+ rm -rf "$original"
1804
+ mkdir -p "$replacement/state"
1805
+ replacement=$(cd "$replacement" && pwd -P)
1806
+ printf 'mate\n' > "$replacement/.fm-secondmate-home"
1807
+ fm_write_meta "$replacement/state/scout-reassigned.meta" \
1808
+ "status=working" "x_request=req-unrelated-replacement"
1809
+ fm_write_meta "$home/state/mate.meta" "kind=secondmate" "home=$replacement"
1810
+
1811
+ expect_failure "retire must not clear a reassigned secondmate home" \
1812
+ run_pf "$home" retire pf-reassigned-mate --reason "original child was removed"
1813
+ assert_contains "$EXPECT_OUT" "could not clear the legacy X link" \
1814
+ "retirement must fail when the stable ID resolves to a different home"
1815
+ assert_present "$home/state/public-followup/registry/pf-reassigned-mate" \
1816
+ "a reassigned child must retain the registration"
1817
+ assert_absent "$home/state/public-followup/retired/pf-reassigned-mate" \
1818
+ "a reassigned child must not create a retirement receipt"
1819
+ assert_grep 'x_request=req-unrelated-replacement' "$replacement/state/scout-reassigned.meta" \
1820
+ "failed retirement must preserve the replacement home's Relay link"
1821
+ pass "retire fails closed when a secondmate ID is reassigned"
1822
+ }
1823
+
1824
+ test_rechain_refuses_unclaimed_existing_destination() {
1825
+ local home log out
1826
+ home=$(make_home rechain-existing-destination)
1827
+ log="$home/curl.log"; : > "$log"
1828
+ seed_repro_commitment "$home" public-final-existing-a req-existing main scout-existing
1829
+ "$EMIT" --home "$home" --obligation public-final-existing-a --relation rel-code \
1830
+ --source-home main --work-id scout-existing --generation 1 \
1831
+ --outcome report-ready --deliverable report_path=data/scout-existing/report.md \
1832
+ --outcome-text 'Investigation complete.' >/dev/null || fail "emit failed"
1833
+ run_pf "$home" consume >/dev/null || fail "consume failed"
1834
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver public-final-existing-a >/dev/null \
1835
+ || fail "deliver failed"
1836
+
1837
+ jq -n '{type:"pr-merged", project:"firstmate", required_deliverables:["pr_url"],
1838
+ completion_policy:"all-required"}' > "$home/collision-expected.json"
1839
+ tasks_in "$home" public-followup add public-final-existing-b \
1840
+ --request-context-file "$home/request.json" --purpose promised-final \
1841
+ --expected-final-file "$home/collision-expected.json" \
1842
+ --expires-at 2026-08-28T01:12:00Z >/dev/null || fail "could not seed destination collision"
1843
+
1844
+ expect_failure "a first rechain must not adopt an unrelated existing obligation" \
1845
+ run_pf "$home" rechain public-final-existing-b --from public-final-existing-a \
1846
+ --work-home main --work-id ship-existing --expected pr-merged
1847
+ assert_contains "$EXPECT_OUT" "was not created by this rechain" \
1848
+ "the collision refusal must identify the unclaimed destination"
1849
+ out=$(cat "$home/state/public-followup/registry/public-final-existing-a")
1850
+ case "$out" in
1851
+ *rechain_to=*) fail "a destination collision must not claim the source" ;;
1852
+ esac
1853
+ assert_absent "$home/state/public-followup/registry/public-final-existing-b" \
1854
+ "an unrelated obligation must not become a registered destination"
1855
+ pass "rechain refuses an unrelated existing destination"
1856
+ }
1857
+
1858
+ test_pending_skips_concurrent_retirement() {
1859
+ local home log real_tasks pending_pid locker_pid rc=0
1860
+ home=$(make_home pending-retirement-race)
1861
+ log="$home/curl.log"; : > "$log"
1862
+ seed_commitment "$home" pf-race req-race discord main work-race
1863
+ emit_terminal "$home" "$home" pf-race main work-race >/dev/null || fail "race emit failed"
1864
+ run_pf "$home" consume >/dev/null || fail "race consume failed"
1865
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-race >/dev/null || fail "race deliver failed"
1866
+ sed -e 's/^state=delivered$/state=open/' \
1867
+ -e '/^delivered_at=/d' -e '/^delivered_obligation=/d' \
1868
+ "$home/state/public-followup/registry/pf-race" > "$home/race-open"
1869
+ mv "$home/race-open" "$home/state/public-followup/registry/pf-race"
1870
+ chmod 600 "$home/state/public-followup/registry/pf-race"
1871
+
1872
+ seed_commitment "$home" pf-race-other req-race-other discord main work-race-other
1873
+
1874
+ FM_RACE_HOME="$home" FM_RACE_ROOT="$ROOT" bash -c '
1875
+ . "$FM_RACE_ROOT/bin/fm-public-followup-lib.sh"
1876
+ fm_pf_registry_lock_acquire "$FM_RACE_HOME/state" pf-race || exit 1
1877
+ : > "$FM_RACE_HOME/lock-ready"
1878
+ while [ ! -e "$FM_RACE_HOME/release-lock" ]; do sleep 0.02; done
1879
+ sleep 0.1
1880
+ mkdir -p "$FM_RACE_HOME/state/public-followup/retired"
1881
+ printf "reason=concurrent close\nretired_at=2026-08-01T00:00:00Z\n" \
1882
+ > "$FM_RACE_HOME/state/public-followup/retired/pf-race"
1883
+ chmod 600 "$FM_RACE_HOME/state/public-followup/retired/pf-race"
1884
+ rm -f "$FM_RACE_HOME/state/public-followup/registry/pf-race"
1885
+ fm_pf_registry_lock_release "$FM_RACE_HOME/state" pf-race
1886
+ ' &
1887
+ locker_pid=$!
1888
+ for _ in $(seq 1 100); do [ -e "$home/lock-ready" ] && break; sleep 0.02; done
1889
+ [ -e "$home/lock-ready" ] || fail "race locker did not start"
1890
+
1891
+ real_tasks=$(command -v tasks-axi)
1892
+ cat > "$home/fakebin/tasks-axi" <<'SH'
1893
+ #!/usr/bin/env bash
1894
+ "$REAL_TASKS_AXI" "$@"
1895
+ rc=$?
1896
+ if [ "$1" = public-followup ] && [ "$2" = list ]; then
1897
+ : > "$PENDING_LISTED"
1898
+ fi
1899
+ exit "$rc"
1900
+ SH
1901
+ chmod +x "$home/fakebin/tasks-axi"
1902
+ REAL_TASKS_AXI="$real_tasks" PENDING_LISTED="$home/pending-listed" \
1903
+ run_pf "$home" pending > "$home/pending-race.out" 2>&1 &
1904
+ pending_pid=$!
1905
+ for _ in $(seq 1 100); do [ -e "$home/pending-listed" ] && break; sleep 0.02; done
1906
+ [ -e "$home/pending-listed" ] || fail "pending did not snapshot the backlog"
1907
+ : > "$home/release-lock"
1908
+ wait "$locker_pid" || fail "race retirement failed"
1909
+ wait "$pending_pid" || rc=$?
1910
+ [ "$rc" -eq 0 ] || fail "pending aborted on concurrent retirement: $(cat "$home/pending-race.out")"
1911
+ assert_grep 'unresolved pf-race-other ' "$home/pending-race.out" \
1912
+ "pending must continue surfacing unrelated loops after concurrent retirement: $(cat "$home/pending-race.out")"
1913
+ pass "pending skips a registration retired during settlement"
1914
+ }
1915
+
1916
+ test_retire_reason_closes_the_open_loop() {
1917
+ local home log out registry_file receipt_mode
1918
+ home=$(make_home retire-reason)
1919
+ log="$home/curl.log"; : > "$log"
1920
+ seed_commitment "$home" pf-retire req-retire discord main work-retire
1921
+ emit_terminal "$home" "$home" pf-retire main work-retire >/dev/null || fail "emit failed"
1922
+ FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed"
1923
+ FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-retire >/dev/null || fail "deliver failed"
1924
+ run_pf "$home" pending | grep -q '^open-loop pf-retire ' \
1925
+ || fail "pending must show the delivered open loop"
1926
+ expect_failure "retire without --reason must refuse" run_pf "$home" retire pf-retire
1927
+ assert_contains "$EXPECT_OUT" "--reason" "the refusal must name the required reason"
1928
+ assert_present "$home/state/public-followup/registry/pf-retire" \
1929
+ "a reason-less retire must keep the registration"
1930
+ registry_file="$home/state/public-followup/registry/pf-retire"
1931
+ cat > "$home/fakebin/rm" <<EOF
1932
+ #!/usr/bin/env bash
1933
+ for arg in "\$@"; do
1934
+ [ "\$arg" != '$registry_file' ] || exit 1
1935
+ done
1936
+ exec /bin/rm "\$@"
1937
+ EOF
1938
+ chmod +x "$home/fakebin/rm"
1939
+ expect_failure "retire must report registration removal failure" \
1940
+ run_pf "$home" retire pf-retire --reason "the public loop is finished"
1941
+ assert_contains "$EXPECT_OUT" "public loop remains open" \
1942
+ "retire removal failure must report the truthful loop state"
1943
+ assert_present "$registry_file" \
1944
+ "retire removal failure must retain the registration"
1945
+ /bin/rm "$home/fakebin/rm"
1946
+ out=$(run_pf "$home" retire pf-retire --reason "the public loop is finished") \
1947
+ || fail "retire --reason failed"
1948
+ assert_contains "$out" "retired pf-retire reason=the public loop is finished" \
1949
+ "retire must report the reason"
1950
+ assert_present "$home/state/public-followup/retired/pf-retire" \
1951
+ "retire must persist a private receipt before removing the registration"
1952
+ assert_grep 'reason=the public loop is finished' \
1953
+ "$home/state/public-followup/retired/pf-retire" \
1954
+ "the retirement receipt must preserve the reason"
1955
+ assert_grep 'retired_at=' "$home/state/public-followup/retired/pf-retire" \
1956
+ "the retirement receipt must preserve its timestamp"
1957
+ receipt_mode=$(stat -c %a "$home/state/public-followup/retired/pf-retire" 2>/dev/null \
1958
+ || stat -f %Lp "$home/state/public-followup/retired/pf-retire" 2>/dev/null) \
1959
+ || fail "could not inspect the retirement receipt mode"
1960
+ [ "$receipt_mode" = 600 ] || fail "the retirement receipt must be private"
1961
+ assert_absent "$home/state/public-followup/registry/pf-retire" \
1962
+ "retire is the only removal"
1963
+ out=$(run_pf "$home" pending || true)
1964
+ case "$out" in
1965
+ *pf-retire*) fail "pending must stop listing a retired loop: $out" ;;
1966
+ esac
1967
+ pass "retire --reason closes the loop and drops the open-loop line"
1968
+ }
1969
+
1970
+ test_retention_creates_no_false_teardown_refusal() {
1971
+ local home home2 rc out registry tmp
1972
+ home=$(make_home retain-teardown)
1973
+ seed_commitment "$home" pf-retain req-retain discord main ship-retain
1974
+ tasks_in "$home" add ship-retain "ship with a retained delivered registration" --kind ship >/dev/null \
1975
+ || fail "could not add the retained-registration ship to its home's backlog"
1976
+ tasks_in "$home" start ship-retain >/dev/null \
1977
+ || fail "could not mark the retained-registration ship In flight"
1978
+ fm_write_meta "$home/state/ship-retain.meta" \
1979
+ "window=firstmate:fm-ship-retain" \
1980
+ "worktree=$home/projects/gone" \
1981
+ "project=$home/projects/sample" \
1982
+ "harness=codex" \
1983
+ "kind=ship" \
1984
+ "mode=no-mistakes" \
1985
+ "spawn_gen=public-followup-retain"
1986
+ emit_terminal "$home" "$home" pf-retain main ship-retain >/dev/null || fail "emit failed"
1987
+ run_pf "$home" consume >/dev/null || fail "consume failed"
1988
+ FAKE_CURL_LOG="$home/curl.log" run_pf "$home" deliver pf-retain >/dev/null || fail "delivery failed"
1989
+ run_pf "$home" guard-work main ship-retain \
1990
+ || fail "guard-work must pass for the work whose reply already landed"
1991
+ assert_present "$home/state/public-followup/registry/pf-retain" \
1992
+ "the delivered registration must still be present"
1993
+ rc=0
1994
+ PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \
1995
+ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \
1996
+ FM_CONFIG_OVERRIDE="$home/config" "$TEARDOWN" ship-retain \
1997
+ > "$home/td.out" 2> "$home/td.err" || rc=$?
1998
+ [ "$rc" -eq 0 ] || fail "teardown must proceed with a retained delivered registration (rc=$rc)"
1999
+ case "$(cat "$home/td.err")" in
2000
+ *"still owes a public reply"*) fail "retention must not create a false public-reply refusal" ;;
2001
+ esac
2002
+ # pending still sees the loop because teardown does not retire it
2003
+ home2=$(make_home retain-pending)
2004
+ seed_commitment "$home2" pf-keep req-keep discord main work-keep
2005
+ emit_terminal "$home2" "$home2" pf-keep main work-keep >/dev/null || fail "emit2 failed"
2006
+ run_pf "$home2" consume >/dev/null || fail "consume2 failed"
2007
+ FAKE_CURL_LOG="$home2/curl.log" run_pf "$home2" deliver pf-keep >/dev/null || fail "deliver2 failed"
2008
+ registry="$home2/state/public-followup/registry/pf-keep"
2009
+ tmp="$registry.tmp"
2010
+ grep -v -E '^(state|delivered_at|delivered_obligation)=' "$registry" > "$tmp"
2011
+ printf 'state=open\n' >> "$tmp"
2012
+ chmod 600 "$tmp"
2013
+ mv "$tmp" "$registry"
2014
+ out=$(run_pf "$home2" pending)
2015
+ assert_contains "$out" "open-loop pf-keep" "pending must keep a delivered registration as an open loop"
2016
+ assert_grep 'state=delivered' "$registry" \
2017
+ "pending must repair a settled registration left open after legacy cleanup failed"
2018
+ pass "retention creates no false teardown refusal and pending no longer prunes"
2019
+ }
2020
+
2021
+ test_expiry_escalation_uses_now_override() {
2022
+ local home out exp now_closing now_expired registry tmp
2023
+ home=$(make_home expiry-window)
2024
+ seed_repro_commitment "$home" pf-exp req-exp main work-exp
2025
+ exp=$(date -u -j -f '%Y-%m-%dT%H:%M:%SZ' '2026-08-28T01:12:00Z' +%s 2>/dev/null) \
2026
+ || exp=$(date -u -d '2026-08-28T01:12:00Z' +%s)
2027
+ now_closing=$((exp - 3600))
2028
+ now_expired=$((exp + 60))
2029
+ out=$(FMX_NOW_OVERRIDE="$now_expired" run_pf "$home" pending)
2030
+ assert_contains "$out" "unresolved pf-exp" "an owed reply must remain listed after expiry"
2031
+ assert_contains "$out" "can no longer be reached" \
2032
+ "an expired unresolved reply must escalate the unreachable thread"
2033
+ assert_contains "$out" "captain decision" \
2034
+ "an expired unresolved reply must name the captain call"
2035
+ "$EMIT" --home "$home" --obligation pf-exp --relation rel-code \
2036
+ --source-home main --work-id work-exp --generation 1 \
2037
+ --outcome report-ready --deliverable report_path=data/x/report.md \
2038
+ --outcome-text 'Reproduced.' >/dev/null || fail "emit failed"
2039
+ run_pf "$home" consume >/dev/null || fail "consume failed"
2040
+ FAKE_CURL_LOG="$home/curl.log" run_pf "$home" deliver pf-exp >/dev/null || fail "deliver failed"
2041
+ out=$(FMX_NOW_OVERRIDE="$now_closing" run_pf "$home" pending)
2042
+ assert_contains "$out" "open-loop pf-exp" "closing window must still list the loop"
2043
+ assert_contains "$out" "DEADLINE:" "a window under 48 hours must escalate"
2044
+ assert_contains "$out" "under 48 hours" "the closing wording must name the remaining window"
2045
+ out=$(FMX_NOW_OVERRIDE="$now_expired" run_pf "$home" pending)
2046
+ assert_contains "$out" "can no longer be reached" "a past expiry must name the unreachable thread"
2047
+ assert_contains "$out" "captain decision" "a past expiry is a captain call"
2048
+ FMX_NOW_OVERRIDE="$now_expired" expect_failure "rechain past expiry must refuse" \
2049
+ run_pf "$home" rechain pf-exp-next --from pf-exp --work-home main --work-id work-next --expected pr-merged
2050
+ assert_contains "$EXPECT_OUT" "can no longer be reached" "rechain must name the closed window"
2051
+ registry="$home/state/public-followup/registry/pf-exp"
2052
+ tmp="$registry.tmp"
2053
+ awk '
2054
+ /^followup_expires_at=/ { print "followup_expires_at=not-a-time"; next }
2055
+ { print }
2056
+ ' "$registry" > "$tmp"
2057
+ chmod 600 "$tmp"
2058
+ mv "$tmp" "$registry"
2059
+ expect_failure "rechain with an unknown expiry window must refuse" \
2060
+ run_pf "$home" rechain pf-exp-next --from pf-exp --work-home main --work-id work-next --expected pr-merged
2061
+ assert_contains "$EXPECT_OUT" "thread window cannot be checked" \
2062
+ "rechain must fail closed when its retained expiry cannot be parsed"
2063
+ pass "expiry escalation is pinned by FMX_NOW_OVERRIDE"
2064
+ }
2065
+
2066
+ test_brief_fails_without_typed_deliverable_keys() {
2067
+ local home real_tasks invalid
2068
+ home=$(make_home brief-keys)
2069
+ seed_commitment "$home" pf-brief req-brief discord main work-brief
2070
+ real_tasks=$(command -v tasks-axi)
2071
+ cat > "$home/fakebin/tasks-axi" <<'SH'
2072
+ #!/usr/bin/env bash
2073
+ if [ "${FAKE_INVALID_KEYS:-}" = unreadable ]; then
2074
+ exit 69
2075
+ fi
2076
+ "$REAL_TASKS_AXI" "$@" | jq --argjson invalid "$FAKE_INVALID_KEYS" '
2077
+ if .public_followups then
2078
+ .public_followups |= map(
2079
+ if .id == "pf-brief" then
2080
+ .public_followup.expected_final.required_deliverables = $invalid
2081
+ else . end)
2082
+ else . end'
2083
+ SH
2084
+ chmod +x "$home/fakebin/tasks-axi"
2085
+ REAL_TASKS_AXI="$real_tasks" FAKE_INVALID_KEYS=unreadable expect_failure \
2086
+ "brief must fail when typed deliverable keys cannot be read" \
2087
+ run_pf "$home" brief pf-brief
2088
+ assert_contains "$EXPECT_OUT" "could not read public-followup obligation" \
2089
+ "brief must explain why it cannot produce executable instructions"
2090
+ assert_not_contains "$EXPECT_OUT" "<key>=<value>" \
2091
+ "brief must never substitute a generic deliverable placeholder"
2092
+
2093
+ for invalid in '["pr_url",7]' '["pr_url",""]' '["PR_URL"]' '["pr-url"]'; do
2094
+ REAL_TASKS_AXI="$real_tasks" FAKE_INVALID_KEYS="$invalid" expect_failure \
2095
+ "brief must reject an invalid required deliverable array" \
2096
+ run_pf "$home" brief pf-brief
2097
+ assert_contains "$EXPECT_OUT" "no readable required deliverable keys" \
2098
+ "brief must reject the complete contract when any key is invalid"
2099
+ assert_not_contains "$EXPECT_OUT" "--deliverable pr_url=<value>" \
2100
+ "brief must not emit a partial contract from an invalid key array"
2101
+ done
2102
+ pass "brief fails explicitly when typed deliverable keys are unavailable"
2103
+ }
2104
+
2105
+ test_prechange_registration_is_open_and_unrechainable() {
2106
+ local home file out
2107
+ home=$(make_home prechange)
2108
+ mkdir -p "$home/state/public-followup/registry"
2109
+ chmod 700 "$home/state/public-followup" "$home/state/public-followup/registry"
2110
+ file="$home/state/public-followup/registry/pf-legacy"
2111
+ printf 'obligation_id=pf-legacy\nrelation_id=rel-code\nwork_home=main\nwork_id=work-legacy\ngeneration=1\nplatform=discord\nrequest_id=req-legacy\n' \
2112
+ > "$file"
2113
+ chmod 600 "$file"
2114
+ out=$(run_pf "$home" pending) || fail "pending must not crash on a pre-change registration"
2115
+ assert_contains "$out" "open-loop pf-legacy" "a pre-change record is an open loop"
2116
+ assert_contains "$out" "unrechainable" "a pre-change record must be reported un-rechainable"
2117
+ expect_failure "rechain of a pre-change record must refuse" \
2118
+ run_pf "$home" rechain pf-new --from pf-legacy --work-home main --work-id work-next --expected pr-merged
2119
+ case "$EXPECT_OUT" in
2120
+ *un-rechainable*) ;;
2121
+ *'not state=delivered'*) ;;
2122
+ *) fail "rechain must refuse a pre-change record without crashing: $EXPECT_OUT" ;;
2123
+ esac
2124
+ printf 'state=delivered\ndelivered_at=2026-08-21T00:00:00Z\n' >> "$file"
2125
+ expect_failure "delivered pre-change record without context is un-rechainable" \
2126
+ run_pf "$home" rechain pf-new --from pf-legacy --work-home main --work-id work-next --expected pr-merged
2127
+ assert_contains "$EXPECT_OUT" "un-rechainable" "missing request_context_b64 must be named"
2128
+ pass "pre-change registrations are open loops and un-rechainable, never a crash"
2129
+ }
2130
+
2131
+ test_x_request_teardown_warns_when_final_unposted() {
2132
+ local home rc
2133
+ home=$(make_home xreq-warn)
2134
+ tasks_in "$home" add linked-task "ship with a legacy Relay request link" --kind ship >/dev/null \
2135
+ || fail "could not add the legacy-link ship to its home's backlog"
2136
+ tasks_in "$home" start linked-task >/dev/null \
2137
+ || fail "could not mark the legacy-link ship In flight"
2138
+ fm_write_meta "$home/state/linked-task.meta" \
2139
+ "window=firstmate:fm-linked-task" \
2140
+ "worktree=$home/projects/gone" \
2141
+ "project=$home/projects/sample" \
2142
+ "kind=ship" \
2143
+ "mode=local-only" \
2144
+ "spawn_gen=public-followup-legacy-link" \
2145
+ "x_request=req-legacy-final"
2146
+ rc=0
2147
+ PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \
2148
+ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \
2149
+ FM_CONFIG_OVERRIDE="$home/config" "$TEARDOWN" linked-task \
2150
+ > "$home/td.out" 2> "$home/td.err" || rc=$?
2151
+ [ "$rc" -eq 0 ] || fail "legacy-link warning must not block teardown (rc=$rc)"
2152
+ assert_grep "still carries an unreconciled Relay request link (req-legacy-final) on its task record" "$home/td.err" \
2153
+ "teardown must report the remaining link without claiming the post failed"
2154
+ assert_no_grep "never posted" "$home/td.err" \
2155
+ "a remaining legacy link must not be treated as proof that no post landed"
2156
+ pass "teardown reports an unreconciled legacy Relay link"
2157
+ }
2158
+
2159
+ test_secondmate_promotion_uses_teardown_parent_resolution() {
2160
+ local parent stale child remote_child out
2161
+ parent=$(make_home promote-parent)
2162
+ stale=$(make_home promote-stale-parent)
2163
+ child=$(make_home promote-child relay-off)
2164
+ printf '%s\n' mate > "$child/.fm-secondmate-home"
2165
+ printf 'schema=fm-secondmate-parent.v1\nroute=local\nparent_home=%s\n' \
2166
+ "$stale" > "$child/.fm-secondmate-parent"
2167
+ printf -- '- mate - synthetic (home: %s; scope: synthetic; projects: ; added 2026-08-21)\n' \
2168
+ "$child" > "$parent/data/secondmates.md"
2169
+ fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child"
2170
+ mkdir -p "$parent/state/public-followup/registry" "$stale/state/public-followup/registry"
2171
+ printf 'obligation_id=pf-valid\nwork_home=secondmate:mate\nwork_id=promote-legacy\nstate=delivered\n' \
2172
+ > "$parent/state/public-followup/registry/pf-valid"
2173
+ printf 'obligation_id=pf-stale\nwork_home=secondmate:mate\nwork_id=promote-conflict\nstate=delivered\n' \
2174
+ > "$stale/state/public-followup/registry/pf-stale"
2175
+ chmod 600 "$parent/state/public-followup/registry/pf-valid" \
2176
+ "$stale/state/public-followup/registry/pf-stale"
2177
+
2178
+ fm_write_meta "$child/state/promote-conflict.meta" \
2179
+ "window=firstmate:fm-promote-conflict" "kind=scout"
2180
+ out=$(PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
2181
+ FM_STATE_OVERRIDE="$child/state" FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" \
2182
+ "$PROMOTE" promote-conflict --mode local-only --yolo off 2>&1) \
2183
+ || fail "promotion must not block on conflicting parent bindings: $out"
2184
+ assert_contains "$out" "promoted promote-conflict to ship" \
2185
+ "parent-resolution trouble must never refuse the kind flip"
2186
+ assert_contains "$out" "could not resolve the consent-holding parent home" \
2187
+ "conflicting live and durable bindings must warn"
2188
+ assert_not_contains "$out" "--from pf-stale" \
2189
+ "a stale durable parent must not produce a rechain hint"
2190
+
2191
+ rm -f "$child/.fm-secondmate-parent"
2192
+ fm_write_meta "$child/state/promote-legacy.meta" \
2193
+ "window=firstmate:fm-promote-legacy" "kind=scout"
2194
+ out=$(PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \
2195
+ FM_STATE_OVERRIDE="$child/state" FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" \
2196
+ "$PROMOTE" promote-legacy --mode local-only --yolo off 2>&1) \
2197
+ || fail "legacy parent recovery must not block promotion: $out"
2198
+ assert_contains "$out" "next: FM_HOME=" \
2199
+ "a recovered legacy parent must identify the consent-holding home"
2200
+ assert_contains "$out" "--from pf-valid --work-home secondmate:mate --work-id promote-legacy" \
2201
+ "legacy parent recovery must print the rechain hint"
2202
+
2203
+ remote_child=$(make_home promote-remote-child relay-off)
2204
+ printf '%s\n' remote-mate > "$remote_child/.fm-secondmate-home"
2205
+ printf 'schema=fm-secondmate-parent.v1\nroute=remote\nparent_host=remote.example\n' \
2206
+ > "$remote_child/.fm-secondmate-parent"
2207
+ printf 'FMX_PAIRING_TOKEN=child-local-token\n' > "$remote_child/.env"
2208
+ fm_write_meta "$remote_child/state/promote-remote.meta" \
2209
+ "window=firstmate:fm-promote-remote" "kind=scout"
2210
+ out=$(PATH="$remote_child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$remote_child" \
2211
+ FM_STATE_OVERRIDE="$remote_child/state" \
2212
+ "$PROMOTE" promote-remote --mode local-only --yolo off 2>&1) \
2213
+ || fail "a remote parent route must not block promotion: $out"
2214
+ assert_contains "$out" "promoted promote-remote to ship" \
2215
+ "an unresolved remote parent must never refuse the kind flip"
2216
+ assert_contains "$out" "could not resolve the consent-holding parent home" \
2217
+ "a remote route with a local Relay token must warn as teardown does"
2218
+ pass "secondmate promotion matches teardown parent resolution"
2219
+ }
2220
+
2221
+ test_outcome_text_is_bounded_without_corrupting_characters
2222
+ test_restart_e2e_delivers_exactly_once
2223
+ test_duplicate_event_and_replay_are_noops
2224
+ test_invalid_events_are_refused_and_quarantined
2225
+ test_relay_failure_holds_without_false_completion
2226
+ test_dry_run_does_not_close_commitment
2227
+ test_late_receipt_closes_the_exact_attempt_without_reposting
2228
+ test_typed_terminal_clear_only_removes_legacy_link
2229
+ test_interrupted_delivery_refuses_to_repost
2230
+ test_outward_delivery_stays_with_the_owning_home
2231
+ test_delivery_requires_registration_before_posting
2232
+ test_secondmate_teardown_requires_parent_binding
2233
+ test_local_secondmate_seed_publishes_parent_before_identity
2234
+ test_secondmate_teardown_resolves_parent_from_durable_record_when_env_lost
2235
+ test_secondmate_teardown_durable_record_missing_parent_registration_still_refuses
2236
+ test_secondmate_teardown_durable_record_with_unknown_field_succeeds
2237
+ test_secondmate_teardown_rejects_conflicting_live_and_durable_parent_bindings
2238
+ test_secondmate_teardown_rejects_unsafe_durable_parent_records
2239
+ test_secondmate_teardown_rejects_nul_bearing_durable_parent_record
2240
+ test_relay_disabled_unmarked_teardown_skips_public_path
2241
+ test_relay_disabled_parent_allows_marked_child_teardown
2242
+ test_secondmate_parent_binding_matches_literal_id
2243
+ test_traversal_registration_is_refused_before_delivery
2244
+ test_pending_rejects_malformed_listing
2245
+ test_private_context_survives_inbox_cleanup
2246
+ test_cleanup_refuses_while_a_public_reply_is_owed
2247
+ test_relay_disabled_home_pays_nothing
2248
+ test_relay_enabled_empty_state_makes_no_calls
2249
+ test_exhausted_binding_is_not_retried
2250
+ test_relay_poll_stays_inert_and_surfaces_once
2251
+ test_session_start_surfaces_only_when_owed
2252
+ test_typed_records_exclude_raw_public_material
2253
+ test_dropped_baton_now_surfaces_open_loop
2254
+ test_control_registered_followon_is_guarded
2255
+ test_rechain_delivers_second_post_on_same_thread
2256
+ test_rechain_resumes_after_partial_add
2257
+ test_rechain_claims_delivered_source_once
2258
+ test_failed_rechain_retirement_keeps_source_claimed
2259
+ test_registration_replay_preserves_delivery_and_retirement
2260
+ test_redelivery_does_not_report_retired_loop_open
2261
+ test_retire_after_secondmate_home_removal
2262
+ test_retire_refuses_unbound_existing_secondmate
2263
+ test_retire_refuses_reassigned_secondmate_home
2264
+ test_rechain_refuses_unclaimed_existing_destination
2265
+ test_pending_skips_concurrent_retirement
2266
+ test_retire_reason_closes_the_open_loop
2267
+ test_retention_creates_no_false_teardown_refusal
2268
+ test_expiry_escalation_uses_now_override
2269
+ test_brief_fails_without_typed_deliverable_keys
2270
+ test_prechange_registration_is_open_and_unrechainable
2271
+ test_x_request_teardown_warns_when_final_unposted
2272
+ test_secondmate_promotion_uses_teardown_parent_resolution