@zerwiz/ymir 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (850) hide show
  1. package/.agents/README.md +291 -0
  2. package/.agents/agents/bragi-marketer.md +111 -0
  3. package/.agents/agents/brokk.md +352 -0
  4. package/.agents/agents/forseti-reviewer.md +93 -0
  5. package/.agents/agents/frigg-consent.md +77 -0
  6. package/.agents/agents/groa-update.md +77 -0
  7. package/.agents/agents/hnoss-designer.md +69 -0
  8. package/.agents/agents/huginn-researcher.md +99 -0
  9. package/.agents/agents/jord-projects.md +78 -0
  10. package/.agents/agents/kvasir-scout.md +91 -0
  11. package/.agents/agents/mimir-planner.md +92 -0
  12. package/.agents/agents/muninn-stow.md +77 -0
  13. package/.agents/agents/saga-bearings.md +78 -0
  14. package/.agents/agents/sindri-developer.md +114 -0
  15. package/.agents/agents/snotra-documenter.md +105 -0
  16. package/.agents/agents/syn-recovery.md +78 -0
  17. package/.agents/agents/tyr-check.md +78 -0
  18. package/.agents/agents/urdh-hold.md +77 -0
  19. package/.agents/agents/volundr-smith.md +80 -0
  20. package/.agents/agents/vor-diagnostics.md +77 -0
  21. package/.agents/assets/agents/naming.md +50 -0
  22. package/.agents/assets/agents/registry.md +93 -0
  23. package/.agents/assets/agents/runtime.md +36 -0
  24. package/.agents/assets/agents/toon-tasks-cli.md +42 -0
  25. package/.agents/assets/templates/company_entity.template.md +37 -0
  26. package/.agents/backend/backends/cmux.sh +649 -0
  27. package/.agents/backend/backends/herdr-eventwait.py +157 -0
  28. package/.agents/backend/backends/herdr-workspace-move.py +114 -0
  29. package/.agents/backend/backends/herdr.sh +3386 -0
  30. package/.agents/backend/backends/orca.sh +290 -0
  31. package/.agents/backend/backends/tmux.sh +357 -0
  32. package/.agents/backend/backends/zellij.sh +700 -0
  33. package/.agents/backend/fm-afk-launch.sh +654 -0
  34. package/.agents/backend/fm-afk-return.sh +241 -0
  35. package/.agents/backend/fm-afk-start.sh +172 -0
  36. package/.agents/backend/fm-arm-command-policy.mjs +960 -0
  37. package/.agents/backend/fm-arm-pretool-check.sh +200 -0
  38. package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
  39. package/.agents/backend/fm-backend.sh +981 -0
  40. package/.agents/backend/fm-backlog-handoff.sh +909 -0
  41. package/.agents/backend/fm-backlog-receive.sh +187 -0
  42. package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
  43. package/.agents/backend/fm-bearings-board.sh +204 -0
  44. package/.agents/backend/fm-bearings-snapshot.sh +546 -0
  45. package/.agents/backend/fm-bootstrap.sh +1568 -0
  46. package/.agents/backend/fm-branch-outcome.sh +203 -0
  47. package/.agents/backend/fm-branch-prompt.sh +101 -0
  48. package/.agents/backend/fm-brief.sh +452 -0
  49. package/.agents/backend/fm-busy-event.sh +234 -0
  50. package/.agents/backend/fm-busy-lib.sh +986 -0
  51. package/.agents/backend/fm-captain-hold.sh +1001 -0
  52. package/.agents/backend/fm-cd-command-policy.mjs +153 -0
  53. package/.agents/backend/fm-cd-pretool-check.sh +190 -0
  54. package/.agents/backend/fm-check-lib.sh +74 -0
  55. package/.agents/backend/fm-check-register.sh +42 -0
  56. package/.agents/backend/fm-check-unregister.sh +52 -0
  57. package/.agents/backend/fm-classify-lib.sh +1770 -0
  58. package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
  59. package/.agents/backend/fm-composer-lib.sh +1416 -0
  60. package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
  61. package/.agents/backend/fm-config-push.sh +254 -0
  62. package/.agents/backend/fm-control-lib.sh +251 -0
  63. package/.agents/backend/fm-control.sh +878 -0
  64. package/.agents/backend/fm-crew-state.sh +625 -0
  65. package/.agents/backend/fm-cursor-lib.sh +243 -0
  66. package/.agents/backend/fm-decision-hold.sh +232 -0
  67. package/.agents/backend/fm-doc-audience-check.sh +269 -0
  68. package/.agents/backend/fm-dod-lib.sh +67 -0
  69. package/.agents/backend/fm-ensure-agents-md.sh +253 -0
  70. package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
  71. package/.agents/backend/fm-extension.mjs +2577 -0
  72. package/.agents/backend/fm-extension.sh +16 -0
  73. package/.agents/backend/fm-ff-lib.sh +420 -0
  74. package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
  75. package/.agents/backend/fm-fleet-sync.sh +458 -0
  76. package/.agents/backend/fm-fleet-view.sh +96 -0
  77. package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
  78. package/.agents/backend/fm-guard.sh +242 -0
  79. package/.agents/backend/fm-harness.sh +195 -0
  80. package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
  81. package/.agents/backend/fm-herdr-lab.sh +346 -0
  82. package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
  83. package/.agents/backend/fm-home-seed.sh +984 -0
  84. package/.agents/backend/fm-home-summary-refresh.sh +255 -0
  85. package/.agents/backend/fm-hook-host-lib.sh +36 -0
  86. package/.agents/backend/fm-inactive-reconcile.sh +531 -0
  87. package/.agents/backend/fm-inbox.sh +399 -0
  88. package/.agents/backend/fm-install-actionlint.sh +84 -0
  89. package/.agents/backend/fm-install-herdr.sh +96 -0
  90. package/.agents/backend/fm-install-shellcheck.sh +84 -0
  91. package/.agents/backend/fm-install-treehouse.sh +95 -0
  92. package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
  93. package/.agents/backend/fm-lease-lib.sh +218 -0
  94. package/.agents/backend/fm-lease.sh +189 -0
  95. package/.agents/backend/fm-line-cap-lib.sh +51 -0
  96. package/.agents/backend/fm-lint-workflows.sh +137 -0
  97. package/.agents/backend/fm-lint.sh +591 -0
  98. package/.agents/backend/fm-lock-lib.sh +104 -0
  99. package/.agents/backend/fm-lock.sh +107 -0
  100. package/.agents/backend/fm-marker-lib.sh +12 -0
  101. package/.agents/backend/fm-merge-local.sh +74 -0
  102. package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
  103. package/.agents/backend/fm-nm-run-lib.sh +124 -0
  104. package/.agents/backend/fm-on.sh +125 -0
  105. package/.agents/backend/fm-operational-input.sh +253 -0
  106. package/.agents/backend/fm-peek.sh +46 -0
  107. package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
  108. package/.agents/backend/fm-pr-check.sh +135 -0
  109. package/.agents/backend/fm-pr-lib.sh +1016 -0
  110. package/.agents/backend/fm-pr-merge.sh +707 -0
  111. package/.agents/backend/fm-pr-poll.sh +110 -0
  112. package/.agents/backend/fm-primary-scope-lib.sh +33 -0
  113. package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
  114. package/.agents/backend/fm-procevent-lavish.sh +628 -0
  115. package/.agents/backend/fm-procevent-lib.sh +892 -0
  116. package/.agents/backend/fm-procevent-quota.sh +290 -0
  117. package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
  118. package/.agents/backend/fm-procevent-when.sh +504 -0
  119. package/.agents/backend/fm-procevent.sh +1466 -0
  120. package/.agents/backend/fm-project-mode.sh +92 -0
  121. package/.agents/backend/fm-project-origin-lib.sh +180 -0
  122. package/.agents/backend/fm-promote.sh +282 -0
  123. package/.agents/backend/fm-public-followup-emit.sh +260 -0
  124. package/.agents/backend/fm-public-followup-lib.sh +398 -0
  125. package/.agents/backend/fm-public-followup.sh +1330 -0
  126. package/.agents/backend/fm-push-transition-lib.sh +168 -0
  127. package/.agents/backend/fm-quota-axi-lib.sh +93 -0
  128. package/.agents/backend/fm-quota-choose.sh +384 -0
  129. package/.agents/backend/fm-remote-delta-read.sh +189 -0
  130. package/.agents/backend/fm-remote-doctor.sh +800 -0
  131. package/.agents/backend/fm-remote-entrypoint.sh +193 -0
  132. package/.agents/backend/fm-remote-file.sh +234 -0
  133. package/.agents/backend/fm-remote-home-provision.sh +259 -0
  134. package/.agents/backend/fm-remote-home-seed.sh +259 -0
  135. package/.agents/backend/fm-remote-inherit-push.sh +90 -0
  136. package/.agents/backend/fm-remote-inherit.sh +179 -0
  137. package/.agents/backend/fm-remote-job-lib.sh +1241 -0
  138. package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
  139. package/.agents/backend/fm-remote-job-worker.sh +1124 -0
  140. package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
  141. package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
  142. package/.agents/backend/fm-review-diff.sh +158 -0
  143. package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
  144. package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
  145. package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
  146. package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
  147. package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
  148. package/.agents/backend/fm-secondmate-report.sh +88 -0
  149. package/.agents/backend/fm-send.sh +1059 -0
  150. package/.agents/backend/fm-session-lock-lib.sh +176 -0
  151. package/.agents/backend/fm-session-start.sh +970 -0
  152. package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
  153. package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
  154. package/.agents/backend/fm-sessionstart-run.sh +145 -0
  155. package/.agents/backend/fm-spawn.sh +3153 -0
  156. package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
  157. package/.agents/backend/fm-startup-memory-budget.sh +94 -0
  158. package/.agents/backend/fm-startup-network.sh +642 -0
  159. package/.agents/backend/fm-stow-cascade.sh +251 -0
  160. package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
  161. package/.agents/backend/fm-supervise-daemon.sh +1732 -0
  162. package/.agents/backend/fm-supervision-instructions.sh +215 -0
  163. package/.agents/backend/fm-supervision-lib.sh +90 -0
  164. package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
  165. package/.agents/backend/fm-tangle-lib.sh +53 -0
  166. package/.agents/backend/fm-task-inbox-lib.sh +402 -0
  167. package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
  168. package/.agents/backend/fm-teardown.sh +2922 -0
  169. package/.agents/backend/fm-test-isolation-proof.sh +588 -0
  170. package/.agents/backend/fm-test-run.sh +2191 -0
  171. package/.agents/backend/fm-timeout-lib.sh +141 -0
  172. package/.agents/backend/fm-timing-lib.sh +179 -0
  173. package/.agents/backend/fm-tmux-lib.sh +291 -0
  174. package/.agents/backend/fm-tool-update-check.sh +898 -0
  175. package/.agents/backend/fm-trace-context-lib.sh +227 -0
  176. package/.agents/backend/fm-transition-lib.sh +103 -0
  177. package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
  178. package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
  179. package/.agents/backend/fm-turnend-guard.sh +432 -0
  180. package/.agents/backend/fm-update.sh +110 -0
  181. package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
  182. package/.agents/backend/fm-voice-client.py +1373 -0
  183. package/.agents/backend/fm-voice-relay.py +1256 -0
  184. package/.agents/backend/fm-wake-drain.sh +592 -0
  185. package/.agents/backend/fm-wake-grant.sh +127 -0
  186. package/.agents/backend/fm-wake-lib.sh +1849 -0
  187. package/.agents/backend/fm-watch-arm.sh +602 -0
  188. package/.agents/backend/fm-watch-checkpoint.sh +109 -0
  189. package/.agents/backend/fm-watch.sh +1962 -0
  190. package/.agents/backend/fm-x-dismiss.sh +116 -0
  191. package/.agents/backend/fm-x-followup.sh +286 -0
  192. package/.agents/backend/fm-x-lib.sh +1000 -0
  193. package/.agents/backend/fm-x-link.sh +234 -0
  194. package/.agents/backend/fm-x-poll.sh +185 -0
  195. package/.agents/backend/fm-x-reply.sh +385 -0
  196. package/.agents/backend/fm_voice_frame.py +166 -0
  197. package/.agents/backend/fm_voice_records.py +574 -0
  198. package/.agents/backend/model-bridge.py +290 -0
  199. package/.agents/backend/opencode-go-bridge.py +30 -0
  200. package/.agents/bus/README.md +12 -0
  201. package/.agents/bus/messages.json +1 -0
  202. package/.agents/config/agents.machine.example.yaml +16 -0
  203. package/.agents/config/agents.yaml +50 -0
  204. package/.agents/config/agents.yaml.example +72 -0
  205. package/.agents/config/crew-dispatch.json +22 -0
  206. package/.agents/config/cron.yaml +9 -0
  207. package/.agents/config/eindri-dispatch.json +67 -0
  208. package/.agents/config/eindri-harness +1 -0
  209. package/.agents/config/startup-memory-budget +1 -0
  210. package/.agents/config/tailscale-sync.yaml +27 -0
  211. package/.agents/filebrowser/.filebrowser.json +6 -0
  212. package/.agents/filebrowser/README.md +11 -0
  213. package/.agents/gateway/README.md +12 -0
  214. package/.agents/github/README.md +15 -0
  215. package/.agents/github/webhooks/.gitkeep +0 -0
  216. package/.agents/github/workflows/.gitkeep +0 -0
  217. package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
  218. package/.agents/harness/opencode/plugins/package.json +4 -0
  219. package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
  220. package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
  221. package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
  222. package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
  223. package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
  224. package/.agents/memory/README.md +46 -0
  225. package/.agents/memory/kaia.engram +0 -0
  226. package/.agents/memory/kaia.engram-shm +0 -0
  227. package/.agents/memory/kaia.engram-wal +0 -0
  228. package/.agents/memory/runes_audit.md +5 -0
  229. package/.agents/memory/well/episodes.jsonl +367 -0
  230. package/.agents/memory/well/workspace.jsonl +27 -0
  231. package/.agents/migrations/0001-hodd-layout.sh +38 -0
  232. package/.agents/migrations/0002-a2a-mcp.sh +27 -0
  233. package/.agents/migrations/0003-private-data-separation.sh +111 -0
  234. package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
  235. package/.agents/sandbox/Dockerfile.utgard +19 -0
  236. package/.agents/sandbox/README.md +14 -0
  237. package/.agents/sandbox/utgard.config.json +14 -0
  238. package/.agents/skills/README.md +59 -0
  239. package/.agents/skills/bragi-marketing/README.md +3 -0
  240. package/.agents/skills/bragi-marketing/SKILL.md +82 -0
  241. package/.agents/skills/eindri-homes/README.md +3 -0
  242. package/.agents/skills/eindri-homes/SKILL.md +291 -0
  243. package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
  244. package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
  245. package/.agents/skills/frigg-consent/README.md +3 -0
  246. package/.agents/skills/frigg-consent/SKILL.md +56 -0
  247. package/.agents/skills/galdr-ymirsystem/README.md +3 -0
  248. package/.agents/skills/galdr-ymirsystem/SKILL.md +158 -0
  249. package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
  250. package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
  251. package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
  252. package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
  253. package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
  254. package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
  255. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +673 -0
  256. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
  257. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
  258. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
  259. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
  260. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
  261. package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +496 -0
  262. package/.agents/skills/galdr-ymirsystem/assets/installation.md +571 -0
  263. package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
  264. package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
  265. package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +161 -0
  266. package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +377 -0
  267. package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
  268. package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +129 -0
  269. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
  270. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
  271. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +96 -0
  272. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
  273. package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
  274. package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
  275. package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
  276. package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
  277. package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
  278. package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
  279. package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
  280. package/.agents/skills/galdr-ymirsystem/assets/smidja.md +175 -0
  281. package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
  282. package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
  283. package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
  284. package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +360 -0
  285. package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
  286. package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
  287. package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
  288. package/.agents/skills/gjallarhorn-relay/README.md +3 -0
  289. package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
  290. package/.agents/skills/groa-update/SKILL.md +70 -0
  291. package/.agents/skills/hamr-adapters/README.md +3 -0
  292. package/.agents/skills/hamr-adapters/SKILL.md +123 -0
  293. package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
  294. package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
  295. package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
  296. package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
  297. package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
  298. package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
  299. package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
  300. package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
  301. package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
  302. package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
  303. package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
  304. package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
  305. package/.agents/skills/herdr-panes/README.md +3 -0
  306. package/.agents/skills/herdr-panes/SKILL.md +92 -0
  307. package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
  308. package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
  309. package/.agents/skills/hnoss-design/DESIGN.md +19 -0
  310. package/.agents/skills/hnoss-design/README.md +3 -0
  311. package/.agents/skills/hnoss-design/SKILL.md +83 -0
  312. package/.agents/skills/hvild-afk/README.md +3 -0
  313. package/.agents/skills/hvild-afk/SKILL.md +241 -0
  314. package/.agents/skills/jord-projects/README.md +3 -0
  315. package/.agents/skills/jord-projects/SKILL.md +92 -0
  316. package/.agents/skills/lifecycle/SKILL.md +56 -0
  317. package/.agents/skills/lifecycle/smoke_test.sh +52 -0
  318. package/.agents/skills/lifecycle/start.sh +21 -0
  319. package/.agents/skills/lifecycle/status.sh +35 -0
  320. package/.agents/skills/lifecycle/stop.sh +16 -0
  321. package/.agents/skills/muninn-stow/README.md +3 -0
  322. package/.agents/skills/muninn-stow/SKILL.md +309 -0
  323. package/.agents/skills/no-mistakes/SKILL.md +379 -0
  324. package/.agents/skills/nornir-schedule/README.md +3 -0
  325. package/.agents/skills/nornir-schedule/SKILL.md +25 -0
  326. package/.agents/skills/nornir-schedule/assets/events.md +137 -0
  327. package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
  328. package/.agents/skills/nsr-compliance/README.md +3 -0
  329. package/.agents/skills/nsr-compliance/SKILL.md +29 -0
  330. package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
  331. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
  332. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
  333. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
  334. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
  335. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
  336. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
  337. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
  338. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
  339. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
  340. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
  341. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
  342. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
  343. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
  344. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
  345. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
  346. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
  347. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
  348. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
  349. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
  350. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
  351. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
  352. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
  353. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  354. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
  355. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
  356. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
  357. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
  358. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
  359. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
  360. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
  361. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
  362. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
  363. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
  364. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
  365. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
  366. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
  367. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
  368. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
  369. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
  370. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
  371. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
  372. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
  373. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
  374. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
  375. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
  376. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
  377. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
  378. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
  379. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
  380. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
  381. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
  382. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
  383. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
  384. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
  385. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
  386. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
  387. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
  388. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
  389. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
  390. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
  391. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
  392. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
  393. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
  394. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
  395. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
  396. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
  397. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
  398. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
  399. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
  400. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
  401. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
  402. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
  403. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
  404. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
  405. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
  406. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
  407. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
  408. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
  409. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
  410. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
  411. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
  412. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
  413. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
  414. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
  415. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
  416. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
  417. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
  418. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
  419. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
  420. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
  421. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
  422. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
  423. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
  424. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
  425. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
  426. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
  427. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
  428. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
  429. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
  430. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
  431. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
  432. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
  433. package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
  434. package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
  435. package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
  436. package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
  437. package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
  438. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
  439. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
  440. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
  441. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
  442. package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
  443. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
  444. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
  445. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
  446. package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
  447. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
  448. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
  449. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
  450. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
  451. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
  452. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
  453. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
  454. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  455. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
  456. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
  457. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
  458. package/.agents/skills/pr-ops/README.md +3 -0
  459. package/.agents/skills/pr-ops/SKILL.md +109 -0
  460. package/.agents/skills/ratatoskr-a2a/README.md +3 -0
  461. package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
  462. package/.agents/skills/rules-check-drift/SKILL.md +81 -0
  463. package/.agents/skills/saga-bearings/README.md +3 -0
  464. package/.agents/skills/saga-bearings/SKILL.md +26 -0
  465. package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
  466. package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
  467. package/.agents/skills/saga-bearings/assets/recap.md +56 -0
  468. package/.agents/skills/syn-recovery/README.md +3 -0
  469. package/.agents/skills/syn-recovery/SKILL.md +54 -0
  470. package/.agents/skills/tyr-check/README.md +3 -0
  471. package/.agents/skills/tyr-check/SKILL.md +218 -0
  472. package/.agents/skills/urdh-hold/README.md +3 -0
  473. package/.agents/skills/urdh-hold/SKILL.md +25 -0
  474. package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
  475. package/.agents/skills/urdh-hold/assets/hold.md +54 -0
  476. package/.agents/skills/vor-diagnostics/README.md +3 -0
  477. package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
  478. package/.agents/skills/ymir-host/README.md +3 -0
  479. package/.agents/skills/ymir-host/SKILL.md +34 -0
  480. package/.agents/skills/ymir-host/assets/desktop.md +36 -0
  481. package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
  482. package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
  483. package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
  484. package/.agents/skills/ymir-host/assets/update.md +64 -0
  485. package/.agents/tests/assets/board-render-harness.mjs +117 -0
  486. package/.agents/tests/cmux-test-safety.sh +51 -0
  487. package/.agents/tests/fixtures.sh +291 -0
  488. package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
  489. package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
  490. package/.agents/tests/fm-afk-launch.test.sh +961 -0
  491. package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
  492. package/.agents/tests/fm-afk-return.test.sh +283 -0
  493. package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
  494. package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
  495. package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
  496. package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
  497. package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
  498. package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
  499. package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
  500. package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
  501. package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
  502. package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
  503. package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
  504. package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
  505. package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
  506. package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
  507. package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
  508. package/.agents/tests/fm-backend-orca.test.sh +1357 -0
  509. package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
  510. package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
  511. package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
  512. package/.agents/tests/fm-backend.test.sh +1142 -0
  513. package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
  514. package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
  515. package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
  516. package/.agents/tests/fm-bearings-board.test.sh +414 -0
  517. package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
  518. package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
  519. package/.agents/tests/fm-bootstrap.test.sh +1178 -0
  520. package/.agents/tests/fm-branch-supervision.test.sh +552 -0
  521. package/.agents/tests/fm-brief.test.sh +785 -0
  522. package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
  523. package/.agents/tests/fm-busy-state.test.sh +465 -0
  524. package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
  525. package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
  526. package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
  527. package/.agents/tests/fm-check-unregister.test.sh +198 -0
  528. package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
  529. package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
  530. package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
  531. package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
  532. package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
  533. package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
  534. package/.agents/tests/fm-composer-ghost.test.sh +716 -0
  535. package/.agents/tests/fm-composer-lib.test.sh +666 -0
  536. package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
  537. package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
  538. package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
  539. package/.agents/tests/fm-control.test.sh +910 -0
  540. package/.agents/tests/fm-crew-state.test.sh +1610 -0
  541. package/.agents/tests/fm-cursor-harness.test.sh +404 -0
  542. package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
  543. package/.agents/tests/fm-cursor-primary.test.sh +708 -0
  544. package/.agents/tests/fm-daemon.test.sh +2740 -0
  545. package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
  546. package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
  547. package/.agents/tests/fm-extension-binding.test.sh +2187 -0
  548. package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
  549. package/.agents/tests/fm-fleet-sync.test.sh +721 -0
  550. package/.agents/tests/fm-gate-refuse.test.sh +356 -0
  551. package/.agents/tests/fm-gitignore-config.test.sh +89 -0
  552. package/.agents/tests/fm-gotmp.test.sh +230 -0
  553. package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
  554. package/.agents/tests/fm-grok-harness.test.sh +117 -0
  555. package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
  556. package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
  557. package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
  558. package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
  559. package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
  560. package/.agents/tests/fm-herdr-lab.test.sh +243 -0
  561. package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
  562. package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
  563. package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
  564. package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
  565. package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
  566. package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
  567. package/.agents/tests/fm-kimi-harness.test.sh +687 -0
  568. package/.agents/tests/fm-lint-workflows.test.sh +532 -0
  569. package/.agents/tests/fm-lint.test.sh +1023 -0
  570. package/.agents/tests/fm-muse-harness.test.sh +932 -0
  571. package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
  572. package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
  573. package/.agents/tests/fm-on.test.sh +507 -0
  574. package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
  575. package/.agents/tests/fm-operational-input.test.sh +160 -0
  576. package/.agents/tests/fm-peek-remote.test.sh +110 -0
  577. package/.agents/tests/fm-pending-reply.test.sh +1288 -0
  578. package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
  579. package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
  580. package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
  581. package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
  582. package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
  583. package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
  584. package/.agents/tests/fm-pr-merge.test.sh +2135 -0
  585. package/.agents/tests/fm-procevent-quota.test.sh +225 -0
  586. package/.agents/tests/fm-procevent-when.test.sh +406 -0
  587. package/.agents/tests/fm-procevent.test.sh +1745 -0
  588. package/.agents/tests/fm-project-origin.test.sh +110 -0
  589. package/.agents/tests/fm-public-followup.test.sh +2272 -0
  590. package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
  591. package/.agents/tests/fm-quota-choose.test.sh +626 -0
  592. package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
  593. package/.agents/tests/fm-remote-doctor.test.sh +625 -0
  594. package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
  595. package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
  596. package/.agents/tests/fm-remote-job.test.sh +768 -0
  597. package/.agents/tests/fm-remote-reply.test.sh +520 -0
  598. package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
  599. package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
  600. package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
  601. package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
  602. package/.agents/tests/fm-review-diff.test.sh +176 -0
  603. package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
  604. package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
  605. package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
  606. package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
  607. package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
  608. package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
  609. package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
  610. package/.agents/tests/fm-send-inbox.test.sh +352 -0
  611. package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
  612. package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
  613. package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
  614. package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
  615. package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
  616. package/.agents/tests/fm-send-settle.test.sh +147 -0
  617. package/.agents/tests/fm-send-strict.test.sh +241 -0
  618. package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
  619. package/.agents/tests/fm-session-start.test.sh +2515 -0
  620. package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
  621. package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
  622. package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
  623. package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
  624. package/.agents/tests/fm-spawn-batch.test.sh +148 -0
  625. package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
  626. package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
  627. package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
  628. package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
  629. package/.agents/tests/fm-startup-network.test.sh +712 -0
  630. package/.agents/tests/fm-stow-cascade.test.sh +370 -0
  631. package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
  632. package/.agents/tests/fm-supervision-events.test.sh +156 -0
  633. package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
  634. package/.agents/tests/fm-tangle-guard.test.sh +277 -0
  635. package/.agents/tests/fm-task-delivery.test.sh +420 -0
  636. package/.agents/tests/fm-task-inbox.test.sh +513 -0
  637. package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
  638. package/.agents/tests/fm-teardown.test.sh +2665 -0
  639. package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
  640. package/.agents/tests/fm-test-fixtures.test.sh +132 -0
  641. package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
  642. package/.agents/tests/fm-test-run.test.sh +1196 -0
  643. package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
  644. package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
  645. package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
  646. package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
  647. package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
  648. package/.agents/tests/fm-transition-lib.test.sh +54 -0
  649. package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
  650. package/.agents/tests/fm-update.test.sh +304 -0
  651. package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
  652. package/.agents/tests/fm-voice-relay.test.sh +4741 -0
  653. package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
  654. package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
  655. package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
  656. package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
  657. package/.agents/tests/fm-wake-queue.test.sh +1224 -0
  658. package/.agents/tests/fm-watch-arm.test.sh +821 -0
  659. package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
  660. package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
  661. package/.agents/tests/fm-watch-triage.test.sh +3889 -0
  662. package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
  663. package/.agents/tests/fm-x-mode.test.sh +3042 -0
  664. package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
  665. package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
  666. package/.agents/tests/herdr-test-safety.sh +42 -0
  667. package/.agents/tests/lib.sh +315 -0
  668. package/.agents/tests/remote-herdr-fixture.sh +125 -0
  669. package/.agents/tests/secondmate-helpers.sh +193 -0
  670. package/.agents/tests/smoke.test.sh +93 -0
  671. package/.agents/tests/wake-helpers.sh +338 -0
  672. package/.agents/tests/zellij-test-safety.sh +60 -0
  673. package/.agents/tools/README.md +10 -0
  674. package/.agents/tools/bin/tasks-cli +267 -0
  675. package/.agents/tools/tasks-cli.ts +216 -0
  676. package/AGENTS.md +454 -0
  677. package/CHANGELOG.md +881 -0
  678. package/LICENSE +1 -1
  679. package/NOTICE +63 -0
  680. package/README.md +113 -59
  681. package/RULES/01-domains.md +51 -0
  682. package/RULES/02-agents.md +78 -0
  683. package/RULES/03-houses.md +61 -0
  684. package/RULES/04-hoard.md +65 -0
  685. package/RULES/05-platforms.md +67 -0
  686. package/RULES/06-append-only.md +58 -0
  687. package/RULES/07-config.md +45 -0
  688. package/RULES/08-delivery-gate.md +75 -0
  689. package/RULES/README.md +20 -0
  690. package/bin/README.md +107 -0
  691. package/bin/a2a-mcp.sh +98 -0
  692. package/bin/a2a-serve.py +103 -0
  693. package/bin/a2a-serve.sh +8 -0
  694. package/bin/a2a-talk.sh +50 -0
  695. package/bin/a2abridge-ensure.sh +108 -0
  696. package/bin/agent-run.sh +43 -0
  697. package/bin/agents-config.sh +233 -0
  698. package/bin/apodex-smoke-test.sh +92 -0
  699. package/bin/bifrost-bridge.sh +112 -0
  700. package/bin/bifrost-ingress.sh +77 -0
  701. package/bin/bootstrap-macos.sh +107 -0
  702. package/bin/bootstrap-windows.ps1 +112 -0
  703. package/bin/branch-guard.sh +64 -0
  704. package/bin/brokk +73 -0
  705. package/bin/brokk-classify-lib.sh +1770 -0
  706. package/bin/brokk-lease-lib.sh +222 -0
  707. package/bin/brokk-lease.sh +193 -0
  708. package/bin/brokk-lint.sh +65 -0
  709. package/bin/brokk-send.sh +38 -0
  710. package/bin/brokk-update.sh +9 -0
  711. package/bin/brokk-wake-grant.sh +127 -0
  712. package/bin/brokk-wake-lib.sh +1849 -0
  713. package/bin/changelog-guard.sh +165 -0
  714. package/bin/crash-sense.sh +160 -0
  715. package/bin/desktop-place.sh +224 -0
  716. package/bin/docs-guard.sh +39 -0
  717. package/bin/editor-place.sh +207 -0
  718. package/bin/eindri-acclaim.sh +34 -0
  719. package/bin/eindri-control.sh +28 -0
  720. package/bin/eindri-role.sh +83 -0
  721. package/bin/eindri-seen.sh +41 -0
  722. package/bin/eindri-send.sh +15 -0
  723. package/bin/eindri-start.sh +85 -0
  724. package/bin/eindri-watch.sh +57 -0
  725. package/bin/einherjar-spawn.sh +576 -0
  726. package/bin/eir-doctor.sh +112 -0
  727. package/bin/erindi-brief.sh +271 -0
  728. package/bin/fleet-apply.sh +68 -0
  729. package/bin/github-deploy.sh +81 -0
  730. package/bin/gjallarhorn-expose.sh +102 -0
  731. package/bin/gjallarhorn-notify.sh +58 -0
  732. package/bin/gjallarhorn-purge.sh +54 -0
  733. package/bin/gjallarhorn-tunnel.sh +72 -0
  734. package/bin/gleipnir-lock-lib.sh +284 -0
  735. package/bin/groa-update.sh +122 -0
  736. package/bin/hall-snapshot.sh +92 -0
  737. package/bin/hamr-harness.sh +246 -0
  738. package/bin/heimdall-ssh-keys.sh +374 -0
  739. package/bin/herdr-agents.py +44 -0
  740. package/bin/herdr-ensure.sh +104 -0
  741. package/bin/herdr-run.sh +431 -0
  742. package/bin/hermes-ensure.sh +88 -0
  743. package/bin/hoard-lib.sh +25 -0
  744. package/bin/hodd.sh +78 -0
  745. package/bin/host-sense.sh +199 -0
  746. package/bin/huginn-research-worker.sh +108 -0
  747. package/bin/local-model-lock.sh +51 -0
  748. package/bin/mimir-bridge.py +226 -0
  749. package/bin/mimir-bridge.sh +87 -0
  750. package/bin/mimir-ingest.sh +131 -0
  751. package/bin/mimir.sh +113 -0
  752. package/bin/mjollnir-webhook.sh +59 -0
  753. package/bin/mjollnir.sh +85 -0
  754. package/bin/model-hardware.sh +82 -0
  755. package/bin/model-resolve.sh +147 -0
  756. package/bin/models-detect.sh +178 -0
  757. package/bin/nornir-cron-start.sh +162 -0
  758. package/bin/nornir-job-daily-briefing.sh +191 -0
  759. package/bin/nornir-job-git-sync.sh +152 -0
  760. package/bin/nornir-job-memory-housekeeping.sh +114 -0
  761. package/bin/nornir-job-observer.sh +166 -0
  762. package/bin/nornir-job-skillopt-sleep.sh +76 -0
  763. package/bin/omarchy-hook-install.sh +60 -0
  764. package/bin/omarchy-install.sh +148 -0
  765. package/bin/omarchy-plugins.sh +174 -0
  766. package/bin/omarchy-sense.sh +149 -0
  767. package/bin/perm-guard.sh +30 -0
  768. package/bin/pi-agent.sh +68 -0
  769. package/bin/pi-ensure.sh +77 -0
  770. package/bin/pi-local.sh +37 -0
  771. package/bin/pi-seat.sh +63 -0
  772. package/bin/prereq-ensure.sh +166 -0
  773. package/bin/private-guard.sh +38 -0
  774. package/bin/project-git.sh +55 -0
  775. package/bin/public-guard.sh +70 -0
  776. package/bin/ratatoskr.sh +49 -0
  777. package/bin/realm-lib.sh +32 -0
  778. package/bin/repo-scrub.sh +62 -0
  779. package/bin/rodd-operational-input.sh +250 -0
  780. package/bin/runes-append.sh +198 -0
  781. package/bin/saga-session-start.sh +172 -0
  782. package/bin/saga-sessionstart-run.sh +79 -0
  783. package/bin/saga-wake-drain.sh +41 -0
  784. package/bin/secret-guard.sh +63 -0
  785. package/bin/sessrumnir-ensure.sh +100 -0
  786. package/bin/sessrumnir-sync.sh +237 -0
  787. package/bin/sessrumnir.sh +91 -0
  788. package/bin/skillopt-setup.sh +76 -0
  789. package/bin/skuld-branch-outcome.sh +203 -0
  790. package/bin/skuld-branch-prompt.sh +101 -0
  791. package/bin/smidja-bootstrap.sh +90 -0
  792. package/bin/smidja-observe.sh +86 -0
  793. package/bin/syn-arm-pretool-check.sh +57 -0
  794. package/bin/syn-asset-pretool-check.sh +76 -0
  795. package/bin/syn-cd-pretool-check.sh +28 -0
  796. package/bin/syn-guard-pretool-check.sh +83 -0
  797. package/bin/syn-turnend-guard.sh +36 -0
  798. package/bin/syn-watch-arm.sh +99 -0
  799. package/bin/tailscale-sync.sh +190 -0
  800. package/bin/telegram-bot.sh +69 -0
  801. package/bin/toolchain.sh +74 -0
  802. package/bin/utgard.sh +111 -0
  803. package/bin/valhalla.sh +115 -0
  804. package/bin/valknut-load.sh +260 -0
  805. package/bin/vor-crew-state.sh +124 -0
  806. package/bin/wedge-notify.sh +47 -0
  807. package/bin/workspace-provision.sh +80 -0
  808. package/bin/workspace-rag.sh +150 -0
  809. package/bin/wyrd-db.sh +47 -0
  810. package/bin/yggdrasil.sh +152 -0
  811. package/bin/ymir-edit.sh +145 -0
  812. package/bin/ymir-install.sh +641 -0
  813. package/bin/ymir-invite.sh +93 -0
  814. package/bin/ymir-isolation.sh +88 -0
  815. package/bin/ymir-migrate.sh +61 -0
  816. package/bin/ymir-platform.sh +348 -0
  817. package/bin/ymir-say.sh +97 -0
  818. package/bin/ymir-setup-auth.sh +108 -0
  819. package/bin/ymir-validate.sh +173 -0
  820. package/bin/ymir.js +74 -16
  821. package/docs/Architecture.md +339 -0
  822. package/docs/design.md +351 -0
  823. package/docs/installations/README.md +63 -0
  824. package/docs/installations/ymir-home.md +93 -0
  825. package/docs/integration.md +105 -0
  826. package/docs/lore.md +1056 -0
  827. package/docs/research/agent-harnesses.md +290 -0
  828. package/docs/runbooks/README.md +19 -0
  829. package/docs/runbooks/a2a.md +91 -0
  830. package/docs/runbooks/agent-permissions.md +99 -0
  831. package/docs/runbooks/agents.md +57 -0
  832. package/docs/runbooks/models.md +69 -0
  833. package/docs/runbooks/realm-onboarding.md +60 -0
  834. package/docs/runbooks/secrets-and-hoard.md +60 -0
  835. package/docs/runbooks/updates-and-migrations.md +110 -0
  836. package/docs/runbooks/worktrees-isolation.md +72 -0
  837. package/docs/session-start.md +105 -0
  838. package/docs/skillopt-integration.md +321 -0
  839. package/docs/sync-system.md +66 -0
  840. package/docs/workspaces.md +104 -0
  841. package/docs/ymir-rut.md +260 -0
  842. package/package.json +44 -14
  843. package/packaging/build.sh +74 -0
  844. package/packaging/macos/Ymir Installer.command +43 -0
  845. package/packaging/windows/ymir-setup.nsi +74 -0
  846. package/scripts/README.md +22 -0
  847. package/scripts/electron.sh +210 -0
  848. package/scripts/start.sh +167 -0
  849. package/scripts/stop.sh +113 -0
  850. package/install.sh +0 -35
@@ -0,0 +1,3042 @@
1
+ #!/usr/bin/env bash
2
+ # Behavior tests for X mode: the relay poll client (fm-x-poll.sh), the answer
3
+ # poster (fm-x-reply.sh), and bootstrap's .env-presence activation.
4
+ #
5
+ # X mode must be INERT by default (no token -> the poll is a hard no-op and
6
+ # bootstrap writes/prints nothing) and additive when on (a check shim + a 30s
7
+ # cadence config, both idempotent). The network is stubbed with a fakebin `curl`
8
+ # so these stay hermetic: no ports, no server, deterministic in CI. jq stays the
9
+ # real tool. End-to-end verification against a real HTTP relay is done out of
10
+ # band; this suite pins the client logic and the activation contract.
11
+ set -u
12
+
13
+ # shellcheck source=tests/lib.sh
14
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
15
+
16
+ BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin}
17
+ # The client under test uses the real jq; make it resolvable regardless of where
18
+ # it is installed (Homebrew, Nix profile bins, etc.), which the bare BASE_PATH may
19
+ # not include. Prepended after the fakebin so the fake curl still wins.
20
+ JQ_DIR=$(command -v jq 2>/dev/null) && JQ_DIR=$(dirname "$JQ_DIR") || JQ_DIR=
21
+ [ -n "$JQ_DIR" ] && BASE_PATH="$JQ_DIR:$BASE_PATH"
22
+ TMP_ROOT=$(fm_test_tmproot fm-x-mode-tests)
23
+
24
+ # A fakebin `curl` that mimics the relay: it reads its behavior from env
25
+ # (FAKE_POLL_CODE/FAKE_POLL_BODY/FAKE_ANSWER_CODE, and
26
+ # FAKE_REQCTX_CODE/FAKE_REQCTX_BODY for the request-context lookup), records each
27
+ # call to FAKE_CURL_LOG, writes the poll/lookup body to the script's -o file, and
28
+ # prints the HTTP code to stdout exactly as the real `-w '%{http_code}'` would.
29
+ make_fake_curl() {
30
+ local dir=$1 fakebin
31
+ fakebin=$(fm_fakebin "$dir")
32
+ cat > "$fakebin/curl" <<'SH'
33
+ #!/usr/bin/env bash
34
+ ofile="" method=GET data="" url="" auth=""
35
+ argv=$*
36
+ while [ $# -gt 0 ]; do
37
+ case "$1" in
38
+ -o) ofile=$2; shift 2 ;;
39
+ -X) method=$2; shift 2 ;;
40
+ --data) data=$2; shift 2 ;;
41
+ --data-binary)
42
+ case "$2" in
43
+ @-) data=$(cat) ;;
44
+ @*) data=$(cat -- "${2#@}") ;;
45
+ *) data=$2 ;;
46
+ esac
47
+ shift 2
48
+ ;;
49
+ -H)
50
+ case "$2" in
51
+ @*) while IFS= read -r header; do case "$header" in Authorization:*) auth=$header ;; esac; done < "${2#@}" ;;
52
+ Authorization:*) auth=$2 ;;
53
+ esac
54
+ shift 2
55
+ ;;
56
+ -m|-w) shift 2 ;;
57
+ -s) shift ;;
58
+ http://*|https://*) url=$1; shift ;;
59
+ *) shift ;;
60
+ esac
61
+ done
62
+ if [ -n "${FAKE_CURL_LOG:-}" ]; then
63
+ { echo "argv=$argv"; echo "method=$method"; echo "url=$url"; echo "auth=$auth"; echo "data=$data"; } >> "$FAKE_CURL_LOG"
64
+ fi
65
+ case "$url" in
66
+ */connector/poll)
67
+ [ -n "$ofile" ] && printf '%s' "${FAKE_POLL_BODY:-}" > "$ofile"
68
+ printf '%s' "${FAKE_POLL_CODE:-204}"
69
+ ;;
70
+ */connector/answer)
71
+ [ -n "$ofile" ] && printf '%s' "${FAKE_ANSWER_BODY:-}" > "$ofile"
72
+ printf '%s' "${FAKE_ANSWER_CODE:-200}"
73
+ ;;
74
+ */connector/followup)
75
+ [ -n "$ofile" ] && printf '%s' "${FAKE_FOLLOWUP_BODY:-${FAKE_ANSWER_BODY:-}}" > "$ofile"
76
+ [ -n "${FAKE_CURL_TOUCH_AFTER_POST:-}" ] && : > "$FAKE_CURL_TOUCH_AFTER_POST"
77
+ printf '%s' "${FAKE_FOLLOWUP_CODE:-${FAKE_ANSWER_CODE:-200}}"
78
+ ;;
79
+ */connector/dismiss)
80
+ printf '%s' "${FAKE_DISMISS_CODE:-200}"
81
+ ;;
82
+ */connector/request-context)
83
+ [ -n "$ofile" ] && printf '%s' "${FAKE_REQCTX_BODY:-}" > "$ofile"
84
+ printf '%s' "${FAKE_REQCTX_CODE:-200}"
85
+ ;;
86
+ esac
87
+ exit 0
88
+ SH
89
+ chmod +x "$fakebin/curl"
90
+ printf '%s\n' "$fakebin"
91
+ }
92
+
93
+ make_sample_image() {
94
+ local path=$1
95
+ case "$path" in
96
+ *.png) printf '\211PNG\r\n\032\nfirstmate-test-png' > "$path" ;;
97
+ *.jpg|*.jpeg) printf '\377\330\377firstmate-test-jpeg' > "$path" ;;
98
+ *.gif) printf 'GIF89afirstmate-test-gif' > "$path" ;;
99
+ *.webp) printf 'RIFF....WEBPfirstmate-test-webp' > "$path" ;;
100
+ *) printf 'firstmate-test-image' > "$path" ;;
101
+ esac
102
+ }
103
+
104
+ path_mode() {
105
+ if [ "$(uname)" = Darwin ]; then
106
+ stat -f %Lp "$1"
107
+ else
108
+ stat -c %a "$1"
109
+ fi
110
+ }
111
+
112
+ assert_no_private_artifact_temps() {
113
+ local dir=$1 leftovers
114
+ [ -d "$dir" ] || return 0
115
+ leftovers=$(find "$dir" -name '*.fm-x.*' -print 2>/dev/null)
116
+ [ -z "$leftovers" ] || fail "private artifact temp files were not cleaned up: $leftovers"
117
+ }
118
+
119
+ private_artifact_dir() {
120
+ mkdir -p "$1"
121
+ chmod 700 "$1"
122
+ }
123
+
124
+ private_artifact_file() {
125
+ chmod 600 "$1"
126
+ }
127
+
128
+ # ---------------------------------------------------------------------------
129
+
130
+ test_poll_no_token_is_hard_noop() {
131
+ local home fakebin out rc
132
+ home="$TMP_ROOT/poll-noop"; mkdir -p "$home"
133
+ fakebin=$(make_fake_curl "$home")
134
+ # No .env, no FMX_PAIRING_TOKEN: must exit 0 with no output and touch nothing.
135
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_PAIRING_TOKEN='' \
136
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
137
+ expect_code 0 "$rc" "poll no-token exit"
138
+ [ -z "$out" ] || fail "poll no-token must be silent (got: $out)"
139
+ assert_absent "$home/state/x-inbox" "poll no-token must not create an inbox"
140
+ pass "fm-x-poll is a hard no-op without a token (inert default)"
141
+ }
142
+
143
+ test_poll_empty_env_token_overrides_env_file() {
144
+ local home fakebin log out rc
145
+ home="$TMP_ROOT/poll-empty-env-token"; mkdir -p "$home"
146
+ fakebin=$(make_fake_curl "$home")
147
+ log="$home/curl.log"
148
+ printf 'FMX_PAIRING_TOKEN=tok-dotenv\n' > "$home/.env"
149
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_PAIRING_TOKEN='' \
150
+ FAKE_CURL_LOG="$log" FAKE_POLL_CODE=204 \
151
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
152
+ expect_code 0 "$rc" "poll empty-env-token exit"
153
+ [ -z "$out" ] || fail "empty env token must disable X mode despite .env token (got: $out)"
154
+ [ ! -f "$log" ] || fail "empty env token must not call the relay"
155
+ assert_absent "$home/state/x-inbox" "empty env token must not create an inbox"
156
+ pass "fm-x-poll treats an explicitly empty env token as configured"
157
+ }
158
+
159
+ test_poll_204_is_silent() {
160
+ local home fakebin log out rc
161
+ home="$TMP_ROOT/poll-204"; mkdir -p "$home"
162
+ fakebin=$(make_fake_curl "$home")
163
+ log="$home/curl.log"
164
+ printf 'FMX_PAIRING_TOKEN=tok-204\n' > "$home/.env"
165
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
166
+ FAKE_CURL_LOG="$log" FAKE_POLL_CODE=204 \
167
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
168
+ expect_code 0 "$rc" "poll 204 exit"
169
+ [ -z "$out" ] || fail "poll 204 must be silent (got: $out)"
170
+ assert_grep "auth=Authorization: Bearer tok-204" "$log" "poll must send the bearer token"
171
+ grep '^argv=' "$log" | grep -F 'tok-204' >/dev/null 2>&1 \
172
+ && fail "poll must not expose the bearer token in curl argv"
173
+ assert_grep "url=https://relay.test/connector/poll" "$log" "poll must hit /connector/poll"
174
+ ls "$home/state/x-inbox/"*.json >/dev/null 2>&1 && fail "poll 204 must not stash an inbox file"
175
+ pass "fm-x-poll stays silent on HTTP 204 (the common case)"
176
+ }
177
+
178
+ test_poll_empty_env_relay_overrides_env_file() {
179
+ local home fakebin log out rc
180
+ home="$TMP_ROOT/poll-empty-env-relay"; mkdir -p "$home"
181
+ fakebin=$(make_fake_curl "$home")
182
+ log="$home/curl.log"
183
+ printf 'FMX_PAIRING_TOKEN=tok-relay\nFMX_RELAY_URL=https://dotenv-relay.test/\n' > "$home/.env"
184
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL='' \
185
+ FAKE_CURL_LOG="$log" FAKE_POLL_CODE=204 \
186
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
187
+ expect_code 0 "$rc" "poll empty-env-relay exit"
188
+ [ -z "$out" ] || fail "poll 204 with empty env relay must be silent (got: $out)"
189
+ assert_grep "url=https://myfirstmate.io/connector/poll" "$log" \
190
+ "empty env relay must override .env and fall back to the default relay"
191
+ pass "fm-x-poll lets an explicitly empty relay env override .env"
192
+ }
193
+
194
+ test_poll_auth_error_reports_once() {
195
+ local home fakebin out rc
196
+ home="$TMP_ROOT/poll-auth"; mkdir -p "$home"
197
+ fakebin=$(make_fake_curl "$home")
198
+ printf 'FMX_PAIRING_TOKEN=tok-auth\n' > "$home/.env"
199
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
200
+ FAKE_POLL_CODE=401 \
201
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
202
+ expect_code 0 "$rc" "poll auth error exit"
203
+ [ "$out" = "x-mode-error relay returned HTTP 401" ] \
204
+ || fail "poll auth error must emit one visible diagnostic (got: $out)"
205
+ assert_present "$home/state/x-poll.error" "poll auth error must write a dedupe marker"
206
+ [ "$(path_mode "$home/state")" = 700 ] || fail "poll auth error must create private state"
207
+ [ "$(path_mode "$home/state/x-poll.error")" = 600 ] || fail "poll auth error marker must be private"
208
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
209
+ FAKE_POLL_CODE=401 \
210
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
211
+ expect_code 0 "$rc" "poll repeated auth error exit"
212
+ [ -z "$out" ] || fail "repeated poll auth error must be quiet after the first diagnostic (got: $out)"
213
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
214
+ FAKE_POLL_CODE=204 \
215
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
216
+ expect_code 0 "$rc" "poll recovered auth error exit"
217
+ [ -z "$out" ] || fail "poll recovery 204 must stay silent (got: $out)"
218
+ assert_absent "$home/state/x-poll.error" "poll 204 must clear the auth diagnostic marker"
219
+ pass "fm-x-poll surfaces auth/config errors once and clears on recovery"
220
+ }
221
+
222
+ test_poll_error_private_publication_rejects_unsafe_paths() {
223
+ local home fakebin out rc target marker hardlink
224
+
225
+ home="$TMP_ROOT/poll-error-linked-state"; mkdir -p "$home/external-state"
226
+ fakebin=$(make_fake_curl "$home")
227
+ ln -s "$home/external-state" "$home/state"
228
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
229
+ FMX_PAIRING_TOKEN=tok-linked-state FAKE_POLL_CODE=401 \
230
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
231
+ expect_code 0 "$rc" "poll linked state diagnostic exit"
232
+ [ "$out" = "x-mode-error relay returned HTTP 401" ] \
233
+ || fail "poll must still emit a diagnostic when the marker cannot be safely stored (got: $out)"
234
+ assert_absent "$home/external-state/x-poll.error" "poll must not write the diagnostic through a linked state directory"
235
+ [ -L "$home/state" ] || fail "poll must leave a rejected linked state directory in place"
236
+
237
+ home="$TMP_ROOT/poll-error-linked-marker"; mkdir -p "$home/state"
238
+ fakebin=$(make_fake_curl "$home")
239
+ chmod 700 "$home/state"
240
+ target="$home/external-error"
241
+ printf 'relay returned HTTP 401\n' > "$target"
242
+ ln -s "$target" "$home/state/x-poll.error"
243
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
244
+ FMX_PAIRING_TOKEN=tok-linked-marker FAKE_POLL_CODE=401 \
245
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
246
+ expect_code 0 "$rc" "poll linked marker diagnostic exit"
247
+ [ "$out" = "x-mode-error relay returned HTTP 401" ] \
248
+ || fail "poll must not dedupe through a linked diagnostic marker (got: $out)"
249
+ [ "$(cat "$target")" = "relay returned HTTP 401" ] \
250
+ || fail "poll must not write through a linked diagnostic marker"
251
+ [ -L "$home/state/x-poll.error" ] || fail "poll must not replace a rejected linked diagnostic marker"
252
+
253
+ home="$TMP_ROOT/poll-error-hardlink-marker"; mkdir -p "$home/state"
254
+ fakebin=$(make_fake_curl "$home")
255
+ chmod 700 "$home/state"
256
+ marker="$home/state/x-poll.error"
257
+ hardlink="$home/state/x-poll.alias"
258
+ printf 'relay returned HTTP 401\n' > "$marker"
259
+ chmod 600 "$marker"
260
+ ln "$marker" "$hardlink"
261
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
262
+ FMX_PAIRING_TOKEN=tok-hard-marker FAKE_POLL_CODE=401 \
263
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
264
+ expect_code 0 "$rc" "poll hardlinked marker diagnostic exit"
265
+ [ "$out" = "x-mode-error relay returned HTTP 401" ] \
266
+ || fail "poll must not dedupe through a hardlinked diagnostic marker (got: $out)"
267
+ [ "$(cat "$marker")" = "relay returned HTTP 401" ] || fail "poll must preserve the hardlinked marker"
268
+ [ "$(cat "$hardlink")" = "relay returned HTTP 401" ] || fail "poll must preserve the marker hardlink peer"
269
+ assert_no_private_artifact_temps "$home/state"
270
+ pass "fm-x-poll diagnostic markers use private guarded publication"
271
+ }
272
+
273
+ test_poll_question_stashes_and_marks() {
274
+ local home fakebin out rc body
275
+ home="$TMP_ROOT/poll-q"; mkdir -p "$home"
276
+ fakebin=$(make_fake_curl "$home")
277
+ printf 'FMX_PAIRING_TOKEN=tok-q\n' > "$home/.env"
278
+ body='{"request_id":"req-7","tweet_id":"555","author_id":"42","text":"what are you building?"}'
279
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
280
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
281
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
282
+ expect_code 0 "$rc" "poll question exit"
283
+ [ "$out" = "x-mention req-7" ] || fail "poll must print compact marker (got: $out)"
284
+ assert_present "$home/state/x-inbox/req-7.json" "poll must stash the question"
285
+ [ "$(jq -r .text "$home/state/x-inbox/req-7.json")" = "what are you building?" ] \
286
+ || fail "stashed inbox must preserve the question text"
287
+ [ "$(jq -r .tweet_id "$home/state/x-inbox/req-7.json")" = "555" ] \
288
+ || fail "stashed inbox must preserve the full object"
289
+ pass "fm-x-poll stashes the question and prints the compact marker"
290
+ }
291
+
292
+ test_poll_mentions_wake_once_per_durable_offer() {
293
+ local home fakebin out rc body marker
294
+ home="$TMP_ROOT/poll-offer-dedupe"; mkdir -p "$home"
295
+ fakebin=$(make_fake_curl "$home")
296
+ printf 'FMX_PAIRING_TOKEN=tok-offer\n' > "$home/.env"
297
+ body='{"request_id":"req-repeat","platform":"discord","reply_max_chars":1900,"text":"status?"}'
298
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
299
+ FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
300
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
301
+ expect_code 0 "$rc" "first offered mention poll exit"
302
+ [ "$out" = "x-mention req-repeat" ] \
303
+ || fail "a newly offered mention must wake once (got: $out)"
304
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000030 \
305
+ FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
306
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
307
+ expect_code 0 "$rc" "repeated pending mention poll exit"
308
+ [ -z "$out" ] || fail "an already offered pending mention must stay silent (got: $out)"
309
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
310
+ FAKE_DISMISS_CODE=200 "$ROOT/bin/fm-x-dismiss.sh" req-repeat); rc=$?
311
+ expect_code 0 "$rc" "successful dismiss before relay re-offer exit"
312
+ [ "$out" = "req-repeat" ] || fail "the dismiss fixture must succeed before the re-offer"
313
+ rm -f "$home/state/x-inbox/req-repeat.json"
314
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000060 \
315
+ FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
316
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
317
+ expect_code 0 "$rc" "post-answer re-offer poll exit"
318
+ [ -z "$out" ] || fail "a relay re-offer after inbox cleanup must stay silent (got: $out)"
319
+ assert_absent "$home/state/x-inbox/req-repeat.json" \
320
+ "a suppressed post-answer re-offer must not recreate the drained inbox"
321
+ marker="$home/state/x-context/req-repeat.offered.json"
322
+ assert_present "$marker" "the durable offer marker must survive inbox cleanup"
323
+ rm -f "$marker"
324
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000090 \
325
+ FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
326
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
327
+ expect_code 0 "$rc" "mention re-offer after local marker loss exit"
328
+ [ "$out" = "x-mention req-repeat" ] \
329
+ || fail "a re-offer after local marker loss must wake once (got: $out)"
330
+ body='{"request_id":"req-new","platform":"discord","reply_max_chars":1900,"text":"new status?"}'
331
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000120 \
332
+ FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
333
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
334
+ expect_code 0 "$rc" "genuinely new mention poll exit"
335
+ [ "$out" = "x-mention req-new" ] \
336
+ || fail "a genuinely new request_id must wake once (got: $out)"
337
+ marker="$home/state/x-context/req-new.offered.json"
338
+ [ "$(path_mode "$marker")" = 600 ] \
339
+ || fail "the durable offer marker must be a private file"
340
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700604921 \
341
+ FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
342
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
343
+ expect_code 0 "$rc" "mention re-offer after marker expiry exit"
344
+ [ "$out" = "x-mention req-new" ] \
345
+ || fail "a re-offer after the bounded marker expiry must wake once (got: $out)"
346
+ pass "fm-x-poll wakes once per durable request offer across inbox cleanup"
347
+ }
348
+
349
+ test_poll_offer_claim_failure_reports_once() {
350
+ local home fakebin out rc body
351
+ home="$TMP_ROOT/poll-offer-claim-failure"; mkdir -p "$home/state" "$home/external-context"
352
+ fakebin=$(make_fake_curl "$home")
353
+ chmod 700 "$home/state"
354
+ ln -s "$home/external-context" "$home/state/x-context"
355
+ body='{"request_id":"req-claim-failure","text":"status?"}'
356
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
357
+ FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
358
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
359
+ expect_code 0 "$rc" "first offer claim failure poll exit"
360
+ [ "$out" = "x-mode-error cannot record mention offer" ] \
361
+ || fail "an offer claim failure must emit one diagnostic (got: $out)"
362
+ assert_present "$home/state/x-poll.claim-error" "offer claim failure must write a dedupe marker"
363
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
364
+ FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
365
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
366
+ expect_code 0 "$rc" "repeated offer claim failure poll exit"
367
+ [ -z "$out" ] || fail "a repeated offer claim failure must stay silent (got: $out)"
368
+ assert_present "$home/state/x-poll.claim-error" "a repeated offer claim failure must retain its dedupe marker"
369
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
370
+ FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=204 \
371
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
372
+ expect_code 0 "$rc" "no-pending poll after offer claim failure exit"
373
+ [ -z "$out" ] || fail "a no-pending poll must stay silent after an offer claim failure (got: $out)"
374
+ assert_present "$home/state/x-poll.claim-error" \
375
+ "a no-pending poll must retain the offer claim dedupe marker"
376
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
377
+ FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
378
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
379
+ expect_code 0 "$rc" "re-offered claim failure poll exit"
380
+ [ -z "$out" ] || fail "a re-offered claim failure must stay silent (got: $out)"
381
+ rm "$home/state/x-context"
382
+ mkdir "$home/state/x-context"
383
+ chmod 700 "$home/state/x-context"
384
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
385
+ FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
386
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
387
+ expect_code 0 "$rc" "recovered offer claim poll exit"
388
+ [ "$out" = "x-mention req-claim-failure" ] \
389
+ || fail "a recovered offer claim must emit the mention wake (got: $out)"
390
+ assert_absent "$home/state/x-poll.claim-error" "a successful offer claim must clear the diagnostic marker"
391
+ pass "fm-x-poll retains offer claim diagnostics until recovery"
392
+ }
393
+
394
+ test_poll_preserves_conversation_context() {
395
+ local home fakebin out rc body f
396
+ home="$TMP_ROOT/poll-ctx"; mkdir -p "$home"
397
+ fakebin=$(make_fake_curl "$home")
398
+ printf 'FMX_PAIRING_TOKEN=tok-c\n' > "$home/.env"
399
+ # A follow-up reply: the relay includes in_reply_to with the parent tweet.
400
+ body='{"request_id":"req-c","tweet_id":"9","author_id":"42","text":"and then what?","in_reply_to":{"author_handle":"@asker","text":"are you shipping today?"}}'
401
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
402
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
403
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
404
+ expect_code 0 "$rc" "poll conversation exit"
405
+ [ "$out" = "x-mention req-c" ] || fail "poll must mark the follow-up mention (got: $out)"
406
+ f="$home/state/x-inbox/req-c.json"
407
+ assert_present "$f" "poll must stash the follow-up"
408
+ [ "$(jq -r '.in_reply_to.author_handle' "$f")" = "@asker" ] \
409
+ || fail "inbox must preserve in_reply_to.author_handle for continuity"
410
+ [ "$(jq -r '.in_reply_to.text' "$f")" = "are you shipping today?" ] \
411
+ || fail "inbox must preserve in_reply_to.text for continuity"
412
+ # A fresh, standalone mention: in_reply_to is null and round-trips as null.
413
+ home="$TMP_ROOT/poll-ctx-fresh"; mkdir -p "$home"
414
+ fakebin=$(make_fake_curl "$home")
415
+ printf 'FMX_PAIRING_TOKEN=tok-c\n' > "$home/.env"
416
+ body='{"request_id":"req-f","tweet_id":"10","author_id":"42","text":"what are you up to?","in_reply_to":null}'
417
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
418
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
419
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
420
+ expect_code 0 "$rc" "poll fresh-mention exit"
421
+ [ "$(jq -r '.in_reply_to' "$home/state/x-inbox/req-f.json")" = "null" ] \
422
+ || fail "a fresh mention must round-trip in_reply_to as null"
423
+ pass "fm-x-poll preserves in_reply_to conversation context in the inbox"
424
+ }
425
+
426
+ test_poll_inbox_commit_failure_reports_error() {
427
+ local home fakebin out rc body
428
+ home="$TMP_ROOT/poll-mv-fail"; mkdir -p "$home"
429
+ fakebin=$(make_fake_curl "$home")
430
+ cat > "$fakebin/mv" <<'SH'
431
+ #!/usr/bin/env bash
432
+ dest=
433
+ for arg in "$@"; do
434
+ dest=$arg
435
+ done
436
+ case "$dest" in
437
+ */x-inbox/*) exit 1 ;;
438
+ esac
439
+ exec /bin/mv "$@"
440
+ SH
441
+ chmod +x "$fakebin/mv"
442
+ printf 'FMX_PAIRING_TOKEN=tok-q\n' > "$home/.env"
443
+ body='{"request_id":"req-rename","tweet_id":"555","author_id":"42","text":"what are you building?"}'
444
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
445
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
446
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
447
+ expect_code 0 "$rc" "poll inbox commit failure exit"
448
+ [ "$out" = "x-mode-error cannot write inbox" ] \
449
+ || fail "poll inbox commit failure must emit an error, not a wake marker (got: $out)"
450
+ assert_absent "$home/state/x-inbox/req-rename.json" "poll must not report a committed inbox file that was not created"
451
+ assert_absent "$home/state/x-inbox/req-rename.json.tmp" "poll must clean up the failed inbox temp file"
452
+ assert_present "$home/state/x-poll.error" "poll inbox commit failure must write a dedupe marker"
453
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
454
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
455
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
456
+ expect_code 0 "$rc" "poll repeated inbox commit failure exit"
457
+ [ -z "$out" ] || fail "repeated poll inbox commit failure must be quiet after the first diagnostic (got: $out)"
458
+ rm -f "$fakebin/mv"
459
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
460
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
461
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
462
+ expect_code 0 "$rc" "poll recovered inbox commit failure exit"
463
+ [ "$out" = "x-mention req-rename" ] \
464
+ || fail "poll must emit the mention marker once the inbox write succeeds (got: $out)"
465
+ assert_absent "$home/state/x-poll.error" "successful inbox write must clear the diagnostic marker"
466
+ pass "fm-x-poll reports inbox commit failures without emitting a mention wake"
467
+ }
468
+
469
+ test_poll_inbox_private_publication_rejects_unsafe_paths() {
470
+ local home fakebin out rc body target dir dest hardlink
471
+ body='{"request_id":"req-x","tweet_id":"555","author_id":"42","text":"what are you building?"}'
472
+
473
+ home="$TMP_ROOT/poll-inbox-linked-dir"; mkdir -p "$home/state" "$home/external"
474
+ fakebin=$(make_fake_curl "$home")
475
+ ln -s "$home/external" "$home/state/x-inbox"
476
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
477
+ FMX_PAIRING_TOKEN=tok-linked FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
478
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
479
+ expect_code 0 "$rc" "poll linked inbox dir exit"
480
+ [ "$out" = "x-mode-error cannot write inbox" ] \
481
+ || fail "poll must report a linked inbox directory as a write failure (got: $out)"
482
+ assert_absent "$home/external/req-x.json" "poll must not write through a linked inbox directory"
483
+ [ -L "$home/state/x-inbox" ] || fail "poll must leave the rejected inbox symlink in place"
484
+
485
+ home="$TMP_ROOT/poll-inbox-public-dir"; mkdir -p "$home/state/x-inbox"
486
+ fakebin=$(make_fake_curl "$home")
487
+ chmod 755 "$home/state/x-inbox"
488
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
489
+ FMX_PAIRING_TOKEN=tok-public FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
490
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
491
+ expect_code 0 "$rc" "poll public inbox dir exit"
492
+ [ "$out" = "x-mode-error cannot write inbox" ] \
493
+ || fail "poll must reject a nonprivate inbox directory (got: $out)"
494
+ assert_absent "$home/state/x-inbox/req-x.json" "poll must not publish into a nonprivate inbox directory"
495
+ assert_no_private_artifact_temps "$home/state/x-inbox"
496
+
497
+ home="$TMP_ROOT/poll-inbox-linked-dest"; mkdir -p "$home/state/x-inbox"
498
+ fakebin=$(make_fake_curl "$home")
499
+ chmod 700 "$home/state/x-inbox"
500
+ target="$home/external-target.json"
501
+ printf 'external sentinel\n' > "$target"
502
+ ln -s "$target" "$home/state/x-inbox/req-x.json"
503
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
504
+ FMX_PAIRING_TOKEN=tok-linkdest FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
505
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
506
+ expect_code 0 "$rc" "poll linked inbox destination exit"
507
+ [ "$out" = "x-mode-error cannot write inbox" ] \
508
+ || fail "poll must reject a linked inbox destination (got: $out)"
509
+ [ "$(cat "$target")" = "external sentinel" ] || fail "poll must not write through a linked inbox destination"
510
+ [ -L "$home/state/x-inbox/req-x.json" ] || fail "poll must not replace a rejected linked destination"
511
+ assert_no_private_artifact_temps "$home/state/x-inbox"
512
+
513
+ home="$TMP_ROOT/poll-inbox-hardlink-dest"; mkdir -p "$home/state/x-inbox"
514
+ fakebin=$(make_fake_curl "$home")
515
+ chmod 700 "$home/state/x-inbox"
516
+ dest="$home/state/x-inbox/req-x.json"
517
+ hardlink="$home/state/x-inbox/req-x.alias"
518
+ printf '{"request_id":"req-x","text":"old"}\n' > "$dest"
519
+ chmod 600 "$dest"
520
+ ln "$dest" "$hardlink"
521
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
522
+ FMX_PAIRING_TOKEN=tok-hard FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
523
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
524
+ expect_code 0 "$rc" "poll hardlinked inbox destination exit"
525
+ [ "$out" = "x-mode-error cannot write inbox" ] \
526
+ || fail "poll must reject a hardlinked inbox destination (got: $out)"
527
+ [ "$(jq -r .text "$dest")" = "old" ] || fail "poll must preserve a rejected hardlinked destination"
528
+ [ "$(jq -r .text "$hardlink")" = "old" ] || fail "poll must preserve the hardlink peer"
529
+ assert_no_private_artifact_temps "$home/state/x-inbox"
530
+
531
+ home="$TMP_ROOT/poll-inbox-private-success"; mkdir -p "$home"
532
+ fakebin=$(make_fake_curl "$home")
533
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
534
+ FMX_PAIRING_TOKEN=tok-ok FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \
535
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
536
+ expect_code 0 "$rc" "poll private inbox success exit"
537
+ [ "$out" = "x-mention req-x" ] || fail "poll must still emit a wake after private publication (got: $out)"
538
+ dir="$home/state/x-inbox"
539
+ [ "$(path_mode "$dir")" = 700 ] || fail "poll must create the inbox directory as private"
540
+ [ "$(path_mode "$dir/req-x.json")" = 600 ] || fail "poll must publish the inbox file as private"
541
+ assert_no_private_artifact_temps "$dir"
542
+ pass "fm-x-poll publishes inbox records only through private guarded artifacts"
543
+ }
544
+
545
+ test_poll_rejects_unsafe_request_id() {
546
+ local home fakebin out rc
547
+ home="$TMP_ROOT/poll-evil"; mkdir -p "$home"
548
+ fakebin=$(make_fake_curl "$home")
549
+ printf 'FMX_PAIRING_TOKEN=tok-e\n' > "$home/.env"
550
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
551
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY='{"request_id":"../../etc/x","text":"hi"}' \
552
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
553
+ expect_code 0 "$rc" "poll unsafe id exit"
554
+ [ -z "$out" ] || fail "poll must not emit a marker for an unsafe request_id (got: $out)"
555
+ assert_absent "$home/state/x-inbox/../../etc/x.json" "poll must not write outside the inbox"
556
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
557
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY='{"request_id":".hidden","text":"hi"}' \
558
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
559
+ expect_code 0 "$rc" "poll hidden id exit"
560
+ [ -z "$out" ] || fail "poll must not emit a marker for a hidden request_id (got: $out)"
561
+ assert_absent "$home/state/x-inbox/.hidden.json" "poll must not stash a hidden inbox file"
562
+ pass "fm-x-poll rejects an unsafe request_id (path-traversal guard)"
563
+ }
564
+
565
+ test_reply_success_posts_request_bound_only() {
566
+ local home fakebin log out rc keys
567
+ home="$TMP_ROOT/reply-ok"; mkdir -p "$home"
568
+ fakebin=$(make_fake_curl "$home")
569
+ log="$home/curl.log"
570
+ printf 'FMX_PAIRING_TOKEN=tok-r\n' > "$home/.env"
571
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
572
+ FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
573
+ "$ROOT/bin/fm-x-reply.sh" "req-7" "Aye, charting a couple of fixes."); rc=$?
574
+ expect_code 0 "$rc" "reply success exit"
575
+ [ "$out" = "req-7" ] || fail "reply must echo only the request_id (got: $out)"
576
+ assert_grep "url=https://relay.test/connector/answer" "$log" "reply must POST /connector/answer"
577
+ assert_grep "method=POST" "$log" "reply must use POST"
578
+ assert_grep "auth=Authorization: Bearer tok-r" "$log" "reply must send the bearer token"
579
+ grep '^argv=' "$log" | grep -F 'tok-r' >/dev/null 2>&1 \
580
+ && fail "reply must not expose the bearer token in curl argv"
581
+ # The body must be exactly {request_id, text} - never a tweet id.
582
+ local data
583
+ data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
584
+ [ "$(printf '%s' "$data" | jq -r .request_id)" = "req-7" ] || fail "reply body request_id"
585
+ [ "$(printf '%s' "$data" | jq -r .text)" = "Aye, charting a couple of fixes." ] || fail "reply body text"
586
+ keys=$(printf '%s' "$data" | jq -r 'keys|join(",")')
587
+ [ "$keys" = "request_id,text" ] || fail "reply body must carry only request_id,text (got: $keys)"
588
+ pass "fm-x-reply posts a request-bound answer and echoes only the request_id"
589
+ }
590
+
591
+ test_reply_non_2xx_fails() {
592
+ local home fakebin out rc err
593
+ home="$TMP_ROOT/reply-500"; mkdir -p "$home"
594
+ fakebin=$(make_fake_curl "$home")
595
+ err="$home/err.txt"
596
+ printf 'FMX_PAIRING_TOKEN=tok-r\n' > "$home/.env"
597
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
598
+ FAKE_ANSWER_CODE=500 \
599
+ "$ROOT/bin/fm-x-reply.sh" "req-7" "hi" 2>"$err"); rc=$?
600
+ [ "$rc" -ne 0 ] || fail "reply must exit non-zero on a non-2xx response"
601
+ assert_grep "HTTP 500" "$err" "reply must report the failing status"
602
+ pass "fm-x-reply exits non-zero on a non-2xx relay response"
603
+ }
604
+
605
+ test_reply_auth_header_tempfile_cleans_up_on_interrupted_post() {
606
+ local home fakebin log out rc auth_file
607
+ home="$TMP_ROOT/reply-auth-interrupt"; mkdir -p "$home"
608
+ fakebin=$(fm_fakebin "$home")
609
+ log="$home/auth-file.txt"
610
+ cat > "$fakebin/curl" <<'SH'
611
+ #!/usr/bin/env bash
612
+ auth_file=
613
+ while [ $# -gt 0 ]; do
614
+ case "$1" in
615
+ -H)
616
+ case "$2" in @*) auth_file=${2#@} ;; esac
617
+ shift 2
618
+ ;;
619
+ -o|-w|-X|-m|--data|--data-binary) shift 2 ;;
620
+ -s) shift ;;
621
+ *) shift ;;
622
+ esac
623
+ done
624
+ printf '%s\n' "$auth_file" > "$FAKE_AUTH_FILE_LOG"
625
+ kill -TERM "$PPID"
626
+ exit 143
627
+ SH
628
+ chmod +x "$fakebin/curl"
629
+ printf 'FMX_PAIRING_TOKEN=tok-clean\n' > "$home/.env"
630
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
631
+ FAKE_AUTH_FILE_LOG="$log" \
632
+ "$ROOT/bin/fm-x-reply.sh" "req-clean" "Hello." 2>"$home/err"); rc=$?
633
+ [ "$rc" -ne 0 ] || fail "interrupted relay post must fail"
634
+ [ -z "$out" ] || fail "interrupted relay post must not echo the request_id (got: $out)"
635
+ auth_file=$(cat "$log")
636
+ [ -n "$auth_file" ] || fail "fake curl must record the auth header temp file"
637
+ [ ! -e "$auth_file" ] || fail "auth header temp file must be removed after an interrupted post"
638
+ pass "fm-x-reply cleans up auth header temp files on interrupted posts"
639
+ }
640
+
641
+ test_reply_usage_error() {
642
+ local home rc err
643
+ home="$TMP_ROOT/reply-usage"; mkdir -p "$home"
644
+ err="$home/err.txt"
645
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-reply.sh" "only-one" >/dev/null 2>"$err"; rc=$?
646
+ expect_code 2 "$rc" "reply usage error exit"
647
+ assert_grep "--image <path>" "$err" "reply usage must mention --image"
648
+ pass "fm-x-reply rejects missing arguments with a usage error"
649
+ }
650
+
651
+ test_reply_help_mentions_image() {
652
+ local home out rc
653
+ home="$TMP_ROOT/reply-help"; mkdir -p "$home"
654
+ out=$(PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-reply.sh" --help); rc=$?
655
+ expect_code 0 "$rc" "reply --help exit"
656
+ assert_contains "$out" "--image <path>" "reply help must mention --image"
657
+ assert_contains "$out" "threaded replies attach it to the opener tweet" \
658
+ "reply help must document thread image placement"
659
+ pass "fm-x-reply --help makes image support discoverable"
660
+ }
661
+
662
+ test_reply_whitespace_text_rejected() {
663
+ local home out rc err
664
+ home="$TMP_ROOT/reply-whitespace"; mkdir -p "$home"
665
+ err="$home/err.txt"
666
+ out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
667
+ "$ROOT/bin/fm-x-reply.sh" "req-space" " " 2>"$err"); rc=$?
668
+ expect_code 2 "$rc" "reply whitespace text exit"
669
+ [ -z "$out" ] || fail "whitespace-only reply must not echo the request_id (got: $out)"
670
+ assert_grep "empty reply text" "$err" "reply must reject whitespace-only text"
671
+ assert_absent "$home/state/x-outbox/req-space.json" "whitespace-only dry-run must not record an outbox preview"
672
+ pass "fm-x-reply rejects whitespace-only reply text"
673
+ }
674
+
675
+ test_bootstrap_activates_on_env_token() {
676
+ local home out sum1 sum2 n
677
+ home="$TMP_ROOT/boot-on"; mkdir -p "$home"
678
+ printf 'FMX_PAIRING_TOKEN=tok-boot\n' > "$home/.env"
679
+ out=$(FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
680
+ assert_contains "$out" "FMX: X mode on" "bootstrap must announce X mode"
681
+ assert_present "$home/state/x-watch.check.sh" "bootstrap must drop the check shim"
682
+ [ -x "$home/state/x-watch.check.sh" ] || fail "the check shim must be executable"
683
+ assert_grep "fm-x-poll.sh" "$home/state/x-watch.check.sh" "the shim must exec the poll script"
684
+ assert_present "$home/config/x-mode.env" "bootstrap must drop the cadence config"
685
+ assert_grep "export FM_CHECK_INTERVAL=30" "$home/config/x-mode.env" "cadence must be 30s"
686
+ # Cadence inheritance: sourcing the config exports the 30s interval to a child,
687
+ # exactly how fm-watch-arm.sh's forked watcher inherits it.
688
+ local inherited
689
+ # shellcheck source=/dev/null
690
+ inherited=$( . "$home/config/x-mode.env" && bash -c 'echo "${FM_CHECK_INTERVAL:-300}"' )
691
+ [ "$inherited" = "30" ] \
692
+ || fail "sourcing the cadence config must export FM_CHECK_INTERVAL=30 to a child"
693
+ # Idempotent: re-running changes nothing and does not duplicate the shim.
694
+ sum1=$(cat "$home/state/x-watch.check.sh" "$home/config/x-mode.env" | shasum)
695
+ FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" >/dev/null 2>&1
696
+ sum2=$(cat "$home/state/x-watch.check.sh" "$home/config/x-mode.env" | shasum)
697
+ [ "$sum1" = "$sum2" ] || fail "bootstrap X-mode setup must be idempotent"
698
+ n=$(find "$home/state" -maxdepth 1 -name 'x-watch*' | wc -l | tr -d ' ')
699
+ [ "$n" = "1" ] || fail "bootstrap must not duplicate the shim (found $n)"
700
+ pass "bootstrap activates X mode from an .env token, idempotently"
701
+ }
702
+
703
+ test_bootstrap_relative_home_writes_absolute_poll_shim() {
704
+ local root home out quoted_home
705
+ root="$TMP_ROOT/boot-relative-home"
706
+ mkdir -p "$root/home" "$root/cdpath/home"
707
+ home=$(cd "$root/home" && pwd -P)
708
+ printf 'FMX_PAIRING_TOKEN=tok-relative\n' > "$home/.env"
709
+ out=$(
710
+ cd "$root" || exit 1
711
+ CDPATH="$root/cdpath" FM_HOME=home "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null
712
+ )
713
+ assert_contains "$out" "FMX: X mode on" "relative-home bootstrap must announce X mode"
714
+ quoted_home=$(printf '%q' "$home")
715
+ assert_grep "export FM_HOME=$quoted_home" "$home/state/x-watch.check.sh" \
716
+ "relative FM_HOME leaked into the durable X-mode poll shim"
717
+ pass "bootstrap ignores CDPATH when writing absolute FM_HOME into the durable X-mode poll shim"
718
+ }
719
+
720
+ test_bootstrap_reports_missing_x_dependency() {
721
+ local home fakebin out tool tool_path
722
+ home="$TMP_ROOT/boot-missing-x"; mkdir -p "$home"
723
+ fakebin=$(fm_fakebin "$home")
724
+ fm_fake_exit0 "$fakebin" tmux node no-mistakes chrome-devtools-axi curl
725
+ fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46
726
+ cat > "$fakebin/gh-axi" <<'SH'
727
+ #!/usr/bin/env bash
728
+ if [ "${1:-}" = --version ]; then
729
+ printf '%s\n' '0.1.29'
730
+ exit 0
731
+ fi
732
+ exit 0
733
+ SH
734
+ chmod +x "$fakebin/gh-axi"
735
+ for tool in dirname grep tail; do
736
+ tool_path=$(command -v "$tool") || fail "test host must provide $tool"
737
+ ln -s "$tool_path" "$fakebin/$tool"
738
+ done
739
+ cat > "$fakebin/gh" <<'SH'
740
+ #!/usr/bin/env bash
741
+ if [ "${1:-}" = auth ] && [ "${2:-}" = status ]; then
742
+ exit 0
743
+ fi
744
+ exit 0
745
+ SH
746
+ chmod +x "$fakebin/gh"
747
+ cat > "$fakebin/treehouse" <<'SH'
748
+ #!/usr/bin/env bash
749
+ if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then
750
+ printf '%s\n' 'Usage: treehouse get [--lease] [--lease-holder <holder>]'
751
+ exit 0
752
+ fi
753
+ exit 0
754
+ SH
755
+ chmod +x "$fakebin/treehouse"
756
+ printf 'FMX_PAIRING_TOKEN=tok-missing\n' > "$home/.env"
757
+ out=$(PATH="$fakebin" FM_HOME="$home" FM_ROOT_OVERRIDE="$home" \
758
+ "$BASH" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
759
+ assert_contains "$out" "MISSING: jq" "bootstrap must report missing jq when X mode is opted in"
760
+ assert_not_contains "$out" "FMX: X mode on" "bootstrap must not announce X mode when a dependency is missing"
761
+ assert_absent "$home/state/x-watch.check.sh" "missing jq must not arm the check shim"
762
+ assert_absent "$home/config/x-mode.env" "missing jq must not write the cadence config"
763
+ pass "bootstrap reports missing X-mode dependencies before arming"
764
+ }
765
+
766
+ test_bootstrap_does_not_announce_when_arm_fails() {
767
+ local home out
768
+ home="$TMP_ROOT/boot-arm-fail"; mkdir -p "$home"
769
+ printf 'FMX_PAIRING_TOKEN=tok-boot\n' > "$home/.env"
770
+ printf '%s\n' 'not a directory' > "$home/config"
771
+ out=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$home/config" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
772
+ assert_contains "$out" "FMX: X mode off - failed to arm relay poll shim or 30s cadence" \
773
+ "bootstrap must report a failed X-mode activation"
774
+ assert_not_contains "$out" "FMX: X mode on" \
775
+ "bootstrap must not announce X mode when the shim or cadence was not armed"
776
+ assert_absent "$home/state/x-watch.check.sh" "failed X-mode activation must not leave an armed shim"
777
+ pass "bootstrap does not report X mode on when activation artifacts cannot be written"
778
+ }
779
+
780
+ test_bootstrap_does_not_follow_x_artifact_symlinks() {
781
+ local home shim_target cadence_target out
782
+ home="$TMP_ROOT/boot-linked-artifacts"
783
+ mkdir -p "$home/state" "$home/config"
784
+ printf 'FMX_PAIRING_TOKEN=tok-linked\n' > "$home/.env"
785
+ shim_target="$home/external-shim"
786
+ cadence_target="$home/external-cadence"
787
+ printf 'external shim sentinel\n' > "$shim_target"
788
+ printf 'external cadence sentinel\n' > "$cadence_target"
789
+ chmod 0640 "$shim_target" "$cadence_target"
790
+ ln -s "$shim_target" "$home/state/x-watch.check.sh"
791
+ ln -s "$cadence_target" "$home/config/x-mode.env"
792
+
793
+ out=$(FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>"$home/bootstrap.err")
794
+
795
+ assert_contains "$out" "FMX: X mode off - failed to arm relay poll shim or 30s cadence" \
796
+ "bootstrap must reject linked X-mode destinations"
797
+ assert_not_contains "$out" "FMX: X mode on" \
798
+ "bootstrap must not announce X mode after rejecting linked destinations"
799
+ [ "$(cat "$shim_target")" = 'external shim sentinel' ] \
800
+ || fail "bootstrap changed the linked shim target"
801
+ [ "$(cat "$cadence_target")" = 'external cadence sentinel' ] \
802
+ || fail "bootstrap changed the linked cadence target"
803
+ [ "$(path_mode "$shim_target")" = 640 ] \
804
+ || fail "bootstrap changed the linked shim target mode"
805
+ [ "$(path_mode "$cadence_target")" = 640 ] \
806
+ || fail "bootstrap changed the linked cadence target mode"
807
+ assert_absent "$home/state/x-watch.check.sh" "bootstrap must remove the rejected shim link"
808
+ assert_absent "$home/config/x-mode.env" "bootstrap must remove the rejected cadence link"
809
+ pass "bootstrap rejects linked X artifacts without touching their targets"
810
+ }
811
+
812
+ test_bootstrap_inert_without_token() {
813
+ local home out
814
+ # No .env at all.
815
+ home="$TMP_ROOT/boot-off"; mkdir -p "$home"
816
+ out=$(FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
817
+ assert_not_contains "$out" "FMX:" "bootstrap must say nothing about X mode without a token"
818
+ assert_absent "$home/state/x-watch.check.sh" "no token -> no check shim"
819
+ assert_absent "$home/config/x-mode.env" "no token -> no cadence config"
820
+ # .env present but token empty -> still off.
821
+ home="$TMP_ROOT/boot-empty"; mkdir -p "$home"
822
+ printf 'FMX_PAIRING_TOKEN=\n' > "$home/.env"
823
+ out=$(FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
824
+ assert_not_contains "$out" "FMX:" "an empty token must be treated as off"
825
+ assert_absent "$home/state/x-watch.check.sh" "empty token -> no check shim"
826
+ pass "bootstrap is inert without a non-empty .env token (non-X users unaffected)"
827
+ }
828
+
829
+ test_poll_empty_text_is_silent() {
830
+ local home fakebin out rc
831
+ home="$TMP_ROOT/poll-empty-text"; mkdir -p "$home"
832
+ fakebin=$(make_fake_curl "$home")
833
+ printf 'FMX_PAIRING_TOKEN=tok-t\n' > "$home/.env"
834
+ # A 200 with a request_id but an empty .text is not an actionable question.
835
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
836
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY='{"request_id":"req-9","text":""}' \
837
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
838
+ expect_code 0 "$rc" "poll empty-text exit"
839
+ [ -z "$out" ] || fail "poll must not emit a marker for an empty question (got: $out)"
840
+ assert_absent "$home/state/x-inbox/req-9.json" "poll must not stash an empty question"
841
+ # Same when .text is missing entirely.
842
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
843
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY='{"request_id":"req-10"}' \
844
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
845
+ expect_code 0 "$rc" "poll missing-text exit"
846
+ [ -z "$out" ] || fail "poll must not emit a marker when .text is absent (got: $out)"
847
+ assert_absent "$home/state/x-inbox/req-10.json" "poll must not stash when .text is absent"
848
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
849
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY='{"request_id":"req-11","text":" \n\t "}' \
850
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
851
+ expect_code 0 "$rc" "poll whitespace-text exit"
852
+ [ -z "$out" ] || fail "poll must not emit a marker for a whitespace-only question (got: $out)"
853
+ assert_absent "$home/state/x-inbox/req-11.json" "poll must not stash a whitespace-only question"
854
+ pass "fm-x-poll requires a non-empty question before waking"
855
+ }
856
+
857
+ test_reply_text_file_and_stdin() {
858
+ local home fakebin log data rc out
859
+ home="$TMP_ROOT/reply-input"; mkdir -p "$home"
860
+ fakebin=$(make_fake_curl "$home")
861
+ printf 'FMX_PAIRING_TOKEN=tok-r\n' > "$home/.env"
862
+ # --text-file: text with shell metacharacters must survive verbatim (no shell
863
+ # expansion) because it never touches a shell command line.
864
+ log="$home/file.log"
865
+ # shellcheck disable=SC2016 # single quotes are deliberate: the metacharacters must stay literal
866
+ printf '%s' 'Aye $(whoami) & "fixes" `now`' > "$home/reply.txt"
867
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
868
+ FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
869
+ "$ROOT/bin/fm-x-reply.sh" "req-1" --text-file "$home/reply.txt"); rc=$?
870
+ expect_code 0 "$rc" "reply --text-file exit"
871
+ [ "$out" = "req-1" ] || fail "reply --text-file must echo only the request_id (got: $out)"
872
+ data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
873
+ # shellcheck disable=SC2016 # single quotes are deliberate: comparing against the literal text
874
+ [ "$(printf '%s' "$data" | jq -r .text)" = 'Aye $(whoami) & "fixes" `now`' ] \
875
+ || fail "reply --text-file must send the text verbatim, unexpanded"
876
+ # stdin form.
877
+ log="$home/stdin.log"
878
+ out=$(printf '%s' 'reply via stdin' | PATH="$fakebin:$BASE_PATH" FM_HOME="$home" \
879
+ FMX_RELAY_URL="https://relay.test" FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
880
+ "$ROOT/bin/fm-x-reply.sh" "req-2" -); rc=$?
881
+ expect_code 0 "$rc" "reply stdin exit"
882
+ data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
883
+ [ "$(printf '%s' "$data" | jq -r .text)" = 'reply via stdin' ] \
884
+ || fail "reply via stdin must send the piped text"
885
+ pass "fm-x-reply accepts the reply via --text-file and stdin (safe, unexpanded)"
886
+ }
887
+
888
+ test_bootstrap_opt_out_cleanup() {
889
+ local home out
890
+ home="$TMP_ROOT/boot-optout"; mkdir -p "$home"
891
+ # Opt in, artifacts appear.
892
+ printf 'FMX_PAIRING_TOKEN=tok-out\n' > "$home/.env"
893
+ FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" >/dev/null 2>&1
894
+ assert_present "$home/state/x-watch.check.sh" "opt-in must create the shim"
895
+ assert_present "$home/config/x-mode.env" "opt-in must create the cadence config"
896
+ # Opt out: empty the token, re-run bootstrap -> artifacts removed + one off line.
897
+ printf 'FMX_PAIRING_TOKEN=\n' > "$home/.env"
898
+ out=$(CLAUDECODE=1 FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
899
+ assert_contains "$out" "FMX: X mode off" "opt-out must announce X mode off when it removed artifacts"
900
+ assert_contains "$out" "watcher supervision needs Stop-owned automatic recovery" "opt-out remediation must use neutral automatic-recovery guidance"
901
+ assert_not_contains "$out" "is broken" "opt-out remediation claimed an unverified mechanism failure"
902
+ assert_not_contains "$out" "bin/fm-watch-arm.sh --restart" "opt-out remediation must not hardcode a background-arm restart"
903
+ assert_absent "$home/state/x-watch.check.sh" "opt-out must remove the shim"
904
+ assert_absent "$home/config/x-mode.env" "opt-out must remove the cadence config"
905
+ # Steady-state off: another run with nothing to remove is silent.
906
+ out=$(FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
907
+ assert_not_contains "$out" "FMX:" "steady-state off must be silent"
908
+ pass "bootstrap cleans up X artifacts on opt-out and is silent once off"
909
+ }
910
+
911
+ test_bootstrap_opt_out_reports_cleanup_failure() {
912
+ local home fakebin out
913
+ home="$TMP_ROOT/boot-optout-fail"; mkdir -p "$home"
914
+ printf 'FMX_PAIRING_TOKEN=tok-out\n' > "$home/.env"
915
+ FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" >/dev/null 2>&1
916
+ assert_present "$home/state/x-watch.check.sh" "opt-in must create the shim before cleanup failure"
917
+ assert_present "$home/config/x-mode.env" "opt-in must create the cadence config before cleanup failure"
918
+ fakebin=$(fm_fakebin "$home")
919
+ cat > "$fakebin/rm" <<'SH'
920
+ #!/usr/bin/env bash
921
+ exit 1
922
+ SH
923
+ chmod +x "$fakebin/rm"
924
+ printf 'FMX_PAIRING_TOKEN=\n' > "$home/.env"
925
+ out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
926
+ assert_contains "$out" "FMX: X mode off - failed to remove relay poll shim or 30s cadence" \
927
+ "opt-out cleanup failure must be reported"
928
+ assert_present "$home/state/x-watch.check.sh" "failed opt-out cleanup must leave the stale shim visible"
929
+ assert_present "$home/config/x-mode.env" "failed opt-out cleanup must leave the stale cadence visible"
930
+ pass "bootstrap reports failed X artifact cleanup on opt-out"
931
+ }
932
+
933
+ test_reply_dry_run_records_not_posts() {
934
+ local home fakebin log out rc
935
+ home="$TMP_ROOT/reply-dry"; mkdir -p "$home"
936
+ fakebin=$(make_fake_curl "$home")
937
+ log="$home/curl.log"
938
+ printf 'FMX_PAIRING_TOKEN=tok-d\n' > "$home/.env"
939
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
940
+ FMX_DRY_RUN=1 FAKE_CURL_LOG="$log" \
941
+ "$ROOT/bin/fm-x-reply.sh" "req-1" "Aye, a couple of fixes underway." 2>"$home/err"); rc=$?
942
+ expect_code 0 "$rc" "dry-run reply exit"
943
+ [ "$out" = "req-1" ] || fail "dry-run must still echo the request_id (got: $out)"
944
+ # It must NOT have posted: the fake curl is never invoked, so no POST is logged.
945
+ [ -f "$log" ] && grep -q "method=POST" "$log" && fail "dry-run must not POST to the relay"
946
+ assert_present "$home/state/x-outbox/req-1.json" "dry-run must record the would-be reply"
947
+ [ "$(jq -r .text "$home/state/x-outbox/req-1.json")" = "Aye, a couple of fixes underway." ] \
948
+ || fail "outbox record must hold the would-be reply text"
949
+ [ "$(jq -r .request_id "$home/state/x-outbox/req-1.json")" = "req-1" ] \
950
+ || fail "outbox record must hold the request_id"
951
+ assert_grep "DRY RUN" "$home/err" "dry-run must surface a DRY RUN summary on stderr"
952
+ pass "fm-x-reply dry-run records the would-be reply and never posts"
953
+ }
954
+
955
+ test_reply_dry_run_needs_no_token() {
956
+ local home out rc
957
+ home="$TMP_ROOT/reply-dry-notoken"; mkdir -p "$home"
958
+ # No token at all: dry-run still previews (it neither authenticates nor posts).
959
+ out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
960
+ "$ROOT/bin/fm-x-reply.sh" "req-2" "preview without creds" 2>/dev/null); rc=$?
961
+ expect_code 0 "$rc" "dry-run no-token exit"
962
+ [ "$out" = "req-2" ] || fail "dry-run without a token must still echo the request_id (got: $out)"
963
+ assert_present "$home/state/x-outbox/req-2.json" "dry-run without a token must still record the preview"
964
+ pass "fm-x-reply dry-run works without a token"
965
+ }
966
+
967
+ test_reply_dry_run_from_env_file() {
968
+ local home fakebin log out rc
969
+ home="$TMP_ROOT/reply-dry-env"; mkdir -p "$home"
970
+ fakebin=$(make_fake_curl "$home")
971
+ log="$home/curl.log"
972
+ # FMX_DRY_RUN read from .env (not just the environment).
973
+ printf 'FMX_PAIRING_TOKEN=tok-d\nFMX_DRY_RUN=1\n' > "$home/.env"
974
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
975
+ FAKE_CURL_LOG="$log" "$ROOT/bin/fm-x-reply.sh" "req-3" "from dotenv" 2>/dev/null); rc=$?
976
+ expect_code 0 "$rc" "dry-run-from-.env exit"
977
+ [ "$out" = "req-3" ] || fail "dry-run from .env must echo the request_id (got: $out)"
978
+ [ -f "$log" ] && grep -q "method=POST" "$log" && fail "dry-run from .env must not POST"
979
+ assert_present "$home/state/x-outbox/req-3.json" "dry-run from .env must record the preview"
980
+ pass "fm-x-reply honors FMX_DRY_RUN from .env"
981
+ }
982
+
983
+ test_reply_empty_env_dry_run_overrides_env_file() {
984
+ local home fakebin log out rc
985
+ home="$TMP_ROOT/reply-dry-empty-env"; mkdir -p "$home"
986
+ fakebin=$(make_fake_curl "$home")
987
+ log="$home/curl.log"
988
+ printf 'FMX_PAIRING_TOKEN=tok-d\nFMX_DRY_RUN=1\n' > "$home/.env"
989
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
990
+ FMX_DRY_RUN='' FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
991
+ "$ROOT/bin/fm-x-reply.sh" "req-5" "empty env disables dry run" 2>/dev/null); rc=$?
992
+ expect_code 0 "$rc" "dry-run empty-env override exit"
993
+ [ "$out" = "req-5" ] || fail "empty dry-run env override must still echo the request_id (got: $out)"
994
+ assert_grep "method=POST" "$log" "empty dry-run env override must post instead of previewing"
995
+ assert_absent "$home/state/x-outbox/req-5.json" "empty dry-run env override must not record an outbox preview"
996
+ pass "fm-x-reply lets an explicitly empty dry-run env override .env"
997
+ }
998
+
999
+ test_reply_dry_run_fails_when_outbox_unwritable() {
1000
+ local home err out rc
1001
+ home="$TMP_ROOT/reply-dry-unwritable"; mkdir -p "$home/state"
1002
+ err="$home/err.txt"
1003
+ printf '%s\n' 'not a directory' > "$home/state/x-outbox"
1004
+ out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
1005
+ "$ROOT/bin/fm-x-reply.sh" "req-4" "preview text" 2>"$err"); rc=$?
1006
+ [ "$rc" -ne 0 ] || fail "dry-run must fail when it cannot record the preview"
1007
+ [ -z "$out" ] || fail "dry-run record failure must not echo the request_id (got: $out)"
1008
+ assert_grep "cannot write dry-run outbox" "$err" "dry-run must explain the outbox failure"
1009
+ pass "fm-x-reply dry-run fails when it cannot record the preview"
1010
+ }
1011
+
1012
+ test_reply_dry_run_outbox_private_publication_rejects_unsafe_paths() {
1013
+ local home out rc err target dest hardlink
1014
+
1015
+ home="$TMP_ROOT/reply-outbox-linked-dir"; mkdir -p "$home/state" "$home/external"
1016
+ err="$home/err.txt"
1017
+ ln -s "$home/external" "$home/state/x-outbox"
1018
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>"$err"); rc=$?
1019
+ [ "$rc" -ne 0 ] || fail "reply dry-run must reject a linked outbox directory"
1020
+ [ -z "$out" ] || fail "rejected linked outbox must not echo the request_id (got: $out)"
1021
+ assert_grep "cannot write dry-run outbox" "$err" "reply dry-run must report the linked outbox write failure"
1022
+ assert_absent "$home/external/req-x.json" "reply dry-run must not write through a linked outbox directory"
1023
+
1024
+ home="$TMP_ROOT/reply-outbox-linked-dest"; mkdir -p "$home/state/x-outbox"
1025
+ err="$home/err.txt"
1026
+ chmod 700 "$home/state/x-outbox"
1027
+ target="$home/external-target.json"
1028
+ printf 'external sentinel\n' > "$target"
1029
+ ln -s "$target" "$home/state/x-outbox/req-x.json"
1030
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>"$err"); rc=$?
1031
+ [ "$rc" -ne 0 ] || fail "reply dry-run must reject a linked outbox destination"
1032
+ [ "$(cat "$target")" = "external sentinel" ] || fail "reply dry-run must not write through a linked outbox destination"
1033
+ [ -L "$home/state/x-outbox/req-x.json" ] || fail "reply dry-run must not replace a rejected linked destination"
1034
+ assert_no_private_artifact_temps "$home/state/x-outbox"
1035
+
1036
+ home="$TMP_ROOT/reply-outbox-hardlink-dest"; mkdir -p "$home/state/x-outbox"
1037
+ err="$home/err.txt"
1038
+ chmod 700 "$home/state/x-outbox"
1039
+ dest="$home/state/x-outbox/req-x.json"
1040
+ hardlink="$home/state/x-outbox/req-x.alias"
1041
+ printf '{"request_id":"req-x","text":"old"}\n' > "$dest"
1042
+ chmod 600 "$dest"
1043
+ ln "$dest" "$hardlink"
1044
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>"$err"); rc=$?
1045
+ [ "$rc" -ne 0 ] || fail "reply dry-run must reject a hardlinked outbox destination"
1046
+ [ "$(jq -r .text "$dest")" = "old" ] || fail "reply dry-run must preserve a rejected hardlinked destination"
1047
+ [ "$(jq -r .text "$hardlink")" = "old" ] || fail "reply dry-run must preserve the hardlink peer"
1048
+ assert_no_private_artifact_temps "$home/state/x-outbox"
1049
+
1050
+ home="$TMP_ROOT/reply-outbox-wrong-mode-dest"; mkdir -p "$home/state/x-outbox"
1051
+ err="$home/err.txt"
1052
+ chmod 700 "$home/state/x-outbox"
1053
+ dest="$home/state/x-outbox/req-x.json"
1054
+ printf '{"request_id":"req-x","text":"old"}\n' > "$dest"
1055
+ chmod 644 "$dest"
1056
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>"$err"); rc=$?
1057
+ [ "$rc" -ne 0 ] || fail "reply dry-run must reject a wrong-mode outbox destination"
1058
+ [ "$(jq -r .text "$dest")" = "old" ] || fail "reply dry-run must preserve a rejected wrong-mode destination"
1059
+ [ "$(path_mode "$dest")" = 644 ] || fail "reply dry-run must leave a rejected wrong-mode destination unchanged"
1060
+ assert_no_private_artifact_temps "$home/state/x-outbox"
1061
+
1062
+ home="$TMP_ROOT/reply-outbox-private-success"; mkdir -p "$home"
1063
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "preview text" 2>/dev/null); rc=$?
1064
+ expect_code 0 "$rc" "reply private outbox success exit"
1065
+ [ "$out" = "req-x" ] || fail "reply dry-run must still echo the request_id after private publication (got: $out)"
1066
+ [ "$(path_mode "$home/state/x-outbox")" = 700 ] || fail "reply dry-run must create the outbox directory as private"
1067
+ [ "$(path_mode "$home/state/x-outbox/req-x.json")" = 600 ] || fail "reply dry-run must publish the outbox file as private"
1068
+ assert_no_private_artifact_temps "$home/state/x-outbox"
1069
+ pass "fm-x-reply dry-run publishes outbox records only through private guarded artifacts"
1070
+ }
1071
+
1072
+ test_split_thread_lib() {
1073
+ # shellcheck source=/dev/null
1074
+ . "$ROOT/bin/fm-x-lib.sh"
1075
+ local out n last rejoin maxlen txt
1076
+ # A reply that fits one tweet stays a single, UNNUMBERED chunk.
1077
+ out=$(printf 'Aye, all shipshape.' | fmx_split_thread 280 25)
1078
+ [ "$(printf '%s' "$out" | jq 'length')" = "1" ] || fail "short reply must be one chunk"
1079
+ [ "$(printf '%s' "$out" | jq -r '.[0]')" = "Aye, all shipshape." ] || fail "short reply must be verbatim and unnumbered"
1080
+ # A long reply splits on word boundaries; every chunk within the limit; lossless.
1081
+ txt="alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november"
1082
+ out=$(printf '%s' "$txt" | fmx_split_thread 30 25)
1083
+ n=$(printf '%s' "$out" | jq 'length')
1084
+ [ "$n" -gt 1 ] || fail "a long reply must split into more than one chunk"
1085
+ maxlen=$(printf '%s' "$out" | jq 'map(length)|max')
1086
+ [ "$maxlen" -le 30 ] || fail "every thread chunk must be within the limit (got max $maxlen)"
1087
+ last=$(printf '%s' "$out" | jq -r '.[0]')
1088
+ case "$last" in *" (1/$n)") : ;; *) fail "chunks must be numbered (k/n): $last" ;; esac
1089
+ rejoin=$(printf '%s' "$out" | jq -r 'map(sub(" \\([0-9]+/[0-9]+\\)$";""))|join(" ")')
1090
+ [ "$rejoin" = "$txt" ] || fail "thread must rejoin losslessly (got: $rejoin)"
1091
+ # A single over-long word is hard-split so no chunk exceeds the limit.
1092
+ out=$(printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' | fmx_split_thread 20 25)
1093
+ [ "$(printf '%s' "$out" | jq 'map(length)|max')" -le 20 ] || fail "over-long word must hard-split within the limit"
1094
+ # The cap bounds the thread; a truncated thread is marked with an ellipsis.
1095
+ out=$(printf 'one two three four five six seven eight nine ten' | fmx_split_thread 20 2)
1096
+ [ "$(printf '%s' "$out" | jq 'length')" -le 2 ] || fail "thread must respect the cap"
1097
+ case "$(printf '%s' "$out" | jq -r '.[-1]')" in *…*) : ;; *) fail "a capped thread must mark truncation" ;; esac
1098
+ txt=$(cat <<'TXT'
1099
+ Intro paragraph has enough words to make the reply split before the fenced block.
1100
+
1101
+ ```bash
1102
+ printf '%s\n' "hello from a fenced block"
1103
+ printf '%s\n' "the marker must not land in here"
1104
+ ```
1105
+
1106
+ Final paragraph also has enough words to make the reply split after the fenced block.
1107
+ TXT
1108
+ )
1109
+ out=$(printf '%s' "$txt" | fmx_split_thread 120 25)
1110
+ [ "$(printf '%s' "$out" | jq 'length')" -gt 1 ] || fail "fenced markdown reply must split"
1111
+ printf '%s' "$out" | jq -e \
1112
+ 'all(.[]; (((gsub(" \\([0-9]+/[0-9]+\\)$"; "") | split("```") | length) - 1) % 2) == 0)' \
1113
+ >/dev/null || fail "thread chunks must not leave an open code fence"
1114
+ printf '%s' "$out" | jq -e \
1115
+ 'any(.[]; contains("```bash\nprintf") and contains("marker must not land in here\"") and contains("\n```"))' \
1116
+ >/dev/null || fail "the fenced code block must stay in one chunk"
1117
+ printf '%s' "$out" | jq -e \
1118
+ 'all(.[] | split("\n")[]; (test("^[[:space:]]*```.* \\([0-9]+/[0-9]+\\)$") | not))' \
1119
+ >/dev/null || fail "numbering markers must not be appended to fenced-code boundary lines"
1120
+ pass "fmx_split_thread: word-boundary, fence-aware, within-limit, numbered, lossless, capped"
1121
+ }
1122
+
1123
+ test_reply_single_no_texts() {
1124
+ local home out
1125
+ home="$TMP_ROOT/reply-single"; mkdir -p "$home"
1126
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-s "Short and sweet." 2>/dev/null)
1127
+ [ "$out" = "req-s" ] || fail "single dry-run must echo the request_id (got: $out)"
1128
+ jq -e 'has("texts")|not' "$home/state/x-outbox/req-s.json" >/dev/null || fail "a one-tweet reply must not include texts"
1129
+ [ "$(jq -r '.text' "$home/state/x-outbox/req-s.json")" = "Short and sweet." ] || fail "single reply text must be verbatim and unnumbered"
1130
+ pass "fm-x-reply keeps a concise reply as a single unnumbered tweet"
1131
+ }
1132
+
1133
+ test_reply_thread_dry_run() {
1134
+ local home out long
1135
+ home="$TMP_ROOT/reply-thread"; mkdir -p "$home"
1136
+ long="The captain has me on a sign-in redirect fix, a docs tidy, and keeping the build green while other jobs run in the background today."
1137
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_X_REPLY_MAX_CHARS=50 \
1138
+ "$ROOT/bin/fm-x-reply.sh" req-t "$long" 2>/dev/null)
1139
+ [ "$out" = "req-t" ] || fail "thread dry-run must echo the request_id (got: $out)"
1140
+ assert_present "$home/state/x-outbox/req-t.json" "thread dry-run must record the outbox preview"
1141
+ jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-t.json" >/dev/null || fail "a long reply must record a texts[] thread"
1142
+ [ "$(jq '.texts|map(length)|max' "$home/state/x-outbox/req-t.json")" -le 50 ] || fail "each thread tweet must be within the limit"
1143
+ [ "$(jq -r '.text' "$home/state/x-outbox/req-t.json")" = "$(jq -r '.texts[0]' "$home/state/x-outbox/req-t.json")" ] || fail "text must equal the first chunk"
1144
+ pass "fm-x-reply auto-splits a long reply into a numbered thread (texts[])"
1145
+ }
1146
+
1147
+ test_reply_discord_inbox_uses_discord_budget() {
1148
+ local home out reply
1149
+ home="$TMP_ROOT/reply-discord-budget"; private_artifact_dir "$home/state/x-inbox"
1150
+ jq -cn '{request_id:"req-discord",tweet_id:"discord:channel:message",text:"question"}' \
1151
+ > "$home/state/x-inbox/req-discord.json"
1152
+ private_artifact_file "$home/state/x-inbox/req-discord.json"
1153
+ reply=$(cat <<'TXT'
1154
+ First paragraph stays intact in a single Discord reply even though it is comfortably over the X tweet budget.
1155
+
1156
+ ```bash
1157
+ printf '%s\n' "the code fence must remain intact"
1158
+ printf '%s\n' "no numbering marker belongs here"
1159
+ ```
1160
+
1161
+ Final paragraph also remains in the same public Discord message because the total is far below the 1900 character split budget.
1162
+ TXT
1163
+ )
1164
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-discord "$reply" 2>/dev/null)
1165
+ [ "$out" = "req-discord" ] || fail "Discord dry-run must echo the request_id (got: $out)"
1166
+ jq -e 'has("texts")|not' "$home/state/x-outbox/req-discord.json" >/dev/null \
1167
+ || fail "Discord reply below its message budget must not be split into texts[]"
1168
+ assert_contains "$(jq -r '.text' "$home/state/x-outbox/req-discord.json")" '```bash' \
1169
+ "Discord reply must preserve the fenced code block"
1170
+ pass "fm-x-reply uses the Discord inbox platform budget instead of the X tweet budget"
1171
+ }
1172
+
1173
+ test_reply_x_inbox_still_uses_x_budget() {
1174
+ local home out long
1175
+ home="$TMP_ROOT/reply-x-budget"; private_artifact_dir "$home/state/x-inbox"
1176
+ jq -cn '{request_id:"req-x",tweet_id:"1234567890",text:"question"}' > "$home/state/x-inbox/req-x.json"
1177
+ private_artifact_file "$home/state/x-inbox/req-x.json"
1178
+ long="This X reply intentionally runs beyond the default tweet budget so it still needs a numbered thread on X. It has enough plain words to cross the limit while staying easy to split at word boundaries without code fences or platform ambiguity. The old default must remain intact for numeric tweet ids."
1179
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-x "$long" 2>/dev/null)
1180
+ [ "$out" = "req-x" ] || fail "X dry-run must echo the request_id (got: $out)"
1181
+ jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-x.json" >/dev/null \
1182
+ || fail "X reply over 280 characters must still split into texts[]"
1183
+ [ "$(jq '.texts|map(length)|max' "$home/state/x-outbox/req-x.json")" -le 280 ] \
1184
+ || fail "X reply chunks must stay within the default X budget"
1185
+ pass "fm-x-reply keeps numeric X requests on the X tweet budget"
1186
+ }
1187
+
1188
+ test_reply_inbox_explicit_limit_wins() {
1189
+ local home out long
1190
+ home="$TMP_ROOT/reply-explicit-limit"; private_artifact_dir "$home/state/x-inbox"
1191
+ jq -cn '{request_id:"req-limit",platform:"discord",reply_max_chars:90,text:"question"}' \
1192
+ > "$home/state/x-inbox/req-limit.json"
1193
+ private_artifact_file "$home/state/x-inbox/req-limit.json"
1194
+ long="Discord normally has a much larger budget, but an explicit relay-provided reply_max_chars value must be honored when the payload carries one."
1195
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-limit "$long" 2>/dev/null)
1196
+ [ "$out" = "req-limit" ] || fail "explicit-limit dry-run must echo the request_id (got: $out)"
1197
+ jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-limit.json" >/dev/null \
1198
+ || fail "explicit reply_max_chars must force a split even on Discord"
1199
+ [ "$(jq '.texts|map(length)|max' "$home/state/x-outbox/req-limit.json")" -le 90 ] \
1200
+ || fail "explicit-limit chunks must stay within the relay-provided budget"
1201
+ pass "fm-x-reply prefers an explicit relay-provided reply limit"
1202
+ }
1203
+
1204
+ test_reply_rejects_unsafe_inbox_context_reads() {
1205
+ local home out rc reply target dest hardlink
1206
+ reply="This reply is intentionally longer than a single X tweet, but shorter than a Discord message. If an unsafe inbox artifact is trusted it will stay one message; if it is rejected it will split at the default X budget, which is the fail-closed behavior for a local lookalike context record."
1207
+
1208
+ home="$TMP_ROOT/reply-inbox-linked-dir"; mkdir -p "$home/state" "$home/external-inbox"
1209
+ jq -cn '{request_id:"req-linked-dir",platform:"discord",reply_max_chars:1900,text:"question"}' \
1210
+ > "$home/external-inbox/req-linked-dir.json"
1211
+ chmod 600 "$home/external-inbox/req-linked-dir.json"
1212
+ ln -s "$home/external-inbox" "$home/state/x-inbox"
1213
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-linked-dir "$reply" 2>/dev/null); rc=$?
1214
+ expect_code 0 "$rc" "reply linked inbox dir read exit"
1215
+ jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-linked-dir.json" >/dev/null \
1216
+ || fail "reply must not trust a linked inbox directory for Discord budget context"
1217
+
1218
+ home="$TMP_ROOT/reply-inbox-linked-file"; private_artifact_dir "$home/state/x-inbox"
1219
+ target="$home/external-inbox-record.json"
1220
+ jq -cn '{request_id:"req-linked-file",platform:"discord",reply_max_chars:1900,text:"question"}' > "$target"
1221
+ ln -s "$target" "$home/state/x-inbox/req-linked-file.json"
1222
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-linked-file "$reply" 2>/dev/null); rc=$?
1223
+ expect_code 0 "$rc" "reply linked inbox file read exit"
1224
+ jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-linked-file.json" >/dev/null \
1225
+ || fail "reply must not trust a linked inbox file for Discord budget context"
1226
+
1227
+ home="$TMP_ROOT/reply-inbox-hardlink-file"; private_artifact_dir "$home/state/x-inbox"
1228
+ dest="$home/state/x-inbox/req-hardlink.json"
1229
+ hardlink="$home/state/x-inbox/req-hardlink.alias"
1230
+ jq -cn '{request_id:"req-hardlink",platform:"discord",reply_max_chars:1900,text:"question"}' > "$dest"
1231
+ private_artifact_file "$dest"
1232
+ ln "$dest" "$hardlink"
1233
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-hardlink "$reply" 2>/dev/null); rc=$?
1234
+ expect_code 0 "$rc" "reply hardlinked inbox file read exit"
1235
+ jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-hardlink.json" >/dev/null \
1236
+ || fail "reply must not trust a hardlinked inbox file for Discord budget context"
1237
+ [ "$(jq -r .platform "$dest")" = "discord" ] || fail "reply must leave a hardlinked inbox lookalike unchanged"
1238
+
1239
+ home="$TMP_ROOT/reply-inbox-public-dir"; mkdir -p "$home/state/x-inbox"
1240
+ chmod 755 "$home/state/x-inbox"
1241
+ jq -cn '{request_id:"req-public-dir",platform:"discord",reply_max_chars:1900,text:"question"}' \
1242
+ > "$home/state/x-inbox/req-public-dir.json"
1243
+ private_artifact_file "$home/state/x-inbox/req-public-dir.json"
1244
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-public-dir "$reply" 2>/dev/null); rc=$?
1245
+ expect_code 0 "$rc" "reply public inbox dir read exit"
1246
+ jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-public-dir.json" >/dev/null \
1247
+ || fail "reply must not trust a nonprivate inbox directory for Discord budget context"
1248
+ pass "fm-x-reply rejects unsafe inbox context artifacts"
1249
+ }
1250
+
1251
+ test_reply_max_chars_floor_clamps_to_minimum() {
1252
+ local home out long
1253
+ home="$TMP_ROOT/reply-max-floor"; mkdir -p "$home"
1254
+ long="alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november"
1255
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_X_REPLY_MAX_CHARS=49 \
1256
+ "$ROOT/bin/fm-x-reply.sh" req-floor "$long" 2>/dev/null)
1257
+ [ "$out" = "req-floor" ] || fail "reply max floor dry-run must echo the request_id (got: $out)"
1258
+ jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-floor.json" >/dev/null || fail "a below-floor max must clamp to 50 and still split"
1259
+ [ "$(jq '.texts|map(length)|max' "$home/state/x-outbox/req-floor.json")" -le 50 ] || fail "clamped thread tweets must be within the 50 character floor"
1260
+ pass "fm-x-reply clamps a below-floor max to 50 characters"
1261
+ }
1262
+
1263
+ test_reply_thread_live_posts_texts() {
1264
+ local home fakebin log out data
1265
+ home="$TMP_ROOT/reply-thread-live"; mkdir -p "$home"
1266
+ fakebin=$(make_fake_curl "$home")
1267
+ log="$home/curl.log"
1268
+ printf 'FMX_PAIRING_TOKEN=tok-th\n' > "$home/.env"
1269
+ # 50 is the configured minimum per-tweet budget; the text is well over it so it
1270
+ # must split into a multi-tweet thread.
1271
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1272
+ FMX_X_REPLY_MAX_CHARS=50 FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
1273
+ "$ROOT/bin/fm-x-reply.sh" req-l "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo")
1274
+ [ "$out" = "req-l" ] || fail "live thread must echo the request_id (got: $out)"
1275
+ assert_grep "method=POST" "$log" "live thread must POST"
1276
+ data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
1277
+ printf '%s' "$data" | jq -e '.texts and (.texts|length>1)' >/dev/null || fail "live thread POST body must carry texts[]"
1278
+ printf '%s' "$data" | jq -e '.text == .texts[0]' >/dev/null || fail "live thread text must equal the first chunk"
1279
+ pass "fm-x-reply posts a thread payload (texts[]) to the relay"
1280
+ }
1281
+
1282
+ test_reply_image_live_posts_image_object() {
1283
+ local home fakebin log out rc data img expected
1284
+ home="$TMP_ROOT/reply-image-live"; mkdir -p "$home"
1285
+ fakebin=$(make_fake_curl "$home")
1286
+ log="$home/curl.log"
1287
+ img="$home/diagram.png"
1288
+ make_sample_image "$img"
1289
+ expected=$(base64 < "$img" | tr -d '\n\r')
1290
+ printf 'FMX_PAIRING_TOKEN=tok-img\n' > "$home/.env"
1291
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1292
+ FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
1293
+ "$ROOT/bin/fm-x-reply.sh" "req-img" --image "$img" "Here is the illustration."); rc=$?
1294
+ expect_code 0 "$rc" "reply image live exit"
1295
+ [ "$out" = "req-img" ] || fail "image reply must echo only the request_id (got: $out)"
1296
+ data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
1297
+ [ "$(printf '%s' "$data" | jq -r '.image.media_type')" = "image/png" ] \
1298
+ || fail "image reply must detect PNG media_type"
1299
+ [ "$(printf '%s' "$data" | jq -r '.image.data_base64')" = "$expected" ] \
1300
+ || fail "image reply must include base64 image bytes"
1301
+ [ "$(printf '%s' "$data" | jq -r '.text')" = "Here is the illustration." ] \
1302
+ || fail "image reply must preserve text"
1303
+ pass "fm-x-reply --image posts an image object on answer"
1304
+ }
1305
+
1306
+ test_reply_image_live_streams_payload_file() {
1307
+ local home fakebin log out rc data img i
1308
+ home="$TMP_ROOT/reply-image-stream"; mkdir -p "$home"
1309
+ fakebin=$(make_fake_curl "$home")
1310
+ log="$home/curl.log"
1311
+ img="$home/large.png"
1312
+ make_sample_image "$img"
1313
+ i=0
1314
+ while [ "$i" -lt 4096 ]; do
1315
+ printf '0123456789abcdef0123456789abcdef' >> "$img"
1316
+ i=$((i + 1))
1317
+ done
1318
+ printf 'FMX_PAIRING_TOKEN=tok-img-stream\n' > "$home/.env"
1319
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1320
+ FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
1321
+ "$ROOT/bin/fm-x-reply.sh" "req-img-stream" --image "$img" "Here is the illustration."); rc=$?
1322
+ expect_code 0 "$rc" "streamed image reply exit"
1323
+ [ "$out" = "req-img-stream" ] || fail "streamed image reply must echo only the request_id (got: $out)"
1324
+ assert_grep "--data-binary @" "$log" "image reply must stream the POST body from a file"
1325
+ grep '^argv=' "$log" | tail -1 | grep -F 'data_base64' >/dev/null 2>&1 \
1326
+ && fail "image reply must not place image JSON in curl argv"
1327
+ data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
1328
+ printf '%s' "$data" | jq -e '.image.media_type == "image/png" and (.image.data_base64 | length > 100000)' >/dev/null \
1329
+ || fail "streamed image reply must still send the base64 image body"
1330
+ pass "fm-x-reply streams large image payloads outside curl argv"
1331
+ }
1332
+
1333
+ test_reply_image_thread_dry_run_records_compact_marker() {
1334
+ local home fakebin log out rc img bytes
1335
+ home="$TMP_ROOT/reply-image-thread-dry"; mkdir -p "$home"
1336
+ fakebin=$(make_fake_curl "$home")
1337
+ log="$home/curl.log"
1338
+ img="$home/illustration.webp"
1339
+ make_sample_image "$img"
1340
+ bytes=$(wc -c < "$img" | tr -d '[:space:]')
1341
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 FMX_X_REPLY_MAX_CHARS=50 \
1342
+ FAKE_CURL_LOG="$log" \
1343
+ "$ROOT/bin/fm-x-reply.sh" "req-img-dry" --image "$img" \
1344
+ "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november" \
1345
+ 2>"$home/err"); rc=$?
1346
+ expect_code 0 "$rc" "reply image dry-run exit"
1347
+ [ "$out" = "req-img-dry" ] || fail "image dry-run must echo the request_id (got: $out)"
1348
+ [ -f "$log" ] && grep -q "method=POST" "$log" && fail "image dry-run must not POST"
1349
+ assert_present "$home/state/x-outbox/req-img-dry.json" "image dry-run must record the preview"
1350
+ jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-img-dry.json" >/dev/null \
1351
+ || fail "image dry-run thread must keep texts[]"
1352
+ [ "$(jq -r '.image.media_type' "$home/state/x-outbox/req-img-dry.json")" = "image/webp" ] \
1353
+ || fail "image dry-run marker must hold media_type"
1354
+ [ "$(jq -r '.image.bytes' "$home/state/x-outbox/req-img-dry.json")" = "$bytes" ] \
1355
+ || fail "image dry-run marker must hold byte count"
1356
+ [ "$(jq -r '.image.source_path' "$home/state/x-outbox/req-img-dry.json")" = "$img" ] \
1357
+ || fail "image dry-run marker must hold source_path"
1358
+ jq -e '.image | has("data_base64") | not' "$home/state/x-outbox/req-img-dry.json" >/dev/null \
1359
+ || fail "image dry-run marker must not include base64 bytes"
1360
+ pass "fm-x-reply dry-run records compact image metadata for threaded replies"
1361
+ }
1362
+
1363
+ test_reply_image_dry_run_cleans_payload_temp_files() {
1364
+ local home tmpdir img out rc leftovers
1365
+ home="$TMP_ROOT/reply-image-temp-clean"; mkdir -p "$home"
1366
+ tmpdir="$home/tmp"; mkdir -p "$tmpdir"
1367
+ img="$home/preview.png"
1368
+ make_sample_image "$img"
1369
+ out=$(PATH="$BASE_PATH" TMPDIR="$tmpdir" FM_HOME="$home" FMX_DRY_RUN=1 \
1370
+ "$ROOT/bin/fm-x-reply.sh" "req-img-temp-clean" --image "$img" "Here is the image." \
1371
+ 2>"$home/err"); rc=$?
1372
+ expect_code 0 "$rc" "reply image temp cleanup exit"
1373
+ [ "$out" = "req-img-temp-clean" ] || fail "image dry-run temp cleanup must echo the request_id (got: $out)"
1374
+ leftovers=$(find "$tmpdir" -type f -name 'fm-x-reply.*' -print)
1375
+ [ -z "$leftovers" ] || fail "reply temp files must be cleaned (left: $leftovers)"
1376
+ pass "fm-x-reply cleans image and payload temp files"
1377
+ }
1378
+
1379
+ test_reply_image_path_errors_are_clear() {
1380
+ local home out rc err img
1381
+ home="$TMP_ROOT/reply-image-errors"; mkdir -p "$home"
1382
+ err="$home/err.txt"
1383
+ out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
1384
+ "$ROOT/bin/fm-x-reply.sh" "req-missing" --image "$home/missing.png" "text" 2>"$err"); rc=$?
1385
+ [ "$rc" -ne 0 ] || fail "missing image path must fail"
1386
+ [ -z "$out" ] || fail "missing image path must not echo the request_id (got: $out)"
1387
+ assert_grep "image file does not exist" "$err" "missing image path must explain the error"
1388
+ img="$home/not-image.txt"
1389
+ printf 'not an image' > "$img"
1390
+ out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
1391
+ "$ROOT/bin/fm-x-reply.sh" "req-badtype" --image "$img" "text" 2>"$err"); rc=$?
1392
+ [ "$rc" -ne 0 ] || fail "unsupported image path must fail"
1393
+ assert_grep "unsupported image media type" "$err" "unsupported image path must explain the error"
1394
+ out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
1395
+ "$ROOT/bin/fm-x-reply.sh" "req-noarg" --image 2>"$err"); rc=$?
1396
+ expect_code 2 "$rc" "missing --image argument exit"
1397
+ assert_grep "missing --image path" "$err" "missing --image argument must explain the error"
1398
+ pass "fm-x-reply --image rejects missing and unsupported image paths clearly"
1399
+ }
1400
+
1401
+ # --- follow-up reply mode (--followup -> /connector/followup) ----------------
1402
+
1403
+ test_reply_followup_live_posts_to_followup_endpoint() {
1404
+ local home fakebin log out rc data keys
1405
+ home="$TMP_ROOT/reply-followup-live"; mkdir -p "$home"
1406
+ fakebin=$(make_fake_curl "$home")
1407
+ log="$home/curl.log"
1408
+ printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
1409
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1410
+ FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
1411
+ FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=200 \
1412
+ "$ROOT/bin/fm-x-reply.sh" "req-7" --followup "Done, captain - the fix has shipped."); rc=$?
1413
+ expect_code 0 "$rc" "followup live exit"
1414
+ [ "$out" = "req-7" ] || fail "followup must echo only the request_id (got: $out)"
1415
+ assert_grep "url=https://relay.test/connector/followup" "$log" "followup must POST /connector/followup"
1416
+ assert_grep "method=POST" "$log" "followup must use POST"
1417
+ assert_grep "auth=Authorization: Bearer tok-fu" "$log" "followup must send the bearer token"
1418
+ # The live body is identical to an answer: {request_id, text}, never a marker.
1419
+ data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
1420
+ keys=$(printf '%s' "$data" | jq -r 'keys|join(",")')
1421
+ [ "$keys" = "request_id,text" ] || fail "followup live body must carry only request_id,text (got: $keys)"
1422
+ [ "$(printf '%s' "$data" | jq -r .request_id)" = "req-7" ] || fail "followup body request_id"
1423
+ pass "fm-x-reply --followup posts to /connector/followup with the same request-bound body"
1424
+ }
1425
+
1426
+ test_reply_followup_409_marker_exits_distinctly() {
1427
+ local home fakebin out rc err
1428
+ home="$TMP_ROOT/reply-followup-409-marker"; mkdir -p "$home"
1429
+ fakebin=$(make_fake_curl "$home")
1430
+ err="$home/err.txt"
1431
+ printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
1432
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1433
+ FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
1434
+ FAKE_FOLLOWUP_CODE=409 FAKE_FOLLOWUP_BODY='{"error":"followup_unavailable"}' \
1435
+ "$ROOT/bin/fm-x-reply.sh" "req-409-marker" --followup "Late follow-up." 2>"$err"); rc=$?
1436
+ expect_code 9 "$rc" "followup 409 marker exit"
1437
+ [ -z "$out" ] || fail "followup 409 marker must not echo the request_id (got: $out)"
1438
+ assert_grep "confirmed followup_unavailable marker" "$err" \
1439
+ "followup 409 marker must be reflected in diagnostics"
1440
+ pass "fm-x-reply maps a followup_unavailable follow-up 409 to exit 9"
1441
+ }
1442
+
1443
+ test_reply_followup_409_without_marker_still_exits_distinctly() {
1444
+ local home fakebin out rc err
1445
+ home="$TMP_ROOT/reply-followup-409-fallback"; mkdir -p "$home"
1446
+ fakebin=$(make_fake_curl "$home")
1447
+ err="$home/err.txt"
1448
+ printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
1449
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1450
+ FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
1451
+ FAKE_FOLLOWUP_CODE=409 \
1452
+ "$ROOT/bin/fm-x-reply.sh" "req-409-bare" --followup "Late follow-up." 2>"$err"); rc=$?
1453
+ expect_code 9 "$rc" "followup bare 409 exit"
1454
+ [ -z "$out" ] || fail "followup bare 409 must not echo the request_id (got: $out)"
1455
+ assert_grep "marker absent" "$err" "bare followup 409 must use the fallback diagnostic"
1456
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1457
+ FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
1458
+ FAKE_FOLLOWUP_CODE=409 FAKE_FOLLOWUP_BODY='{"error":"some_other_conflict"}' \
1459
+ "$ROOT/bin/fm-x-reply.sh" "req-409-other" --followup "Late follow-up." 2>"$err"); rc=$?
1460
+ expect_code 9 "$rc" "followup unrelated-body 409 exit"
1461
+ [ -z "$out" ] || fail "followup unrelated-body 409 must not echo the request_id (got: $out)"
1462
+ assert_grep "marker absent" "$err" \
1463
+ "unrelated followup 409 body must still use the fallback diagnostic"
1464
+ pass "fm-x-reply maps every follow-up 409 to exit 9 even without the marker"
1465
+ }
1466
+
1467
+ test_reply_answer_409_is_generic_failure() {
1468
+ local home fakebin out rc err
1469
+ home="$TMP_ROOT/reply-answer-409"; mkdir -p "$home"
1470
+ fakebin=$(make_fake_curl "$home")
1471
+ err="$home/err.txt"
1472
+ printf 'FMX_PAIRING_TOKEN=tok-answer\n' > "$home/.env"
1473
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1474
+ FAKE_ANSWER_CODE=409 FAKE_ANSWER_BODY='{"error":"followup_unavailable"}' \
1475
+ "$ROOT/bin/fm-x-reply.sh" "req-answer-409" "Normal answer." 2>"$err"); rc=$?
1476
+ expect_code 1 "$rc" "answer 409 exit"
1477
+ [ -z "$out" ] || fail "answer 409 must not echo the request_id (got: $out)"
1478
+ assert_grep "relay returned HTTP 409" "$err" "answer 409 must stay on the generic failure path"
1479
+ pass "fm-x-reply treats answer-endpoint 409 as a generic failure"
1480
+ }
1481
+
1482
+ test_reply_followup_image_live_posts_image_object() {
1483
+ local home fakebin log out rc data img expected
1484
+ home="$TMP_ROOT/reply-followup-image-live"; mkdir -p "$home"
1485
+ fakebin=$(make_fake_curl "$home")
1486
+ log="$home/curl.log"
1487
+ img="$home/result.jpg"
1488
+ make_sample_image "$img"
1489
+ expected=$(base64 < "$img" | tr -d '\n\r')
1490
+ printf 'FMX_PAIRING_TOKEN=tok-fu-img\n' > "$home/.env"
1491
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1492
+ FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
1493
+ FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=200 \
1494
+ "$ROOT/bin/fm-x-reply.sh" "req-fu-img" --followup --image "$img" \
1495
+ "Done - here is the generated image."); rc=$?
1496
+ expect_code 0 "$rc" "followup image live exit"
1497
+ [ "$out" = "req-fu-img" ] || fail "followup image must echo only the request_id (got: $out)"
1498
+ assert_grep "url=https://relay.test/connector/followup" "$log" "image followup must hit followup endpoint"
1499
+ data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
1500
+ [ "$(printf '%s' "$data" | jq -r '.image.media_type')" = "image/jpeg" ] \
1501
+ || fail "image followup must detect JPEG media_type"
1502
+ [ "$(printf '%s' "$data" | jq -r '.image.data_base64')" = "$expected" ] \
1503
+ || fail "image followup must include base64 image bytes"
1504
+ pass "fm-x-reply --followup --image posts an image object"
1505
+ }
1506
+
1507
+ test_reply_followup_flag_position_is_flexible() {
1508
+ local home fakebin log rc out
1509
+ home="$TMP_ROOT/reply-followup-pos"; mkdir -p "$home"
1510
+ fakebin=$(make_fake_curl "$home")
1511
+ printf 'FMX_PAIRING_TOKEN=tok-fp\n' > "$home/.env"
1512
+ printf '%s' 'done via file' > "$home/reply.txt"
1513
+ # --followup AFTER the text source must still select the followup endpoint.
1514
+ log="$home/after.log"
1515
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1516
+ FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
1517
+ FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=200 \
1518
+ "$ROOT/bin/fm-x-reply.sh" "req-a" --text-file "$home/reply.txt" --followup); rc=$?
1519
+ expect_code 0 "$rc" "followup-after-textfile exit"
1520
+ assert_grep "url=https://relay.test/connector/followup" "$log" "--followup after --text-file must still hit followup"
1521
+ # Without --followup, the answer endpoint is unchanged.
1522
+ log="$home/answer.log"
1523
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1524
+ FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \
1525
+ "$ROOT/bin/fm-x-reply.sh" "req-a" --text-file "$home/reply.txt"); rc=$?
1526
+ expect_code 0 "$rc" "answer-still-default exit"
1527
+ assert_grep "url=https://relay.test/connector/answer" "$log" "no flag must keep the answer endpoint"
1528
+ pass "fm-x-reply --followup is accepted in any position and leaves the answer path default"
1529
+ }
1530
+
1531
+ test_reply_followup_dry_run_marks_endpoint() {
1532
+ local home out rc
1533
+ home="$TMP_ROOT/reply-followup-dry"; mkdir -p "$home"
1534
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
1535
+ "$ROOT/bin/fm-x-reply.sh" "req-d" --followup "Shipped - all green." 2>"$home/err"); rc=$?
1536
+ expect_code 0 "$rc" "followup dry-run exit"
1537
+ [ "$out" = "req-d" ] || fail "followup dry-run must echo the request_id (got: $out)"
1538
+ assert_present "$home/state/x-outbox/req-d.json" "followup dry-run must record the preview"
1539
+ [ "$(jq -r '.endpoint' "$home/state/x-outbox/req-d.json")" = "followup" ] \
1540
+ || fail "followup dry-run preview must carry the endpoint marker"
1541
+ [ "$(jq -r '.text' "$home/state/x-outbox/req-d.json")" = "Shipped - all green." ] \
1542
+ || fail "followup dry-run preview must hold the reply text"
1543
+ assert_grep "/connector/followup" "$home/err" "followup dry-run summary must name the followup endpoint"
1544
+ # An answer dry-run must remain unchanged: no endpoint marker.
1545
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" "req-ans" "Aye." 2>/dev/null)
1546
+ jq -e 'has("endpoint")|not' "$home/state/x-outbox/req-ans.json" >/dev/null \
1547
+ || fail "an answer dry-run preview must not gain an endpoint marker"
1548
+ pass "fm-x-reply --followup dry-run marks the endpoint without changing the answer path"
1549
+ }
1550
+
1551
+ test_reply_followup_thread_dry_run() {
1552
+ local home out long
1553
+ home="$TMP_ROOT/reply-followup-thread"; mkdir -p "$home"
1554
+ long="The captain has me on a sign-in redirect fix, a docs tidy, and keeping the build green while other jobs run in the background today."
1555
+ # This test exercises follow-up thread-split + endpoint-marker mechanics for a
1556
+ # fully resolved X follow-up.
1557
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_X_REPLY_MAX_CHARS=50 \
1558
+ FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=50 \
1559
+ "$ROOT/bin/fm-x-reply.sh" req-ft --followup "$long" 2>/dev/null)
1560
+ [ "$out" = "req-ft" ] || fail "followup thread dry-run must echo the request_id (got: $out)"
1561
+ jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-ft.json" >/dev/null \
1562
+ || fail "a long followup must record a texts[] thread"
1563
+ [ "$(jq -r '.endpoint' "$home/state/x-outbox/req-ft.json")" = "followup" ] \
1564
+ || fail "followup thread preview must carry the endpoint marker"
1565
+ [ "$(jq -r '.text' "$home/state/x-outbox/req-ft.json")" = "$(jq -r '.texts[0]' "$home/state/x-outbox/req-ft.json")" ] \
1566
+ || fail "followup thread text must equal the first chunk"
1567
+ pass "fm-x-reply --followup auto-splits a long follow-up into a marked thread"
1568
+ }
1569
+
1570
+ test_reply_followup_image_dry_run_marks_endpoint_and_compacts_image() {
1571
+ local home out rc img
1572
+ home="$TMP_ROOT/reply-followup-image-dry"; mkdir -p "$home"
1573
+ img="$home/result.gif"
1574
+ make_sample_image "$img"
1575
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \
1576
+ "$ROOT/bin/fm-x-reply.sh" "req-fu-img-dry" --followup --image "$img" "Done with art." \
1577
+ 2>"$home/err"); rc=$?
1578
+ expect_code 0 "$rc" "followup image dry-run exit"
1579
+ [ "$out" = "req-fu-img-dry" ] || fail "followup image dry-run must echo the request_id (got: $out)"
1580
+ [ "$(jq -r '.endpoint' "$home/state/x-outbox/req-fu-img-dry.json")" = "followup" ] \
1581
+ || fail "followup image dry-run must carry endpoint marker"
1582
+ [ "$(jq -r '.image.media_type' "$home/state/x-outbox/req-fu-img-dry.json")" = "image/gif" ] \
1583
+ || fail "followup image dry-run must detect GIF media_type"
1584
+ jq -e '.image | has("data_base64") | not' "$home/state/x-outbox/req-fu-img-dry.json" >/dev/null \
1585
+ || fail "followup image dry-run must omit base64 bytes"
1586
+ pass "fm-x-reply followup dry-run keeps endpoint marker and compact image metadata"
1587
+ }
1588
+
1589
+ # --- durable per-request context registry + follow-up platform fail-safe ------
1590
+ # Regression: a Discord milestone follow-up posted DIRECTLY by request_id (no task
1591
+ # link, because one persistent secondmate's single x_request slot was already
1592
+ # taken by a concurrent request) AFTER the inbox was drained silently defaulted to
1593
+ # the X 280-char budget and threaded a >280 Discord reply as "(1/2)". The fix: the
1594
+ # poll records a durable per-request reply-context registry, fm-x-reply resolves
1595
+ # platform/budget through registry -> inbox -> relay, and refuses to split an
1596
+ # unresolved follow-up.
1597
+
1598
+ test_poll_records_context_registry_from_relay_platform() {
1599
+ local home fakebin out rc body reg
1600
+ home="$TMP_ROOT/poll-registry"; mkdir -p "$home"
1601
+ fakebin=$(make_fake_curl "$home")
1602
+ printf 'FMX_PAIRING_TOKEN=tok-reg\n' > "$home/.env"
1603
+ # An explicit Discord mention: the registry must capture platform=discord.
1604
+ body=$(jq -cn '{request_id:"req-disc",platform:"discord",reply_max_chars:1900,text:"question from discord"}')
1605
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1606
+ FMX_NOW_OVERRIDE=1700000000 \
1607
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" "$ROOT/bin/fm-x-poll.sh"); rc=$?
1608
+ expect_code 0 "$rc" "poll discord registry exit"
1609
+ [ "$out" = "x-mention req-disc" ] || fail "poll must still print the wake marker (got: $out)"
1610
+ reg="$home/state/x-context/req-disc.json"
1611
+ assert_present "$reg" "poll must record the durable per-request context"
1612
+ [ "$(jq -r .platform "$reg")" = "discord" ] || fail "registry must capture the Discord platform"
1613
+ [ "$(jq -r .reply_max_chars "$reg")" = "1900" ] || fail "registry must capture the Discord reply budget"
1614
+ [ "$(jq -r .recorded_at "$reg")" = "1700000000" ] || fail "registry must timestamp the context locally"
1615
+ # A numeric-tweet_id X mention: the registry must capture platform=x.
1616
+ body=$(jq -cn '{request_id:"req-x",tweet_id:"1234567890",reply_max_chars:280,text:"question from x"}')
1617
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1618
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" "$ROOT/bin/fm-x-poll.sh"); rc=$?
1619
+ expect_code 0 "$rc" "poll x registry exit"
1620
+ [ "$(jq -r .platform "$home/state/x-context/req-x.json")" = "x" ] \
1621
+ || fail "registry must capture the X platform from a numeric tweet_id"
1622
+ # A mention with no platform signal at all: no useless empty record is written.
1623
+ body=$(jq -cn '{request_id:"req-unk",text:"platformless question"}')
1624
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1625
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" "$ROOT/bin/fm-x-poll.sh"); rc=$?
1626
+ expect_code 0 "$rc" "poll unknown-platform exit"
1627
+ assert_present "$home/state/x-inbox/req-unk.json" "an unknown-platform mention is still stashed"
1628
+ assert_absent "$home/state/x-context/req-unk.json" \
1629
+ "no registry record is written when the platform is unknown (no dead entry)"
1630
+ pass "fm-x-poll records the durable per-request reply context from the relay payload"
1631
+ }
1632
+
1633
+ test_context_registry_private_publication_rejects_unsafe_paths() {
1634
+ local home rc dest hardlink target out
1635
+ # shellcheck source=/dev/null
1636
+ . "$ROOT/bin/fm-x-lib.sh"
1637
+
1638
+ home="$TMP_ROOT/context-linked-dir"; mkdir -p "$home/state" "$home/external"
1639
+ ln -s "$home/external" "$home/state/x-context"
1640
+ fmx_context_registry_set "$home/state" req-x x 280; rc=$?
1641
+ [ "$rc" -ne 0 ] || fail "context registry must reject a linked context directory"
1642
+ assert_absent "$home/external/req-x.json" "context registry must not write through a linked context directory"
1643
+ [ -L "$home/state/x-context" ] || fail "context registry must leave the rejected directory symlink in place"
1644
+
1645
+ home="$TMP_ROOT/context-public-dir"; mkdir -p "$home/state/x-context"
1646
+ chmod 755 "$home/state/x-context"
1647
+ fmx_context_registry_set "$home/state" req-x x 280; rc=$?
1648
+ [ "$rc" -ne 0 ] || fail "context registry must reject a nonprivate context directory"
1649
+ assert_absent "$home/state/x-context/req-x.json" "context registry must not publish into a nonprivate context directory"
1650
+ assert_no_private_artifact_temps "$home/state/x-context"
1651
+
1652
+ home="$TMP_ROOT/context-linked-dest"; mkdir -p "$home/state/x-context"
1653
+ chmod 700 "$home/state/x-context"
1654
+ target="$home/external-target.json"
1655
+ printf 'external sentinel\n' > "$target"
1656
+ ln -s "$target" "$home/state/x-context/req-x.json"
1657
+ fmx_context_registry_set "$home/state" req-x x 280; rc=$?
1658
+ [ "$rc" -ne 0 ] || fail "context registry must reject a linked destination"
1659
+ [ "$(cat "$target")" = "external sentinel" ] || fail "context registry must not write through a linked destination"
1660
+ [ -L "$home/state/x-context/req-x.json" ] || fail "context registry must not replace a rejected linked destination"
1661
+ assert_no_private_artifact_temps "$home/state/x-context"
1662
+
1663
+ home="$TMP_ROOT/context-hardlink-dest"; mkdir -p "$home/state/x-context"
1664
+ chmod 700 "$home/state/x-context"
1665
+ dest="$home/state/x-context/req-x.json"
1666
+ hardlink="$home/state/x-context/req-x.alias"
1667
+ jq -cn '{request_id:"req-x",platform:"x",reply_max_chars:"280",recorded_at:1700000000}' > "$dest"
1668
+ chmod 600 "$dest"
1669
+ ln "$dest" "$hardlink"
1670
+ fmx_context_registry_set "$home/state" req-x discord 1900; rc=$?
1671
+ [ "$rc" -ne 0 ] || fail "context registry must reject a hardlinked destination"
1672
+ [ "$(jq -r .platform "$dest")" = "x" ] || fail "context registry must preserve a rejected hardlinked destination"
1673
+ [ "$(jq -r .platform "$hardlink")" = "x" ] || fail "context registry must preserve the hardlink peer"
1674
+ assert_no_private_artifact_temps "$home/state/x-context"
1675
+
1676
+ home="$TMP_ROOT/context-wrong-mode-dest"; mkdir -p "$home/state/x-context"
1677
+ chmod 700 "$home/state/x-context"
1678
+ dest="$home/state/x-context/req-x.json"
1679
+ jq -cn '{request_id:"req-x",platform:"x",reply_max_chars:"280",recorded_at:1700000000}' > "$dest"
1680
+ chmod 644 "$dest"
1681
+ fmx_context_registry_set "$home/state" req-x discord 1900; rc=$?
1682
+ [ "$rc" -ne 0 ] || fail "context registry must reject a wrong-mode destination"
1683
+ [ "$(jq -r .platform "$dest")" = "x" ] || fail "context registry must preserve a rejected wrong-mode destination"
1684
+ [ "$(path_mode "$dest")" = 644 ] || fail "context registry must leave a rejected wrong-mode destination unchanged"
1685
+ assert_no_private_artifact_temps "$home/state/x-context"
1686
+
1687
+ home="$TMP_ROOT/context-private-success"; mkdir -p "$home"
1688
+ out=$(FMX_NOW_OVERRIDE=1700000000 bash -c '. "$1/bin/fm-x-lib.sh"; fmx_context_registry_set "$2/state" req-x x 280' _ "$ROOT" "$home")
1689
+ rc=$?
1690
+ expect_code 0 "$rc" "context private publication success"
1691
+ [ -z "$out" ] || fail "context registry setter must stay silent on success"
1692
+ [ "$(path_mode "$home/state/x-context")" = 700 ] || fail "context registry must create the context directory as private"
1693
+ [ "$(path_mode "$home/state/x-context/req-x.json")" = 600 ] || fail "context registry must publish the context file as private"
1694
+ assert_no_private_artifact_temps "$home/state/x-context"
1695
+ pass "context registry publishes records only through private guarded artifacts"
1696
+ }
1697
+
1698
+ test_context_registry_rejects_unsafe_reads() {
1699
+ local home out target dest hardlink
1700
+ # shellcheck source=/dev/null
1701
+ . "$ROOT/bin/fm-x-lib.sh"
1702
+
1703
+ home="$TMP_ROOT/context-read-linked-dir"; mkdir -p "$home/state" "$home/external-context"
1704
+ jq -cn '{request_id:"req-linked-dir",platform:"discord",reply_max_chars:"1900",recorded_at:1700000000}' \
1705
+ > "$home/external-context/req-linked-dir.json"
1706
+ chmod 600 "$home/external-context/req-linked-dir.json"
1707
+ ln -s "$home/external-context" "$home/state/x-context"
1708
+ out=$(fmx_context_registry_get "$home/state" req-linked-dir)
1709
+ [ "$(printf '%s' "$out" | jq -r .platform)" = "" ] \
1710
+ || fail "context registry must not read through a linked context directory"
1711
+
1712
+ home="$TMP_ROOT/context-read-linked-file"; private_artifact_dir "$home/state/x-context"
1713
+ target="$home/external-context-record.json"
1714
+ jq -cn '{request_id:"req-linked-file",platform:"discord",reply_max_chars:"1900",recorded_at:1700000000}' > "$target"
1715
+ ln -s "$target" "$home/state/x-context/req-linked-file.json"
1716
+ out=$(fmx_context_registry_get "$home/state" req-linked-file)
1717
+ [ "$(printf '%s' "$out" | jq -r .platform)" = "" ] \
1718
+ || fail "context registry must not read through a linked context file"
1719
+ [ -L "$home/state/x-context/req-linked-file.json" ] \
1720
+ || fail "context registry must not replace a rejected linked context file"
1721
+
1722
+ home="$TMP_ROOT/context-read-hardlink-file"; private_artifact_dir "$home/state/x-context"
1723
+ dest="$home/state/x-context/req-hardlink.json"
1724
+ hardlink="$home/state/x-context/req-hardlink.alias"
1725
+ jq -cn '{request_id:"req-hardlink",platform:"discord",reply_max_chars:"1900",recorded_at:1700000000}' > "$dest"
1726
+ private_artifact_file "$dest"
1727
+ ln "$dest" "$hardlink"
1728
+ out=$(fmx_context_registry_get "$home/state" req-hardlink)
1729
+ [ "$(printf '%s' "$out" | jq -r .platform)" = "" ] \
1730
+ || fail "context registry must not read a hardlinked context file"
1731
+ [ "$(jq -r .platform "$hardlink")" = "discord" ] \
1732
+ || fail "context registry must not rewrite a hardlink peer when rejecting the local record"
1733
+
1734
+ home="$TMP_ROOT/context-read-wrong-mode-file"; private_artifact_dir "$home/state/x-context"
1735
+ dest="$home/state/x-context/req-mode.json"
1736
+ jq -cn '{request_id:"req-mode",platform:"discord",reply_max_chars:"1900",recorded_at:1700000000}' > "$dest"
1737
+ chmod 644 "$dest"
1738
+ out=$(fmx_context_registry_get "$home/state" req-mode)
1739
+ [ "$(printf '%s' "$out" | jq -r .platform)" = "" ] \
1740
+ || fail "context registry must not read a wrong-mode context file"
1741
+ pass "context registry reads only private single-link artifacts"
1742
+ }
1743
+
1744
+ test_private_artifact_publisher_runs_under_system_bash() {
1745
+ local home out rc
1746
+ home="$TMP_ROOT/private-publisher-system-bash"; mkdir -p "$home"
1747
+ [ -x /bin/bash ] || { pass "private artifact publisher compatibility check skipped without /bin/bash"; return 0; }
1748
+ out=$(/bin/bash -c \
1749
+ '. "$1/bin/fm-x-lib.sh"; printf "%s\n" "{\"request_id\":\"req-bash\"}" | fmx_private_artifact_publish_stdin "$2/state/x-outbox" req-bash.json 600' \
1750
+ _ "$ROOT" "$home"); rc=$?
1751
+ expect_code 0 "$rc" "private artifact publisher under /bin/bash"
1752
+ [ -z "$out" ] || fail "private artifact publisher must stay silent under /bin/bash"
1753
+ assert_present "$home/state/x-outbox/req-bash.json" "private artifact publisher must create the artifact under /bin/bash"
1754
+ [ "$(path_mode "$home/state/x-outbox/req-bash.json")" = 600 ] \
1755
+ || fail "private artifact publisher must preserve private file mode under /bin/bash"
1756
+ pass "private artifact publisher is compatible with the system bash path"
1757
+ }
1758
+
1759
+ test_context_registry_prunes_expired_records() {
1760
+ local home dir fakebin keep preserved legacy malformed future out rc
1761
+ home="$TMP_ROOT/registry-retention"
1762
+ dir="$home/state/x-context"
1763
+ private_artifact_dir "$dir"
1764
+ keep="$dir/req-keep.json"
1765
+ preserved="$dir/req-iP49shRy-8ue4dtxEo87Yw.json"
1766
+ legacy="$dir/req-legacy.json"
1767
+ malformed="$dir/req-malformed.json"
1768
+ future="$dir/req-future.json"
1769
+ jq -cn '{request_id:"req-expired",platform:"x",reply_max_chars:"280",recorded_at:1699395199}' \
1770
+ > "$dir/req-expired.json"
1771
+ jq -cn '{request_id:"req-keep",platform:"discord",reply_max_chars:"1900",recorded_at:1699395200}' \
1772
+ > "$keep"
1773
+ jq -cn '{request_id:"req-iP49shRy-8ue4dtxEo87Yw",platform:"x",reply_max_chars:"280",recorded_at:1700000000}' \
1774
+ > "$preserved"
1775
+ jq -cn '{request_id:"req-legacy",platform:"discord",reply_max_chars:"1900"}' > "$legacy"
1776
+ printf '{not-json\n' > "$malformed"
1777
+ jq -cn '{request_id:"req-future",platform:"x",reply_max_chars:"280",recorded_at:"9999999999999999999"}' \
1778
+ > "$future"
1779
+ chmod 600 "$dir/"*.json
1780
+ touch -t 202001010000 "$legacy" "$malformed" "$future"
1781
+ out=$(FMX_NOW_OVERRIDE=1700000000 bash -c \
1782
+ '. "$1/bin/fm-x-lib.sh"; fmx_context_registry_get "$2" req-keep' _ "$ROOT" "$home/state")
1783
+ [ "$(printf '%s' "$out" | jq -r .platform)" = "discord" ] \
1784
+ || fail "a record exactly seven days old must remain usable"
1785
+ assert_absent "$dir/req-expired.json" "a registry record beyond seven days must be pruned"
1786
+ assert_present "$keep" "a registry record at the seven-day boundary must remain"
1787
+ assert_present "$preserved" "the preserved request must remain while it is within the follow-up window"
1788
+ assert_absent "$legacy" "an expired legacy record must be pruned using its file timestamp"
1789
+ assert_absent "$malformed" "an expired malformed record must be pruned using its file timestamp"
1790
+ assert_absent "$future" "an absurd future timestamp must fall back to bounded file age"
1791
+ fakebin=$(make_fake_curl "$home")
1792
+ printf 'FMX_PAIRING_TOKEN=tok-retention\n' > "$home/.env"
1793
+ jq -cn '{request_id:"req-poll-expired",platform:"x",reply_max_chars:"280",recorded_at:1699395199}' \
1794
+ > "$dir/req-poll-expired.json"
1795
+ private_artifact_file "$dir/req-poll-expired.json"
1796
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
1797
+ FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=204 "$ROOT/bin/fm-x-poll.sh"); rc=$?
1798
+ expect_code 0 "$rc" "poll retention sweep exit"
1799
+ [ -z "$out" ] || fail "a 204 poll retention sweep must stay silent (got: $out)"
1800
+ assert_absent "$dir/req-poll-expired.json" "a recurring empty poll must prune expired registry records"
1801
+ jq -cn '{request_id:"req-short-window",platform:"x",reply_max_chars:"280",recorded_at:1699999899}' \
1802
+ > "$dir/req-short-window.json"
1803
+ private_artifact_file "$dir/req-short-window.json"
1804
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
1805
+ FMX_FOLLOWUP_MAX_AGE_SECS=100 FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=204 \
1806
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
1807
+ expect_code 0 "$rc" "short retention window poll exit"
1808
+ assert_absent "$dir/req-short-window.json" "a smaller configured follow-up window must prune earlier"
1809
+ jq -cn '{request_id:"req-overlong-window",platform:"x",reply_max_chars:"280",recorded_at:1699395199}' \
1810
+ > "$dir/req-overlong-window.json"
1811
+ private_artifact_file "$dir/req-overlong-window.json"
1812
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
1813
+ FMX_FOLLOWUP_MAX_AGE_SECS=999999999 FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=204 \
1814
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
1815
+ expect_code 0 "$rc" "capped retention window poll exit"
1816
+ assert_absent "$dir/req-overlong-window.json" "a configured window must not extend retention past seven days"
1817
+ pass "context registry retention is bounded to the seven-day follow-up window"
1818
+ }
1819
+
1820
+ test_context_registry_preserves_first_seen_timestamp() {
1821
+ local home fakebin out rc reg
1822
+ home="$TMP_ROOT/registry-first-seen"
1823
+ mkdir -p "$home"
1824
+ fakebin=$(make_fake_curl "$home")
1825
+ printf 'FMX_PAIRING_TOKEN=tok-first-seen\n' > "$home/.env"
1826
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
1827
+ FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 \
1828
+ FAKE_POLL_BODY='{"request_id":"req-repeat","platform":"x","reply_max_chars":280,"text":"q"}' \
1829
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
1830
+ expect_code 0 "$rc" "first registry poll exit"
1831
+ reg="$home/state/x-context/req-repeat.json"
1832
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000100 \
1833
+ FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 \
1834
+ FAKE_POLL_BODY='{"request_id":"req-repeat","platform":"x","reply_max_chars":280,"text":"q"}' \
1835
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
1836
+ expect_code 0 "$rc" "repeated registry poll exit"
1837
+ [ "$(jq -r .recorded_at "$reg")" = "1700000000" ] \
1838
+ || fail "repeated writes must preserve the request's first-seen timestamp"
1839
+ pass "context registry rewrites preserve the first-seen timestamp"
1840
+ }
1841
+
1842
+ test_context_registry_retention_starts_on_successful_live_answer() {
1843
+ local home fakebin out rc reg
1844
+ home="$TMP_ROOT/registry-answer-window"
1845
+ mkdir -p "$home"
1846
+ fakebin=$(make_fake_curl "$home")
1847
+ printf 'FMX_PAIRING_TOKEN=tok-answer-window\n' > "$home/.env"
1848
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
1849
+ FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 \
1850
+ FAKE_POLL_BODY='{"request_id":"req-answer-window","platform":"discord","reply_max_chars":1900,"text":"q"}' \
1851
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
1852
+ expect_code 0 "$rc" "answer-window poll exit"
1853
+ reg="$home/state/x-context/req-answer-window.json"
1854
+ [ "$(jq -r .recorded_at "$reg")" = "1700000000" ] \
1855
+ || fail "the pending context must start at poll time"
1856
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000100 \
1857
+ FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 \
1858
+ FAKE_POLL_BODY='{"request_id":"req-answer-window","platform":"discord","reply_max_chars":1900,"text":"q"}' \
1859
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
1860
+ expect_code 0 "$rc" "repeated answer-window poll exit"
1861
+ [ "$(jq -r .recorded_at "$reg")" = "1700000000" ] \
1862
+ || fail "repeated polling must not move the pending context window"
1863
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000200 \
1864
+ FMX_RELAY_URL="https://relay.test" FAKE_ANSWER_CODE=500 \
1865
+ "$ROOT/bin/fm-x-reply.sh" req-answer-window "Working on it." 2>/dev/null); rc=$?
1866
+ [ "$rc" -ne 0 ] || fail "the failed answer fixture must fail"
1867
+ [ "$(jq -r .recorded_at "$reg")" = "1700000000" ] \
1868
+ || fail "a failed answer must not refresh context retention"
1869
+ out=$(FM_HOME="$home" FMX_NOW_OVERRIDE=1700000300 FMX_DRY_RUN=1 \
1870
+ "$ROOT/bin/fm-x-reply.sh" req-answer-window "Working on it." 2>/dev/null); rc=$?
1871
+ expect_code 0 "$rc" "answer-window dry-run exit"
1872
+ [ "$(jq -r .recorded_at "$reg")" = "1700000000" ] \
1873
+ || fail "an answer dry-run must not refresh context retention"
1874
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700604900 \
1875
+ FMX_RELAY_URL="https://relay.test" FAKE_ANSWER_CODE=200 \
1876
+ "$ROOT/bin/fm-x-reply.sh" req-answer-window "Working on it."); rc=$?
1877
+ expect_code 0 "$rc" "successful answer-window answer exit"
1878
+ [ "$(jq -r .recorded_at "$reg")" = "1700604900" ] \
1879
+ || fail "a late successful live initial answer must recreate and start the retained follow-up window"
1880
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700605000 \
1881
+ FMX_RELAY_URL="https://relay.test" FAKE_FOLLOWUP_CODE=200 \
1882
+ "$ROOT/bin/fm-x-reply.sh" req-answer-window --followup "Still working."); rc=$?
1883
+ expect_code 0 "$rc" "answer-window follow-up exit"
1884
+ [ "$(jq -r .recorded_at "$reg")" = "1700604900" ] \
1885
+ || fail "a follow-up must not refresh context retention"
1886
+ pass "context retention starts only when a live initial answer succeeds"
1887
+ }
1888
+
1889
+ # Regression case 1: a Discord follow-up >280 but < the Discord budget stays ONE
1890
+ # message even after the inbox is deleted AND posted late by request_id.
1891
+ test_regression_discord_followup_survives_inbox_cleanup() {
1892
+ local home fakebin out rc reply reg
1893
+ home="$TMP_ROOT/reg-discord-cleanup"; mkdir -p "$home"
1894
+ fakebin=$(make_fake_curl "$home")
1895
+ printf 'FMX_PAIRING_TOKEN=tok-rc\n' > "$home/.env"
1896
+ # 1. Poll a Discord mention: it stashes the inbox AND records the registry.
1897
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1898
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$(jq -cn '{request_id:"req-disc",platform:"discord",reply_max_chars:1900,text:"q"}')" \
1899
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
1900
+ expect_code 0 "$rc" "poll exit"
1901
+ reg="$home/state/x-context/req-disc.json"
1902
+ assert_present "$reg" "poll recorded the per-request context"
1903
+ # 2. The acknowledgement drains the inbox file (fmx-respond step 2f).
1904
+ rm -f "$home/state/x-inbox/req-disc.json"
1905
+ # 3. The delayed milestone follow-up is posted DIRECTLY by request_id, with no
1906
+ # task link at all - the exact path that regressed.
1907
+ reply=$(cat <<'TXT'
1908
+ Aye captain, the sign-in redirect is patched and up for review. The fix restores the callback path that was dropping the return URL, adds a regression guard so it cannot silently break again, and keeps the existing session handling untouched. This message deliberately runs well past a single X tweet so it proves a Discord follow-up stays one message after the inbox is gone.
1909
+ TXT
1910
+ )
1911
+ [ "$(printf '%s' "$reply" | wc -m | tr -d '[:space:]')" -gt 280 ] \
1912
+ || fail "the regression reply must exceed the X 280-char budget to be meaningful"
1913
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-disc --followup - <<<"$reply" 2>/dev/null); rc=$?
1914
+ expect_code 0 "$rc" "delayed discord follow-up exit"
1915
+ [ "$out" = "req-disc" ] || fail "follow-up must echo the request_id (got: $out)"
1916
+ jq -e 'has("texts")|not' "$home/state/x-outbox/req-disc.json" >/dev/null \
1917
+ || fail "a >280 <2000 Discord follow-up must post as ONE message after inbox cleanup - NO (1/2) split"
1918
+ pass "a delayed Discord follow-up stays one message after inbox cleanup via the durable registry"
1919
+ }
1920
+
1921
+ # Regression case 2: an X follow-up >280 still splits correctly (not broken).
1922
+ test_regression_x_followup_still_splits_after_cleanup() {
1923
+ local home fakebin out rc reply
1924
+ home="$TMP_ROOT/reg-x-split"; mkdir -p "$home"
1925
+ fakebin=$(make_fake_curl "$home")
1926
+ printf 'FMX_PAIRING_TOKEN=tok-rx\n' > "$home/.env"
1927
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1928
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$(jq -cn '{request_id:"req-xs",tweet_id:"777",reply_max_chars:280,text:"q"}')" \
1929
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
1930
+ expect_code 0 "$rc" "poll x exit"
1931
+ rm -f "$home/state/x-inbox/req-xs.json"
1932
+ reply="This X follow-up intentionally runs well beyond the default single-tweet budget so it still needs a numbered thread on X. It carries enough plain words to comfortably cross the two hundred and eighty character limit while staying easy to split at word boundaries, which proves the established X behavior is not broken by the Discord platform fix at all."
1933
+ [ "$(printf '%s' "$reply" | wc -m | tr -d '[:space:]')" -gt 280 ] \
1934
+ || fail "the X regression reply must exceed 280 chars to force a split"
1935
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-xs --followup - <<<"$reply" 2>/dev/null); rc=$?
1936
+ expect_code 0 "$rc" "delayed x follow-up exit"
1937
+ jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-xs.json" >/dev/null \
1938
+ || fail "an X follow-up over 280 characters must still split into a numbered thread"
1939
+ [ "$(jq '.texts|map(length)|max' "$home/state/x-outbox/req-xs.json")" -le 280 ] \
1940
+ || fail "X follow-up chunks must stay within the X budget"
1941
+ pass "an X follow-up over 280 still splits correctly after inbox cleanup"
1942
+ }
1943
+
1944
+ # Regression case 3: when the platform/budget cannot be authoritatively
1945
+ # determined, a splitting follow-up is REFUSED (fail-safe) - never a silent X
1946
+ # split, never a wrong-platform post.
1947
+ test_regression_unresolved_followup_fails_safe() {
1948
+ local home fakebin log out rc reply err
1949
+ reply="Short follow-up."
1950
+ # (a) Dry-run, nothing resolvable, no relay reachable: refuse, no outbox.
1951
+ home="$TMP_ROOT/reg-failsafe-dry"; mkdir -p "$home"
1952
+ err="$home/err.txt"
1953
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-none --followup - <<<"$reply" 2>"$err"); rc=$?
1954
+ [ "$rc" -eq 8 ] || fail "any unresolved follow-up must exit 8 (fail-safe), got: $rc"
1955
+ [ -z "$out" ] || fail "a refused follow-up must echo nothing (got: $out)"
1956
+ assert_absent "$home/state/x-outbox/req-none.json" "a refused follow-up must record NO outbox preview"
1957
+ assert_grep "refusing follow-up" "$err" "the refusal must be reported plainly"
1958
+ # (b) Live, relay unavailable (404): refuse BEFORE any followup POST.
1959
+ home="$TMP_ROOT/reg-failsafe-live"; mkdir -p "$home"
1960
+ fakebin=$(make_fake_curl "$home")
1961
+ log="$home/curl.log"
1962
+ err="$home/err.txt"
1963
+ printf 'FMX_PAIRING_TOKEN=tok-fs\n' > "$home/.env"
1964
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1965
+ FAKE_CURL_LOG="$log" FAKE_REQCTX_CODE=404 \
1966
+ "$ROOT/bin/fm-x-reply.sh" req-live-none --followup - <<<"$reply" 2>"$err"); rc=$?
1967
+ [ "$rc" -eq 8 ] || fail "a live unresolved follow-up must exit 8 (fail-safe), got: $rc"
1968
+ assert_grep "url=https://relay.test/connector/request-context" "$log" \
1969
+ "the fail-safe must have TRIED the authoritative relay lookup first"
1970
+ assert_no_grep "url=https://relay.test/connector/followup" "$log" \
1971
+ "the fail-safe must refuse BEFORE any follow-up post - no wrong-platform post lands"
1972
+ assert_grep "relay did not supply" "$err" "the refusal must note the relay could not supply both values"
1973
+ pass "every unresolved follow-up is refused before posting"
1974
+ }
1975
+
1976
+ # Requirement 1 (authoritative relay recovery): a live follow-up with only a
1977
+ # registry platform recovers the missing explicit budget from the relay by
1978
+ # request_id, so a Discord reply stays one message.
1979
+ test_followup_partial_registry_uses_relay_budget_live() {
1980
+ local home fakebin log out rc reply data
1981
+ home="$TMP_ROOT/reg-relay-fallback"; private_artifact_dir "$home/state/x-context"
1982
+ fakebin=$(make_fake_curl "$home")
1983
+ log="$home/curl.log"
1984
+ printf 'FMX_PAIRING_TOKEN=tok-rf\n' > "$home/.env"
1985
+ jq -cn '{request_id:"req-relay",platform:"discord",reply_max_chars:""}' \
1986
+ > "$home/state/x-context/req-relay.json"
1987
+ private_artifact_file "$home/state/x-context/req-relay.json"
1988
+ reply=$(cat <<'TXT'
1989
+ Aye captain, that one is shipped and green. The change is landed, the regression guard is in place, and nothing else was disturbed along the way. This confirmation deliberately runs past a single X tweet so it proves the relay-recovered Discord budget keeps it one message.
1990
+ TXT
1991
+ )
1992
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
1993
+ FAKE_CURL_LOG="$log" FAKE_REQCTX_CODE=200 FAKE_REQCTX_BODY='{"reply_max_chars":1900}' \
1994
+ FAKE_FOLLOWUP_CODE=200 \
1995
+ "$ROOT/bin/fm-x-reply.sh" req-relay --followup - <<<"$reply"); rc=$?
1996
+ expect_code 0 "$rc" "live relay-fallback follow-up exit"
1997
+ [ "$out" = "req-relay" ] || fail "relay-fallback follow-up must echo the request_id (got: $out)"
1998
+ assert_grep "url=https://relay.test/connector/request-context" "$log" \
1999
+ "a live follow-up with partial local context must consult the relay for the missing budget"
2000
+ assert_grep "url=https://relay.test/connector/followup" "$log" "it must then post the follow-up"
2001
+ data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
2002
+ printf '%s' "$data" | jq -e 'has("texts")|not' >/dev/null \
2003
+ || fail "the relay-recovered Discord follow-up must post as ONE message, not a thread"
2004
+ pass "a partial registry platform combines with the relay's authoritative budget"
2005
+ }
2006
+
2007
+ # Regression case 4: concurrent requests through one secondmate keep their own
2008
+ # platform/budget - the per-request registry cannot be cross-overwritten the way a
2009
+ # single x_request per task was.
2010
+ test_regression_concurrent_requests_keep_own_platform() {
2011
+ local home fakebin out rc discord_reply x_reply
2012
+ home="$TMP_ROOT/reg-concurrent"; mkdir -p "$home"
2013
+ fakebin=$(make_fake_curl "$home")
2014
+ printf 'FMX_PAIRING_TOKEN=tok-cc\n' > "$home/.env"
2015
+ # Two concurrent public requests arrive (one Discord, one X) - as if routed
2016
+ # through ONE persistent secondmate whose single x_request slot would collide.
2017
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2018
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$(jq -cn '{request_id:"req-cd",platform:"discord",reply_max_chars:1900,text:"q"}')" \
2019
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
2020
+ expect_code 0 "$rc" "poll concurrent discord exit"
2021
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2022
+ FAKE_POLL_CODE=200 FAKE_POLL_BODY="$(jq -cn '{request_id:"req-cx",tweet_id:"888",reply_max_chars:280,text:"q"}')" \
2023
+ "$ROOT/bin/fm-x-poll.sh"); rc=$?
2024
+ expect_code 0 "$rc" "poll concurrent x exit"
2025
+ # Each keeps its OWN context - neither overwrote the other.
2026
+ [ "$(jq -r .platform "$home/state/x-context/req-cd.json")" = "discord" ] \
2027
+ || fail "concurrent Discord request must keep its own platform"
2028
+ [ "$(jq -r .platform "$home/state/x-context/req-cx.json")" = "x" ] \
2029
+ || fail "concurrent X request must keep its own platform"
2030
+ # Both inboxes drained; each delayed follow-up recovers its own budget.
2031
+ rm -f "$home/state/x-inbox/req-cd.json" "$home/state/x-inbox/req-cx.json"
2032
+ discord_reply=$(cat <<'TXT'
2033
+ The Discord one is done, captain - patched, guarded, and up for review. This reply is deliberately over a single X tweet to prove the Discord follow-up recovers its own one-message budget while a concurrent X request is in flight.
2034
+ TXT
2035
+ )
2036
+ x_reply="The X request is progressing on its own track, and this update deliberately runs well beyond the single-tweet budget on purpose, proving that the concurrent X follow-up still threads correctly at the X budget and did not inherit the larger Discord budget from the other in-flight request routed through the same secondmate."
2037
+ [ "$(printf '%s' "$x_reply" | wc -m | tr -d '[:space:]')" -gt 280 ] \
2038
+ || fail "the concurrent X reply must exceed 280 chars to force a split"
2039
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-cd --followup - <<<"$discord_reply" 2>/dev/null); rc=$?
2040
+ expect_code 0 "$rc" "concurrent discord follow-up exit"
2041
+ jq -e 'has("texts")|not' "$home/state/x-outbox/req-cd.json" >/dev/null \
2042
+ || fail "the concurrent Discord follow-up must stay one message"
2043
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" req-cx --followup - <<<"$x_reply" 2>/dev/null); rc=$?
2044
+ expect_code 0 "$rc" "concurrent x follow-up exit"
2045
+ jq -e '.texts and (.texts|length>1)' "$home/state/x-outbox/req-cx.json" >/dev/null \
2046
+ || fail "the concurrent X follow-up must still split - it kept the X budget"
2047
+ pass "concurrent requests each recover their own platform/budget with no cross-overwrite"
2048
+ }
2049
+
2050
+ test_dismiss_clears_context_registry() {
2051
+ local home out rc reg
2052
+ home="$TMP_ROOT/dismiss-clears-registry"; private_artifact_dir "$home/state/x-context"
2053
+ reg="$home/state/x-context/req-dis.json"
2054
+ jq -cn '{request_id:"req-dis",platform:"discord",reply_max_chars:""}' > "$reg"
2055
+ private_artifact_file "$reg"
2056
+ # A dismissed mention will never get a follow-up, so its context is dropped.
2057
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-dismiss.sh" req-dis 2>/dev/null); rc=$?
2058
+ expect_code 0 "$rc" "dismiss registry-clear exit"
2059
+ [ "$out" = "req-dis" ] || fail "dismiss must still echo the request_id (got: $out)"
2060
+ assert_absent "$reg" "dismiss must clear the durable per-request context"
2061
+ pass "fm-x-dismiss clears the durable per-request context (a dismissed mention gets no follow-up)"
2062
+ }
2063
+
2064
+ # --- fm-x-dismiss: drop a mention at the relay without replying ---------------
2065
+
2066
+ test_dismiss_success_posts_request_only() {
2067
+ local home fakebin log out rc data keys
2068
+ home="$TMP_ROOT/dismiss-ok"; mkdir -p "$home"
2069
+ fakebin=$(make_fake_curl "$home")
2070
+ log="$home/curl.log"
2071
+ printf 'FMX_PAIRING_TOKEN=tok-d\n' > "$home/.env"
2072
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2073
+ FAKE_CURL_LOG="$log" FAKE_DISMISS_CODE=200 \
2074
+ "$ROOT/bin/fm-x-dismiss.sh" "req-9"); rc=$?
2075
+ expect_code 0 "$rc" "dismiss success exit"
2076
+ [ "$out" = "req-9" ] || fail "dismiss must echo only the request_id (got: $out)"
2077
+ assert_grep "url=https://relay.test/connector/dismiss" "$log" "dismiss must POST /connector/dismiss"
2078
+ assert_grep "method=POST" "$log" "dismiss must use POST"
2079
+ assert_grep "auth=Authorization: Bearer tok-d" "$log" "dismiss must send the bearer token"
2080
+ grep '^argv=' "$log" | grep -F 'tok-d' >/dev/null 2>&1 \
2081
+ && fail "dismiss must not expose the bearer token in curl argv"
2082
+ # The body must be exactly {request_id} - no text, no tweet id.
2083
+ data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
2084
+ [ "$(printf '%s' "$data" | jq -r .request_id)" = "req-9" ] || fail "dismiss body request_id"
2085
+ keys=$(printf '%s' "$data" | jq -r 'keys|join(",")')
2086
+ [ "$keys" = "request_id" ] || fail "dismiss body must carry only request_id (got: $keys)"
2087
+ pass "fm-x-dismiss posts a request-bound dismiss and echoes only the request_id"
2088
+ }
2089
+
2090
+ test_dismiss_dry_run_records_not_posts() {
2091
+ local home fakebin log out rc
2092
+ home="$TMP_ROOT/dismiss-dry"; mkdir -p "$home"
2093
+ fakebin=$(make_fake_curl "$home")
2094
+ log="$home/curl.log"
2095
+ printf 'FMX_PAIRING_TOKEN=tok-d\n' > "$home/.env"
2096
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2097
+ FMX_DRY_RUN=1 FAKE_CURL_LOG="$log" \
2098
+ "$ROOT/bin/fm-x-dismiss.sh" "req-1" 2>"$home/err"); rc=$?
2099
+ expect_code 0 "$rc" "dry-run dismiss exit"
2100
+ [ "$out" = "req-1" ] || fail "dry-run dismiss must still echo the request_id (got: $out)"
2101
+ # It must NOT have posted: the fake curl is never invoked, so no POST is logged.
2102
+ [ -f "$log" ] && grep -q "method=POST" "$log" && fail "dry-run dismiss must not POST to the relay"
2103
+ assert_present "$home/state/x-outbox/req-1.json" "dry-run dismiss must record the would-be body"
2104
+ [ "$(jq -r .request_id "$home/state/x-outbox/req-1.json")" = "req-1" ] \
2105
+ || fail "dismiss outbox record must hold the request_id"
2106
+ [ "$(jq -r '.endpoint' "$home/state/x-outbox/req-1.json")" = "dismiss" ] \
2107
+ || fail "dismiss dry-run preview must carry the endpoint marker"
2108
+ assert_grep "DRY RUN" "$home/err" "dry-run dismiss must surface a DRY RUN summary on stderr"
2109
+ assert_grep "/connector/dismiss" "$home/err" "dry-run dismiss summary must name the dismiss endpoint"
2110
+ pass "fm-x-dismiss dry-run records the would-be body and never posts"
2111
+ }
2112
+
2113
+ test_dismiss_dry_run_needs_no_token() {
2114
+ local home out rc
2115
+ home="$TMP_ROOT/dismiss-dry-notoken"; mkdir -p "$home"
2116
+ # No token at all: dry-run still previews (it neither authenticates nor posts).
2117
+ out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
2118
+ "$ROOT/bin/fm-x-dismiss.sh" "req-2" 2>/dev/null); rc=$?
2119
+ expect_code 0 "$rc" "dry-run no-token dismiss exit"
2120
+ [ "$out" = "req-2" ] || fail "dry-run dismiss without a token must still echo the request_id (got: $out)"
2121
+ assert_present "$home/state/x-outbox/req-2.json" "dry-run dismiss without a token must still record the preview"
2122
+ pass "fm-x-dismiss dry-run works without a token"
2123
+ }
2124
+
2125
+ test_dismiss_dry_run_outbox_private_publication_rejects_unsafe_paths() {
2126
+ local home out rc err target
2127
+
2128
+ home="$TMP_ROOT/dismiss-outbox-linked-dir"; mkdir -p "$home/state" "$home/external"
2129
+ err="$home/err.txt"
2130
+ ln -s "$home/external" "$home/state/x-outbox"
2131
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-dismiss.sh" req-x 2>"$err"); rc=$?
2132
+ [ "$rc" -ne 0 ] || fail "dismiss dry-run must reject a linked outbox directory"
2133
+ [ -z "$out" ] || fail "rejected dismiss outbox must not echo the request_id (got: $out)"
2134
+ assert_grep "cannot write dry-run outbox" "$err" "dismiss dry-run must report the linked outbox write failure"
2135
+ assert_absent "$home/external/req-x.json" "dismiss dry-run must not write through a linked outbox directory"
2136
+
2137
+ home="$TMP_ROOT/dismiss-outbox-linked-dest"; mkdir -p "$home/state/x-outbox"
2138
+ err="$home/err.txt"
2139
+ chmod 700 "$home/state/x-outbox"
2140
+ target="$home/external-target.json"
2141
+ printf 'external sentinel\n' > "$target"
2142
+ ln -s "$target" "$home/state/x-outbox/req-x.json"
2143
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-dismiss.sh" req-x 2>"$err"); rc=$?
2144
+ [ "$rc" -ne 0 ] || fail "dismiss dry-run must reject a linked outbox destination"
2145
+ [ "$(cat "$target")" = "external sentinel" ] || fail "dismiss dry-run must not write through a linked outbox destination"
2146
+ [ -L "$home/state/x-outbox/req-x.json" ] || fail "dismiss dry-run must not replace a rejected linked destination"
2147
+ assert_no_private_artifact_temps "$home/state/x-outbox"
2148
+
2149
+ home="$TMP_ROOT/dismiss-outbox-private-success"; mkdir -p "$home"
2150
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 "$ROOT/bin/fm-x-dismiss.sh" req-x 2>/dev/null); rc=$?
2151
+ expect_code 0 "$rc" "dismiss private outbox success exit"
2152
+ [ "$out" = "req-x" ] || fail "dismiss dry-run must still echo the request_id after private publication (got: $out)"
2153
+ [ "$(path_mode "$home/state/x-outbox")" = 700 ] || fail "dismiss dry-run must create the outbox directory as private"
2154
+ [ "$(path_mode "$home/state/x-outbox/req-x.json")" = 600 ] || fail "dismiss dry-run must publish the outbox file as private"
2155
+ assert_no_private_artifact_temps "$home/state/x-outbox"
2156
+ pass "fm-x-dismiss dry-run publishes outbox records only through private guarded artifacts"
2157
+ }
2158
+
2159
+ test_dismiss_non_2xx_fails() {
2160
+ local home fakebin out rc err
2161
+ home="$TMP_ROOT/dismiss-500"; mkdir -p "$home"
2162
+ fakebin=$(make_fake_curl "$home")
2163
+ err="$home/err.txt"
2164
+ printf 'FMX_PAIRING_TOKEN=tok-d\n' > "$home/.env"
2165
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2166
+ FAKE_DISMISS_CODE=500 \
2167
+ "$ROOT/bin/fm-x-dismiss.sh" "req-9" 2>"$err"); rc=$?
2168
+ [ "$rc" -ne 0 ] || fail "dismiss must exit non-zero on a non-2xx response"
2169
+ [ -z "$out" ] || fail "a failed dismiss must not echo the request_id (got: $out)"
2170
+ assert_grep "HTTP 500" "$err" "dismiss must report the failing status"
2171
+ pass "fm-x-dismiss exits non-zero on a non-2xx relay response"
2172
+ }
2173
+
2174
+ test_dismiss_transport_failure_fails() {
2175
+ local home fakebin err out rc
2176
+ home="$TMP_ROOT/dismiss-transport"; mkdir -p "$home"
2177
+ fakebin=$(fm_fakebin "$home")
2178
+ # A curl that fails to reach the relay (non-zero exit, no HTTP code).
2179
+ cat > "$fakebin/curl" <<'SH'
2180
+ #!/usr/bin/env bash
2181
+ exit 7
2182
+ SH
2183
+ chmod +x "$fakebin/curl"
2184
+ err="$home/err.txt"
2185
+ printf 'FMX_PAIRING_TOKEN=tok-d\n' > "$home/.env"
2186
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2187
+ "$ROOT/bin/fm-x-dismiss.sh" "req-9" 2>"$err"); rc=$?
2188
+ [ "$rc" -ne 0 ] || fail "dismiss must exit non-zero on a transport failure"
2189
+ [ -z "$out" ] || fail "a transport-failed dismiss must not echo the request_id (got: $out)"
2190
+ assert_grep "request to relay failed" "$err" "dismiss must report the transport failure"
2191
+ pass "fm-x-dismiss exits non-zero on a transport failure"
2192
+ }
2193
+
2194
+ test_dismiss_unsafe_request_id_rejected() {
2195
+ local home err out rc
2196
+ home="$TMP_ROOT/dismiss-unsafe"; mkdir -p "$home"
2197
+ err="$home/err.txt"
2198
+ # Path-traversal-shaped id must be refused before it becomes an outbox filename.
2199
+ out=$(PATH="$BASE_PATH" FM_HOME="$home" FMX_DRY_RUN=1 \
2200
+ "$ROOT/bin/fm-x-dismiss.sh" "../evil" 2>"$err"); rc=$?
2201
+ expect_code 2 "$rc" "dismiss unsafe id exit"
2202
+ [ -z "$out" ] || fail "dismiss must not echo an unsafe request_id (got: $out)"
2203
+ assert_grep "unsafe request_id" "$err" "dismiss must reject an unsafe request_id"
2204
+ assert_absent "$home/state/../evil.json" "dismiss must not touch a path for an unsafe id"
2205
+ pass "fm-x-dismiss rejects an unsafe request_id (path-traversal guard)"
2206
+ }
2207
+
2208
+ test_dismiss_usage_error() {
2209
+ local home rc
2210
+ home="$TMP_ROOT/dismiss-usage"; mkdir -p "$home"
2211
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-dismiss.sh" >/dev/null 2>&1; rc=$?
2212
+ expect_code 2 "$rc" "dismiss missing-arg usage exit"
2213
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-dismiss.sh" req-1 extra >/dev/null 2>&1; rc=$?
2214
+ expect_code 2 "$rc" "dismiss extra-arg usage exit"
2215
+ pass "fm-x-dismiss rejects missing or extra arguments with a usage error"
2216
+ }
2217
+
2218
+ # --- fm-x-link: task <-> X-request association in meta -----------------------
2219
+
2220
+ test_link_records_request_and_timestamp() {
2221
+ local home meta out rc
2222
+ home="$TMP_ROOT/link-ok"; mkdir -p "$home/state"
2223
+ meta="$home/state/fix-login-k3.meta"
2224
+ printf 'window=w\nworktree=/wt\nkind=ship\nmode=no-mistakes\nyolo=off\n' > "$meta"
2225
+ # No inbox and no relay reachable here: this test pins the request/timestamp
2226
+ # recording, not platform resolution, so fm-x-link's no-platform warning to
2227
+ # stderr is expected and dropped.
2228
+ out=$(FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
2229
+ "$ROOT/bin/fm-x-link.sh" fix-login-k3 req-42 2>/dev/null); rc=$?
2230
+ expect_code 0 "$rc" "link exit"
2231
+ assert_grep "x_request=req-42" "$meta" "link must record the request_id"
2232
+ assert_grep "x_request_ts=1700000000" "$meta" "link must record the timestamp"
2233
+ assert_grep "x_followups=0" "$meta" "a fresh link must start the follow-up counter at 0"
2234
+ assert_grep "kind=ship" "$meta" "link must preserve other meta lines"
2235
+ assert_grep "yolo=off" "$meta" "link must preserve other meta lines"
2236
+ # Re-linking replaces the prior link rather than appending a duplicate.
2237
+ FM_HOME="$home" FMX_NOW_OVERRIDE=1700009999 "$ROOT/bin/fm-x-link.sh" fix-login-k3 req-99 >/dev/null 2>&1
2238
+ [ "$(grep -c '^x_request=' "$meta")" = "1" ] || fail "re-link must not duplicate x_request"
2239
+ [ "$(grep -c '^x_request_ts=' "$meta")" = "1" ] || fail "re-link must not duplicate x_request_ts"
2240
+ [ "$(grep -c '^x_followups=' "$meta")" = "1" ] || fail "re-link must not duplicate x_followups"
2241
+ assert_grep "x_request=req-99" "$meta" "re-link must replace the request_id"
2242
+ assert_grep "x_request_ts=1700009999" "$meta" "re-link must refresh the timestamp"
2243
+ assert_grep "x_followups=0" "$meta" "a plain re-link must reset the follow-up counter to 0"
2244
+ pass "fm-x-link records and refreshes the X-request link without disturbing meta"
2245
+ }
2246
+
2247
+ test_link_records_discord_platform_for_followups() {
2248
+ local home meta out rc reply
2249
+ home="$TMP_ROOT/link-discord-platform"; private_artifact_dir "$home/state/x-inbox"
2250
+ meta="$home/state/fix-discord.meta"
2251
+ printf 'window=w\nworktree=/wt\nkind=ship\nmode=no-mistakes\nyolo=off\n' > "$meta"
2252
+ jq -cn '{request_id:"req-discord-follow",tweet_id:"discord:channel:message",reply_max_chars:1900,text:"question"}' \
2253
+ > "$home/state/x-inbox/req-discord-follow.json"
2254
+ private_artifact_file "$home/state/x-inbox/req-discord-follow.json"
2255
+ FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
2256
+ "$ROOT/bin/fm-x-link.sh" fix-discord req-discord-follow >/dev/null; rc=$?
2257
+ expect_code 0 "$rc" "Discord link exit"
2258
+ assert_grep "x_platform=discord" "$meta" "link must record Discord platform context"
2259
+ assert_grep "x_reply_max_chars=1900" "$meta" "link must record the Discord split budget for follow-ups"
2260
+ rm -f "$home/state/x-inbox/req-discord-follow.json"
2261
+ reply=$(cat <<'TXT'
2262
+ The follow-up is longer than an X tweet but should stay in one Discord message because the linked task meta recorded the platform before the inbox was drained.
2263
+
2264
+ ```bash
2265
+ printf '%s\n' "this fenced block should stay whole"
2266
+ ```
2267
+
2268
+ The final sentence confirms that the follow-up path did not fall back to the X budget after the inbox file disappeared.
2269
+ TXT
2270
+ )
2271
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 \
2272
+ "$ROOT/bin/fm-x-followup.sh" fix-discord - <<<"$reply" 2>/dev/null); rc=$?
2273
+ expect_code 0 "$rc" "Discord follow-up dry-run exit"
2274
+ [ "$out" = "req-discord-follow" ] || fail "Discord follow-up must echo the request_id (got: $out)"
2275
+ jq -e 'has("texts")|not' "$home/state/x-outbox/req-discord-follow.json" >/dev/null \
2276
+ || fail "Discord follow-up below its message budget must not split after inbox drain"
2277
+ pass "fm-x-link records Discord platform context so follow-ups keep the Discord budget"
2278
+ }
2279
+
2280
+ # Regression (2026-07-10 incident): a ~470-char Discord follow-up posted as a
2281
+ # (1/2)(2/2) thread because the link was recorded AFTER the ack reply drained the
2282
+ # inbox file, so the platform was lost and the splitter defaulted to X's 280-char
2283
+ # budget. The fix resolves the platform AUTHORITATIVELY from the relay by
2284
+ # request_id, so this ordering no longer loses it: the follow-up posts as ONE
2285
+ # message even though the inbox file is already gone at link time.
2286
+ test_link_resolves_platform_by_request_id_after_inbox_cleanup() {
2287
+ local home fakebin log meta out rc reply
2288
+ home="$TMP_ROOT/link-relay-lookup"; mkdir -p "$home/state"
2289
+ fakebin=$(make_fake_curl "$home")
2290
+ log="$home/curl.log"
2291
+ meta="$home/state/fix-after-cleanup.meta"
2292
+ printf 'window=w\nworktree=/wt\nkind=ship\nmode=no-mistakes\nyolo=off\n' > "$meta"
2293
+ printf 'FMX_PAIRING_TOKEN=tok-reqctx\n' > "$home/.env"
2294
+ # No inbox file at all: the ack reply already cleaned it up before the link.
2295
+ # The relay resolves the platform by request_id.
2296
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2297
+ FMX_NOW_OVERRIDE=1700000000 FAKE_CURL_LOG="$log" \
2298
+ FAKE_REQCTX_CODE=200 FAKE_REQCTX_BODY='{"platform":"discord","reply_max_chars":1900}' \
2299
+ "$ROOT/bin/fm-x-link.sh" fix-after-cleanup req-after-cleanup); rc=$?
2300
+ expect_code 0 "$rc" "link after inbox cleanup exit"
2301
+ assert_grep "url=https://relay.test/connector/request-context" "$log" \
2302
+ "link must resolve the platform authoritatively by request_id when the inbox is gone"
2303
+ grep '^data=' "$log" | tail -1 | sed 's/^data=//' | jq -e '.request_id == "req-after-cleanup"' >/dev/null \
2304
+ || fail "the relay lookup must send the request_id in the body"
2305
+ assert_grep "x_platform=discord" "$meta" "relay lookup must record the Discord platform after inbox cleanup"
2306
+ assert_grep "x_reply_max_chars=1900" "$meta" "relay lookup must record the Discord split budget after inbox cleanup"
2307
+ # The follow-up (still with the inbox gone) must post the ~470-char reply as ONE
2308
+ # Discord message, not an X-length thread.
2309
+ reply=$(cat <<'TXT'
2310
+ Aye captain, the sign-in redirect is patched and the change is up for review. The fix restores the callback path that was dropping the return URL, adds a regression guard so it cannot silently break again, and keeps the existing session handling untouched. This message is deliberately longer than a single X tweet so the test proves a Discord follow-up stays in one message instead of splitting into a numbered thread.
2311
+ TXT
2312
+ )
2313
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 \
2314
+ "$ROOT/bin/fm-x-followup.sh" fix-after-cleanup - <<<"$reply" 2>/dev/null); rc=$?
2315
+ expect_code 0 "$rc" "Discord follow-up after relay lookup exit"
2316
+ [ "$out" = "req-after-cleanup" ] || fail "follow-up must echo the request_id (got: $out)"
2317
+ [ "$(printf '%s' "$reply" | wc -m | tr -d '[:space:]')" -gt 280 ] \
2318
+ || fail "the regression reply must exceed the X 280-char budget to be meaningful"
2319
+ jq -e 'has("texts")|not' "$home/state/x-outbox/req-after-cleanup.json" >/dev/null \
2320
+ || fail "a >280 <2000 Discord follow-up must post as ONE message even when linked after inbox cleanup"
2321
+ pass "fm-x-link resolves the platform by request_id so a post-cleanup link keeps the Discord budget"
2322
+ }
2323
+
2324
+ # Criterion 2 loud-warning branch: when the inbox and relay cannot resolve both
2325
+ # axes, the link is still recorded but fm-x-link warns loudly and every follow-up
2326
+ # is refused.
2327
+ test_link_warns_loudly_when_platform_unresolvable() {
2328
+ local home fakebin err meta out rc reply
2329
+ home="$TMP_ROOT/link-warn-unresolvable"; mkdir -p "$home/state"
2330
+ fakebin=$(make_fake_curl "$home")
2331
+ err="$home/err.txt"
2332
+ meta="$home/state/fix-unresolvable.meta"
2333
+ printf 'window=w\nkind=ship\n' > "$meta"
2334
+ printf 'FMX_PAIRING_TOKEN=tok-unresolved\n' > "$home/.env"
2335
+ # No inbox, and the relay cannot resolve the request (404).
2336
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2337
+ FMX_NOW_OVERRIDE=1700000000 FAKE_REQCTX_CODE=404 \
2338
+ "$ROOT/bin/fm-x-link.sh" fix-unresolvable req-unresolvable 2>"$err"); rc=$?
2339
+ expect_code 0 "$rc" "link with unresolvable platform still records the link"
2340
+ [ "$out" = "linked fix-unresolvable to X request req-unresolvable" ] \
2341
+ || fail "link must still succeed on stdout even when the platform is unknown (got: $out)"
2342
+ assert_grep "WARNING" "$err" "an unresolvable platform must warn loudly, never silently default to X"
2343
+ assert_grep "req-unresolvable" "$err" "the warning must name the request it could not resolve"
2344
+ assert_grep "x_request=req-unresolvable" "$meta" "the link itself must still be recorded"
2345
+ assert_no_grep "x_platform=" "$meta" "no platform must be recorded when none could be resolved"
2346
+ assert_no_grep "x_reply_max_chars=" "$meta" "no split budget must be recorded when none could be resolved"
2347
+ reply="Short follow-up."
2348
+ err="$home/fu-err.txt"
2349
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 \
2350
+ "$ROOT/bin/fm-x-followup.sh" fix-unresolvable - <<<"$reply" 2>"$err"); rc=$?
2351
+ [ "$rc" -ne 0 ] || fail "an unresolvable follow-up must be held (non-zero), not posted"
2352
+ [ -z "$out" ] || fail "a held follow-up must not echo the request_id (got: $out)"
2353
+ assert_absent "$home/state/x-outbox/req-unresolvable.json" \
2354
+ "a refused follow-up must leave no outbox preview - nothing was posted or split"
2355
+ assert_grep "held" "$err" "the hold must be reported plainly for retry"
2356
+ assert_grep "x_request=req-unresolvable" "$meta" "a held follow-up must leave the link in place to retry"
2357
+ pass "fm-x-link warns loudly and the follow-up is held (not wrongly split) when the platform is unknown"
2358
+ }
2359
+
2360
+ test_link_carry_count_and_ts_preserve_followup_binding() {
2361
+ local home meta rc
2362
+ home="$TMP_ROOT/link-carry"; mkdir -p "$home/state"
2363
+ meta="$home/state/successor-task.meta"
2364
+ printf 'window=w\nkind=ship\n' > "$meta"
2365
+ FM_HOME="$home" FMX_NOW_OVERRIDE=1700999999 \
2366
+ "$ROOT/bin/fm-x-link.sh" successor-task req-carry \
2367
+ --carry-count 2 --carry-ts 1700000000 --carry-platform x --carry-max 280 >/dev/null; rc=$?
2368
+ expect_code 0 "$rc" "link paired carry flags exit"
2369
+ assert_grep "x_request=req-carry" "$meta" "carried link must record the request_id"
2370
+ assert_grep "x_request_ts=1700000000" "$meta" "--carry-ts must preserve the original timestamp, not the current time"
2371
+ assert_grep "x_followups=2" "$meta" "--carry-count must seed the follow-up counter, not reset it"
2372
+ assert_grep "x_platform=x" "$meta" "--carry-platform must preserve the prior reply platform"
2373
+ assert_grep "x_reply_max_chars=280" "$meta" "--carry-max must preserve the prior split budget"
2374
+ pass "fm-x-link paired carry flags preserve a prior task's follow-up binding onto a successor"
2375
+ }
2376
+
2377
+ test_link_recovery_relink_carries_discord_context_after_inbox_drain() {
2378
+ local home meta out rc reply
2379
+ home="$TMP_ROOT/link-carry-discord"; mkdir -p "$home/state"
2380
+ meta="$home/state/successor-discord.meta"
2381
+ printf 'window=w\nkind=ship\n' > "$meta"
2382
+ FM_HOME="$home" FMX_NOW_OVERRIDE=1700999999 \
2383
+ "$ROOT/bin/fm-x-link.sh" successor-discord req-discord-recovery \
2384
+ --carry-count 1 --carry-ts 1700000000 --carry-platform discord --carry-max 1900 >/dev/null; rc=$?
2385
+ expect_code 0 "$rc" "Discord recovery relink exit"
2386
+ assert_grep "x_platform=discord" "$meta" "Discord recovery relink must preserve the platform after inbox drain"
2387
+ assert_grep "x_reply_max_chars=1900" "$meta" "Discord recovery relink must preserve the split budget after inbox drain"
2388
+ reply=$(cat <<'TXT'
2389
+ The recovered task is reporting back with enough text to exceed an X tweet, but it is still comfortably within the Discord budget carried over from the prior task.
2390
+
2391
+ ```bash
2392
+ printf '%s\n' "the code fence should not force an unnecessary Discord split"
2393
+ ```
2394
+
2395
+ The successor task must post this as one Discord follow-up even though the original inbox payload has already been drained.
2396
+ TXT
2397
+ )
2398
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 \
2399
+ "$ROOT/bin/fm-x-followup.sh" successor-discord - <<<"$reply" 2>/dev/null); rc=$?
2400
+ expect_code 0 "$rc" "Discord recovery follow-up dry-run exit"
2401
+ [ "$out" = "req-discord-recovery" ] || fail "Discord recovery follow-up must echo the request_id (got: $out)"
2402
+ jq -e 'has("texts")|not' "$home/state/x-outbox/req-discord-recovery.json" >/dev/null \
2403
+ || fail "Discord recovery follow-up below its message budget must not fall back to X splitting"
2404
+ assert_grep "x_followups=2" "$meta" "Discord recovery follow-up must increment the carried count"
2405
+ pass "fm-x-link recovery relink preserves Discord platform context after inbox drain"
2406
+ }
2407
+
2408
+ test_link_carry_count_validation() {
2409
+ local home rc err
2410
+ home="$TMP_ROOT/link-carry-bad"; mkdir -p "$home/state"
2411
+ err="$home/err.txt"
2412
+ printf 'window=w\nkind=ship\n' > "$home/state/ok.meta"
2413
+ PATH="$BASE_PATH" FM_HOME="$home" \
2414
+ "$ROOT/bin/fm-x-link.sh" ok req-1 --carry-count abc >/dev/null 2>"$err"; rc=$?
2415
+ expect_code 2 "$rc" "link --carry-count non-numeric exit"
2416
+ assert_grep "non-negative integer" "$err" "link must explain a bad --carry-count value"
2417
+ PATH="$BASE_PATH" FM_HOME="$home" \
2418
+ "$ROOT/bin/fm-x-link.sh" ok req-1 --carry-ts abc >/dev/null 2>"$err"; rc=$?
2419
+ expect_code 2 "$rc" "link --carry-ts non-numeric exit"
2420
+ assert_grep "non-negative epoch integer" "$err" "link must explain a bad --carry-ts value"
2421
+ PATH="$BASE_PATH" FM_HOME="$home" \
2422
+ "$ROOT/bin/fm-x-link.sh" ok req-1 --carry-count >/dev/null 2>&1; rc=$?
2423
+ expect_code 2 "$rc" "link --carry-count missing value exit"
2424
+ PATH="$BASE_PATH" FM_HOME="$home" \
2425
+ "$ROOT/bin/fm-x-link.sh" ok req-1 --carry-count 1 >/dev/null 2>"$err"; rc=$?
2426
+ expect_code 2 "$rc" "link --carry-count without --carry-ts exit"
2427
+ assert_grep "--carry-count requires --carry-ts" "$err" "link must require --carry-ts when carrying count"
2428
+ PATH="$BASE_PATH" FM_HOME="$home" \
2429
+ "$ROOT/bin/fm-x-link.sh" ok req-1 --carry-ts 1700000000 >/dev/null 2>"$err"; rc=$?
2430
+ expect_code 2 "$rc" "link --carry-ts without --carry-count exit"
2431
+ assert_grep "--carry-ts requires --carry-count" "$err" "link must require --carry-count when carrying timestamp"
2432
+ PATH="$BASE_PATH" FM_HOME="$home" \
2433
+ "$ROOT/bin/fm-x-link.sh" ok req-1 --carry-count 1 --carry-ts 1700000000 >/dev/null 2>"$err"; rc=$?
2434
+ expect_code 2 "$rc" "link carry without reply context exit"
2435
+ assert_grep "relink requires carried reply context" "$err" "link must not silently drop reply context on relink"
2436
+ PATH="$BASE_PATH" FM_HOME="$home" \
2437
+ "$ROOT/bin/fm-x-link.sh" ok req-1 --carry-platform discord >/dev/null 2>"$err"; rc=$?
2438
+ expect_code 2 "$rc" "link --carry-platform without paired carry flags exit"
2439
+ assert_grep "--carry-platform and --carry-max require --carry-count and --carry-ts" "$err" \
2440
+ "link must require the paired carry binding when carrying reply context"
2441
+ PATH="$BASE_PATH" FM_HOME="$home" \
2442
+ "$ROOT/bin/fm-x-link.sh" ok req-1 --carry-count 1 --carry-ts 1700000000 --carry-max 49 >/dev/null 2>"$err"; rc=$?
2443
+ expect_code 2 "$rc" "link --carry-max below floor exit"
2444
+ assert_grep "--carry-max needs an integer of at least 50" "$err" "link must reject an unusable carried split budget"
2445
+ pass "fm-x-link rejects malformed or unpaired carry flags"
2446
+ }
2447
+
2448
+ test_meta_rewrites_do_not_depend_on_tmpdir() {
2449
+ local home badtmp meta out rc
2450
+ home="$TMP_ROOT/link-local-tmp"; mkdir -p "$home/state"
2451
+ badtmp="$home/missing-tmp"
2452
+ meta="$home/state/fix-meta-k4.meta"
2453
+ printf 'window=w\nkind=ship\n' > "$meta"
2454
+ out=$(TMPDIR="$badtmp" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
2455
+ "$ROOT/bin/fm-x-link.sh" fix-meta-k4 req-local 2>/dev/null); rc=$?
2456
+ expect_code 0 "$rc" "link with unusable TMPDIR exit"
2457
+ [ "$out" = "linked fix-meta-k4 to X request req-local" ] \
2458
+ || fail "link with unusable TMPDIR must still succeed (got: $out)"
2459
+ assert_grep "x_request=req-local" "$meta" "link must record request with an unusable TMPDIR"
2460
+ out=$(TMPDIR="$badtmp" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000001 FMX_FOLLOWUP_MAX_AGE_SECS=0 \
2461
+ "$ROOT/bin/fm-x-followup.sh" --check fix-meta-k4 2>/dev/null); rc=$?
2462
+ expect_code 1 "$rc" "expired check with unusable TMPDIR exit"
2463
+ [ -z "$out" ] || fail "expired check must stay silent (got: $out)"
2464
+ assert_no_grep "x_request=" "$meta" "clear must remove request with an unusable TMPDIR"
2465
+ assert_no_grep "x_followups=" "$meta" "clear must remove the follow-up counter with an unusable TMPDIR"
2466
+ assert_grep "kind=ship" "$meta" "clear must preserve other meta lines"
2467
+ pass "meta rewrites are independent of TMPDIR"
2468
+ }
2469
+
2470
+ # The shared publisher must refuse a symlink at state/<id>.meta so Relay field
2471
+ # rewrites cannot follow it and overwrite the target. Each helper is a real
2472
+ # rewrite path: link, follow-up counter, and clear.
2473
+ test_meta_helpers_refuse_a_symlinked_task_record() {
2474
+ local home meta target original rc leftover fakebin
2475
+
2476
+ assert_symlink_untouched() {
2477
+ local why=$1
2478
+ [ -L "$meta" ] || fail "$why replaced or removed the symlink record"
2479
+ cmp -s "$target" "$original" \
2480
+ || fail "$why rewrote the symlink target in place"
2481
+ leftover=$(find "$home/state" -maxdepth 1 -name '.*.fm-x.*' -print 2>/dev/null || true)
2482
+ [ -z "$leftover" ] || fail "$why left a staging file after a refused publish: $leftover"
2483
+ }
2484
+
2485
+ home="$TMP_ROOT/meta-symlink"
2486
+ mkdir -p "$home/state"
2487
+ meta="$home/state/sym-task.meta"
2488
+ target="$TMP_ROOT/meta-symlink-foreign.meta"
2489
+ original="$TMP_ROOT/meta-symlink-foreign.expected"
2490
+
2491
+ printf '%s\n' 'window=w' 'kind=ship' 'mode=no-mistakes' 'yolo=off' > "$target"
2492
+ cp "$target" "$original"
2493
+ ln -s "$target" "$meta"
2494
+ FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \
2495
+ "$ROOT/bin/fm-x-link.sh" sym-task req-sym >/dev/null 2>&1; rc=$?
2496
+ [ "$rc" -ne 0 ] || fail "link through a symlink record should refuse"
2497
+ assert_no_grep "x_request=" "$target" "link wrote an X request through the symlink"
2498
+ assert_symlink_untouched "link"
2499
+
2500
+ printf '%s\n' 'window=w' 'kind=ship' 'mode=no-mistakes' 'yolo=off' \
2501
+ 'x_request=req-sym' 'x_request_ts=1700000000' 'x_followups=0' \
2502
+ 'x_platform=x' 'x_reply_max_chars=280' > "$target"
2503
+ cp "$target" "$original"
2504
+ rm -f "$meta"
2505
+ ln -s "$target" "$meta"
2506
+
2507
+ FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" --clear sym-task >/dev/null 2>&1; rc=$?
2508
+ [ "$rc" -ne 0 ] || fail "clear through a symlink record should refuse"
2509
+ assert_grep "x_request=req-sym" "$target" "clear removed the X request through the symlink"
2510
+ assert_symlink_untouched "clear"
2511
+
2512
+ rm -f "$meta" "$target"
2513
+ ln -s "$target" "$meta"
2514
+ FM_HOME="$home" STATE="$home/state" ROOT="$ROOT" META="$meta" bash -c '
2515
+ . "$ROOT/bin/fm-x-lib.sh"
2516
+ . "$ROOT/bin/fm-wake-lib.sh"
2517
+ fmx_meta_link_clear "$META"
2518
+ ' >/dev/null 2>&1; rc=$?
2519
+ [ "$rc" -ne 0 ] || fail "the clear helper should refuse a dangling symlink record"
2520
+ [ -L "$meta" ] || fail "the clear helper replaced or removed the dangling symlink record"
2521
+ [ ! -e "$target" ] || fail "the clear helper created the dangling symlink target"
2522
+ leftover=$(find "$home/state" -maxdepth 1 -name '.*.fm-x.*' -print 2>/dev/null || true)
2523
+ [ -z "$leftover" ] || fail "the clear helper left a staging file after refusing a dangling symlink: $leftover"
2524
+
2525
+ printf '%s\n' 'window=w' 'kind=ship' 'mode=no-mistakes' 'yolo=off' \
2526
+ 'x_request=req-sym' 'x_request_ts=1700000000' 'x_followups=0' \
2527
+ 'x_platform=x' 'x_reply_max_chars=280' > "$target"
2528
+ cp "$target" "$original"
2529
+ fakebin=$(make_fake_curl "$home")
2530
+ printf 'FMX_PAIRING_TOKEN=tok-sym\n' > "$home/.env"
2531
+ FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 PATH="$fakebin:$BASE_PATH" \
2532
+ "$ROOT/bin/fm-x-followup.sh" sym-task - <<<"milestone update" >/dev/null 2>&1; rc=$?
2533
+ [ "$rc" -ne 0 ] || fail "a follow-up through a symlink record should refuse"
2534
+ assert_absent "$home/state/x-outbox/req-sym.json" \
2535
+ "a refused symlink record still published a follow-up"
2536
+ assert_grep "x_followups=0" "$target" "a refused follow-up incremented the counter through the symlink"
2537
+ assert_symlink_untouched "follow-up"
2538
+ pass "x-lib meta helpers refuse a symlinked task record and leave its target untouched"
2539
+ }
2540
+
2541
+ test_link_rejects_unsafe_and_missing() {
2542
+ local home rc
2543
+ home="$TMP_ROOT/link-bad"; mkdir -p "$home/state"
2544
+ printf 'kind=ship\n' > "$home/state/ok.meta"
2545
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-link.sh" "../evil" req-1 >/dev/null 2>&1; rc=$?
2546
+ expect_code 2 "$rc" "link unsafe task id exit"
2547
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-link.sh" ok "../../etc/x" >/dev/null 2>&1; rc=$?
2548
+ expect_code 2 "$rc" "link unsafe request_id exit"
2549
+ assert_absent "$home/state/../evil.meta" "link must not touch meta for an unsafe id"
2550
+ # Missing meta is a hard error, not a silent create.
2551
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-link.sh" no-such req-1 >/dev/null 2>&1; rc=$?
2552
+ expect_code 1 "$rc" "link missing meta exit"
2553
+ assert_absent "$home/state/no-such.meta" "link must not create meta for a non-existent task"
2554
+ # Missing arguments are a usage error.
2555
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-link.sh" ok >/dev/null 2>&1; rc=$?
2556
+ expect_code 2 "$rc" "link missing arg exit"
2557
+ pass "fm-x-link rejects unsafe ids, missing meta, and missing arguments"
2558
+ }
2559
+
2560
+ # A home with no secondmates at all learns nothing from the registry, so its
2561
+ # missing-task error must stay the plain one instead of routing every typo at a
2562
+ # mechanism that does not apply.
2563
+ test_link_missing_task_without_secondmates_stays_plain() {
2564
+ local home err rc
2565
+ home="$TMP_ROOT/link-no-secondmates"; mkdir -p "$home/state" "$home/data"
2566
+ err="$TMP_ROOT/link-no-secondmates.err"
2567
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-link.sh" no-such req-1 >/dev/null 2>"$err"; rc=$?
2568
+ expect_code 1 "$rc" "plain missing-task exit"
2569
+ assert_grep "no such task: state/no-such.meta" "$err" "the plain missing-task error must still be reported"
2570
+ assert_no_grep "fm-public-followup.sh register" "$err" \
2571
+ "a home with no second mates must not be pointed at the promised-final path"
2572
+ pass "fm-x-link keeps the plain missing-task error when no second mate is registered"
2573
+ }
2574
+
2575
+ # The link writes into THIS home's own state/<id>.meta, so it can never bind work
2576
+ # that lives in a secondmate home. Refusing with a bare "no such task" left the
2577
+ # public promise silently orphaned; the refusal must name the secondmate holding
2578
+ # the task and the promised-final path that can actually bind it.
2579
+ test_link_refuses_secondmate_routed_task_with_promised_final_pointer() {
2580
+ local main sub err out rc
2581
+ main="$TMP_ROOT/link-secondmate-main"; mkdir -p "$main/state" "$main/data"
2582
+ sub="$TMP_ROOT/link-secondmate-sub"; mkdir -p "$sub/state"
2583
+ printf 'sm-axi\n' > "$sub/.fm-secondmate-home"
2584
+ printf 'window=w\nworktree=/wt\nkind=ship\n' > "$sub/state/routed-k1.meta"
2585
+ printf '# Second mates\n\n- sm-axi - owns the axi domain (home: %s; scope: axi tooling; projects: axi; added 2026-01-01)\n' \
2586
+ "$sub" > "$main/data/secondmates.md"
2587
+ err="$TMP_ROOT/link-secondmate.err"
2588
+ out=$(PATH="$BASE_PATH" FM_HOME="$main" "$ROOT/bin/fm-x-link.sh" routed-k1 req-routed 2>"$err"); rc=$?
2589
+ expect_code 1 "$rc" "secondmate-routed link exit"
2590
+ [ -z "$out" ] || fail "a refused link must print no success line (got: $out)"
2591
+ assert_grep "sm-axi" "$err" "the refusal must name the second mate holding the task"
2592
+ assert_grep "--work-home secondmate:sm-axi" "$err" \
2593
+ "the refusal must name the exact promised-final binding for that second mate"
2594
+ assert_grep "fm-public-followup.sh register" "$err" \
2595
+ "the refusal must point at the promised-final registration command"
2596
+ assert_absent "$main/state/routed-k1.meta" "a refused link must not create a local record"
2597
+ assert_no_grep "x_request=" "$sub/state/routed-k1.meta" \
2598
+ "a refused link must not write into the second mate's task record"
2599
+ # The guardrail is scoped to the missing-record case: a task this home does own
2600
+ # still links normally with second mates registered.
2601
+ printf 'window=w\nworktree=/wt\nkind=ship\n' > "$main/state/local-k1.meta"
2602
+ out=$(PATH="$BASE_PATH" FM_HOME="$main" FMX_NOW_OVERRIDE=1700000000 \
2603
+ "$ROOT/bin/fm-x-link.sh" local-k1 req-local 2>/dev/null); rc=$?
2604
+ expect_code 0 "$rc" "local link exit with second mates registered"
2605
+ assert_grep "x_request=req-local" "$main/state/local-k1.meta" \
2606
+ "a local task must still link while second mates are registered"
2607
+ pass "fm-x-link refuses a second-mate-routed task and points at the promised-final path"
2608
+ }
2609
+
2610
+ # A remote secondmate's home cannot be inspected from here, and neither can a
2611
+ # task the parent never recorded, so the refusal degrades to naming the routing
2612
+ # possibility and the mechanism rather than silently reporting a missing file.
2613
+ test_link_missing_task_with_secondmates_points_at_promised_final() {
2614
+ local home err rc
2615
+ home="$TMP_ROOT/link-remote-secondmate"; mkdir -p "$home/state" "$home/data"
2616
+ printf '# Second mates\n\n- sm-far - owns the far domain (host: box; root: /srv/fm; home: /srv/fm/home; scope: far things; projects: far; added 2026-01-01)\n' \
2617
+ > "$home/data/secondmates.md"
2618
+ err="$TMP_ROOT/link-remote-secondmate.err"
2619
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-link.sh" unknown-k1 req-unknown >/dev/null 2>"$err"; rc=$?
2620
+ expect_code 1 "$rc" "unknown-task link exit with second mates registered"
2621
+ assert_grep "no such task: state/unknown-k1.meta" "$err" "the concrete missing record must still be reported"
2622
+ assert_grep "fm-public-followup.sh register" "$err" \
2623
+ "an unlocatable task in a home with second mates must be pointed at the promised-final path"
2624
+ assert_grep "--work-home secondmate:<id>" "$err" \
2625
+ "an unlocatable task must leave the second mate id for the caller to fill in"
2626
+ pass "fm-x-link points an unlocatable task at the promised-final path when second mates exist"
2627
+ }
2628
+
2629
+ # --- fm-x-followup: detect, post up to 3 follow-ups, manage the link --------
2630
+
2631
+ mk_linked_task() { # <home> <id> <request_id> <link-epoch> [starting-count]
2632
+ local home=$1 id=$2 rid=$3 ts=$4 count=${5:-} meta
2633
+ mkdir -p "$home/state"
2634
+ meta="$home/state/$id.meta"
2635
+ printf 'window=w\nworktree=/wt\nkind=ship\nmode=no-mistakes\nyolo=off\n' > "$meta"
2636
+ FM_HOME="$home" FMX_NOW_OVERRIDE="$ts" "$ROOT/bin/fm-x-link.sh" "$id" "$rid" \
2637
+ --carry-count "${count:-0}" --carry-ts "$ts" --carry-platform x --carry-max 280 >/dev/null
2638
+ }
2639
+
2640
+ test_followup_check_states() {
2641
+ local home out rc
2642
+ home="$TMP_ROOT/fu-check"; mkdir -p "$home/state"
2643
+ mk_linked_task "$home" task-a req-a 1700000000
2644
+ # Within window -> exit 0, prints the request_id.
2645
+ out=$(FM_HOME="$home" FMX_NOW_OVERRIDE=1700003600 \
2646
+ "$ROOT/bin/fm-x-followup.sh" --check task-a); rc=$?
2647
+ expect_code 0 "$rc" "check within-window exit"
2648
+ [ "$out" = "req-a" ] || fail "check within window must print the request_id (got: $out)"
2649
+ # Not linked -> exit 1, silent.
2650
+ printf 'kind=ship\n' > "$home/state/plain.meta"
2651
+ out=$(FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" --check plain 2>/dev/null); rc=$?
2652
+ expect_code 1 "$rc" "check not-linked exit"
2653
+ [ -z "$out" ] || fail "check on a non-linked task must be silent (got: $out)"
2654
+ # Missing meta -> exit 1, silent.
2655
+ out=$(FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" --check nope 2>/dev/null); rc=$?
2656
+ expect_code 1 "$rc" "check missing-meta exit"
2657
+ pass "fm-x-followup --check reports postable / not-linked correctly"
2658
+ }
2659
+
2660
+ test_followup_check_expired_prunes_link() {
2661
+ local home out rc meta
2662
+ home="$TMP_ROOT/fu-check-exp"; mkdir -p "$home/state"
2663
+ mk_linked_task "$home" task-e req-e 1700000000
2664
+ meta="$home/state/task-e.meta"
2665
+ # 8 days later: past the 7-day window -> exit 1, link pruned, other lines intact.
2666
+ out=$(FM_HOME="$home" FMX_NOW_OVERRIDE=$((1700000000 + 8*86400)) \
2667
+ "$ROOT/bin/fm-x-followup.sh" --check task-e 2>/dev/null); rc=$?
2668
+ expect_code 1 "$rc" "check expired exit"
2669
+ [ -z "$out" ] || fail "check on an expired link must be silent (got: $out)"
2670
+ assert_no_grep "x_request=" "$meta" "expired check must prune the link"
2671
+ assert_grep "kind=ship" "$meta" "expired check must preserve other meta lines"
2672
+ pass "fm-x-followup --check prunes a link past the 7-day window"
2673
+ }
2674
+
2675
+ test_followup_check_cap_reached_prunes_link() {
2676
+ local home out rc meta
2677
+ home="$TMP_ROOT/fu-check-cap"; mkdir -p "$home/state"
2678
+ # Already at the cap (3 posted) even though well within the window.
2679
+ mk_linked_task "$home" task-cap req-cap 1700000000 3
2680
+ meta="$home/state/task-cap.meta"
2681
+ out=$(FM_HOME="$home" FMX_NOW_OVERRIDE=1700003600 \
2682
+ "$ROOT/bin/fm-x-followup.sh" --check task-cap 2>/dev/null); rc=$?
2683
+ expect_code 1 "$rc" "check cap-reached exit"
2684
+ [ -z "$out" ] || fail "check at the cap must be silent (got: $out)"
2685
+ assert_no_grep "x_request=" "$meta" "a cap-reached check must prune the link"
2686
+ assert_grep "kind=ship" "$meta" "cap-reached check must preserve other meta lines"
2687
+ pass "fm-x-followup --check prunes a link that already reached the follow-up cap"
2688
+ }
2689
+
2690
+ test_followup_post_increments_counter_keeps_link() {
2691
+ local home fakebin log out rc meta data
2692
+ home="$TMP_ROOT/fu-post"; mkdir -p "$home/state"
2693
+ fakebin=$(make_fake_curl "$home")
2694
+ log="$home/curl.log"
2695
+ printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
2696
+ mk_linked_task "$home" task-p req-p 1700000000
2697
+ meta="$home/state/task-p.meta"
2698
+ printf 'Done, captain - build has started.' > "$home/reply.txt"
2699
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2700
+ FMX_NOW_OVERRIDE=1700003600 FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=200 \
2701
+ "$ROOT/bin/fm-x-followup.sh" task-p --text-file "$home/reply.txt"); rc=$?
2702
+ expect_code 0 "$rc" "followup post exit"
2703
+ [ "$out" = "req-p" ] || fail "followup post must echo the request_id (got: $out)"
2704
+ assert_grep "url=https://relay.test/connector/followup" "$log" "post must hit the followup endpoint"
2705
+ data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
2706
+ [ "$(printf '%s' "$data" | jq -r .text)" = "Done, captain - build has started." ] \
2707
+ || fail "post must send the composed follow-up text"
2708
+ # One post under the cap must NOT clear the link - it increments the counter
2709
+ # so up to two more follow-ups can still land against the same binding.
2710
+ assert_grep "x_request=req-p" "$meta" "a post under the cap must keep the link"
2711
+ assert_grep "x_followups=1" "$meta" "a successful post must increment the follow-up counter"
2712
+ assert_grep "kind=ship" "$meta" "posting must preserve other meta lines"
2713
+ pass "fm-x-followup posts a follow-up, increments the counter, and keeps the link under the cap"
2714
+ }
2715
+
2716
+ test_followup_post_final_clears_link_immediately() {
2717
+ local home fakebin out rc meta
2718
+ home="$TMP_ROOT/fu-post-final"; mkdir -p "$home/state"
2719
+ fakebin=$(make_fake_curl "$home")
2720
+ printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
2721
+ mk_linked_task "$home" task-final req-final 1700000000
2722
+ meta="$home/state/task-final.meta"
2723
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2724
+ FMX_NOW_OVERRIDE=1700003600 FAKE_FOLLOWUP_CODE=200 \
2725
+ "$ROOT/bin/fm-x-followup.sh" task-final --final - <<<"Shipped - all green."); rc=$?
2726
+ expect_code 0 "$rc" "followup --final post exit"
2727
+ [ "$out" = "req-final" ] || fail "followup --final post must echo the request_id (got: $out)"
2728
+ assert_no_grep "x_request=" "$meta" "--final must clear the link even with follow-ups remaining under the cap"
2729
+ assert_grep "kind=ship" "$meta" "clearing the link must preserve other meta lines"
2730
+ pass "fm-x-followup --final clears the link after one post regardless of the remaining count"
2731
+ }
2732
+
2733
+ test_followup_post_cap_reached_clears_link() {
2734
+ local home fakebin out rc meta
2735
+ home="$TMP_ROOT/fu-post-cap"; mkdir -p "$home/state"
2736
+ fakebin=$(make_fake_curl "$home")
2737
+ printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
2738
+ # Two follow-ups already posted; this third one reaches the cap.
2739
+ mk_linked_task "$home" task-cap3 req-cap3 1700000000 2
2740
+ meta="$home/state/task-cap3.meta"
2741
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2742
+ FMX_NOW_OVERRIDE=1700003600 FAKE_FOLLOWUP_CODE=200 \
2743
+ "$ROOT/bin/fm-x-followup.sh" task-cap3 - <<<"Third and final update."); rc=$?
2744
+ expect_code 0 "$rc" "followup cap-reaching post exit"
2745
+ [ "$out" = "req-cap3" ] || fail "followup cap-reaching post must echo the request_id (got: $out)"
2746
+ assert_no_grep "x_request=" "$meta" "reaching the cap must clear the link even without --final"
2747
+ pass "fm-x-followup clears the link once the third follow-up reaches the cap"
2748
+ }
2749
+
2750
+ test_followup_post_forwards_image_to_reply_client() {
2751
+ local home fakebin log out rc meta data img expected
2752
+ home="$TMP_ROOT/fu-post-image"; mkdir -p "$home/state"
2753
+ fakebin=$(make_fake_curl "$home")
2754
+ log="$home/curl.log"
2755
+ img="$home/followup.png"
2756
+ make_sample_image "$img"
2757
+ expected=$(base64 < "$img" | tr -d '\n\r')
2758
+ printf 'FMX_PAIRING_TOKEN=tok-fu-img\n' > "$home/.env"
2759
+ mk_linked_task "$home" task-img req-img 1700000000
2760
+ meta="$home/state/task-img.meta"
2761
+ printf 'Done - generated image attached.' > "$home/reply.txt"
2762
+ # --final keeps this test focused on image forwarding, not counter bookkeeping.
2763
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2764
+ FMX_NOW_OVERRIDE=1700003600 FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=200 \
2765
+ "$ROOT/bin/fm-x-followup.sh" task-img --image "$img" --final --text-file "$home/reply.txt"); rc=$?
2766
+ expect_code 0 "$rc" "followup wrapper image post exit"
2767
+ [ "$out" = "req-img" ] || fail "followup wrapper image post must echo the request_id (got: $out)"
2768
+ data=$(grep '^data=' "$log" | tail -1 | sed 's/^data=//')
2769
+ [ "$(printf '%s' "$data" | jq -r '.image.media_type')" = "image/png" ] \
2770
+ || fail "followup wrapper must forward image media_type"
2771
+ [ "$(printf '%s' "$data" | jq -r '.image.data_base64')" = "$expected" ] \
2772
+ || fail "followup wrapper must forward image base64"
2773
+ assert_no_grep "x_request=" "$meta" "a --final image followup must clear the link"
2774
+ pass "fm-x-followup --image forwards the attachment through fm-x-reply --followup"
2775
+ }
2776
+
2777
+ test_followup_post_failure_keeps_link() {
2778
+ local home fakebin out rc meta
2779
+ home="$TMP_ROOT/fu-post-fail"; mkdir -p "$home/state"
2780
+ fakebin=$(make_fake_curl "$home")
2781
+ printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
2782
+ mk_linked_task "$home" task-f req-f 1700000000
2783
+ meta="$home/state/task-f.meta"
2784
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2785
+ FMX_NOW_OVERRIDE=1700003600 FAKE_FOLLOWUP_CODE=500 \
2786
+ "$ROOT/bin/fm-x-followup.sh" task-f - <<<"retry me" 2>/dev/null); rc=$?
2787
+ [ "$rc" -ne 0 ] || fail "a failed follow-up post must exit non-zero"
2788
+ [ -z "$out" ] || fail "a failed post must not echo the request_id (got: $out)"
2789
+ assert_grep "x_request=req-f" "$meta" "a failed post must leave the link for a retry"
2790
+ assert_grep "x_followups=0" "$meta" "a failed post must not increment the follow-up counter"
2791
+ pass "fm-x-followup keeps the link and counter when the post fails"
2792
+ }
2793
+
2794
+ test_followup_post_record_failure_clears_link() {
2795
+ local home fakebin out rc meta err flag mvflag
2796
+ home="$TMP_ROOT/fu-post-record-fail"; mkdir -p "$home/state"
2797
+ fakebin=$(make_fake_curl "$home")
2798
+ flag="$home/fail-followups-write"
2799
+ mvflag="$home/mv-failed-once"
2800
+ err="$home/err.txt"
2801
+ cat > "$fakebin/mv" <<'SH'
2802
+ #!/usr/bin/env bash
2803
+ if [ -n "${FAKE_MV_FAIL_AFTER_FLAG:-}" ] \
2804
+ && [ -f "$FAKE_MV_FAIL_AFTER_FLAG" ] \
2805
+ && [ -n "${FAKE_MV_FAILED_ONCE:-}" ] \
2806
+ && [ ! -f "$FAKE_MV_FAILED_ONCE" ]; then
2807
+ : > "$FAKE_MV_FAILED_ONCE"
2808
+ exit 2
2809
+ fi
2810
+ exec /bin/mv "$@"
2811
+ SH
2812
+ chmod +x "$fakebin/mv"
2813
+ printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
2814
+ mk_linked_task "$home" task-rf req-rf 1700000000
2815
+ meta="$home/state/task-rf.meta"
2816
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2817
+ FMX_NOW_OVERRIDE=1700003600 FAKE_FOLLOWUP_CODE=200 FAKE_CURL_TOUCH_AFTER_POST="$flag" \
2818
+ FAKE_MV_FAIL_AFTER_FLAG="$flag" FAKE_MV_FAILED_ONCE="$mvflag" \
2819
+ "$ROOT/bin/fm-x-followup.sh" task-rf - <<<"posted but local state write fails" 2>"$err"); rc=$?
2820
+ expect_code 0 "$rc" "followup post state-record failure exit"
2821
+ [ "$out" = "req-rf" ] || fail "posted followup with tombstoned state must echo the request_id (got: $out)"
2822
+ assert_no_grep "x_request=" "$meta" "a failed counter write must tombstone the link"
2823
+ assert_no_grep "x_followups=" "$meta" "a failed counter write must remove the stale counter"
2824
+ assert_grep "cleared the link to avoid duplicate follow-ups" "$err" "state-record failure must explain the tombstone"
2825
+ pass "fm-x-followup tombstones the link when a post-success counter write fails"
2826
+ }
2827
+
2828
+ test_followup_post_relay_rejection_degrades_gracefully() {
2829
+ local home fakebin out rc meta err
2830
+ home="$TMP_ROOT/fu-post-409"; mkdir -p "$home/state"
2831
+ fakebin=$(make_fake_curl "$home")
2832
+ err="$home/err.txt"
2833
+ printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
2834
+ mk_linked_task "$home" task-409 req-409 1700000000
2835
+ meta="$home/state/task-409.meta"
2836
+ # The relay's own cap/window rejection (HTTP 409) must be treated exactly like
2837
+ # a locally-detected expiry - not a transient failure worth retrying - so an
2838
+ # old single-follow-up relay or an already-exhausted binding degrades
2839
+ # gracefully instead of leaving a link nothing will ever clear.
2840
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2841
+ FMX_NOW_OVERRIDE=1700003600 FAKE_FOLLOWUP_CODE=409 \
2842
+ "$ROOT/bin/fm-x-followup.sh" task-409 - <<<"rejected by relay" 2>"$err"); rc=$?
2843
+ expect_code 0 "$rc" "relay-rejected post exit"
2844
+ [ -z "$out" ] || fail "a relay-rejected post must echo nothing (got: $out)"
2845
+ assert_no_grep "x_request=" "$meta" "a relay rejection must clear the link"
2846
+ assert_grep "cap or window" "$err" "a relay rejection must explain itself distinctly from a generic failure"
2847
+ pass "fm-x-followup treats a relay cap/window rejection as an already-exhausted link, not a retry"
2848
+ }
2849
+
2850
+ test_followup_post_expired_skips_and_clears() {
2851
+ local home fakebin out rc meta
2852
+ home="$TMP_ROOT/fu-post-exp"; mkdir -p "$home/state"
2853
+ fakebin=$(make_fake_curl "$home")
2854
+ printf 'FMX_PAIRING_TOKEN=tok-fu\n' > "$home/.env"
2855
+ mk_linked_task "$home" task-x req-x 1700000000
2856
+ meta="$home/state/task-x.meta"
2857
+ out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \
2858
+ FMX_NOW_OVERRIDE=$((1700000000 + 8*86400)) FAKE_FOLLOWUP_CODE=200 \
2859
+ "$ROOT/bin/fm-x-followup.sh" task-x - <<<"too late" 2>/dev/null); rc=$?
2860
+ expect_code 0 "$rc" "expired post exit"
2861
+ [ -z "$out" ] || fail "an expired post must post nothing and echo nothing (got: $out)"
2862
+ assert_no_grep "x_request=" "$meta" "an expired post must clear the link"
2863
+ assert_absent "$home/state/x-outbox/req-x.json" "an expired post must not record any reply"
2864
+ pass "fm-x-followup skips silently and clears the link past the 7-day window"
2865
+ }
2866
+
2867
+ test_followup_post_not_linked_is_noop() {
2868
+ local home out rc
2869
+ home="$TMP_ROOT/fu-noop"; mkdir -p "$home/state"
2870
+ printf 'kind=ship\n' > "$home/state/plain.meta"
2871
+ out=$(FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" plain - <<<"nothing to do" 2>/dev/null); rc=$?
2872
+ expect_code 0 "$rc" "not-linked post exit"
2873
+ [ -z "$out" ] || fail "a not-linked post must be a silent no-op (got: $out)"
2874
+ assert_absent "$home/state/x-outbox" "a not-linked post must not record a reply"
2875
+ pass "fm-x-followup is a no-op for a task with no X link"
2876
+ }
2877
+
2878
+ test_followup_post_dry_run_increments_counter_keeps_link() {
2879
+ local home out rc meta
2880
+ home="$TMP_ROOT/fu-dry"; mkdir -p "$home/state"
2881
+ mk_linked_task "$home" task-d req-d 1700000000
2882
+ meta="$home/state/task-d.meta"
2883
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 \
2884
+ "$ROOT/bin/fm-x-followup.sh" task-d - <<<"Shipped in dry run." 2>/dev/null); rc=$?
2885
+ expect_code 0 "$rc" "dry-run post exit"
2886
+ [ "$out" = "req-d" ] || fail "dry-run post must echo the request_id (got: $out)"
2887
+ assert_present "$home/state/x-outbox/req-d.json" "dry-run post must record the would-be follow-up"
2888
+ [ "$(jq -r '.endpoint' "$home/state/x-outbox/req-d.json")" = "followup" ] \
2889
+ || fail "dry-run post preview must carry the followup endpoint marker"
2890
+ # Dry-run must mutate the counter/link exactly as a live post would: keep the
2891
+ # link and increment the counter when under the cap and --final is absent.
2892
+ assert_grep "x_request=req-d" "$meta" "dry-run under the cap must keep the link just as a live post would"
2893
+ assert_grep "x_followups=1" "$meta" "dry-run must increment the follow-up counter just as a live post would"
2894
+ pass "fm-x-followup dry-run records the follow-up and increments the counter, keeping the link"
2895
+ }
2896
+
2897
+ test_followup_post_dry_run_final_clears_link() {
2898
+ local home out rc meta
2899
+ home="$TMP_ROOT/fu-dry-final"; mkdir -p "$home/state"
2900
+ mk_linked_task "$home" task-df req-df 1700000000
2901
+ meta="$home/state/task-df.meta"
2902
+ out=$(FM_HOME="$home" FMX_DRY_RUN=1 FMX_NOW_OVERRIDE=1700003600 \
2903
+ "$ROOT/bin/fm-x-followup.sh" task-df --final - <<<"Shipped in dry run, for real this time." 2>/dev/null); rc=$?
2904
+ expect_code 0 "$rc" "dry-run --final post exit"
2905
+ [ "$out" = "req-df" ] || fail "dry-run --final post must echo the request_id (got: $out)"
2906
+ assert_no_grep "x_request=" "$meta" "dry-run --final must clear the link just as a live --final post would"
2907
+ pass "fm-x-followup dry-run --final clears the link just as a live post would"
2908
+ }
2909
+
2910
+ test_followup_usage_errors() {
2911
+ local home rc err out
2912
+ home="$TMP_ROOT/fu-usage"; mkdir -p "$home/state"
2913
+ err="$home/err.txt"
2914
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" >/dev/null 2>"$err"; rc=$?
2915
+ expect_code 2 "$rc" "followup no-args exit"
2916
+ assert_grep "--image <path>" "$err" "followup usage must mention --image"
2917
+ assert_grep "--final" "$err" "followup usage must mention --final"
2918
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" --check >/dev/null 2>&1; rc=$?
2919
+ expect_code 2 "$rc" "followup --check no-id exit"
2920
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" some-task >/dev/null 2>&1; rc=$?
2921
+ expect_code 2 "$rc" "followup post no-text-source exit"
2922
+ out=$(PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" --help); rc=$?
2923
+ expect_code 0 "$rc" "followup --help exit"
2924
+ assert_contains "$out" "--image <path>" "followup help must mention --image"
2925
+ assert_contains "$out" "threaded replies attach it to the opener tweet" \
2926
+ "followup help must document thread image placement"
2927
+ assert_contains "$out" "--final" "followup help must mention --final"
2928
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" "../evil" --text-file /dev/null >/dev/null 2>&1; rc=$?
2929
+ expect_code 2 "$rc" "followup unsafe-id exit"
2930
+ PATH="$BASE_PATH" FM_HOME="$home" "$ROOT/bin/fm-x-followup.sh" some-task --image >/dev/null 2>"$err"; rc=$?
2931
+ expect_code 2 "$rc" "followup missing --image argument exit"
2932
+ assert_grep "missing --image path" "$err" "followup missing --image argument must explain the error"
2933
+ pass "fm-x-followup rejects malformed invocations"
2934
+ }
2935
+
2936
+ test_poll_no_token_is_hard_noop
2937
+ test_poll_empty_env_token_overrides_env_file
2938
+ test_poll_204_is_silent
2939
+ test_poll_empty_env_relay_overrides_env_file
2940
+ test_poll_auth_error_reports_once
2941
+ test_poll_error_private_publication_rejects_unsafe_paths
2942
+ test_poll_question_stashes_and_marks
2943
+ test_poll_mentions_wake_once_per_durable_offer
2944
+ test_poll_offer_claim_failure_reports_once
2945
+ test_poll_preserves_conversation_context
2946
+ test_poll_inbox_commit_failure_reports_error
2947
+ test_poll_inbox_private_publication_rejects_unsafe_paths
2948
+ test_poll_empty_text_is_silent
2949
+ test_poll_rejects_unsafe_request_id
2950
+ test_reply_success_posts_request_bound_only
2951
+ test_reply_text_file_and_stdin
2952
+ test_reply_non_2xx_fails
2953
+ test_reply_auth_header_tempfile_cleans_up_on_interrupted_post
2954
+ test_reply_usage_error
2955
+ test_reply_help_mentions_image
2956
+ test_reply_whitespace_text_rejected
2957
+ test_reply_dry_run_records_not_posts
2958
+ test_reply_dry_run_needs_no_token
2959
+ test_reply_dry_run_from_env_file
2960
+ test_reply_empty_env_dry_run_overrides_env_file
2961
+ test_reply_dry_run_fails_when_outbox_unwritable
2962
+ test_reply_dry_run_outbox_private_publication_rejects_unsafe_paths
2963
+ test_split_thread_lib
2964
+ test_reply_single_no_texts
2965
+ test_reply_thread_dry_run
2966
+ test_reply_discord_inbox_uses_discord_budget
2967
+ test_reply_x_inbox_still_uses_x_budget
2968
+ test_reply_inbox_explicit_limit_wins
2969
+ test_reply_rejects_unsafe_inbox_context_reads
2970
+ test_reply_max_chars_floor_clamps_to_minimum
2971
+ test_reply_thread_live_posts_texts
2972
+ test_reply_image_live_posts_image_object
2973
+ test_reply_image_live_streams_payload_file
2974
+ test_reply_image_thread_dry_run_records_compact_marker
2975
+ test_reply_image_dry_run_cleans_payload_temp_files
2976
+ test_reply_image_path_errors_are_clear
2977
+ test_reply_followup_live_posts_to_followup_endpoint
2978
+ test_reply_followup_409_marker_exits_distinctly
2979
+ test_reply_followup_409_without_marker_still_exits_distinctly
2980
+ test_reply_answer_409_is_generic_failure
2981
+ test_reply_followup_image_live_posts_image_object
2982
+ test_reply_followup_flag_position_is_flexible
2983
+ test_reply_followup_dry_run_marks_endpoint
2984
+ test_reply_followup_thread_dry_run
2985
+ test_reply_followup_image_dry_run_marks_endpoint_and_compacts_image
2986
+ test_poll_records_context_registry_from_relay_platform
2987
+ test_context_registry_private_publication_rejects_unsafe_paths
2988
+ test_context_registry_rejects_unsafe_reads
2989
+ test_private_artifact_publisher_runs_under_system_bash
2990
+ test_context_registry_prunes_expired_records
2991
+ test_context_registry_preserves_first_seen_timestamp
2992
+ test_context_registry_retention_starts_on_successful_live_answer
2993
+ test_regression_discord_followup_survives_inbox_cleanup
2994
+ test_regression_x_followup_still_splits_after_cleanup
2995
+ test_regression_unresolved_followup_fails_safe
2996
+ test_followup_partial_registry_uses_relay_budget_live
2997
+ test_regression_concurrent_requests_keep_own_platform
2998
+ test_dismiss_clears_context_registry
2999
+ test_dismiss_success_posts_request_only
3000
+ test_dismiss_dry_run_records_not_posts
3001
+ test_dismiss_dry_run_needs_no_token
3002
+ test_dismiss_dry_run_outbox_private_publication_rejects_unsafe_paths
3003
+ test_dismiss_non_2xx_fails
3004
+ test_dismiss_transport_failure_fails
3005
+ test_dismiss_unsafe_request_id_rejected
3006
+ test_dismiss_usage_error
3007
+ test_link_records_request_and_timestamp
3008
+ test_link_records_discord_platform_for_followups
3009
+ test_link_resolves_platform_by_request_id_after_inbox_cleanup
3010
+ test_link_warns_loudly_when_platform_unresolvable
3011
+ test_link_carry_count_and_ts_preserve_followup_binding
3012
+ test_link_recovery_relink_carries_discord_context_after_inbox_drain
3013
+ test_link_carry_count_validation
3014
+ test_meta_rewrites_do_not_depend_on_tmpdir
3015
+ test_meta_helpers_refuse_a_symlinked_task_record
3016
+ test_link_rejects_unsafe_and_missing
3017
+ test_link_missing_task_without_secondmates_stays_plain
3018
+ test_link_refuses_secondmate_routed_task_with_promised_final_pointer
3019
+ test_link_missing_task_with_secondmates_points_at_promised_final
3020
+ test_followup_check_states
3021
+ test_followup_check_expired_prunes_link
3022
+ test_followup_check_cap_reached_prunes_link
3023
+ test_followup_post_increments_counter_keeps_link
3024
+ test_followup_post_final_clears_link_immediately
3025
+ test_followup_post_cap_reached_clears_link
3026
+ test_followup_post_forwards_image_to_reply_client
3027
+ test_followup_post_failure_keeps_link
3028
+ test_followup_post_record_failure_clears_link
3029
+ test_followup_post_relay_rejection_degrades_gracefully
3030
+ test_followup_post_expired_skips_and_clears
3031
+ test_followup_post_not_linked_is_noop
3032
+ test_followup_post_dry_run_increments_counter_keeps_link
3033
+ test_followup_post_dry_run_final_clears_link
3034
+ test_followup_usage_errors
3035
+ test_bootstrap_activates_on_env_token
3036
+ test_bootstrap_relative_home_writes_absolute_poll_shim
3037
+ test_bootstrap_reports_missing_x_dependency
3038
+ test_bootstrap_does_not_announce_when_arm_fails
3039
+ test_bootstrap_does_not_follow_x_artifact_symlinks
3040
+ test_bootstrap_inert_without_token
3041
+ test_bootstrap_opt_out_cleanup
3042
+ test_bootstrap_opt_out_reports_cleanup_failure