@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,2740 @@
1
+ #!/usr/bin/env bash
2
+ # tests/fm-daemon.test.sh - supervise-daemon classifiers, the captain-relevant
3
+ # status-phrase matrix (a product contract), escalation batching/dedupe, afk
4
+ # presence-gating, and the injection-hardening units that an e2e cannot
5
+ # deterministically reach (persistent-Enter-swallow, max-defer wedge alarms,
6
+ # fm-send typed-plane swallow reporting, composer-pending ANSI parsing). The operator-visible
7
+ # inject flow lives in fm-afk-inject-e2e and fm-wake-daemon-lifecycle-e2e.
8
+ set -u
9
+
10
+ # shellcheck source=tests/wake-helpers.sh
11
+ . "$(dirname "${BASH_SOURCE[0]}")/wake-helpers.sh"
12
+
13
+ DAEMON="$ROOT/bin/fm-supervise-daemon.sh"
14
+ AFK_START="$ROOT/bin/fm-afk-start.sh"
15
+ # Source the daemon's pure functions once. Its main loop is skipped under sourcing
16
+ # via a BASH_SOURCE guard, so only classify_*/housekeeping/escalate_*/afk_* and the
17
+ # pane/submit helpers become defined.
18
+ if [ -z "${FM_TEST_DAEMON_SOURCED:-}" ]; then
19
+ export FM_TEST_DAEMON_SOURCED=1
20
+ # shellcheck source=bin/fm-supervise-daemon.sh
21
+ . "$DAEMON"
22
+ fi
23
+
24
+ TMP_ROOT=$(fm_test_tmproot fm-daemon-tests)
25
+ FM_DAEMON_PRIMARY_HARNESS=claude
26
+ export FM_DAEMON_PRIMARY_HARNESS
27
+
28
+ test_afk_start_refuses_when_flag_cannot_be_written() {
29
+ local dir state out status
30
+ dir=$(make_supercase afk-start-flag-unwritable)
31
+ state="$dir/state"
32
+ mkdir -p "$state/.afk"
33
+
34
+ out=$(FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1)
35
+ status=$?
36
+
37
+ [ "$status" -ne 0 ] || fail "fm-afk-start.sh should fail when state/.afk cannot be written"
38
+ assert_not_contains "$out" "starting supervise daemon" "fm-afk-start.sh continued into daemon startup after .afk write failure"
39
+ assert_absent "$state/.supervise-daemon.log" "fm-afk-start.sh started the daemon after .afk write failure"
40
+ pass "fm-afk-start.sh fails before daemon startup when the afk flag cannot be written"
41
+ }
42
+
43
+ test_afk_start_ignores_stale_pidfile_without_lock() {
44
+ local dir state out status
45
+ dir=$(make_supercase afk-start-stale-pidfile)
46
+ state="$dir/state"
47
+ printf '%s\n' "$$" > "$state/.supervise-daemon.pid"
48
+
49
+ out=$(FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1)
50
+ status=$?
51
+
52
+ [ "$status" -ne 0 ] || fail "fm-afk-start.sh should attempt daemon startup instead of trusting a pidfile-only live pid"
53
+ assert_contains "$out" "starting supervise daemon" "fm-afk-start.sh did not attempt daemon startup"
54
+ assert_contains "$out" "does not support supervisor backend 'unsupported'" "daemon startup did not reach backend validation"
55
+ assert_not_contains "$out" "daemon already running" "fm-afk-start.sh trusted a stale pidfile-only live pid"
56
+ pass "fm-afk-start.sh ignores stale pidfile-only live pids"
57
+ }
58
+
59
+ test_afk_start_reclaims_stale_daemon_lock_reused_pid() {
60
+ local dir state out status lock
61
+ dir=$(make_supercase afk-start-stale-lock-reused-pid)
62
+ state="$dir/state"
63
+ lock="$state/.supervise-daemon.lock"
64
+ mkdir -p "$lock"
65
+ printf '%s\n' "$$" > "$state/.supervise-daemon.pid"
66
+ printf '%s\n' "$$" > "$lock/pid"
67
+ printf '%s\n' "stale daemon identity" > "$lock/pid-identity"
68
+
69
+ out=$(FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1)
70
+ status=$?
71
+
72
+ [ "$status" -ne 0 ] || fail "fm-afk-start.sh should attempt daemon startup after rejecting a reused-pid lock"
73
+ assert_contains "$out" "starting supervise daemon" "fm-afk-start.sh did not attempt daemon startup after rejecting the stale lock"
74
+ assert_contains "$out" "does not support supervisor backend 'unsupported'" "daemon startup did not reach backend validation after stale lock cleanup"
75
+ assert_not_contains "$out" "daemon already running" "fm-afk-start.sh trusted a stale daemon lock with a reused pid"
76
+ assert_not_contains "$out" "another fm-supervise-daemon is already running" "daemon singleton lock still trusted the reused pid"
77
+ pass "fm-afk-start.sh reclaims stale daemon locks whose live pid identity no longer matches"
78
+ }
79
+
80
+ test_daemon_state_root_uses_fm_home() {
81
+ local dir home override out
82
+ dir=$(make_supercase daemon-fm-home)
83
+ home="$dir/firstmate-home"
84
+ override="$dir/override-state"
85
+ mkdir -p "$home" "$override"
86
+
87
+ out=$(FM_HOME="$home" FM_STATE_OVERRIDE='' _state_root)
88
+ [ "$out" = "$home/state" ] || fail "daemon state root ignored FM_HOME: $out"
89
+
90
+ out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$override" _state_root)
91
+ [ "$out" = "$override" ] || fail "daemon state root ignored FM_STATE_OVERRIDE: $out"
92
+
93
+ pass "supervise daemon state root is scoped by FM_HOME"
94
+ }
95
+
96
+ # Byte size of a status log: the daemon records escalation progress as a
97
+ # position in the append-only stream, so a fixture that means "already escalated
98
+ # through here" writes that position.
99
+ log_size() { LC_ALL=C wc -c < "$1" | tr -d '[:space:]'; }
100
+
101
+ seen_through() { # <state> <task>
102
+ local state=$1 task=$2 key ident
103
+ key=$(printf '%s' "$task" | tr ':/.' '___')
104
+ ident=$(_fm_open_decisions_file_ident "$state/$task.status")
105
+ printf '%s@%s' "$(log_size "$state/$task.status")" "$ident" > "$state/.subsuper-seen-status-$key"
106
+ }
107
+
108
+ # The reported bug in away mode: the captain is away, a worker reports something
109
+ # the captain must hear, then keeps appending routine progress. Classifying only
110
+ # the last line self-handles the wake and the work stalls silently until the
111
+ # captain returns.
112
+ test_classify_signal_skips_turn_end_markers() {
113
+ local dir state reader turn status out
114
+ dir=$(make_supercase signal-turn-end); state="$dir/state"
115
+ reader="$dir/identity-reader"
116
+ printf '#!/usr/bin/env bash\nexit 1\n' > "$reader"; chmod +x "$reader"
117
+ turn="$state/task.turn-ended"; : > "$turn"
118
+ out=$(FM_STATUS_IDENTITY_READER="$reader" classify_signal "$turn" "$state")
119
+ case "$out" in self\|routine\ signal:*) ;;
120
+ *) fail "an empty turn-end marker was not routine under unavailable identity: $out" ;;
121
+ esac
122
+ status="$state/task.status"
123
+ printf 'blocked: release approval required\nworking: preparing notes\n' > "$status"
124
+ out=$(classify_signal "$turn $status" "$state")
125
+ case "$out" in escalate\|*"blocked: release approval required"*) ;;
126
+ *) fail "a mixed turn-end and actionable status batch did not name the status event: $out" ;;
127
+ esac
128
+ pass "turn-end markers stay routine while mixed actionable status batches escalate"
129
+ }
130
+
131
+ test_classify_signal_survives_a_later_routine_append() {
132
+ local dir state out
133
+ dir=$(make_supercase classify-masked)
134
+ state="$dir/state"
135
+
136
+ printf 'working: setup\nblocked [key=release]: cannot reach the release host\nfailed: release verification broke\nworking: retrying the upload\n' \
137
+ > "$state/mask-b1.status"
138
+ out=$(FM_STATE_OVERRIDE="$state" classify_signal "$state/mask-b1.status" "$state")
139
+ case "$out" in
140
+ escalate\|*) ;;
141
+ *) fail "a blocker hidden behind a later working: line was self-handled: $out" ;;
142
+ esac
143
+ case "$out" in
144
+ *"blocked [key=release]: cannot reach the release host"*"failed: release verification broke"*) ;;
145
+ *) fail "the escalation did not report every actionable event before its endpoint: $out" ;;
146
+ esac
147
+
148
+ # The captain-reported shape: a finished release/install followed by routine
149
+ # cleanup chatter must still reach an away captain.
150
+ printf 'working: publishing\ndone: release 1.4.0 published and installed\nworking: cleaning the build dir\n' \
151
+ > "$state/mask-r1.status"
152
+ out=$(FM_STATE_OVERRIDE="$state" classify_signal "$state/mask-r1.status" "$state")
153
+ case "$out" in
154
+ escalate\|*) ;;
155
+ *) fail "a release/install completion hidden behind later routine appends was self-handled: $out" ;;
156
+ esac
157
+ case "$out" in
158
+ *"done: release 1.4.0 published and installed"*) ;;
159
+ *) fail "the escalation named the routine line instead of the completion: $out" ;;
160
+ esac
161
+
162
+ # Once escalated through the end of the log, a further routine append is
163
+ # routine again: the fix must not turn every later signal into a re-escalation.
164
+ seen_through "$state" "mask-b1"
165
+ printf 'working: still retrying\n' >> "$state/mask-b1.status"
166
+ out=$(FM_STATE_OVERRIDE="$state" classify_signal "$state/mask-b1.status" "$state")
167
+ case "$out" in
168
+ self\|*) ;;
169
+ *) fail "a routine append after the blocker was escalated re-escalated it: $out" ;;
170
+ esac
171
+ pass "an actionable event is escalated to an away captain despite later routine appends"
172
+ }
173
+
174
+ # The away-mode backstop must reach the same event when the per-wake path never
175
+ # ran, which is exactly what it exists for.
176
+ test_classification_commits_its_captured_endpoint() {
177
+ local dir state capture out captured
178
+ dir=$(make_supercase captured-endpoint); state="$dir/state"
179
+ capture="$dir/endpoints"
180
+ printf 'done: first release complete\n' > "$state/race-r1.status"
181
+ out=$(FM_STATUS_SPAN_ENDPOINT_FILE="$capture" \
182
+ classify_signal "$state/race-r1.status" "$state")
183
+ case "$out" in escalate\|*) ;; *) fail "the first event did not classify actionable: $out" ;; esac
184
+ captured=$(log_size "$state/race-r1.status")
185
+ printf 'failed: second release verification failed\n' >> "$state/race-r1.status"
186
+ mark_escalated_seen "$state" "$capture"
187
+ [ "$(status_seen_offset "$state" race-r1)" = "$captured" ] \
188
+ || fail "the daemon advanced past bytes appended after classification"
189
+ out=$(classify_signal "$state/race-r1.status" "$state")
190
+ case "$out" in
191
+ escalate\|*"failed: second release verification failed"*) ;;
192
+ *) fail "an event appended between classification and commit was lost: $out" ;;
193
+ esac
194
+ pass "the daemon commits exactly the endpoint captured by classification"
195
+ }
196
+
197
+ test_stale_masked_event_escalates_at_captured_endpoint() {
198
+ local dir state key out
199
+ dir=$(make_supercase stale-masked); state="$dir/state"
200
+ printf 'blocked: release host unavailable\nworking: retrying upload\n' > "$state/stale-r2.status"
201
+ FM_ESCALATE_BATCH_SECS=999 handle_wake "stale: sess:fm-stale-r2" "$state"
202
+ out=$(cat "$state/.subsuper-escalations" 2>/dev/null || true)
203
+ case "$out" in *"blocked: release host unavailable"*) ;; *) fail "a stale wake hid the blocker behind routine progress: $out" ;; esac
204
+ key=$(printf '%s' stale-r2 | tr ':/.' '___')
205
+ [ "$(status_seen_offset "$state" stale-r2)" = "$(log_size "$state/stale-r2.status")" ] \
206
+ || fail "the stale escalation did not commit its captured endpoint"
207
+ pass "a stale wake escalates a blocker hidden by later progress"
208
+ }
209
+
210
+ test_stale_read_failure_surfaces_without_advancing_seen() {
211
+ local dir state key out
212
+ dir=$(make_supercase stale-unreadable); state="$dir/state"
213
+ printf 'working: retrying upload\n' > "$state/stale-r3.status"
214
+ key=$(printf '%s' stale-r3 | tr ':/.' '___')
215
+ printf '3@%s' "$(_fm_open_decisions_file_ident "$state/stale-r3.status")" \
216
+ > "$state/.subsuper-seen-status-$key"
217
+ (
218
+ # shellcheck disable=SC2329 # Invoked indirectly by the function under test.
219
+ _fm_status_read_span() { return 1; }
220
+ FM_ESCALATE_BATCH_SECS=999 handle_wake "stale: sess:fm-stale-r3" "$state"
221
+ )
222
+ out=$(cat "$state/.subsuper-escalations" 2>/dev/null || true)
223
+ case "$out" in *"unreadable status span"*) ;; *) fail "a stale span read failure was silently absorbed" ;; esac
224
+ [ "$(status_seen_offset "$state" stale-r3)" = 3 ] \
225
+ || fail "a stale span read failure advanced the daemon seen marker"
226
+ pass "a stale span read failure surfaces without advancing its marker"
227
+ }
228
+
229
+ test_recreated_status_rejects_captured_identity() {
230
+ local dir state reader record rest endpoint ident out
231
+ dir=$(make_supercase recreated-identity); state="$dir/state"
232
+ reader="$dir/identity-reader"
233
+ cat > "$reader" <<EOF
234
+ #!/usr/bin/env bash
235
+ cat "$dir/identity-value"
236
+ EOF
237
+ chmod +x "$reader"
238
+ printf '1:2:old-birth' > "$dir/identity-value"
239
+ printf 'done: old task complete\n' > "$state/reused-r4.status"
240
+ record=$(FM_STATUS_IDENTITY_READER="$reader" \
241
+ status_span_first_actionable_record "$state/reused-r4.status" 0)
242
+ endpoint=${record%%$'\t'*}; rest=${record#*$'\t'}; ident=${rest%%$'\t'*}
243
+ rm -f "$state/reused-r4.status"
244
+ printf 'blocked: replacement task needs captain\nworking: routine padding after replacement\n' \
245
+ > "$state/reused-r4.status"
246
+ printf '1:2:new-birth' > "$dir/identity-value"
247
+ FM_STATUS_IDENTITY_READER="$reader" mark_status_seen "$state" reused-r4 "$endpoint" "$ident"
248
+ [ ! -e "$state/.subsuper-seen-status-reused-r4" ] \
249
+ || fail "a stale captured identity advanced the replacement task marker"
250
+ out=$(FM_STATUS_IDENTITY_READER="$reader" classify_signal "$state/reused-r4.status" "$state")
251
+ case "$out" in escalate\|*"blocked: replacement task needs captain"*) ;;
252
+ *) fail "the replacement task blocker did not surface after identity rejection: $out" ;;
253
+ esac
254
+ pass "a recreated status rejects the old captured identity"
255
+ }
256
+
257
+ test_unverifiable_identity_surfaces_without_marker() {
258
+ local dir state reader key out
259
+ dir=$(make_supercase unverifiable-identity); state="$dir/state"
260
+ reader="$dir/identity-reader"
261
+ printf '#!/usr/bin/env bash\nexit 1\n' > "$reader"; chmod +x "$reader"
262
+ printf 'working: routine progress\n' > "$state/unknown-r5.status"
263
+ key=$(printf '%s' unknown-r5 | tr ':/.' '___')
264
+ (
265
+ FM_STATUS_IDENTITY_READER="$reader" FM_ESCALATE_BATCH_SECS=999 \
266
+ handle_wake "signal: $state/unknown-r5.status" "$state"
267
+ )
268
+ out=$(cat "$state/.subsuper-escalations" 2>/dev/null || true)
269
+ case "$out" in *"unreadable status span"*) ;; *) fail "an unverifiable identity was silently absorbed" ;; esac
270
+ [ "$(status_seen_offset "$state" unknown-r5)" = 0 ] \
271
+ || fail "an unverifiable identity advanced the daemon classification position"
272
+ pass "an unverifiable status identity surfaces without advancing markers"
273
+ }
274
+
275
+ test_status_read_failure_surfaces_without_advancing_seen() {
276
+ local dir state key out
277
+ dir=$(make_supercase unreadable-span); state="$dir/state"
278
+ printf 'done: release complete\n' > "$state/read-r1.status"
279
+ key=$(printf '%s' read-r1 | tr ':/.' '___')
280
+ printf '3@%s' "$(_fm_open_decisions_file_ident "$state/read-r1.status")" \
281
+ > "$state/.subsuper-seen-status-$key"
282
+ (
283
+ # shellcheck disable=SC2329 # Invoked indirectly by the function under test.
284
+ last_status_line() { return 0; }
285
+ # shellcheck disable=SC2329 # Invoked indirectly by the function under test.
286
+ _fm_status_read_span() { return 1; }
287
+ FM_ESCALATE_BATCH_SECS=999 handle_wake "signal: $state/read-r1.status" "$state"
288
+ )
289
+ out=$(cat "$state/.subsuper-escalations" 2>/dev/null || true)
290
+ case "$out" in *"unreadable status span"*) ;; *) fail "a status span read failure was silently absorbed" ;; esac
291
+ [ "$(status_seen_offset "$state" read-r1)" = 3 ] \
292
+ || fail "a status span read failure advanced the daemon seen marker"
293
+ pass "a status read failure surfaces without advancing the daemon suppressor"
294
+ }
295
+
296
+ test_catchall_advances_routine_then_surfaces_append() {
297
+ local dir state out
298
+ dir=$(make_supercase catchall-routine); state="$dir/state"
299
+ printf 'working: routine history\nworking: still routine\n' > "$state/routine-r6.status"
300
+ rm -f "$state/.subsuper-last-scan"
301
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
302
+ [ "$(status_seen_offset "$state" routine-r6)" = "$(log_size "$state/routine-r6.status")" ] \
303
+ || fail "routine catch-all classification did not advance its captured endpoint"
304
+ printf 'blocked: appended after routine endpoint\n' >> "$state/routine-r6.status"
305
+ rm -f "$state/.subsuper-last-scan"
306
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
307
+ out=$(cat "$state/.subsuper-escalations" 2>/dev/null || true)
308
+ case "$out" in *"blocked: appended after routine endpoint"*) ;;
309
+ *) fail "an actionable append after the routine endpoint did not surface: $out" ;;
310
+ esac
311
+ pass "catch-all routine scans advance before later actionable appends"
312
+ }
313
+
314
+ test_escalation_buffer_failure_retains_wake_and_position() {
315
+ local dir state fakebin buffer out
316
+ dir=$(make_supercase escalation-write-failure); state="$dir/state"; fakebin="$dir/daemon-bin"
317
+ buffer="$state/.subsuper-escalations"
318
+ printf 'blocked: release approval required\nworking: preparing notes\n' > "$state/write-r1.status"
319
+ mkdir -p "$fakebin" "$buffer"
320
+ cat > "$fakebin/fm-wake-drain.sh" <<EOF
321
+ #!/usr/bin/env bash
322
+ if [ "\${1:-}" = --ack-through ]; then printf '%s\n' ack >> "$dir/acked"; exit 0; fi
323
+ printf '1\t1\tsignal\twrite-r1.status\tsignal: $state/write-r1.status\n'
324
+ printf 'WAKE_ACK_REQUIRED: retry --ack-through 1 --recovery-generation gen\n' >&2
325
+ EOF
326
+ chmod +x "$fakebin/fm-wake-drain.sh"
327
+
328
+ ! FM_DAEMON_DIR="$fakebin" handle_durable_wakes fallback "$state" 2>/dev/null \
329
+ || fail "an unwritable escalation buffer acknowledged the wake"
330
+ [ ! -e "$dir/acked" ] || fail "a wake was acknowledged before its escalation was buffered"
331
+ [ "$(status_seen_offset "$state" write-r1)" = 0 ] \
332
+ || fail "a failed escalation append advanced the classification position"
333
+
334
+ rmdir "$buffer"
335
+ FM_DAEMON_DIR="$fakebin" handle_durable_wakes fallback "$state" \
336
+ || fail "the wake did not recover after the escalation buffer became writable"
337
+ out=$(cat "$buffer" 2>/dev/null || true)
338
+ case "$out" in *"blocked: release approval required"*) ;;
339
+ *) fail "the recovered wake did not buffer its actionable event: $out" ;;
340
+ esac
341
+ [ "$(status_seen_offset "$state" write-r1)" = "$(log_size "$state/write-r1.status")" ] \
342
+ || fail "successful buffering did not advance the classification position"
343
+ [ "$(wc -l < "$dir/acked" | tr -d ' ')" = 1 ] \
344
+ || fail "the recovered durable wake was not acknowledged exactly once"
345
+ pass "failed escalation writes retain durable wakes and classification positions"
346
+ }
347
+
348
+ test_catchall_buffer_failure_preserves_position() {
349
+ local dir state buffer out
350
+ dir=$(make_supercase catchall-write-failure); state="$dir/state"
351
+ buffer="$state/.subsuper-escalations"
352
+ printf 'failed: release verification broke\nworking: collecting logs\n' > "$state/catch-write-r2.status"
353
+ mkdir "$buffer"
354
+ rm -f "$state/.subsuper-last-scan"
355
+ FM_STATE_OVERRIDE="$state" housekeeping "$state" 2>/dev/null || true
356
+ [ "$(status_seen_offset "$state" catch-write-r2)" = 0 ] \
357
+ || fail "a failed catch-all append advanced the classification position"
358
+
359
+ rmdir "$buffer"
360
+ rm -f "$state/.subsuper-last-scan"
361
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
362
+ out=$(cat "$buffer" 2>/dev/null || true)
363
+ case "$out" in *"failed: release verification broke"*) ;;
364
+ *) fail "the catch-all did not retry its actionable event after recovery: $out" ;;
365
+ esac
366
+ [ "$(status_seen_offset "$state" catch-write-r2)" = "$(log_size "$state/catch-write-r2.status")" ] \
367
+ || fail "the recovered catch-all did not advance its classification position"
368
+ pass "catch-all markers advance only after escalation buffering succeeds"
369
+ }
370
+
371
+ test_durable_wake_failure_retains_entire_batch() {
372
+ local dir state fakebin attempts
373
+ dir=$(make_supercase durable-failure); state="$dir/state"; fakebin="$dir/daemon-bin"; attempts="$dir/attempts"
374
+ mkdir -p "$fakebin"
375
+ cat > "$fakebin/fm-wake-drain.sh" <<EOF
376
+ #!/usr/bin/env bash
377
+ if [ "\${1:-}" = --ack-through ]; then printf ack > "$dir/acked"; exit 0; fi
378
+ printf '1\t1\tsignal\ttask.status\tsignal: first\n1\t2\theartbeat\theartbeat\theartbeat\n'
379
+ printf 'WAKE_ACK_REQUIRED: retry --ack-through 2 --recovery-generation gen\n' >&2
380
+ EOF
381
+ chmod +x "$fakebin/fm-wake-drain.sh"
382
+ (
383
+ FM_DAEMON_DIR="$fakebin"
384
+ handle_wake() { printf '%s\n' "$1" >> "$attempts"; [ "$1" != 'signal: first' ]; }
385
+ ! handle_durable_wakes fallback "$state"
386
+ ) || fail "a failed wake classification was acknowledged"
387
+ [ "$(wc -l < "$attempts" | tr -d ' ')" = 2 ] \
388
+ || fail "a failed wake prevented later batch entries from being accounted"
389
+ [ ! -e "$dir/acked" ] || fail "a partially handled durable batch was acknowledged"
390
+ pass "classification failure retains the complete durable wake batch"
391
+ }
392
+
393
+ test_missing_status_stale_is_acknowledged_without_diagnostic() {
394
+ local dir state fakebin
395
+ dir=$(make_supercase durable-no-status); state="$dir/state"; fakebin="$dir/daemon-bin"
396
+ mkdir -p "$fakebin"
397
+ cat > "$fakebin/fm-wake-drain.sh" <<EOF
398
+ #!/usr/bin/env bash
399
+ if [ "\${1:-}" = --ack-through ]; then printf '%s\n' ack >> "$dir/acked"; exit 0; fi
400
+ printf '1\t1\tstale\tmissing-r8\tstale: sess:fm-missing-r8\n'
401
+ printf 'WAKE_ACK_REQUIRED: ordinary --ack-through 1 --recovery-generation gen\n' >&2
402
+ EOF
403
+ chmod +x "$fakebin/fm-wake-drain.sh"
404
+ FM_DAEMON_DIR="$fakebin" handle_durable_wakes fallback "$state" \
405
+ || fail "a stale wake without a status file was retained for retry"
406
+ FM_DAEMON_DIR="$fakebin" handle_durable_wakes fallback "$state" \
407
+ || fail "repeated missing-status handling became a classification failure"
408
+ [ "$(wc -l < "$dir/acked" | tr -d ' ')" = 2 ] \
409
+ || fail "a missing-status stale wake was not acknowledged"
410
+ [ ! -s "$state/.subsuper-escalations" ] \
411
+ || fail "a missing status file produced an unreadable-span escalation"
412
+ pass "missing-status stale wakes remain ordinary and acknowledgeable"
413
+ }
414
+
415
+ test_transient_unreadable_signal_recovers_without_advancing() {
416
+ local dir state fakebin out key
417
+ dir=$(make_supercase durable-unreadable); state="$dir/state"; fakebin="$dir/daemon-bin"
418
+ printf 'blocked: status cannot be classified\n' > "$state/unreadable-r7.status"
419
+ mkdir -p "$fakebin"
420
+ cat > "$fakebin/fm-wake-drain.sh" <<EOF
421
+ #!/usr/bin/env bash
422
+ if [ "\${1:-}" = --ack-through ]; then printf '%s\n' ack >> "$dir/acked"; exit 0; fi
423
+ printf '1\t1\tsignal\tunreadable-r7.status\tsignal: $state/unreadable-r7.status\n'
424
+ printf 'WAKE_ACK_REQUIRED: retry --ack-through 1 --recovery-generation gen\n' >&2
425
+ EOF
426
+ chmod +x "$fakebin/fm-wake-drain.sh"
427
+ (
428
+ FM_DAEMON_DIR="$fakebin"
429
+ # shellcheck disable=SC2329 # Invoked indirectly by the function under test.
430
+ _fm_status_read_span() { return 1; }
431
+ handle_durable_wakes fallback "$state"
432
+ ) || fail "an unreadable signal did not acknowledge its wake after reporting"
433
+ out=$(cat "$state/.subsuper-escalations" 2>/dev/null || true)
434
+ case "$out" in *"unreadable status span"*) ;;
435
+ *) fail "an unreadable durable signal did not surface its diagnostic: $out" ;;
436
+ esac
437
+ key=$(printf '%s' unreadable-r7 | tr ':/.' '___')
438
+ [ "$(status_seen_offset "$state" unreadable-r7)" = 0 ] \
439
+ || fail "an unreadable signal advanced its classification position"
440
+ FM_DAEMON_DIR="$fakebin" handle_durable_wakes fallback "$state" \
441
+ || fail "a readable status did not recover after a transient failure"
442
+ out=$(cat "$state/.subsuper-escalations" 2>/dev/null || true)
443
+ case "$out" in *"blocked: status cannot be classified"*) ;;
444
+ *) fail "the recovered status was not classified from its original position: $out" ;;
445
+ esac
446
+ pass "transient unreadable signals recover without advancing their position"
447
+ }
448
+
449
+ test_permission_recovery_reclassifies_catchall_status() {
450
+ local dir state status before_ident after_ident out
451
+ dir=$(make_supercase catchall-permission-recovery); state="$dir/state"
452
+ status="$state/permission-r8.status"
453
+ printf 'blocked: release approval required\nworking: preserving context\n' > "$status"
454
+ before_ident=$(_fm_open_decisions_file_ident "$status")
455
+ chmod 000 "$status"
456
+ if [ -r "$status" ]; then
457
+ chmod 600 "$status"
458
+ pass "daemon permission recovery skipped because permissions cannot deny reads"
459
+ return
460
+ fi
461
+
462
+ rm -f "$state/.subsuper-last-scan"
463
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
464
+ [ "$(status_seen_offset "$state" permission-r8)" = 0 ] \
465
+ || { chmod 600 "$status"; fail "an unreadable catch-all status advanced its classification position"; }
466
+ rm -f "$state/.subsuper-last-scan"
467
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
468
+ [ "$(grep -c 'unreadable status span' "$state/.subsuper-escalations")" = 1 ] \
469
+ || { chmod 600 "$status"; fail "an unchanged unreadable catch-all status reported repeatedly"; }
470
+
471
+ chmod 600 "$status"
472
+ after_ident=$(_fm_open_decisions_file_ident "$status")
473
+ [ "$after_ident" = "$before_ident" ] || fail "permission recovery changed the catch-all file identity"
474
+ rm -f "$state/.subsuper-last-scan"
475
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
476
+ out=$(cat "$state/.subsuper-escalations" 2>/dev/null || true)
477
+ case "$out" in *"blocked: release approval required"*) ;;
478
+ *) fail "the catch-all did not surface preserved content after readability recovery: $out" ;;
479
+ esac
480
+ [ "$(status_seen_offset "$state" permission-r8)" = "$(log_size "$status")" ] \
481
+ || fail "the catch-all did not classify from the unadvanced position"
482
+ pass "daemon catch-all reclassifies permission-recovered status content"
483
+ }
484
+
485
+ # A permanently unclassifiable status must not wedge supervision. The accepted
486
+ # contract is deliberately simple: report it, acknowledge the wake so an unchanged
487
+ # permanent failure cannot re-alarm on every pass, and never advance the
488
+ # classification position, so the log is classified from where it stopped once it
489
+ # becomes readable. The residual risk - no guaranteed automatic retry inside a
490
+ # crash-mid-read window - is accepted and covered by the locked startup replay.
491
+ test_permanent_classification_failure_is_reported_and_acknowledged() {
492
+ local dir state fakebin out
493
+ dir=$(make_supercase durable-symlink); state="$dir/state"; fakebin="$dir/daemon-bin"
494
+ printf 'blocked: first target\n' > "$dir/target-one"
495
+ ln -s "$dir/target-one" "$state/symlink-r9.status"
496
+ mkdir -p "$fakebin"
497
+ cat > "$fakebin/fm-wake-drain.sh" <<EOF
498
+ #!/usr/bin/env bash
499
+ if [ "\${1:-}" = --ack-through ]; then printf '%s\n' ack >> "$dir/acked"; exit 0; fi
500
+ printf '1\t1\tsignal\tsymlink-r9.status\tsignal: $state/symlink-r9.status\n'
501
+ printf 'WAKE_ACK_REQUIRED: bounded --ack-through 1 --recovery-generation gen\n' >&2
502
+ EOF
503
+ chmod +x "$fakebin/fm-wake-drain.sh"
504
+
505
+ FM_DAEMON_DIR="$fakebin" handle_durable_wakes fallback "$state" \
506
+ || fail "a permanent classification failure left its wake unacknowledged"
507
+ out=$(cat "$state/.subsuper-escalations" 2>/dev/null || true)
508
+ case "$out" in *"unreadable status span"*) ;;
509
+ *) fail "a permanent classification failure did not surface its diagnostic: $out" ;;
510
+ esac
511
+ [ "$(status_seen_offset "$state" symlink-r9)" = 0 ] \
512
+ || fail "a classification failure advanced its position"
513
+
514
+ rm -f "$state/.subsuper-last-scan"
515
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
516
+ FM_DAEMON_DIR="$fakebin" handle_durable_wakes fallback "$state" \
517
+ || fail "a repeated permanent failure retained its wake"
518
+ [ "$(grep -c 'unreadable status span' "$state/.subsuper-escalations")" = 1 ] \
519
+ || fail "an unchanged failure was reported more than once across daemon paths"
520
+ [ "$(status_seen_offset "$state" symlink-r9)" = 0 ] \
521
+ || fail "a repeated classification failure advanced its position"
522
+
523
+ printf 'blocked: changed target state with a longer path\n' > "$dir/target-two-longer"
524
+ ln -snf "$dir/target-two-longer" "$state/symlink-r9.status"
525
+ FM_DAEMON_DIR="$fakebin" handle_durable_wakes fallback "$state" \
526
+ || fail "a changed permanent failure retained its wake"
527
+ [ "$(grep -c 'unreadable status span' "$state/.subsuper-escalations")" = 2 ] \
528
+ || fail "a changed failure state did not report again exactly once"
529
+ [ "$(status_seen_offset "$state" symlink-r9)" = 0 ] \
530
+ || fail "a changed classification failure advanced its position"
531
+
532
+ # Once the log is readable, its content is classified from the position that
533
+ # was never advanced, so nothing written before the failure is lost.
534
+ rm -f "$state/symlink-r9.status"
535
+ printf 'blocked: readable replacement\nworking: cleanup\n' > "$state/symlink-r9.status"
536
+ : > "$state/.subsuper-escalations"
537
+ FM_DAEMON_DIR="$fakebin" handle_durable_wakes fallback "$state" \
538
+ || fail "a readable replacement did not classify normally"
539
+ out=$(cat "$state/.subsuper-escalations" 2>/dev/null || true)
540
+ case "$out" in *"blocked: readable replacement"*) ;;
541
+ *) fail "the readable replacement was not classified from the unadvanced position: $out" ;;
542
+ esac
543
+ [ "$(status_seen_offset "$state" symlink-r9)" = "$(log_size "$state/symlink-r9.status")" ] \
544
+ || fail "successful recovery did not advance through the readable replacement"
545
+ [ "$(wc -l < "$dir/acked" | tr -d ' ')" = 4 ] \
546
+ || fail "every durable wake should be acknowledged, including the failures"
547
+ pass "a permanent classification failure is reported, acknowledged, and never advances its position"
548
+ }
549
+
550
+ test_catchall_scan_surfaces_a_masked_event() {
551
+ local dir state
552
+ dir=$(make_supercase catchall-masked)
553
+ state="$dir/state"
554
+ printf 'working: setup\nneeds-decision [key=release]: pick A or B\nfailed: release build broke\nworking: tidying the branch\n' \
555
+ > "$state/catch-m1.status"
556
+ rm -f "$state/.subsuper-last-scan"
557
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
558
+ [ -s "$state/.subsuper-escalations" ] \
559
+ || fail "the catch-all scan missed a decision hidden behind a later working: line"
560
+ grep -F "needs-decision [key=release]: pick A or B" "$state/.subsuper-escalations" >/dev/null \
561
+ || fail "the catch-all scan omitted the decision it found"
562
+ grep -F "failed: release build broke" "$state/.subsuper-escalations" >/dev/null \
563
+ || fail "the catch-all scan committed past a failure it did not report"
564
+ # And it records progress, so the next scan does not re-fire the same event.
565
+ : > "$state/.subsuper-escalations"
566
+ rm -f "$state/.subsuper-last-scan"
567
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
568
+ [ ! -s "$state/.subsuper-escalations" ] \
569
+ || fail "the catch-all scan re-fired an event it had already escalated"
570
+ pass "the away-mode catch-all scan surfaces a masked event once"
571
+ }
572
+
573
+ test_classify_routine_signal_self() {
574
+ local dir state out
575
+ dir=$(make_supercase classify-routine)
576
+ state="$dir/state"
577
+ printf 'working: step 1\nworking: step 2\n' > "$state/foo-x1.status"
578
+ out=$(FM_STATE_OVERRIDE="$state" classify_signal "$state/foo-x1.status" "$state")
579
+ case "$out" in self\|*) pass "routine signal self-handles" ;; *) fail "routine signal did not self-handle: $out" ;; esac
580
+ }
581
+
582
+ test_classify_terminal_signal_escalates() {
583
+ local dir state kw out
584
+ dir=$(make_supercase classify-terminal)
585
+ state="$dir/state"
586
+ for kw in "done: PR https://x/y/pull/1" "needs-decision: pick A" "blocked: no perms" \
587
+ "failed: rc 2" "PR ready https://x/y/pull/2" "checks green" \
588
+ "ready in branch fm/t1" "merged"; do
589
+ printf 'working\n%s\n' "$kw" > "$state/t.status"
590
+ out=$(FM_STATE_OVERRIDE="$state" classify_signal "$state/t.status" "$state")
591
+ case "$out" in escalate\|*) ;; *) fail "captain verb did not escalate ($kw): $out" ;; esac
592
+ done
593
+ pass "captain-relevant status verbs escalate"
594
+ }
595
+
596
+ test_classify_check_and_unknown_escalate() {
597
+ local out
598
+ out=$(classify_check "check: /s/c.check.sh: merged: https://x")
599
+ case "$out" in escalate\|*) ;; *) fail "check did not escalate: $out" ;; esac
600
+ out=$(classify_unknown "frobnicate: weird")
601
+ case "$out" in escalate\|*) ;; *) fail "unknown did not fail-safe escalate: $out" ;; esac
602
+ out=$(classify_heartbeat)
603
+ case "$out" in self\|*) ;; *) fail "heartbeat did not self-handle: $out" ;; esac
604
+ pass "check + unknown escalate; heartbeat self-handles"
605
+ }
606
+
607
+ test_stale_transient_self_records_marker() {
608
+ local dir state out key
609
+ dir=$(make_supercase stale-transient)
610
+ state="$dir/state"
611
+ printf 'working: building\n' > "$state/qux-w4.status"
612
+ stale_marker_record "sess:fm-qux-w4" "$state"
613
+ out=$(FM_STATE_OVERRIDE="$state" classify_stale "sess:fm-qux-w4" "$state")
614
+ case "$out" in self\|*) ;; *) fail "transient stale did not self-handle: $out" ;; esac
615
+ key=$(printf '%s' "$(window_to_task "sess:fm-qux-w4")" | tr ':/.' '___')
616
+ [ -e "$state/.subsuper-stale-$key" ] || fail "stale marker was not recorded"
617
+ pass "transient stale self-handles and records a persistence marker"
618
+ }
619
+
620
+ test_stale_diagnostic_wedge_survives_busy_housekeeping() {
621
+ local case_name dir state fakebin key task win pane reason status_line action_log
622
+ for case_name in working prior-terminal paused; do
623
+ dir=$(make_supercase "stale-diagnostic-$case_name")
624
+ state="$dir/state"
625
+ fakebin="$dir/fakebin"
626
+ task="suffix-$case_name"
627
+ win="sess:fm-$task"
628
+ pane="$dir/pane.txt"
629
+ action_log="$dir/actions.log"
630
+ reason="stale: $win (idle 500s, possible wedge, escalation 3, demand-deep-inspection: same pane has wedge-escalated 3 times in a row - do not re-absorb on the run-step/pane state alone)"
631
+ fm_write_meta "$state/$task.meta" "window=$win" "backend=tmux"
632
+ case "$case_name" in
633
+ working) status_line='working: building' ;;
634
+ prior-terminal) status_line='done: already surfaced' ;;
635
+ paused) status_line='paused: awaiting an external dependency' ;;
636
+ esac
637
+ printf '%s\n' "$status_line" > "$state/$task.status"
638
+ printf 'Working...\n' > "$pane"
639
+ key=$(printf '%s' "$task" | tr ':/.' '___')
640
+ echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key"
641
+ [ "$case_name" = prior-terminal ] \
642
+ && seen_through "$state" "$task"
643
+ [ "$case_name" = paused ] \
644
+ && echo $(( $(date +%s) - 500 )) > "$state/.subsuper-paused-$key"
645
+
646
+ (
647
+ kill() { printf 'kill %s\n' "$*" >> "$action_log"; }
648
+ fm_backend_send_text_submit() { printf 'interrupt %s\n' "$*" >> "$action_log"; }
649
+ LOG="$dir/daemon.log" FM_STATE_OVERRIDE="$state" handle_wake "$reason" "$state"
650
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
651
+ FM_STATE_OVERRIDE="$state" FM_ESCALATE_BATCH_SECS=999999 housekeeping "$state"
652
+ )
653
+ case "$case_name" in
654
+ paused)
655
+ # A current declared wait owns the cadence: the enriched wedge routes to the
656
+ # bounded PAUSE_RESURFACE_SECS recheck instead of escalating on the wedge
657
+ # cadence. test_enriched_wedge_under_declared_wait_uses_pause_cadence pins
658
+ # the full cadence, including the one recheck that still re-surfaces it.
659
+ [ ! -s "$state/.subsuper-escalations" ] \
660
+ || fail "paused enriched wedge escalated instead of routing to the pause cadence: $(cat "$state/.subsuper-escalations")"
661
+ [ -e "$state/.subsuper-paused-$key" ] \
662
+ || fail "paused enriched wedge erased ordinary pause tracking" ;;
663
+ *)
664
+ [ "$(wc -l < "$state/.subsuper-escalations" | tr -d ' ')" = 1 ] \
665
+ || fail "$case_name enriched wedge did not produce exactly one escalation"
666
+ grep -F "${reason#stale: }" "$state/.subsuper-escalations" >/dev/null \
667
+ || fail "$case_name enriched wedge lost its demand-deep-inspection detail"
668
+ [ ! -e "$state/.subsuper-paused-$key" ] \
669
+ || fail "$case_name enriched wedge created pause tracking" ;;
670
+ esac
671
+ [ ! -e "$state/.subsuper-stale-$key" ] \
672
+ || fail "$case_name enriched wedge retained ordinary stale tracking"
673
+ [ ! -s "$action_log" ] \
674
+ || fail "$case_name enriched wedge interrupted or killed the busy worker"
675
+ done
676
+ pass "enriched stale wedges bypass status absorption except under a declared wait, without disturbing busy workers"
677
+ }
678
+
679
+ # The second half of issue #3149. The watcher's wedge timer emits an enriched
680
+ # "idle Ns, possible wedge, escalation N" reason for any pane it reads as frozen -
681
+ # including one whose crew has a CURRENT declared wait, because the watcher's own
682
+ # provably-working classification and the crew's status line can disagree (a crew
683
+ # that declares `paused:` while its no-mistakes run is still attributed to its code
684
+ # reads `working` to pause_state_class and takes the wedge timer). handle_wake's
685
+ # enriched-wedge override force-escalated every such reason, discarding the `pause`
686
+ # verdict classify_stale had already returned for the same pane, so a healthy
687
+ # declared wait was escalated once per STALE_ESCALATE_SECS for as long as it lasted.
688
+ # A declaration is categorically stronger than the run-step/pane state the enriched
689
+ # reason tells the supervisor not to re-absorb on, so it routes the pane to the long
690
+ # PAUSE_RESURFACE_SECS recheck instead. This drives repeated enriched wedges through
691
+ # the real handle_wake/housekeeping pair and asserts the cadence, not just one wake.
692
+ test_enriched_wedge_under_declared_wait_uses_pause_cadence() {
693
+ local dir state fakebin task win pane key reason i escalations
694
+ dir=$(make_supercase enriched-wedge-declared-wait)
695
+ state="$dir/state"; fakebin="$dir/fakebin"
696
+ task=paused-wedge-w1; win="sess:fm-$task"; pane="$dir/pane.txt"
697
+ key=$(printf '%s' "$task" | tr ':/.' '___')
698
+ fm_write_meta "$state/$task.meta" "window=$win" "backend=tmux"
699
+ printf 'working: dispatching the long audit\npaused: the audit engine is running to completion\n' \
700
+ > "$state/$task.status"
701
+ printf 'idle prompt $\n' > "$pane"
702
+ case "$(FM_STATE_OVERRIDE="$state" classify_stale "$win" "$state")" in
703
+ pause\|*) ;;
704
+ *) fail "the fixture's own classifier verdict is not a pause, so this case pins nothing about the override" ;;
705
+ esac
706
+
707
+ # Four consecutive wedge-cadence deliveries, exactly as the watcher emits them once
708
+ # a pane crosses STALE_ESCALATE_SECS repeatedly.
709
+ for i in 2 3 4 5; do
710
+ if [ "$i" -ge 3 ]; then
711
+ # Past FM_WEDGE_DEMAND_INSPECT_COUNT the watcher enriches the same reason with
712
+ # its demand-deep-inspection marker; a declaration outranks both forms.
713
+ reason="stale: $win (idle 250s, possible wedge, escalation $i, demand-deep-inspection: same pane has wedge-escalated $i times in a row - do not re-absorb on the run-step/pane state alone)"
714
+ else
715
+ reason="stale: $win (idle 250s, possible wedge, escalation $i)"
716
+ fi
717
+ LOG="$dir/daemon.log" FM_STATE_OVERRIDE="$state" handle_wake "$reason" "$state"
718
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
719
+ FM_STATE_OVERRIDE="$state" FM_ESCALATE_BATCH_SECS=999999 \
720
+ FM_STALE_ESCALATE_SECS=240 FM_PAUSE_RESURFACE_SECS=3600 housekeeping "$state"
721
+ done
722
+
723
+ [ ! -s "$state/.subsuper-escalations" ] \
724
+ || fail "a declared wait escalated inside one PAUSE_RESURFACE_SECS window: $(cat "$state/.subsuper-escalations")"
725
+ [ -e "$state/.subsuper-paused-$key" ] \
726
+ || fail "an enriched wedge under a declared wait did not record pause tracking"
727
+ [ ! -e "$state/.subsuper-stale-$key" ] \
728
+ || fail "an enriched wedge under a declared wait left wedge aging in place"
729
+
730
+ # Past PAUSE_RESURFACE_SECS the wait must re-surface exactly once as an
731
+ # awaiting-external recheck (never a wedge) and reset its window.
732
+ echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-paused-$key"
733
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
734
+ FM_STATE_OVERRIDE="$state" FM_ESCALATE_BATCH_SECS=999999 FM_PAUSE_RESURFACE_SECS=3600 \
735
+ housekeeping "$state"
736
+ escalations=0
737
+ [ -s "$state/.subsuper-escalations" ] \
738
+ && escalations=$(wc -l < "$state/.subsuper-escalations" | tr -d ' ')
739
+ [ "$escalations" = 1 ] || fail "the pause window produced $escalations escalations, expected exactly one recheck"
740
+ grep -F "awaiting external" "$state/.subsuper-escalations" >/dev/null \
741
+ || fail "the one pause-window escalation was not an awaiting-external recheck"
742
+ grep -F "possible wedge" "$state/.subsuper-escalations" >/dev/null \
743
+ && fail "the pause-window recheck was mislabeled a possible wedge"
744
+
745
+ # A later status append that stops declaring the wait ends the routing: the same
746
+ # enriched wedge escalates again, unchanged.
747
+ : > "$state/.subsuper-escalations"
748
+ printf 'working: the audit finished, resuming\n' >> "$state/$task.status"
749
+ reason="stale: $win (idle 250s, possible wedge, escalation 6)"
750
+ LOG="$dir/daemon.log" FM_STATE_OVERRIDE="$state" handle_wake "$reason" "$state"
751
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
752
+ FM_STATE_OVERRIDE="$state" FM_ESCALATE_BATCH_SECS=999999 FM_PAUSE_RESURFACE_SECS=3600 \
753
+ housekeeping "$state"
754
+ grep -F "${reason#stale: }" "$state/.subsuper-escalations" >/dev/null \
755
+ || fail "wedge escalation was not restored after the crew left its declared wait"
756
+ [ ! -e "$state/.subsuper-paused-$key" ] \
757
+ || fail "pause tracking survived a status append that no longer declares the wait"
758
+ pass "an enriched wedge under a declared wait uses the pause cadence and restores wedge detection on resume"
759
+ }
760
+
761
+ test_stale_terminal_escalates() {
762
+ local dir state out
763
+ dir=$(make_supercase stale-terminal)
764
+ state="$dir/state"
765
+ printf 'done: ready in branch fm/t1\n' > "$state/fin-t5.status"
766
+ out=$(FM_STATE_OVERRIDE="$state" classify_stale "sess:fm-fin-t5" "$state")
767
+ case "$out" in escalate\|*) ;; *) fail "terminal stale did not escalate: $out" ;; esac
768
+ fm_write_meta "$state/herdr-t5.meta" "window=default:w1:p2" "backend=herdr"
769
+ printf 'done: ready in branch fm/herdr\n' > "$state/herdr-t5.status"
770
+ out=$(FM_STATE_OVERRIDE="$state" classify_stale "default:w1:p2" "$state")
771
+ case "$out" in escalate\|*) ;; *) fail "terminal herdr stale did not escalate through metadata: $out" ;; esac
772
+ pass "stale + terminal status escalates immediately"
773
+ }
774
+
775
+ test_stale_actionable_wait_escalates_and_keeps_pause_cadence() {
776
+ local dir state win key out reason resumed_win resumed_key
777
+ dir=$(make_supercase stale-actionable-wait); state="$dir/state"
778
+ win="sess:fm-waiting-r10"; key=$(printf '%s' waiting-r10 | tr ':/.' '___')
779
+ printf 'blocked [key=release]: need captain approval\npaused: waiting for release access\n' \
780
+ > "$state/waiting-r10.status"
781
+
782
+ out=$(FM_STATE_OVERRIDE="$state" classify_stale "$win" "$state")
783
+ case "$out" in escalate\|*"blocked [key=release]: need captain approval"*) ;;
784
+ *) fail "a current wait hid an unreported blocker from stale classification: $out" ;;
785
+ esac
786
+ reason="stale: $win (idle 250s, possible wedge, escalation 3, demand-deep-inspection: inspect the repeated wedge)"
787
+ FM_ESCALATE_BATCH_SECS=999 handle_wake "$reason" "$state"
788
+ out=$(cat "$state/.subsuper-escalations" 2>/dev/null || true)
789
+ case "$out" in *"blocked [key=release]: need captain approval"*) ;;
790
+ *) fail "the stale escalation did not name the blocker behind the current wait: $out" ;;
791
+ esac
792
+ [ -e "$state/.subsuper-paused-$key" ] \
793
+ || fail "an actionable current wait did not retain its pause cadence"
794
+ [ ! -e "$state/.subsuper-stale-$key" ] \
795
+ || fail "an actionable current wait was also aged as a wedge"
796
+
797
+ resumed_win="sess:fm-resumed-r10"; resumed_key=$(printf '%s' resumed-r10 | tr ':/.' '___')
798
+ printf 'paused: old wait\nworking: resumed after access arrived\n' > "$state/resumed-r10.status"
799
+ printf '1' > "$state/.subsuper-paused-$resumed_key"
800
+ FM_ESCALATE_BATCH_SECS=999 handle_wake "stale: $resumed_win" "$state"
801
+ [ ! -e "$state/.subsuper-paused-$resumed_key" ] \
802
+ || fail "an older pause declaration kept a resumed crew on pause cadence"
803
+ [ -e "$state/.subsuper-stale-$resumed_key" ] \
804
+ || fail "a resumed crew did not return to ordinary stale aging"
805
+ pass "stale escalation and current wait cadence remain independent"
806
+ }
807
+
808
+ # A DECLARED external-wait pause (paused:) is neither a wedge nor a terminal
809
+ # escalation: classify_stale returns the `pause` action so handle_wake records a
810
+ # pause marker (long re-surface cadence) rather than a wedge stale marker.
811
+ test_stale_paused_classifies_pause() {
812
+ local dir state out pause_reason
813
+ dir=$(make_supercase stale-paused)
814
+ state="$dir/state"
815
+ pause_reason='paused: waiting for upstream checks green, merged, and blocked state to clear'
816
+ status_is_captain_relevant "$pause_reason" && fail "pause reason phrases made the status captain-relevant"
817
+ printf '%s\n' "$pause_reason" > "$state/held-w9.status"
818
+ out=$(FM_STATE_OVERRIDE="$state" classify_stale "sess:fm-held-w9" "$state")
819
+ case "$out" in pause\|*) ;; *) fail "declared pause did not classify as pause: $out" ;; esac
820
+ pass "paused reasons with captain phrases remain pause-classified"
821
+ }
822
+
823
+ # A verified captain-held transfer is the other declaration that leaves an idle pane
824
+ # EXPECTED, so it earns the same pause action as paused: rather than being aged as a
825
+ # wedge. The wait itself is already durable in the captain-held backlog task.
826
+ test_stale_captain_held_classifies_pause() {
827
+ local dir state out held_reason
828
+ dir=$(make_supercase stale-captain-held)
829
+ state="$dir/state"
830
+ held_reason='captain-held [key=route]: tracked by task-decision-route'
831
+ status_is_captain_relevant "$held_reason" && fail "a captain-held transfer line was treated as captain-relevant"
832
+ printf '%s\n' "$held_reason" > "$state/held-w9h.status"
833
+ out=$(FM_STATE_OVERRIDE="$state" classify_stale "sess:fm-held-w9h" "$state")
834
+ case "$out" in pause\|*) ;; *) fail "captain-held transfer did not classify as pause: $out" ;; esac
835
+ pass "a captain-held transfer classifies as pause, not as a wedge candidate"
836
+ }
837
+
838
+ # handle_wake on a paused stale records a pause marker, drops any pre-existing wedge
839
+ # marker (so a working->paused pane is not still wedge-aged), and does NOT escalate
840
+ # on the wake itself - the recheck is housekeeping's job on the long cadence.
841
+ test_handle_wake_paused_records_pause_marker() {
842
+ local dir state key win
843
+ dir=$(make_supercase handle-paused)
844
+ state="$dir/state"
845
+ win="sess:fm-held-w10"
846
+ printf 'paused: awaiting the vendor rate-limit reset\n' > "$state/held-w10.status"
847
+ key=$(printf '%s' "held-w10" | tr ':/.' '___')
848
+ date +%s > "$state/.subsuper-stale-$key"
849
+ FM_STATE_OVERRIDE="$state" handle_wake "stale: $win" "$state"
850
+ [ -e "$state/.subsuper-paused-$key" ] || fail "pause marker not recorded by handle_wake"
851
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "wedge marker not cleared when the crew declared a pause"
852
+ [ ! -s "$state/.subsuper-escalations" ] || fail "a declared pause escalated on the wake itself (should defer to the long recheck)"
853
+ pass "handle_wake on a paused stale records a pause marker, drops the wedge marker, and does not escalate"
854
+ }
855
+
856
+ test_handle_wake_paused_signal_records_pause_marker() {
857
+ local dir state key win
858
+ dir=$(make_supercase handle-paused-signal)
859
+ state="$dir/state"
860
+ win="sess:fm-held-w10-signal"
861
+ printf 'window=%s\nkind=ship\n' "$win" > "$state/held-w10-signal.meta"
862
+ printf 'paused: awaiting the vendor rate-limit reset\n' > "$state/held-w10-signal.status"
863
+ key=$(printf '%s' "held-w10-signal" | tr ':/.' '___')
864
+ date +%s > "$state/.subsuper-stale-$key"
865
+ FM_STATE_OVERRIDE="$state" handle_wake "signal: $state/held-w10-signal.status" "$state"
866
+ [ -e "$state/.subsuper-paused-$key" ] || fail "pause signal did not record a pause marker"
867
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "pause signal did not clear the wedge marker"
868
+ [ ! -s "$state/.subsuper-escalations" ] || fail "a declared pause signal escalated instead of self-handling"
869
+ pass "handle_wake records a declared pause from a routine signal for long-cadence rechecks"
870
+ }
871
+
872
+ test_handle_wake_terminal_signal_clears_pause_tracking() {
873
+ local dir state key watcher_key win
874
+ dir=$(make_supercase handle-terminal-signal)
875
+ state="$dir/state"
876
+ win="sess:fm-held-w10-terminal"
877
+ printf 'window=%s\nkind=ship\n' "$win" > "$state/held-w10-terminal.meta"
878
+ printf 'done: upstream landed\n' > "$state/held-w10-terminal.status"
879
+ key=$(printf '%s' "held-w10-terminal" | tr '.:/' '___')
880
+ watcher_key=$(printf '%s' "$win" | tr '.:/' '___')
881
+ date +%s > "$state/.subsuper-paused-$key"
882
+ date +%s > "$state/.subsuper-stale-$key"
883
+ : > "$state/.paused-$watcher_key"
884
+ : > "$state/.stale-$watcher_key"
885
+ : > "$state/.wedge-escalations-$watcher_key"
886
+ FM_STATE_OVERRIDE="$state" handle_wake "signal: $state/held-w10-terminal.status" "$state"
887
+ [ ! -e "$state/.subsuper-paused-$key" ] || fail "terminal signal retained the daemon pause marker"
888
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "terminal signal retained daemon stale tracking"
889
+ [ ! -e "$state/.paused-$watcher_key" ] || fail "terminal signal retained watcher pause tracking"
890
+ [ ! -e "$state/.stale-$watcher_key" ] || fail "terminal signal retained watcher stale tracking"
891
+ [ ! -e "$state/.wedge-escalations-$watcher_key" ] || fail "terminal signal retained watcher wedge tracking"
892
+ FM_STATE_OVERRIDE="$state" handle_wake "stale: $win" "$state"
893
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "terminal stale dedupe restored daemon stale tracking"
894
+ pass "a terminal signal clears pause and stale tracking across both supervisors"
895
+ }
896
+
897
+ test_housekeeping_migrates_watcher_pause_marker() {
898
+ local dir state key win
899
+ dir=$(make_supercase migrate-watcher-pause)
900
+ state="$dir/state"
901
+ win="sess:fm-held-w10-migrate"
902
+ printf 'window=%s\nkind=ship\n' "$win" > "$state/held-w10-migrate.meta"
903
+ printf 'paused: awaiting the upstream release\n' > "$state/held-w10-migrate.status"
904
+ key=$(printf '%s' "$win" | tr '.:/' '___')
905
+ : > "$state/.paused-$key"
906
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
907
+ key=$(printf '%s' "held-w10-migrate" | tr '.:/' '___')
908
+ [ -e "$state/.subsuper-paused-$key" ] || fail "watcher pause marker was not migrated into daemon tracking"
909
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "watcher pause migration left a wedge marker behind"
910
+ pass "housekeeping migrates a normal-watcher's declared pause into daemon tracking"
911
+ }
912
+
913
+ test_housekeeping_migrates_watcher_unpaused_marker_to_clear() {
914
+ local dir state key watcher_key win
915
+ dir=$(make_supercase migrate-watcher-unpaused)
916
+ state="$dir/state"
917
+ win="sess:fm-held-w10-migrate-unpaused"
918
+ printf 'window=%s\nkind=ship\n' "$win" > "$state/held-w10-migrate-unpaused.meta"
919
+ printf 'working: upstream landed, resuming\n' > "$state/held-w10-migrate-unpaused.status"
920
+ watcher_key=$(printf '%s' "$win" | tr '.:/' '___')
921
+ : > "$state/.paused-$watcher_key"
922
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
923
+ key=$(printf '%s' "held-w10-migrate-unpaused" | tr '.:/' '___')
924
+ [ ! -e "$state/.paused-$watcher_key" ] || fail "stale watcher pause marker was not cleared after resume"
925
+ [ ! -e "$state/.subsuper-paused-$key" ] || fail "unpaused watcher handoff created a daemon pause marker"
926
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "unpaused watcher handoff retained daemon stale tracking"
927
+ [ ! -e "$state/.stale-$watcher_key" ] || fail "unpaused watcher handoff retained watcher stale tracking"
928
+ pass "housekeeping clears an already-resumed watcher pause across both supervisors"
929
+ }
930
+
931
+ test_housekeeping_seeds_pause_marker_from_status() {
932
+ local dir state key win
933
+ dir=$(make_supercase seed-paused-status)
934
+ state="$dir/state"
935
+ win="sess:fm-held-w10-seed"
936
+ printf 'window=%s\nkind=ship\n' "$win" > "$state/held-w10-seed.meta"
937
+ printf 'paused: awaiting the upstream release\n' > "$state/held-w10-seed.status"
938
+ key=$(printf '%s' "held-w10-seed" | tr '.:/' '___')
939
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
940
+ [ -e "$state/.subsuper-paused-$key" ] || fail "paused status did not seed daemon pause tracking"
941
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "paused status seeded wedge tracking"
942
+ pass "housekeeping seeds pause tracking from status without a watcher marker"
943
+ }
944
+
945
+ # housekeeping re-surfaces a stale declared pause only past PAUSE_RESURFACE_SECS,
946
+ # as an awaiting-external recheck (never a wedge), and RESETS the marker so the
947
+ # window repeats rather than firing once.
948
+ test_housekeeping_paused_resurfaces_and_resets() {
949
+ local dir state fakebin win pane key age
950
+ dir=$(make_supercase paused-resurface)
951
+ state="$dir/state"; fakebin="$dir/fakebin"
952
+ win="sess:fm-held-w11"; pane="$dir/pane.txt"
953
+ printf 'paused: holding for the upstream tool release\n' > "$state/held-w11.status"
954
+ printf 'idle prompt $\n' > "$pane"
955
+ key=$(printf '%s' "held-w11" | tr ':/.' '___')
956
+ echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-paused-$key"
957
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
958
+ FM_STATE_OVERRIDE="$state" FM_PAUSE_RESURFACE_SECS=240 housekeeping "$state"
959
+ grep -F "awaiting external" "$state/.subsuper-escalations" >/dev/null 2>&1 || fail "declared pause was not re-surfaced as an awaiting-external recheck"
960
+ grep -F "awaiting the captain" "$state/.subsuper-escalations" >/dev/null 2>&1 && fail "declared pause named the captain instead of its external dependency"
961
+ grep -F "possible wedge" "$state/.subsuper-escalations" >/dev/null 2>&1 && fail "declared pause was mislabeled a possible wedge"
962
+ [ -e "$state/.subsuper-paused-$key" ] || fail "pause marker cleared instead of reset for the next window"
963
+ age=$(( $(date +%s) - $(cat "$state/.subsuper-paused-$key" 2>/dev/null || echo 0) ))
964
+ [ "$age" -lt 60 ] || fail "pause marker was not reset to now on re-surface (age ${age}s)"
965
+ pass "housekeeping re-surfaces a stale declared pause on the long cadence and resets its window"
966
+ }
967
+
968
+ # The other half of quieting a captain-held task: it must NOT be silenced outright.
969
+ # fm-classify-lib.sh's cadence comment is explicit that a forgotten hold cannot rot
970
+ # invisibly, so a held task re-surfaces on the same bounded window as a pause, with
971
+ # its marker reset so the window repeats instead of firing once. The digest the
972
+ # captain reads must also name the captain rather than an external dependency: the
973
+ # hold is waiting on the one person reading the digest, so borrowing the pause verb's
974
+ # awaiting-external wording would point them away from being the blocker.
975
+ test_housekeeping_captain_held_resurfaces_and_resets() {
976
+ local dir state fakebin win pane key age
977
+ dir=$(make_supercase captain-held-resurface)
978
+ state="$dir/state"; fakebin="$dir/fakebin"
979
+ win="sess:fm-held-w11h"; pane="$dir/pane.txt"
980
+ printf 'captain-held [key=route]: tracked by task-decision-route\n' > "$state/held-w11h.status"
981
+ printf 'idle prompt $\n' > "$pane"
982
+ key=$(printf '%s' "held-w11h" | tr ':/.' '___')
983
+ echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-paused-$key"
984
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
985
+ FM_STATE_OVERRIDE="$state" FM_PAUSE_RESURFACE_SECS=240 housekeeping "$state"
986
+ grep -F "awaiting the captain" "$state/.subsuper-escalations" >/dev/null 2>&1 || fail "a captain hold was silenced entirely instead of re-surfacing as a captain-owned recheck: $(cat "$state/.subsuper-escalations" 2>/dev/null || true)"
987
+ grep -F "awaiting external" "$state/.subsuper-escalations" >/dev/null 2>&1 && fail "a captain hold was re-surfaced as an external wait, hiding that the captain is the blocker"
988
+ grep -F "possible wedge" "$state/.subsuper-escalations" >/dev/null 2>&1 && fail "a captain hold was re-surfaced as a possible wedge"
989
+ [ -e "$state/.subsuper-paused-$key" ] || fail "captain-held marker cleared instead of reset for the next window"
990
+ age=$(( $(date +%s) - $(cat "$state/.subsuper-paused-$key" 2>/dev/null || echo 0) ))
991
+ [ "$age" -lt 60 ] || fail "captain-held marker was not reset to now on re-surface (age ${age}s)"
992
+ pass "housekeeping re-surfaces a forgotten captain hold on the long cadence and resets its window"
993
+ }
994
+
995
+ # A crew that RESUMED - whose latest status line no longer declares the wait - drops
996
+ # its pause tracking without escalating. The dimension pinned here is that pane busy
997
+ # state does not GATE that clear: the status append alone ends the wait, on the
998
+ # reconcile path the loop head runs before the pause recheck ever reads a pane, so a
999
+ # crew that resumed into a genuinely busy pane cannot hold a stale window open. The
1000
+ # fixture asserts its own busy verdict first, so it cannot silently decay into an
1001
+ # idle-pane case (already covered by test_housekeeping_paused_unpaused_cleared) and
1002
+ # keep claiming that dimension. The inverse - a busy pane that is STILL declaring the
1003
+ # wait - is test_housekeeping_busy_declared_wait_matures_its_window.
1004
+ test_housekeeping_paused_resumed_cleared() {
1005
+ local dir state fakebin win pane key
1006
+ dir=$(make_supercase paused-resumed)
1007
+ state="$dir/state"; fakebin="$dir/fakebin"
1008
+ win="sess:fm-held-w12"; pane="$dir/pane.txt"
1009
+ printf 'paused: holding for the upstream tool release\nworking: upstream landed, resuming\n' \
1010
+ > "$state/held-w12.status"
1011
+ printf 'Working...\n' > "$pane"
1012
+ fm_write_meta "$state/held-w12.meta" "window=$win" "worktree=$dir/wt" "kind=ship" "harness=pi"
1013
+ local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" held-w12)
1014
+ "$ROOT/bin/fm-busy-event.sh" apply "$state" held-w12 busy --gen "$gen" \
1015
+ --source pi-ext --event agent-start
1016
+ key=$(printf '%s' "held-w12" | tr ':/.' '___')
1017
+ echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-paused-$key"
1018
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1019
+ FM_STATE_OVERRIDE="$state" stale_window_is_busy "$win" "$state" \
1020
+ || fail "the resumed-pause fixture does not actually read busy, so it pins nothing about busy state"
1021
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1022
+ FM_STATE_OVERRIDE="$state" FM_PAUSE_RESURFACE_SECS=240 housekeeping "$state"
1023
+ [ -e "$state/.subsuper-paused-$key" ] && fail "resumed (busy, no longer declaring) pause marker was not cleared"
1024
+ [ ! -s "$state/.subsuper-escalations" ] || fail "a resumed pause was escalated"
1025
+ pass "a busy pane cannot gate the pause clear once its crew's status no longer declares the wait"
1026
+ }
1027
+
1028
+ # The inverse of test_housekeeping_paused_resumed_cleared, and the first half of
1029
+ # issue #3149. A declared wait can legitimately hold a pane BUSY - a worker parked on
1030
+ # a long foreground call it keeps live for as long as the wait lasts - so a busy
1031
+ # verdict is not evidence that the crew resumed. Reading it as one dropped the marker
1032
+ # un-escalated, and migrate_watcher_pause_markers recreated it with a fresh timestamp
1033
+ # on the very next tick, so the window restarted forever and the wait never matured
1034
+ # into its one recheck. Away mode makes that terminal: the watcher hands a busy
1035
+ # declared wait to the daemon exactly once per declaration (bin/fm-watch.sh's
1036
+ # busy_turn_bound_check), so this recheck is the only thing left that can re-surface
1037
+ # the pane at all. Both declaration forms take the same 2b arm, so both are pinned.
1038
+ test_housekeeping_busy_declared_wait_matures_its_window() {
1039
+ local case_name dir state fakebin task win pane key gen tick age escalations digest
1040
+ for case_name in paused captain-held; do
1041
+ dir=$(make_supercase "busy-declared-wait-$case_name")
1042
+ state="$dir/state"; fakebin="$dir/fakebin"
1043
+ task="held-w12b-$case_name"; win="sess:fm-$task"; pane="$dir/pane.txt"
1044
+ case "$case_name" in
1045
+ paused) printf 'paused: the audit engine is running to completion\n' > "$state/$task.status"
1046
+ digest="awaiting external" ;;
1047
+ captain-held) printf 'captain-held [key=route]: tracked by task-decision-route\n' > "$state/$task.status"
1048
+ digest="awaiting the captain" ;;
1049
+ esac
1050
+ printf 'Working...\n' > "$pane"
1051
+ fm_write_meta "$state/$task.meta" "window=$win" "worktree=$dir/wt" "kind=ship" "harness=pi"
1052
+ gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" "$task")
1053
+ "$ROOT/bin/fm-busy-event.sh" apply "$state" "$task" busy --gen "$gen" \
1054
+ --source pi-ext --event agent-start
1055
+ key=$(printf '%s' "$task" | tr ':/.' '___')
1056
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1057
+ FM_STATE_OVERRIDE="$state" stale_window_is_busy "$win" "$state" \
1058
+ || fail "the $case_name fixture does not actually read busy, so it pins nothing about busy state"
1059
+
1060
+ # Immature window: ticks inside PAUSE_RESURFACE_SECS neither escalate nor let the
1061
+ # marker the window ages against be recreated with a fresh timestamp.
1062
+ echo $(( $(date +%s) - 100 )) > "$state/.subsuper-paused-$key"
1063
+ for tick in 1 2 3; do
1064
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1065
+ FM_STATE_OVERRIDE="$state" FM_ESCALATE_BATCH_SECS=999999 FM_PAUSE_RESURFACE_SECS=3600 \
1066
+ housekeeping "$state"
1067
+ [ -e "$state/.subsuper-paused-$key" ] \
1068
+ || fail "$case_name busy declared wait lost its marker on tick $tick inside the window"
1069
+ age=$(( $(date +%s) - $(cat "$state/.subsuper-paused-$key" 2>/dev/null || echo 0) ))
1070
+ [ "$age" -ge 100 ] \
1071
+ || fail "$case_name tick $tick restarted the maturing window (age fell to ${age}s)"
1072
+ done
1073
+ [ ! -s "$state/.subsuper-escalations" ] \
1074
+ || fail "$case_name busy declared wait escalated inside its PAUSE_RESURFACE_SECS window"
1075
+
1076
+ # Matured window: exactly one recheck, named for the right human, never a wedge,
1077
+ # and the window reset so the next one repeats rather than firing once.
1078
+ echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-paused-$key"
1079
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1080
+ FM_STATE_OVERRIDE="$state" FM_ESCALATE_BATCH_SECS=999999 FM_PAUSE_RESURFACE_SECS=3600 \
1081
+ housekeeping "$state"
1082
+ escalations=0
1083
+ [ -s "$state/.subsuper-escalations" ] \
1084
+ && escalations=$(wc -l < "$state/.subsuper-escalations" | tr -d ' ')
1085
+ [ "$escalations" = 1 ] \
1086
+ || fail "$case_name busy declared wait produced $escalations escalations past its window, expected exactly one"
1087
+ grep -F "$digest" "$state/.subsuper-escalations" >/dev/null \
1088
+ || fail "$case_name busy declared wait was not re-surfaced as a '$digest' recheck: $(cat "$state/.subsuper-escalations")"
1089
+ grep -F "possible wedge" "$state/.subsuper-escalations" >/dev/null \
1090
+ && fail "$case_name busy declared wait was mislabeled a possible wedge"
1091
+ [ -e "$state/.subsuper-paused-$key" ] \
1092
+ || fail "$case_name busy declared wait cleared its marker instead of resetting the window"
1093
+ age=$(( $(date +%s) - $(cat "$state/.subsuper-paused-$key" 2>/dev/null || echo 0) ))
1094
+ [ "$age" -lt 60 ] || fail "$case_name busy declared wait did not reset its window to now (age ${age}s)"
1095
+
1096
+ # The next tick, still inside the fresh window, stays silent: one recheck per window.
1097
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1098
+ FM_STATE_OVERRIDE="$state" FM_ESCALATE_BATCH_SECS=999999 FM_PAUSE_RESURFACE_SECS=3600 \
1099
+ housekeeping "$state"
1100
+ escalations=0
1101
+ [ -s "$state/.subsuper-escalations" ] \
1102
+ && escalations=$(wc -l < "$state/.subsuper-escalations" | tr -d ' ')
1103
+ [ "$escalations" = 1 ] \
1104
+ || fail "$case_name busy declared wait re-surfaced again inside its reset window ($escalations escalations)"
1105
+ done
1106
+ pass "housekeeping matures a busy pane's declared-wait window into exactly one recheck per window"
1107
+ }
1108
+
1109
+ # A pane still idle but whose status is no longer a pause (the crew changed state
1110
+ # without becoming busy) drops the marker - the signal path owns the new state, so
1111
+ # the pause recheck must not re-surface a stale pause reason.
1112
+ test_housekeeping_paused_unpaused_cleared() {
1113
+ local dir state fakebin win pane key
1114
+ dir=$(make_supercase paused-unpaused)
1115
+ state="$dir/state"; fakebin="$dir/fakebin"
1116
+ win="sess:fm-held-w13"; pane="$dir/pane.txt"
1117
+ printf 'paused: holding for the upstream release\nworking: resumed, upstream landed\n' > "$state/held-w13.status"
1118
+ printf 'idle prompt $\n' > "$pane"
1119
+ key=$(printf '%s' "held-w13" | tr ':/.' '___')
1120
+ echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-paused-$key"
1121
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1122
+ FM_STATE_OVERRIDE="$state" FM_PAUSE_RESURFACE_SECS=240 housekeeping "$state"
1123
+ [ -e "$state/.subsuper-paused-$key" ] && fail "no-longer-paused marker was not cleared"
1124
+ [ ! -s "$state/.subsuper-escalations" ] || fail "a crew that left its pause was re-surfaced as a pause"
1125
+ pass "housekeeping clears a paused marker once the crew is no longer declaring the pause"
1126
+ }
1127
+
1128
+ # Once the captain answers, the hold is no longer a declared wait: the resolved line
1129
+ # takes over the last-line read, so the pause cadence must stop claiming the task
1130
+ # rather than keep re-surfacing a settled decision.
1131
+ test_housekeeping_captain_held_resolved_cleared() {
1132
+ local dir state fakebin win pane key
1133
+ dir=$(make_supercase captain-held-resolved)
1134
+ state="$dir/state"; fakebin="$dir/fakebin"
1135
+ win="sess:fm-held-w13h"; pane="$dir/pane.txt"
1136
+ printf 'captain-held [key=route]: tracked by task-decision-route\nresolved [key=route]: captain chose the direct path\n' > "$state/held-w13h.status"
1137
+ printf 'idle prompt $\n' > "$pane"
1138
+ key=$(printf '%s' "held-w13h" | tr ':/.' '___')
1139
+ echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-paused-$key"
1140
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1141
+ FM_STATE_OVERRIDE="$state" FM_PAUSE_RESURFACE_SECS=240 housekeeping "$state"
1142
+ [ -e "$state/.subsuper-paused-$key" ] && fail "an answered captain hold kept its pause marker"
1143
+ [ ! -s "$state/.subsuper-escalations" ] || fail "an answered captain hold was re-surfaced as a declared wait"
1144
+ pass "housekeeping clears the pause marker once a captain hold is answered"
1145
+ }
1146
+
1147
+ test_housekeeping_stale_marker_transitions_to_pause() {
1148
+ local dir state fakebin win pane key
1149
+ dir=$(make_supercase stale-to-paused)
1150
+ state="$dir/state"; fakebin="$dir/fakebin"; win="sess:fm-held-w14"; pane="$dir/pane.txt"
1151
+ printf 'paused: awaiting the upstream tool release\n' > "$state/held-w14.status"
1152
+ printf 'idle prompt $\n' > "$pane"
1153
+ key=$(printf '%s' "held-w14" | tr ':/.' '___')
1154
+ echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-stale-$key"
1155
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1156
+ FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state"
1157
+ [ -e "$state/.subsuper-paused-$key" ] || fail "existing stale marker did not move to paused state"
1158
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "existing stale marker remained wedge-aged after pause"
1159
+ [ ! -s "$state/.subsuper-escalations" ] || fail "a newly declared pause was escalated as a possible wedge"
1160
+ pass "housekeeping moves an existing stale marker to pause before wedge escalation"
1161
+ }
1162
+
1163
+ # The quieting half for a captain hold. A finished task marked captain-held is idle by
1164
+ # design, so an already-aged wedge marker converts to pause tracking on the next sweep
1165
+ # instead of firing the possible-wedge escalation.
1166
+ test_housekeeping_captain_held_stale_marker_transitions_to_pause() {
1167
+ local dir state fakebin win pane key
1168
+ dir=$(make_supercase stale-to-captain-held)
1169
+ state="$dir/state"; fakebin="$dir/fakebin"; win="sess:fm-held-w14h"; pane="$dir/pane.txt"
1170
+ printf 'captain-held [key=route]: tracked by task-decision-route\n' > "$state/held-w14h.status"
1171
+ printf 'idle prompt $\n' > "$pane"
1172
+ key=$(printf '%s' "held-w14h" | tr ':/.' '___')
1173
+ echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-stale-$key"
1174
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1175
+ FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state"
1176
+ [ -e "$state/.subsuper-paused-$key" ] || fail "a captain hold did not move its stale marker to pause tracking"
1177
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "a captain hold remained wedge-aged"
1178
+ [ ! -s "$state/.subsuper-escalations" ] || fail "a captain hold was escalated as a possible wedge"
1179
+ pass "housekeeping moves a captain hold's existing stale marker to pause before wedge escalation"
1180
+ }
1181
+
1182
+ test_housekeeping_pause_marker_transitions_to_clear() {
1183
+ local dir state fakebin win pane key
1184
+ dir=$(make_supercase paused-to-stale)
1185
+ state="$dir/state"; fakebin="$dir/fakebin"; win="sess:fm-held-w15"; pane="$dir/pane.txt"
1186
+ printf 'working: upstream landed, resuming\n' > "$state/held-w15.status"
1187
+ printf 'idle prompt $\n' > "$pane"
1188
+ key=$(printf '%s' "held-w15" | tr ':/.' '___')
1189
+ date +%s > "$state/.subsuper-paused-$key"
1190
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1191
+ FM_STATE_OVERRIDE="$state" FM_PAUSE_RESURFACE_SECS=999999 housekeeping "$state"
1192
+ [ ! -e "$state/.subsuper-paused-$key" ] || fail "pause marker remained after the crew resumed"
1193
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "resume retained normal stale tracking"
1194
+ [ ! -s "$state/.subsuper-escalations" ] || fail "resuming from pause escalated immediately"
1195
+ pass "housekeeping clears tracking when a crew leaves pause"
1196
+ }
1197
+
1198
+ test_housekeeping_persistent_stale_escalates() {
1199
+ local dir state fakebin win pane key
1200
+ dir=$(make_supercase stale-persistent)
1201
+ state="$dir/state"
1202
+ fakebin="$dir/fakebin"
1203
+ win="sess:fm-pers-w5"
1204
+ pane="$dir/pane.txt"
1205
+ printf 'working\n' > "$state/pers-w5.status"
1206
+ printf 'idle prompt $\n' > "$pane"
1207
+ key=$(printf '%s' "pers-w5" | tr ':/.' '___')
1208
+ echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key"
1209
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1210
+ FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state"
1211
+ [ -s "$state/.subsuper-escalations" ] || fail "persistent stale was not escalated"
1212
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "stale marker not cleared after escalation"
1213
+ pass "persistent stale escalates after threshold and clears its marker"
1214
+ }
1215
+
1216
+ test_housekeeping_resumed_stale_cleared() {
1217
+ local dir state fakebin win pane key
1218
+ dir=$(make_supercase stale-resumed)
1219
+ state="$dir/state"
1220
+ fakebin="$dir/fakebin"
1221
+ win="sess:fm-res-w6"
1222
+ pane="$dir/pane.txt"
1223
+ printf 'working\n' > "$state/res-w6.status"
1224
+ printf 'Working...\n' > "$pane"
1225
+ # A resumed crew proves it is working through its own semantic busy-state
1226
+ # record (bin/fm-busy-lib.sh), not through the pane's rendered footer.
1227
+ fm_write_meta "$state/res-w6.meta" "window=$win" "worktree=$dir/wt" "kind=ship" "harness=pi"
1228
+ local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" res-w6)
1229
+ "$ROOT/bin/fm-busy-event.sh" apply "$state" res-w6 busy --gen "$gen" \
1230
+ --source pi-ext --event agent-start
1231
+ key=$(printf '%s' "res-w6" | tr ':/.' '___')
1232
+ echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key"
1233
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1234
+ FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state"
1235
+ [ -e "$state/.subsuper-stale-$key" ] && fail "resumed stale marker was not cleared"
1236
+ [ -s "$state/.subsuper-escalations" ] && fail "resumed stale was escalated"
1237
+ pass "resumed (busy) stale clears its marker without escalating"
1238
+ }
1239
+
1240
+ test_housekeeping_herdr_persistent_stale_resolves_meta() {
1241
+ local dir state key
1242
+ dir=$(make_supercase stale-herdr-persistent)
1243
+ state="$dir/state"
1244
+ fm_write_meta "$state/herdr-w7.meta" "window=default:w1:p2" "backend=herdr"
1245
+ printf 'working\n' > "$state/herdr-w7.status"
1246
+ key=$(printf '%s' "herdr-w7" | tr ':/.' '___')
1247
+ echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key"
1248
+ (
1249
+ fm_backend_capture() {
1250
+ [ "$1" = herdr ] || fail "expected herdr capture backend, got $1"
1251
+ [ "$2" = "default:w1:p2" ] || fail "expected herdr window target, got $2"
1252
+ printf 'idle prompt\n'
1253
+ }
1254
+ fm_backend_busy_state() {
1255
+ [ "$1" = herdr ] || fail "expected herdr busy backend, got $1"
1256
+ [ "$2" = "default:w1:p2" ] || fail "expected herdr busy target, got $2"
1257
+ printf 'idle'
1258
+ }
1259
+ fm_backend_capture herdr default:w1:p2 40 >/dev/null
1260
+ [ "$(fm_backend_busy_state herdr default:w1:p2)" = idle ] || fail "herdr busy stub did not report idle"
1261
+ FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state"
1262
+ ) || fail "herdr persistent stale housekeeping failed"
1263
+ [ -s "$state/.subsuper-escalations" ] || fail "persistent herdr stale was not escalated"
1264
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "herdr stale marker not cleared after escalation"
1265
+ pass "persistent herdr stale resolves the target from metadata and escalates"
1266
+ }
1267
+
1268
+ # A herdr crew whose native agent.get reads idle (generation state) but whose
1269
+ # own semantic busy-state record says busy is still working, so its stale
1270
+ # marker clears without escalating. The record - not the pane's rendered
1271
+ # footer - is what proves it.
1272
+ test_housekeeping_herdr_idle_busy_record_clears_stale() {
1273
+ local dir state key gen
1274
+ dir=$(make_supercase stale-herdr-idle-busy-record)
1275
+ state="$dir/state"
1276
+ fm_write_meta "$state/herdr-footer.meta" "window=default:w1:p4" "backend=herdr" "harness=claude"
1277
+ printf 'working\n' > "$state/herdr-footer.status"
1278
+ gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" herdr-footer)
1279
+ "$ROOT/bin/fm-busy-event.sh" apply "$state" herdr-footer busy --gen "$gen" \
1280
+ --source claude-hook --event user-prompt-submit
1281
+ key=$(printf '%s' "herdr-footer" | tr ':/.' '___')
1282
+ echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key"
1283
+ (
1284
+ fm_backend_capture() {
1285
+ [ "$1" = herdr ] || fail "expected herdr capture backend, got $1"
1286
+ [ "$2" = "default:w1:p4" ] || fail "expected herdr window target, got $2"
1287
+ printf 'quiet\n'
1288
+ }
1289
+ fm_backend_busy_state() {
1290
+ [ "$1" = herdr ] || fail "expected herdr busy backend, got $1"
1291
+ [ "$2" = "default:w1:p4" ] || fail "expected herdr busy target, got $2"
1292
+ printf 'idle'
1293
+ }
1294
+ fm_backend_capture herdr default:w1:p4 40 >/dev/null
1295
+ [ "$(fm_backend_busy_state herdr default:w1:p4)" = idle ] || fail "herdr busy stub did not report idle"
1296
+ FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state"
1297
+ ) || fail "herdr idle busy-footer housekeeping failed"
1298
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "idle-native busy-record herdr stale marker was not cleared"
1299
+ [ ! -s "$state/.subsuper-escalations" ] || fail "idle-native busy-record herdr stale was escalated"
1300
+ pass "herdr idle busy-footer stale clears through capture corroboration"
1301
+ }
1302
+
1303
+ test_housekeeping_herdr_resumed_stale_cleared() {
1304
+ local dir state key
1305
+ dir=$(make_supercase stale-herdr-resumed)
1306
+ state="$dir/state"
1307
+ fm_write_meta "$state/herdr-busy.meta" "window=default:w1:p3" "backend=herdr"
1308
+ printf 'working\n' > "$state/herdr-busy.status"
1309
+ key=$(printf '%s' "herdr-busy" | tr ':/.' '___')
1310
+ echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key"
1311
+ (
1312
+ fm_backend_capture() {
1313
+ [ "$1" = herdr ] || fail "expected herdr capture backend, got $1"
1314
+ [ "$2" = "default:w1:p3" ] || fail "expected herdr window target, got $2"
1315
+ printf 'unchanged pane\n'
1316
+ }
1317
+ fm_backend_busy_state() {
1318
+ [ "$1" = herdr ] || fail "expected herdr busy backend, got $1"
1319
+ [ "$2" = "default:w1:p3" ] || fail "expected herdr busy target, got $2"
1320
+ printf 'busy'
1321
+ }
1322
+ fm_backend_capture herdr default:w1:p3 40 >/dev/null
1323
+ [ "$(fm_backend_busy_state herdr default:w1:p3)" = busy ] || fail "herdr busy stub did not report busy"
1324
+ FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state"
1325
+ ) || fail "herdr resumed stale housekeeping failed"
1326
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "busy herdr stale marker was not cleared"
1327
+ [ ! -s "$state/.subsuper-escalations" ] || fail "busy herdr stale was escalated"
1328
+ pass "resumed herdr stale clears through backend-aware busy state"
1329
+ }
1330
+
1331
+ test_housekeeping_orca_persistent_stale_resolves_terminal() {
1332
+ local dir state key
1333
+ dir=$(make_supercase stale-orca-persistent)
1334
+ state="$dir/state"
1335
+ fm_write_meta "$state/orca-w8.meta" "window=fm-orca-w8" "terminal=term-orca-w8" "backend=orca"
1336
+ printf 'working\n' > "$state/orca-w8.status"
1337
+ key=$(printf '%s' "orca-w8" | tr ':/.' '___')
1338
+ echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key"
1339
+ (
1340
+ fm_backend_capture() {
1341
+ [ "$1" = orca ] || fail "expected orca capture backend, got $1"
1342
+ [ "$2" = "term-orca-w8" ] || fail "expected Orca terminal target, got $2"
1343
+ printf 'idle prompt\n'
1344
+ }
1345
+ fm_backend_busy_state() {
1346
+ [ "$1" = orca ] || fail "expected orca busy backend, got $1"
1347
+ [ "$2" = "term-orca-w8" ] || fail "expected Orca busy target, got $2"
1348
+ printf 'idle'
1349
+ }
1350
+ fm_backend_capture orca term-orca-w8 40 >/dev/null
1351
+ [ "$(fm_backend_busy_state orca term-orca-w8)" = idle ] || fail "Orca busy stub did not report idle"
1352
+ FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state"
1353
+ ) || fail "Orca persistent stale housekeeping failed"
1354
+ [ -s "$state/.subsuper-escalations" ] || fail "persistent Orca stale was not escalated"
1355
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "Orca stale marker not cleared after escalation"
1356
+ pass "persistent Orca stale resolves the terminal from metadata"
1357
+ }
1358
+
1359
+ test_escalate_batches_into_one_digest() {
1360
+ local dir state fakebin sent capture n
1361
+ dir=$(make_supercase batch)
1362
+ state="$dir/state"
1363
+ fakebin="$dir/fakebin"
1364
+ sent="$dir/sent.log"; : > "$sent"
1365
+ capture="$dir/pane.txt"; printf '\342\235\257 \n' > "$capture" # a proven-empty bare claude composer: STRICT injection needs positive proof
1366
+ escalate_add "$state" "event A: done: PR 1"
1367
+ escalate_add "$state" "event B: done: PR 2"
1368
+ afk_enter "$state"
1369
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_PANE_ALIVE=1 FM_FAKE_TMUX_SENT="$sent" \
1370
+ FM_FAKE_TMUX_CAPTURE="$capture" FM_ESCALATE_BATCH_SECS=0 escalate_flush "$state" \
1371
+ || fail "escalate_flush failed"
1372
+ grep -F 'FIRSTMATE_OP: v1 away-supervisor: ' "$sent" >/dev/null \
1373
+ || fail "batch digest lacks the exact current away-supervisor kind"
1374
+ grep -F "event A" "$sent" >/dev/null || fail "batch digest missing event A"
1375
+ grep -F "event B" "$sent" >/dev/null || fail "batch digest missing event B"
1376
+ grep -F 'event A: done: PR 1 | event B: done: PR 2' "$sent" >/dev/null \
1377
+ || fail "batch digest did not join events with literal ' | '"
1378
+ [ -s "$state/.subsuper-escalations" ] && fail "escalation buffer not cleared after flush"
1379
+ [ -e "$state/.subsuper-escalations.since" ] && fail "first-append sidecar not cleared after flush"
1380
+ n=$(grep -c '\[ENTER\]' "$sent")
1381
+ [ "$n" -eq 1 ] || fail "expected one injected digest, got $n send-keys submits"
1382
+ pass "multiple escalations flush as a single batched digest"
1383
+ }
1384
+
1385
+ test_escalate_batch_age_uses_first_append() {
1386
+ local dir state fakebin sent capture
1387
+ dir=$(make_supercase batch-age)
1388
+ state="$dir/state"
1389
+ fakebin="$dir/fakebin"
1390
+ sent="$dir/sent.log"; : > "$sent"
1391
+ capture="$dir/pane.txt"; printf '\342\235\257 \n' > "$capture" # a proven-empty bare claude composer: STRICT injection needs positive proof
1392
+ escalate_add "$state" "event A: done: PR 1"
1393
+ escalate_add "$state" "event B: done: PR 2"
1394
+ echo $(( $(date +%s) - 100 )) > "$state/.subsuper-escalations.since"
1395
+ afk_enter "$state"
1396
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_PANE_ALIVE=1 FM_FAKE_TMUX_SENT="$sent" \
1397
+ FM_FAKE_TMUX_CAPTURE="$capture" FM_ESCALATE_BATCH_SECS=90 FM_HOUSEKEEPING_TICK=0 \
1398
+ housekeeping "$state"
1399
+ grep -F 'event A: done: PR 1 | event B: done: PR 2' "$sent" >/dev/null \
1400
+ || fail "backdated batch did not flush as a joined digest (max-delay measured from last append)"
1401
+ [ -s "$state/.subsuper-escalations" ] && fail "escalation buffer not cleared after backdated flush"
1402
+ [ -e "$state/.subsuper-escalations.since" ] && fail "first-append sidecar not cleared after flush"
1403
+ pass "batch flush measures max-delay from the first append, not the last"
1404
+ }
1405
+
1406
+ test_heartbeat_scan_dedup() {
1407
+ local dir state
1408
+ dir=$(make_supercase scan-dedup)
1409
+ state="$dir/state"
1410
+ printf 'done: ready\n' > "$state/dup-t6.status"
1411
+ rm -f "$state/.subsuper-last-scan"
1412
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
1413
+ [ -s "$state/.subsuper-escalations" ] || fail "catch-all scan did not escalate a terminal"
1414
+ : > "$state/.subsuper-escalations"
1415
+ echo $(( $(date +%s) - 99999 )) > "$state/.subsuper-last-scan"
1416
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
1417
+ [ -s "$state/.subsuper-escalations" ] && fail "catch-all scan re-escalated the same terminal (dedup failed)"
1418
+ pass "catch-all scan escalates a missed terminal once, not twice"
1419
+ }
1420
+
1421
+ test_handle_wake_routes_self_and_escalate() {
1422
+ local dir state
1423
+ dir=$(make_supercase handle)
1424
+ state="$dir/state"
1425
+ printf 'working\n' > "$state/h-routine.status"
1426
+ FM_STATE_OVERRIDE="$state" handle_wake "signal: $state/h-routine.status" "$state"
1427
+ [ -s "$state/.subsuper-escalations" ] && fail "routine signal was escalated by handle_wake"
1428
+ printf 'done: PR 1\n' > "$state/h-done.status"
1429
+ FM_STATE_OVERRIDE="$state" handle_wake "signal: $state/h-done.status" "$state"
1430
+ [ -s "$state/.subsuper-escalations" ] || fail "captain signal was not buffered by handle_wake"
1431
+ pass "handle_wake routes routine->self and captain->escalate"
1432
+ }
1433
+
1434
+ test_inject_skip_forces_self() {
1435
+ local dir state
1436
+ dir=$(make_supercase skip)
1437
+ state="$dir/state"
1438
+ printf 'done: PR 1\n' > "$state/s1.status"
1439
+ FM_STATE_OVERRIDE="$state" FM_INJECT_SKIP="signal" handle_wake "signal: $state/s1.status" "$state"
1440
+ [ -s "$state/.subsuper-escalations" ] && fail "INJECT_SKIP=signal did not force self-handle"
1441
+ pass "INJECT_SKIP forces self-handle, bypassing captain-relevant classification"
1442
+ }
1443
+
1444
+ test_is_wake_reason_distinguishes_status_stdout() {
1445
+ # Real wake reasons are recognized; watcher status lines (singleton collision)
1446
+ # are not, so the main loop can idle them without flooding escalations.
1447
+ is_wake_reason "signal: /x/y.status" || fail "signal: not recognized as wake"
1448
+ is_wake_reason "stale: s:fm-x" || fail "stale: not recognized as wake"
1449
+ is_wake_reason "check: /s/c.sh: merged" || fail "check: not recognized as wake"
1450
+ is_wake_reason "heartbeat" || fail "heartbeat not recognized as wake"
1451
+ is_wake_reason "watcher: already running" && fail "singleton status line misclassified as wake"
1452
+ is_wake_reason "watcher: already running pid 123" && fail "singleton status (pid) misclassified as wake"
1453
+ pass "is_wake_reason distinguishes watcher wake reasons from singleton-status stdout"
1454
+ }
1455
+
1456
+ test_terminal_stale_escalate_leaves_no_marker() {
1457
+ local dir state win key
1458
+ dir=$(make_supercase stale-terminal-nomarker)
1459
+ state="$dir/state"
1460
+ win="sess:fm-fin-n7"
1461
+ printf 'done: PR https://x/y/pull/7\n' > "$state/fin-n7.status"
1462
+ key=$(printf '%s' "fin-n7" | tr ':/.' '___')
1463
+ echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key"
1464
+ FM_STATE_OVERRIDE="$state" handle_wake "stale: $win" "$state"
1465
+ [ -s "$state/.subsuper-escalations" ] || fail "terminal stale was not escalated"
1466
+ [ ! -e "$state/.subsuper-stale-$key" ] || fail "terminal stale left a persistence marker (housekeeping would re-escalate)"
1467
+ : > "$state/.subsuper-escalations"
1468
+ rm -f "$state/.subsuper-last-scan"
1469
+ FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state"
1470
+ [ ! -s "$state/.subsuper-escalations" ] || fail "housekeeping re-escalated a terminal stale as a wedge"
1471
+ pass "terminal-stale escalate removes its marker so housekeeping does not re-escalate"
1472
+ }
1473
+
1474
+ test_signal_escalate_marks_seen_no_catchall_refire() {
1475
+ local dir state key
1476
+ dir=$(make_supercase signal-seen)
1477
+ state="$dir/state"
1478
+ printf 'done: PR https://x/y/pull/8\n' > "$state/sig-t8.status"
1479
+ FM_STATE_OVERRIDE="$state" handle_wake "signal: $state/sig-t8.status" "$state"
1480
+ [ -s "$state/.subsuper-escalations" ] || fail "captain signal was not escalated"
1481
+ key=$(printf '%s' "sig-t8" | tr ':/.' '___')
1482
+ [ "$(status_seen_offset "$state" sig-t8)" = "$(log_size "$state/sig-t8.status")" ] \
1483
+ || fail "captain signal escalate did not record the escalated-through offset"
1484
+ : > "$state/.subsuper-escalations"
1485
+ rm -f "$state/.subsuper-last-scan"
1486
+ FM_STATE_OVERRIDE="$state" housekeeping "$state"
1487
+ [ ! -s "$state/.subsuper-escalations" ] || fail "catch-all scan re-fired an already-escalated signal"
1488
+ pass "captain signal escalate marks seen so the catch-all scan does not re-fire"
1489
+ }
1490
+
1491
+ test_collapse_newlines_pure() {
1492
+ local out
1493
+ out=$(_collapse_newlines $'line one\nline two\nline three')
1494
+ [ "$out" = "line one - line two - line three" ] || fail "collapse failed: '$out'"
1495
+ out=$(_collapse_newlines "no newlines here")
1496
+ [ "$out" = "no newlines here" ] || fail "collapse changed no-newline text"
1497
+ out=$(_collapse_newlines $'a\nb')
1498
+ [ "$out" = "a - b" ] || fail "collapse two lines failed: '$out'"
1499
+ pass "_collapse_newlines replaces newlines with literal separator"
1500
+ }
1501
+
1502
+ test_afk_absent_daemon_does_not_inject() {
1503
+ local dir state fakebin sent capture
1504
+ dir=$(make_supercase afk-off)
1505
+ state="$dir/state"
1506
+ fakebin="$dir/fakebin"
1507
+ sent="$dir/sent.log"; : > "$sent"
1508
+ capture="$dir/pane.txt"; printf '\342\235\257 \n' > "$capture" # a proven-empty bare claude composer: STRICT injection needs positive proof
1509
+ escalate_add "$state" "done: PR 1"
1510
+ # afk flag deliberately NOT set
1511
+ if PATH="$fakebin:$PATH" FM_FAKE_TMUX_PANE_ALIVE=1 FM_FAKE_TMUX_SENT="$sent" \
1512
+ FM_FAKE_TMUX_CAPTURE="$capture" FM_ESCALATE_BATCH_SECS=0 escalate_flush "$state"; then
1513
+ fail "escalate_flush succeeded while afk inactive"
1514
+ fi
1515
+ [ -s "$sent" ] && fail "daemon injected while afk inactive"
1516
+ [ -s "$state/.subsuper-escalations" ] || fail "buffer not preserved when afk inactive"
1517
+ pass "afk flag absent: daemon does not inject, buffer preserved"
1518
+ }
1519
+
1520
+ test_busy_guard_defers_when_supervisor_busy() {
1521
+ local dir state fakebin sent capture
1522
+ dir=$(make_supercase busy-guard)
1523
+ state="$dir/state"
1524
+ fakebin="$dir/fakebin"
1525
+ sent="$dir/sent.log"; : > "$sent"
1526
+ capture="$dir/pane.txt"
1527
+ printf 'esc to interrupt\n' > "$capture"
1528
+ escalate_add "$state" "done: PR 1"
1529
+ afk_enter "$state"
1530
+ if PATH="$fakebin:$PATH" FM_FAKE_TMUX_PANE_ALIVE=1 FM_FAKE_TMUX_SENT="$sent" \
1531
+ FM_FAKE_TMUX_CAPTURE="$capture" FM_ESCALATE_BATCH_SECS=0 escalate_flush "$state"; then
1532
+ fail "escalate_flush should defer when supervisor pane busy"
1533
+ fi
1534
+ [ -s "$sent" ] && fail "daemon injected into a busy pane"
1535
+ [ -s "$state/.subsuper-escalations" ] || fail "buffer not preserved when deferred"
1536
+ pass "busy-guard defers injection when supervisor pane is busy"
1537
+ }
1538
+
1539
+ test_marker_detection() {
1540
+ local marker_hex
1541
+ marker_hex=$(printf '%s' "$FM_INJECT_MARK" | od -An -tx1 | tr -d ' \n')
1542
+ [ "$marker_hex" = e281a3 ] \
1543
+ || fail "FM_INJECT_MARK must use terminal-safe U+2063 bytes, got $marker_hex"
1544
+ [ "$FM_OPERATIONAL_PREFIX" = "${FM_INJECT_MARK}FIRSTMATE_OP: " ] \
1545
+ || fail "away-mode operational prefix drifted from the shared captain-boundary marker"
1546
+ # message_is_injection: marker present -> injection; absent -> real message
1547
+ message_is_injection "${FM_OPERATIONAL_PREFIX}Supervisor escalate: done" \
1548
+ || fail "operationally-prefixed message not detected as injection"
1549
+ message_is_injection "${FM_INJECT_MARK}Supervisor escalate: done" \
1550
+ || fail "legacy marker-prefixed message not detected as injection"
1551
+ message_is_injection "how's it going?" \
1552
+ && fail "plain message misdetected as injection"
1553
+ message_is_injection "" && fail "empty message misdetected as injection"
1554
+ # should_exit_afk: the full afk-exit contract
1555
+ local dir state
1556
+ dir=$(make_supercase marker-detect)
1557
+ state="$dir/state"
1558
+ afk_enter "$state"
1559
+ should_exit_afk "$state" "${FM_INJECT_MARK}escalate" \
1560
+ && fail "marker message should not exit afk (internal escalation)"
1561
+ should_exit_afk "$state" "status update please" \
1562
+ || fail "plain message should exit afk (captain is back)"
1563
+ pass "marker detection: marker -> stay afk, no marker -> exit afk"
1564
+ }
1565
+
1566
+ test_afk_turn_exemption() {
1567
+ local dir state
1568
+ dir=$(make_supercase afk-exempt)
1569
+ state="$dir/state"
1570
+ afk_enter "$state"
1571
+ # /afk while already away must NOT self-cancel (re-entering/extending)
1572
+ should_exit_afk "$state" "/afk" \
1573
+ && fail "bare /afk should not exit afk"
1574
+ should_exit_afk "$state" "/afk back in an hour" \
1575
+ && fail "/afk with args should not exit afk"
1576
+ # a non-/afk skill invocation DOES exit (the captain is actively working)
1577
+ should_exit_afk "$state" "/no-mistakes" \
1578
+ || fail "non-afk skill should exit afk"
1579
+ pass "/afk invocation is exempt from afk exit (no self-cancel)"
1580
+ }
1581
+
1582
+ test_should_exit_afk_when_afk_inactive() {
1583
+ local dir state
1584
+ dir=$(make_supercase no-afk)
1585
+ state="$dir/state"
1586
+ # afk flag absent: should never signal exit (nothing to exit)
1587
+ should_exit_afk "$state" "hello" \
1588
+ && fail "should_exit_afk true when afk inactive"
1589
+ should_exit_afk "$state" "${FM_INJECT_MARK}test" \
1590
+ && fail "should_exit_afk true when afk inactive (marker)"
1591
+ pass "should_exit_afk returns false when afk is not active"
1592
+ }
1593
+
1594
+ test_strip_injection_marker() {
1595
+ local encoded stripped
1596
+ fm_operational_input_encode away-supervisor "Supervisor escalate: done" encoded \
1597
+ || fail "could not encode current away fixture"
1598
+ stripped=$(strip_injection_marker "$encoded")
1599
+ [ "$stripped" = "Supervisor escalate: done" ] \
1600
+ || fail "current typed operational envelope not stripped: '$stripped'"
1601
+ stripped=$(strip_injection_marker "${FM_OPERATIONAL_PREFIX}Supervisor escalate: done")
1602
+ [ "$stripped" = "Supervisor escalate: done" ] \
1603
+ || fail "landed untyped operational prefix not stripped: '$stripped'"
1604
+ stripped=$(strip_injection_marker "${FM_INJECT_MARK}Supervisor escalate: done")
1605
+ [ "$stripped" = "Supervisor escalate: done" ] \
1606
+ || fail "legacy marker not stripped: '$stripped'"
1607
+ # No marker → unchanged.
1608
+ stripped=$(strip_injection_marker "no marker here")
1609
+ [ "$stripped" = "no marker here" ] \
1610
+ || fail "non-marker text changed: '$stripped'"
1611
+ # Empty → empty.
1612
+ stripped=$(strip_injection_marker "")
1613
+ [ "$stripped" = "" ] || fail "empty text changed: '$stripped'"
1614
+ # Only marker → empty.
1615
+ stripped=$(strip_injection_marker "$FM_INJECT_MARK")
1616
+ [ "$stripped" = "" ] || fail "bare marker not stripped: '$stripped'"
1617
+ pass "strip_injection_marker removes the sentinel marker cleanly"
1618
+ }
1619
+
1620
+ test_pane_input_pending_detects_partial_input() {
1621
+ local dir state fakebin capture
1622
+ dir=$(make_supercase pending-input)
1623
+ state="$dir/state"
1624
+ fakebin="$dir/fakebin"
1625
+ capture="$dir/pane.txt"
1626
+ # Line 3 (cursor_y=2) has human's partial text (no Enter) → pending.
1627
+ printf 'line one\nline two\nhuman draft text\n' > "$capture"
1628
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=2 \
1629
+ pane_input_pending "fakepane" \
1630
+ || fail "pane_input_pending should detect non-empty composer (human text)"
1631
+ pass "pane_input_pending detects partial input on the cursor line"
1632
+ }
1633
+
1634
+ test_pane_input_pending_blank_defers_strict() {
1635
+ # THE STRICT BLANK-ROW RULE (captain decision blank-row-injection-posture,
1636
+ # 2026-08-09): a blank cursor row with no positive container proof is
1637
+ # `unknown` and the injector DEFERS. The permissive rule this replaced read
1638
+ # the same row as `empty` and injected - into whatever the blank row really
1639
+ # was (a modal dialog, a dead shell between stale transcript rules, a
1640
+ # mid-redraw pane). This assertion IS the posture divergence: if it ever
1641
+ # reads not-pending again, the permissive rule has silently returned.
1642
+ local dir state fakebin capture
1643
+ dir=$(make_supercase pending-blank)
1644
+ state="$dir/state"
1645
+ fakebin="$dir/fakebin"
1646
+ capture="$dir/pane.txt"
1647
+ printf 'some output\nmore output\n\n' > "$capture"
1648
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=2 \
1649
+ pane_input_pending "fakepane" \
1650
+ || fail "a blank unidentified cursor row must defer under the strict rule, not read empty"
1651
+ pass "pane_input_pending: a blank unidentified cursor row defers (strict container-proof rule)"
1652
+ }
1653
+
1654
+ test_pane_input_pending_requires_proven_empty_prompt() {
1655
+ local dir state fakebin capture prompt
1656
+ dir=$(make_supercase pending-prompt)
1657
+ state="$dir/state"
1658
+ fakebin="$dir/fakebin"
1659
+ capture="$dir/pane.txt"
1660
+ for prompt in '$' '>'; do
1661
+ printf 'output\noutput\n%s \n' "$prompt" > "$capture"
1662
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=2 \
1663
+ pane_input_pending "fakepane" \
1664
+ || fail "bare shell prompt '$prompt' should defer as unknown"
1665
+ done
1666
+ for prompt in '❯' '›'; do
1667
+ printf 'output\noutput\n%s \n' "$prompt" > "$capture"
1668
+ if PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=2 \
1669
+ pane_input_pending "fakepane"; then
1670
+ fail "proven empty agent prompt '$prompt' should not defer"
1671
+ fi
1672
+ done
1673
+ pass "pane_input_pending: only proven empty agent prompts pass"
1674
+ }
1675
+
1676
+ # The safety fix at the tmux classifier (task fm-composer-shellglyph-safety): a
1677
+ # bare, unbordered shell prompt is a dead shell (the agent exited to its login
1678
+ # shell), NOT an empty agent composer. It must read `unknown` (unsafe target),
1679
+ # never `empty`. Before this fix a dead-shell pane read `empty` and the away-mode
1680
+ # injector could type (and a shell could execute) an escalation there.
1681
+ test_tmux_composer_state_bare_shell_is_unknown() {
1682
+ local dir fakebin capture g out
1683
+ dir=$(make_supercase composer-bare-shell)
1684
+ fakebin="$dir/fakebin"; capture="$dir/pane.txt"
1685
+ for g in '$' '%' '#' '>'; do
1686
+ printf 'output\noutput\n%s \n' "$g" > "$capture"
1687
+ out=$(PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=2 \
1688
+ fm_tmux_composer_state "fakepane")
1689
+ [ "$out" = unknown ] \
1690
+ || fail "bare shell prompt '$g' must classify unknown (dead shell, unsafe), got '$out'"
1691
+ done
1692
+ pass "fm_tmux_composer_state: a bare shell prompt (\$/%/#/>) reads unknown, never empty (dead-shell injection safety)"
1693
+ }
1694
+
1695
+ # The other side of the fix: a bordered composer box (the harness draws its own
1696
+ # prompt glyph inside it) and a bare AGENT prompt glyph (claude ❯, codex ›) are
1697
+ # genuine empty agent composers and must still read `empty`.
1698
+ test_tmux_composer_state_bordered_and_agent_rows_are_empty() {
1699
+ local dir fakebin capture out
1700
+ dir=$(make_supercase composer-empty-agent)
1701
+ fakebin="$dir/fakebin"; capture="$dir/pane.txt"
1702
+ printf '╭────────────────────────╮\n│ > │\n╰────────────────────────╯\n' > "$capture"
1703
+ out=$(PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=1 \
1704
+ fm_tmux_composer_state "fakepane")
1705
+ [ "$out" = empty ] || fail "a bordered '│ > │' composer should read empty, got '$out'"
1706
+ printf '%s\n' "❯ " > "$capture"
1707
+ out=$(PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=0 \
1708
+ fm_tmux_composer_state "fakepane")
1709
+ [ "$out" = empty ] || fail "a bare claude '❯' composer should read empty, got '$out'"
1710
+ printf '%s\n' "› " > "$capture"
1711
+ out=$(PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=0 \
1712
+ fm_tmux_composer_state "fakepane")
1713
+ [ "$out" = empty ] || fail "a bare codex '›' composer should read empty, got '$out'"
1714
+ pass "fm_tmux_composer_state: a bordered composer box and bare agent glyphs (❯/›) still read empty"
1715
+ }
1716
+
1717
+ test_tmux_composer_state_requires_matching_box_borders() {
1718
+ local dir fakebin capture line out
1719
+ dir=$(make_supercase composer-decorated-shell)
1720
+ fakebin="$dir/fakebin"; capture="$dir/pane.txt"
1721
+ for line in '| $ ' '$ |' '│ % ' '# ┃'; do
1722
+ printf '%s\n' "$line" > "$capture"
1723
+ out=$(PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=0 \
1724
+ fm_tmux_composer_state "fakepane")
1725
+ [ "$out" != empty ] \
1726
+ || fail "a decorated shell prompt '$line' must not read as an empty composer"
1727
+ done
1728
+ pass "fm_tmux_composer_state: only matching edge borders form a composer box"
1729
+ }
1730
+
1731
+ test_pane_input_pending_preserves_bright_placeholder_like_draft() {
1732
+ local dir fakebin capture
1733
+ dir=$(make_supercase pending-custom-idle)
1734
+ fakebin="$dir/fakebin"
1735
+ capture="$dir/pane.txt"
1736
+ printf '╭────────────────╮\n│ custom idle> │\n╰────────────────╯\n' > "$capture"
1737
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=1 \
1738
+ FM_COMPOSER_IDLE_RE='^custom idle>$' pane_input_pending "fakepane" \
1739
+ || fail "bright placeholder-like input must remain pending in a styled capture"
1740
+ pass "pane_input_pending preserves bright placeholder-like drafts in styled captures"
1741
+ }
1742
+
1743
+ test_classify_signal_dedup_against_scan() {
1744
+ # If the catch-all scan already escalated a status (seen marker matches),
1745
+ # classify_signal must self-handle to avoid a duplicate in the digest.
1746
+ local dir state key out
1747
+ dir=$(make_supercase signal-dedup)
1748
+ state="$dir/state"
1749
+ printf 'done: PR https://x/y/pull/9\n' > "$state/dup-s9.status"
1750
+ # Simulate the catch-all scan having already escalated this status.
1751
+ key=$(printf '%s' "dup-s9" | tr ':/.' '___')
1752
+ seen_through "$state" "dup-s9"
1753
+ out=$(FM_STATE_OVERRIDE="$state" classify_signal "$state/dup-s9.status" "$state")
1754
+ case "$out" in self\|*) ;; *) fail "signal not deduped against scan: $out" ;; esac
1755
+ # Without the seen marker, it should escalate.
1756
+ rm -f "$state/.subsuper-seen-status-$key"
1757
+ out=$(FM_STATE_OVERRIDE="$state" classify_signal "$state/dup-s9.status" "$state")
1758
+ case "$out" in escalate\|*) ;; *) fail "signal should escalate when not seen: $out" ;; esac
1759
+ pass "classify_signal dedupes against the catch-all scan seen marker"
1760
+ }
1761
+
1762
+ test_classify_stale_dedup_against_signal() {
1763
+ # If the signal path already escalated a status (seen marker matches),
1764
+ # classify_stale must self-handle to avoid a duplicate in the digest.
1765
+ local dir state key out
1766
+ dir=$(make_supercase stale-dedup)
1767
+ state="$dir/state"
1768
+ printf 'done: PR https://x/y/pull/10\n' > "$state/dup-s10.status"
1769
+ key=$(printf '%s' "dup-s10" | tr ':/.' '___')
1770
+ seen_through "$state" "dup-s10"
1771
+ out=$(FM_STATE_OVERRIDE="$state" classify_stale "sess:fm-dup-s10" "$state")
1772
+ case "$out" in self\|*) ;; *) fail "stale not deduped against signal: $out" ;; esac
1773
+ # Without the seen marker, it should escalate.
1774
+ rm -f "$state/.subsuper-seen-status-$key"
1775
+ out=$(FM_STATE_OVERRIDE="$state" classify_stale "sess:fm-dup-s10" "$state")
1776
+ case "$out" in escalate\|*) ;; *) fail "stale should escalate when not seen: $out" ;; esac
1777
+ pass "classify_stale dedupes against the signal path seen marker"
1778
+ }
1779
+
1780
+ # AFK incident regression: a nonterminal working: line that was already surfaced
1781
+ # (seen marker matches, including free-text "merged") must keep possible-wedge
1782
+ # aging. handle_wake must record the stale marker; housekeeping re-escalates
1783
+ # once at the configured bound.
1784
+ test_afk_nonterminal_working_merged_keeps_wedge_aging() {
1785
+ local dir state key out win pane incident fakebin
1786
+ dir=$(make_supercase afk-working-merged-wedge)
1787
+ state="$dir/state"
1788
+ fakebin="$dir/fakebin"
1789
+ win="sess:fm-wishlist-w1"
1790
+ pane="$dir/pane.txt"
1791
+ incident='working: stage 2 setup complete on PR #74 exact source branch rebased onto merged #76; task dates preserved'
1792
+ printf '%s\n' "$incident" > "$state/wishlist-w1.status"
1793
+ printf 'idle prompt $\n' > "$pane"
1794
+ key=$(printf '%s' "wishlist-w1" | tr ':/.' '___')
1795
+ # Simulate an earlier false-positive escalate that wrote the seen marker.
1796
+ seen_through "$state" "wishlist-w1"
1797
+ out=$(FM_STATE_OVERRIDE="$state" classify_stale "$win" "$state")
1798
+ case "$out" in
1799
+ self\|*transient*) ;;
1800
+ escalate\|*) fail "nonterminal working: escalated as terminal stale: $out" ;;
1801
+ *)
1802
+ case "$out" in
1803
+ *already\ escalated*) fail "nonterminal working: treated as already-escalated terminal: $out" ;;
1804
+ *) fail "nonterminal working: unexpected classify_stale: $out" ;;
1805
+ esac
1806
+ ;;
1807
+ esac
1808
+ FM_STATE_OVERRIDE="$state" handle_wake "stale: $win" "$state"
1809
+ [ -e "$state/.subsuper-stale-$key" ] \
1810
+ || fail "wedge stale marker was not recorded for already-seen nonterminal working:"
1811
+ [ ! -s "$state/.subsuper-escalations" ] \
1812
+ || fail "nonterminal working: stale incorrectly escalated immediately"
1813
+ # Age the marker past the escalate bound (marker stores first-seen epoch).
1814
+ echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key"
1815
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \
1816
+ FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state"
1817
+ [ -s "$state/.subsuper-escalations" ] \
1818
+ || fail "housekeeping did not re-escalate aged nonterminal working: wedge"
1819
+ grep -q 'possible wedge' "$state/.subsuper-escalations" \
1820
+ || fail "housekeeping escalate was not a possible-wedge: $(cat "$state/.subsuper-escalations")"
1821
+ pass "AFK nonterminal working:+merged keeps wedge aging and re-escalates at bound"
1822
+ }
1823
+
1824
+ test_afk_genuine_done_still_terminal_stale() {
1825
+ local dir state out
1826
+ dir=$(make_supercase afk-genuine-done-stale)
1827
+ state="$dir/state"
1828
+ printf 'done: PR https://example.com/pull/76 checks green; stage 1 of 4 ready for firstmate merge\n' \
1829
+ > "$state/stage1-w2.status"
1830
+ out=$(FM_STATE_OVERRIDE="$state" classify_stale "sess:fm-stage1-w2" "$state")
1831
+ case "$out" in escalate\|*) ;; *) fail "genuine done: stale did not escalate: $out" ;; esac
1832
+ out=$(classify_check "check: /s/t.check.sh: merged")
1833
+ case "$out" in escalate\|*) ;; *) fail "validated merge-check did not escalate: $out" ;; esac
1834
+ pass "genuine done: and merge-check events still escalate"
1835
+ }
1836
+
1837
+ test_pane_input_pending_bordered_idle_not_pending() {
1838
+ # THE regression: an idle claude composer is a bordered box ("│ > … │"). The
1839
+ # old idle regex only matched a BARE prompt, so every idle claude pane read as
1840
+ # pending and the away-mode daemon deferred 100% of escalations for 9.5h.
1841
+ local dir state fakebin capture line
1842
+ dir=$(make_supercase pending-bordered-idle)
1843
+ state="$dir/state"; fakebin="$dir/fakebin"; capture="$dir/pane.txt"
1844
+ for line in '>' '❯' ''; do
1845
+ case "$line" in
1846
+ '>') printf '╭────────────╮\n│ > │\n╰────────────╯\n' > "$capture" ;;
1847
+ '❯') printf '╭────────────╮\n│ ❯ │\n╰────────────╯\n' > "$capture" ;;
1848
+ '') printf '╭────────────╮\n│ │\n╰────────────╯\n' > "$capture" ;;
1849
+ esac
1850
+ if PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=1 \
1851
+ pane_input_pending "fakepane"; then
1852
+ fail "bordered idle composer falsely detected as pending: <$line>"
1853
+ fi
1854
+ done
1855
+ pass "pane_input_pending: an idle bordered composer is NOT pending (afk-invx-i5)"
1856
+ }
1857
+
1858
+ test_pane_input_pending_bordered_with_text_is_pending() {
1859
+ # Guard against over-broadening: real unsubmitted text inside the box must
1860
+ # still read as pending so the daemon defers (and the captain-return race is
1861
+ # still protected).
1862
+ local dir state fakebin capture
1863
+ dir=$(make_supercase pending-bordered-text)
1864
+ state="$dir/state"; fakebin="$dir/fakebin"; capture="$dir/pane.txt"
1865
+ printf '╭────────────────────────────────────────────────╮\n│ > fix findings 1 and 3, skip 2 │\n╰────────────────────────────────────────────────╯\n' > "$capture"
1866
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=1 \
1867
+ pane_input_pending "fakepane" \
1868
+ || fail "real text inside a bordered composer was not detected as pending"
1869
+ pass "pane_input_pending: text inside a bordered composer is still pending"
1870
+ }
1871
+
1872
+ test_submit_ack_confirms_on_bordered_empty_composer() {
1873
+ # RC2: the submit acknowledgement must recognize a bordered-EMPTY composer as
1874
+ # "submitted." The old ACK reused the broken check, so on claude it could never
1875
+ # confirm and always reported a false "Enter swallowed."
1876
+ local dir fakebin sent verdict
1877
+ dir=$(make_bordered_case ack-bordered)
1878
+ fakebin="$dir/fakebin"; sent="$dir/sent.log"; : > "$sent"
1879
+ verdict=$(PATH="$fakebin:$PATH" FM_FAKE_COMPOSER="$dir/composer" FM_FAKE_SENT="$sent" \
1880
+ fm_tmux_submit_core "win" "the digest" 3 0.05 0.05)
1881
+ [ "$verdict" = empty ] || fail "submit-ACK did not confirm on a bordered-empty composer: $verdict"
1882
+ [ "$(grep -cv '\[ENTER\]' "$sent")" -eq 1 ] || fail "digest typed more than once (retype)"
1883
+ [ "$(grep -c '\[ENTER\]' "$sent")" -eq 1 ] || fail "expected exactly one submitted Enter"
1884
+ pass "submit-ACK confirms a submit when the composer returns to a bordered-empty box"
1885
+ }
1886
+
1887
+ test_submit_ack_reports_pending_on_persistent_swallow() {
1888
+ # A genuinely swallowed Enter (text stays in the box across all retries) is
1889
+ # reported as "pending" — the daemon keeps the buffer, fm-send exits non-zero —
1890
+ # and the digest is typed ONCE (Enter-only retries, never a retype).
1891
+ local dir fakebin sent verdict
1892
+ dir=$(make_bordered_case ack-swallow)
1893
+ fakebin="$dir/fakebin"; sent="$dir/sent.log"; : > "$sent"
1894
+ touch "$dir/.swallow"
1895
+ verdict=$(PATH="$fakebin:$PATH" FM_FAKE_COMPOSER="$dir/composer" FM_FAKE_SENT="$sent" \
1896
+ FM_FAKE_SWALLOW="$dir/.swallow" FM_FAKE_PERSIST_SWALLOW=1 \
1897
+ fm_tmux_submit_core "win" "the digest" 3 0.05 0.05)
1898
+ [ "$verdict" = pending ] || fail "persistent swallow not reported as pending: $verdict"
1899
+ [ "$(grep -cv '\[ENTER\]' "$sent")" -eq 1 ] || fail "digest retyped on swallow (expected type-once)"
1900
+ pass "submit-ACK reports pending on a persistently swallowed Enter (type-once)"
1901
+ }
1902
+
1903
+ test_max_defer_empty_swallow_types_once_and_alarms() {
1904
+ local dir state fakebin sent
1905
+ dir=$(make_bordered_case maxdefer-stuck)
1906
+ state="$dir/state"; fakebin="$dir/fakebin"
1907
+ sent="$dir/sent.log"; : > "$sent"
1908
+ printf '╭─────╮\n│ > │\n╰─────╯\n' > "$dir/composer"
1909
+ touch "$dir/.swallow"
1910
+ escalate_add "$state" "needs-decision: pick A"
1911
+ echo $(( $(date +%s) - 600 )) > "$state/.subsuper-escalations.since"
1912
+ afk_enter "$state"
1913
+ PATH="$fakebin:$PATH" FM_FAKE_COMPOSER="$dir/composer" FM_FAKE_SENT="$sent" \
1914
+ FM_FAKE_SWALLOW="$dir/.swallow" FM_FAKE_PERSIST_SWALLOW=1 FM_INJECT_CONFIRM_SLEEP=0.05 \
1915
+ FM_ESCALATE_BATCH_SECS=99999 FM_MAX_DEFER_SECS=60 housekeeping "$state"
1916
+ [ "$(grep -c 'Supervisor escalate' "$sent" 2>/dev/null || true)" -eq 1 ] \
1917
+ || fail "max-defer typed the digest more than once"
1918
+ [ -s "$state/.subsuper-inject-wedged" ] \
1919
+ || fail "stuck max-defer inject did not raise a wedge alarm marker"
1920
+ [ -s "$state/.subsuper-escalations" ] \
1921
+ || fail "buffer lost after a failed max-defer inject (must be preserved)"
1922
+ pass "max-defer on an empty stuck pane types once, alarms, and preserves the buffer"
1923
+ }
1924
+
1925
+ test_max_defer_flushes_empty_idle_pane() {
1926
+ local dir state fakebin sent
1927
+ dir=$(make_bordered_case maxdefer-recover)
1928
+ state="$dir/state"; fakebin="$dir/fakebin"
1929
+ sent="$dir/sent.log"; : > "$sent"
1930
+ printf '╭─────╮\n│ > │\n╰─────╯\n' > "$dir/composer"
1931
+ escalate_add "$state" "done: PR https://x/y/pull/1"
1932
+ echo $(( $(date +%s) - 600 )) > "$state/.subsuper-escalations.since"
1933
+ afk_enter "$state"
1934
+ PATH="$fakebin:$PATH" FM_FAKE_COMPOSER="$dir/composer" FM_FAKE_SENT="$sent" \
1935
+ FM_ESCALATE_BATCH_SECS=99999 FM_MAX_DEFER_SECS=60 FM_INJECT_CONFIRM_SLEEP=0.05 \
1936
+ housekeeping "$state"
1937
+ [ ! -s "$state/.subsuper-escalations" ] || fail "buffer not cleared after a recovered max-defer flush"
1938
+ [ ! -e "$state/.subsuper-inject-wedged" ] || fail "wedge alarm left behind after a successful max-defer flush"
1939
+ pass "max-defer flushes and clears the buffer on an empty bordered pane"
1940
+ }
1941
+
1942
+ test_max_defer_pending_composer_alarms_without_typing() {
1943
+ local dir state fakebin sent
1944
+ dir=$(make_bordered_case maxdefer-pending-digest)
1945
+ state="$dir/state"; fakebin="$dir/fakebin"
1946
+ sent="$dir/sent.log"; : > "$sent"
1947
+ printf '╭─────────────────╮\n│ > human draft │\n╰─────────────────╯\n' > "$dir/composer"
1948
+ escalate_add "$state" "needs-decision: pick B"
1949
+ echo $(( $(date +%s) - 600 )) > "$state/.subsuper-escalations.since"
1950
+ afk_enter "$state"
1951
+ PATH="$fakebin:$PATH" FM_FAKE_COMPOSER="$dir/composer" FM_FAKE_SENT="$sent" \
1952
+ FM_ESCALATE_BATCH_SECS=99999 FM_MAX_DEFER_SECS=60 FM_INJECT_CONFIRM_SLEEP=0.05 \
1953
+ housekeeping "$state"
1954
+ [ ! -s "$sent" ] || fail "max-defer typed into a pending composer"
1955
+ [ -s "$state/.subsuper-inject-wedged" ] || fail "pending composer did not raise a wedge alarm marker"
1956
+ [ -s "$state/.subsuper-escalations" ] || fail "buffer lost while composer was pending"
1957
+ grep -F 'human draft' "$dir/composer" >/dev/null || fail "pending composer content changed"
1958
+ pass "max-defer on a pending composer alarms without typing"
1959
+ }
1960
+
1961
+ test_normal_flush_clears_stale_wedge_marker() {
1962
+ local dir state fakebin sent
1963
+ dir=$(make_bordered_case normal-clears-wedge)
1964
+ state="$dir/state"; fakebin="$dir/fakebin"
1965
+ sent="$dir/sent.log"; : > "$sent"
1966
+ printf 'old wedge\n' > "$state/.subsuper-inject-wedged"
1967
+ escalate_add "$state" "done: PR https://x/y/pull/2"
1968
+ afk_enter "$state"
1969
+ PATH="$fakebin:$PATH" FM_FAKE_COMPOSER="$dir/composer" FM_FAKE_SENT="$sent" \
1970
+ FM_INJECT_CONFIRM_SLEEP=0.05 escalate_flush "$state" \
1971
+ || fail "normal escalate_flush failed"
1972
+ [ ! -s "$state/.subsuper-escalations" ] || fail "buffer not cleared after normal flush"
1973
+ [ ! -e "$state/.subsuper-inject-wedged" ] || fail "wedge marker survived successful normal flush"
1974
+ pass "normal flush clears a stale wedge marker"
1975
+ }
1976
+
1977
+ test_below_max_defer_does_nothing() {
1978
+ local dir state fakebin sent capture
1979
+ dir=$(make_supercase below-maxdefer)
1980
+ state="$dir/state"; fakebin="$dir/fakebin"
1981
+ sent="$dir/sent.log"; : > "$sent"
1982
+ capture="$dir/pane.txt"; printf 'stuck junk line\n' > "$capture"
1983
+ escalate_add "$state" "needs-decision: pick A"
1984
+ date +%s > "$state/.subsuper-escalations.since" # just now
1985
+ afk_enter "$state"
1986
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_PANE_ALIVE=1 FM_FAKE_TMUX_SENT="$sent" \
1987
+ FM_FAKE_TMUX_CAPTURE="$capture" FM_FAKE_TMUX_CURSOR_Y=0 \
1988
+ FM_ESCALATE_BATCH_SECS=99999 FM_MAX_DEFER_SECS=300 housekeeping "$state"
1989
+ [ ! -s "$sent" ] || fail "injected before MAX_DEFER elapsed"
1990
+ [ ! -e "$state/.subsuper-inject-wedged" ] || fail "wedge alarm fired before MAX_DEFER"
1991
+ [ -s "$state/.subsuper-escalations" ] || fail "buffer dropped below MAX_DEFER"
1992
+ pass "below MAX_DEFER: no inject, no alarm, buffer preserved"
1993
+ }
1994
+
1995
+ test_max_defer_afk_inactive_does_not_flush_or_alarm() {
1996
+ local dir state fakebin sent
1997
+ dir=$(make_bordered_case maxdefer-inactive)
1998
+ state="$dir/state"; fakebin="$dir/fakebin"
1999
+ sent="$dir/sent.log"; : > "$sent"
2000
+ escalate_add "$state" "needs-decision: pick B"
2001
+ echo $(( $(date +%s) - 600 )) > "$state/.subsuper-escalations.since"
2002
+ PATH="$fakebin:$PATH" FM_FAKE_COMPOSER="$dir/composer" FM_FAKE_SENT="$sent" \
2003
+ FM_ESCALATE_BATCH_SECS=99999 FM_MAX_DEFER_SECS=60 FM_INJECT_CONFIRM_SLEEP=0.05 \
2004
+ housekeeping "$state"
2005
+ [ ! -s "$sent" ] || fail "injected while afk was inactive"
2006
+ [ ! -e "$state/.subsuper-inject-wedged" ] || fail "wedge alarm fired while afk was inactive"
2007
+ [ -s "$state/.subsuper-escalations" ] || fail "buffer dropped while afk was inactive"
2008
+ pass "max-defer does not flush or alarm while afk is inactive"
2009
+ }
2010
+
2011
+ # --- backend-independent active wedge alert ---------------------------------
2012
+ # These cover the 2026-07-10 overnight-incident fix: the max-defer wedge alarm's
2013
+ # ACTIVE alert channel must reach the captain even when the wedged pane and its
2014
+ # backend status-line are unreadable (a claude-on-herdr primary that night).
2015
+ #
2016
+ # NO test here EVER posts a real notification. Every notifier routes through
2017
+ # the FM_WEDGE_ALARM_EXEC seam, which tests/wake-helpers.sh forces to a recorder
2018
+ # ($FM_WEDGE_ALARM_LOG logs "<channel>\t<summary>"); the daemon also defaults
2019
+ # that seam to "discard" whenever it is sourced. Assertions read the recorder
2020
+ # log, so they verify channel SELECTION and summary propagation; the real
2021
+ # osascript/herdr argv is verified once by the bounded manual evidence in
2022
+ # docs/wedge-alarm.md, never from a suite.
2023
+ make_wedge_case() { # <name> -> echoes dir; creates state/, fakebin/{uname,osascript,herdr}, alert.log
2024
+ local name=$1 dir fakebin
2025
+ dir="$TMP_ROOT/$name"; fakebin="$dir/fakebin"
2026
+ mkdir -p "$dir/state" "$fakebin"
2027
+ # Fake uname so `auto` platform resolution is deterministic on any CI host.
2028
+ cat > "$fakebin/uname" <<'SH'
2029
+ #!/usr/bin/env bash
2030
+ printf '%s\n' "${FM_FAKE_UNAME:-Darwin}"
2031
+ SH
2032
+ # Fakes keep command discovery deterministic on any CI host.
2033
+ cat > "$fakebin/osascript" <<'SH'
2034
+ #!/usr/bin/env bash
2035
+ printf '%s\n' osascript >> "${FM_WEDGE_ALARM_REAL_LOG:-/dev/null}"
2036
+ exit 0
2037
+ SH
2038
+ cat > "$fakebin/herdr" <<'SH'
2039
+ #!/usr/bin/env bash
2040
+ printf '%s\n' herdr >> "${FM_WEDGE_ALARM_REAL_LOG:-/dev/null}"
2041
+ exit 0
2042
+ SH
2043
+ chmod +x "$fakebin/uname" "$fakebin/osascript" "$fakebin/herdr"
2044
+ : > "$dir/alert.log"
2045
+ printf '%s\n' "$dir"
2046
+ }
2047
+
2048
+ test_wedge_alarm_library_mode_defaults_to_discard() {
2049
+ # The structural guarantee: sourcing the daemon with NO seam configured defaults
2050
+ # FM_WEDGE_ALARM_EXEC to "discard", so a sourced context (every test) cannot
2051
+ # fire a real notification even if it forgets to stub. Checked in a clean
2052
+ # subshell that first unsets this harness's recorder.
2053
+ local out
2054
+ # shellcheck disable=SC2016 # $1/$FM_WEDGE_ALARM_EXEC must expand in the child, not here
2055
+ out=$(env -u FM_WEDGE_ALARM_EXEC bash -c '. "$1"; printf "%s" "${FM_WEDGE_ALARM_EXEC:-UNSET}"' _ "$DAEMON")
2056
+ [ "$out" = discard ] \
2057
+ || fail "sourcing the daemon did not default the notifier seam to discard (got: $out)"
2058
+ pass "library mode: sourcing the daemon defaults FM_WEDGE_ALARM_EXEC to discard (no test can fire a real notification)"
2059
+ }
2060
+
2061
+ test_wake_helpers_replace_inherited_notifier_override() {
2062
+ local dir unsafe_log alert_log unsafe
2063
+ dir=$(make_wedge_case wedge-inherited-override)
2064
+ unsafe_log="$dir/unsafe.log"
2065
+ alert_log="$dir/alert.log"
2066
+ unsafe="$dir/unsafe-override"
2067
+ cat > "$unsafe" <<'SH'
2068
+ #!/usr/bin/env bash
2069
+ printf '%s\n' invoked >> "${FM_WEDGE_ALARM_UNSAFE_LOG:?}"
2070
+ SH
2071
+ chmod +x "$unsafe"
2072
+ FM_WEDGE_ALARM_EXEC="$unsafe" FM_WEDGE_ALARM_UNSAFE_LOG="$unsafe_log" \
2073
+ FM_WEDGE_ALARM_LOG="$alert_log" FM_WEDGE_ALARM_CHANNEL=osascript \
2074
+ bash -c '. "$1"; . "$2"; wedge_alarm_notify "away-mode WEDGED 900s" "/s/.marker"' \
2075
+ _ "$ROOT/tests/wake-helpers.sh" "$DAEMON"
2076
+ [ ! -s "$unsafe_log" ] || fail "wake helpers preserved an inherited notifier override"
2077
+ grep -F 'osascript' "$alert_log" >/dev/null \
2078
+ || fail "wake helpers did not install the safe notifier recorder"
2079
+ pass "wake helpers replace inherited notifier overrides with the safe recorder"
2080
+ }
2081
+
2082
+ test_wedge_alarm_discard_seam_fires_nothing() {
2083
+ local dir log command_output channel
2084
+ dir=$(make_wedge_case wedge-discard); log="$dir/alert.log"
2085
+ command_output="$dir/command-output"
2086
+ channel="command: printf '%s' \"\$1\" > '$command_output'"
2087
+ PATH="$dir/fakebin:$PATH" FM_WEDGE_ALARM_LOG="$log" FM_WEDGE_ALARM_EXEC=discard \
2088
+ FM_WEDGE_ALARM_CHANNEL=$'osascript\nherdr\n'"$channel" \
2089
+ wedge_alarm_notify "away-mode WEDGED 900s" "/s/.marker"
2090
+ [ ! -s "$log" ] || fail "the discard seam still fired a notifier: $(cat "$log")"
2091
+ [ ! -e "$command_output" ] || fail "the discard seam still fired a command: notifier"
2092
+ pass "the discard seam suppresses every notifier, including command: (fires nothing)"
2093
+ }
2094
+
2095
+ test_wedge_alarm_direct_notifiers_honor_discard_seam() {
2096
+ local dir real_log command_output command
2097
+ dir=$(make_wedge_case wedge-direct-discard); real_log="$dir/real.log"
2098
+ command_output="$dir/command-output"
2099
+ command="printf '%s' \"\$1\" > '$command_output'"
2100
+ PATH="$dir/fakebin:$PATH" FM_WEDGE_ALARM_REAL_LOG="$real_log" FM_WEDGE_ALARM_EXEC=discard \
2101
+ wedge_alarm_via_osascript "away-mode WEDGED 900s"
2102
+ PATH="$dir/fakebin:$PATH" FM_WEDGE_ALARM_REAL_LOG="$real_log" FM_WEDGE_ALARM_EXEC=discard \
2103
+ wedge_alarm_via_herdr "away-mode WEDGED 900s"
2104
+ FM_WEDGE_ALARM_EXEC=discard wedge_alarm_via_command "$command" "away-mode WEDGED 900s"
2105
+ [ ! -s "$real_log" ] || fail "direct notifier helpers bypassed the discard seam: $(cat "$real_log")"
2106
+ [ ! -e "$command_output" ] || fail "direct command helper bypassed the discard seam"
2107
+ pass "direct notifier helpers honor the discard seam, including command:"
2108
+ }
2109
+
2110
+ test_wedge_alarm_osascript_channel_selected() {
2111
+ local dir log
2112
+ dir=$(make_wedge_case wedge-osascript); log="$dir/alert.log"
2113
+ FM_WEDGE_ALARM_LOG="$log" FM_WEDGE_ALARM_CHANNEL=osascript \
2114
+ wedge_alarm_notify "away-mode escalations WEDGED 600s undelivered - see /s/.marker" "/s/.marker"
2115
+ grep -F 'osascript' "$log" >/dev/null || fail "osascript channel not routed through the notifier seam: $(cat "$log")"
2116
+ grep -F 'WEDGED 600s undelivered' "$log" >/dev/null || fail "osascript channel did not carry the summary"
2117
+ grep -F 'herdr' "$log" >/dev/null && fail "osascript-only config also selected herdr"
2118
+ pass "osascript channel routes through the notifier seam with the summary (never a real notification)"
2119
+ }
2120
+
2121
+ test_wedge_alarm_herdr_channel_selected() {
2122
+ local dir log
2123
+ dir=$(make_wedge_case wedge-herdr); log="$dir/alert.log"
2124
+ FM_WEDGE_ALARM_LOG="$log" FM_WEDGE_ALARM_CHANNEL=herdr \
2125
+ wedge_alarm_notify "away-mode escalations WEDGED 800s undelivered - see /s/.marker" "/s/.marker"
2126
+ grep -F 'herdr' "$log" >/dev/null || fail "herdr channel not routed through the notifier seam: $(cat "$log")"
2127
+ grep -F 'WEDGED 800s undelivered' "$log" >/dev/null || fail "herdr channel did not carry the summary"
2128
+ grep -F 'osascript' "$log" >/dev/null && fail "herdr-only config also selected osascript"
2129
+ pass "herdr channel routes through the notifier seam with the summary (never a real notification)"
2130
+ }
2131
+
2132
+ test_wedge_alarm_command_channel_receives_summary() {
2133
+ local dir out_argv out_stdin chan
2134
+ dir=$(make_wedge_case wedge-command)
2135
+ out_argv="$dir/argv.txt"; out_stdin="$dir/stdin.txt"
2136
+ chan="command: printf '%s' \"\$1\" > '$out_argv'; cat > '$out_stdin'"
2137
+ FM_WEDGE_ALARM_EXEC='' FM_WEDGE_ALARM_CHANNEL="$chan" \
2138
+ wedge_alarm_notify "away-mode WEDGED 900s" "/s/.marker"
2139
+ [ "$(cat "$out_argv" 2>/dev/null)" = "away-mode WEDGED 900s" ] || fail "command channel did not receive the summary on \$1"
2140
+ grep -F 'away-mode WEDGED 900s' "$out_stdin" >/dev/null || fail "command channel did not receive the summary on stdin"
2141
+ pass "command channel runs the captain command with the summary on \$1 and on stdin"
2142
+ }
2143
+
2144
+ test_wedge_alarm_command_failure_hides_configured_command() {
2145
+ local dir daemon_log secret rc
2146
+ dir=$(make_wedge_case wedge-command-redaction); daemon_log="$dir/daemon.log"
2147
+ secret="https://alerts.example.invalid/hook?token=private-wedge-token"
2148
+ LOG="$daemon_log" FM_WEDGE_ALARM_EXEC='' FM_WEDGE_ALARM_CHANNEL="command:exit 73 # $secret" \
2149
+ wedge_alarm_notify "away-mode WEDGED 900s" "/s/.marker"
2150
+ rc=$?
2151
+ [ "$rc" -eq 0 ] || fail "a failed command channel made wedge_alarm_notify return non-zero ($rc)"
2152
+ grep -F 'command channel exited 73 (command redacted)' "$daemon_log" >/dev/null \
2153
+ || fail "command channel failure did not log its exit status: $(cat "$daemon_log" 2>/dev/null)"
2154
+ grep -F "$secret" "$daemon_log" >/dev/null \
2155
+ && fail "command channel failure leaked its configured command: $(cat "$daemon_log")"
2156
+ pass "command channel failures redact configured commands while logging their exit status"
2157
+ }
2158
+
2159
+ test_wedge_alarm_unknown_channel_hides_configured_directive() {
2160
+ local dir daemon_log secret rc
2161
+ dir=$(make_wedge_case wedge-unknown-redaction); daemon_log="$dir/daemon.log"
2162
+ secret="https://alerts.example.invalid/hook?token=private-wedge-token"
2163
+ LOG="$daemon_log" FM_WEDGE_ALARM_CHANNEL="webhook:$secret" \
2164
+ wedge_alarm_notify "away-mode WEDGED 900s" "/s/.marker"
2165
+ rc=$?
2166
+ [ "$rc" -eq 0 ] || fail "an unknown channel made wedge_alarm_notify return non-zero ($rc)"
2167
+ grep -F 'unrecognized active-alert channel directive (redacted); marker still written' "$daemon_log" >/dev/null \
2168
+ || fail "an unknown channel did not log the redacted directive category: $(cat "$daemon_log" 2>/dev/null)"
2169
+ grep -F "$secret" "$daemon_log" >/dev/null \
2170
+ && fail "an unknown channel leaked its configured directive: $(cat "$daemon_log")"
2171
+ pass "unknown channel directives are redacted while the alarm keeps running"
2172
+ }
2173
+
2174
+ test_wedge_alarm_off_disables_active_alert_regardless_of_position() {
2175
+ local dir log directives
2176
+ dir=$(make_wedge_case wedge-off); log="$dir/alert.log"
2177
+ for directives in $'osascript\noff' $'off\nosascript'; do
2178
+ : > "$log"
2179
+ FM_WEDGE_ALARM_LOG="$log" FM_WEDGE_ALARM_CHANNEL="$directives" \
2180
+ wedge_alarm_notify "away-mode WEDGED 900s" "/s/.marker"
2181
+ [ ! -s "$log" ] || fail "off did not disable a preceding or following active alert: $(cat "$log")"
2182
+ done
2183
+ pass "off disables every active alert regardless of directive position (marker and tmux flash are unaffected)"
2184
+ }
2185
+
2186
+ test_wedge_alarm_auto_darwin_selects_osascript() {
2187
+ local dir log
2188
+ dir=$(make_wedge_case wedge-auto-darwin); log="$dir/alert.log"
2189
+ PATH="$dir/fakebin:$PATH" FM_WEDGE_ALARM_LOG="$log" FM_FAKE_UNAME=Darwin FM_WEDGE_ALARM_CHANNEL=auto \
2190
+ wedge_alarm_notify "away-mode WEDGED 900s" "/s/.marker"
2191
+ grep -F 'osascript' "$log" >/dev/null || fail "auto did not resolve to osascript on Darwin: $(cat "$log")"
2192
+ pass "auto resolves to the macOS osascript notifier on Darwin (default-on)"
2193
+ }
2194
+
2195
+ test_wedge_alarm_auto_non_darwin_has_no_os_channel() {
2196
+ local dir log
2197
+ dir=$(make_wedge_case wedge-auto-linux); log="$dir/alert.log"
2198
+ PATH="$dir/fakebin:$PATH" FM_WEDGE_ALARM_LOG="$log" FM_FAKE_UNAME=Linux FM_WEDGE_ALARM_CHANNEL=auto \
2199
+ wedge_alarm_notify "away-mode WEDGED 900s" "/s/.marker"
2200
+ [ ! -s "$log" ] || fail "auto selected a built-in OS channel on a non-macOS platform: $(cat "$log")"
2201
+ pass "auto on a non-macOS platform selects no built-in OS channel (the marker or a configured command carries it)"
2202
+ }
2203
+
2204
+ test_wedge_alarm_config_file_multi_channel() {
2205
+ local dir cfgdir log
2206
+ dir=$(make_wedge_case wedge-config); log="$dir/alert.log"
2207
+ cfgdir="$dir/config"; mkdir -p "$cfgdir"
2208
+ printf '# active alert channels\n\nosascript\nherdr\n' > "$cfgdir/wedge-alarm"
2209
+ FM_WEDGE_ALARM_LOG="$log" FM_CONFIG_OVERRIDE="$cfgdir" \
2210
+ wedge_alarm_notify "away-mode WEDGED 700s" "/s/.marker"
2211
+ grep -F 'osascript' "$log" >/dev/null || fail "config/wedge-alarm osascript line was not selected"
2212
+ grep -F 'herdr' "$log" >/dev/null || fail "config/wedge-alarm herdr line was not selected"
2213
+ pass "config/wedge-alarm selects every configured channel and skips comment and blank lines"
2214
+ }
2215
+
2216
+ test_wedge_alarm_failing_channel_degrades_gracefully() {
2217
+ local dir log rc
2218
+ dir=$(make_wedge_case wedge-degrade); log="$dir/alert.log"
2219
+ FM_WEDGE_ALARM_LOG="$log" FM_WEDGE_ALARM_FAIL=osascript \
2220
+ FM_WEDGE_ALARM_CHANNEL=$'osascript\nherdr' \
2221
+ wedge_alarm_notify "away-mode WEDGED 900s" "/s/.marker"
2222
+ rc=$?
2223
+ [ "$rc" -eq 0 ] || fail "a failing channel made wedge_alarm_notify return non-zero ($rc)"
2224
+ grep -F 'osascript' "$log" >/dev/null || fail "the failing osascript channel was not even attempted"
2225
+ grep -F 'herdr' "$log" >/dev/null || fail "a failing earlier channel prevented the herdr channel from firing"
2226
+ pass "a failing channel logs and falls back to the next channel, never crashing the alarm"
2227
+ }
2228
+
2229
+ test_wedge_alarm_hung_channel_times_out_and_falls_through() {
2230
+ local dir daemon_log output channel start elapsed
2231
+ dir=$(make_wedge_case wedge-timeout); daemon_log="$dir/daemon.log"; output="$dir/fallback-output"
2232
+ channel="command: printf '%s' \"\$1\" > '$output'"
2233
+ start=$SECONDS
2234
+ LOG="$daemon_log" FM_WEDGE_ALARM_EXEC='' FM_WEDGE_ALARM_TIMEOUT_SECS=1 \
2235
+ FM_WEDGE_ALARM_CHANNEL=$'command:sleep 30\n'"$channel" \
2236
+ wedge_alarm_notify "away-mode WEDGED 900s" "/s/.marker"
2237
+ elapsed=$((SECONDS - start))
2238
+ [ "$elapsed" -lt 5 ] || fail "a hung wedge notifier blocked the alarm for ${elapsed}s"
2239
+ grep -F 'command notifier timed out' "$daemon_log" >/dev/null \
2240
+ || fail "a hung wedge notifier did not log its timeout: $(cat "$daemon_log" 2>/dev/null)"
2241
+ [ "$(cat "$output" 2>/dev/null)" = "away-mode WEDGED 900s" ] \
2242
+ || fail "a timed-out command notifier prevented the next channel"
2243
+ pass "a hung notifier is bounded, logged, and falls through to the next channel"
2244
+ }
2245
+
2246
+ test_wedge_alarm_backgrounded_command_times_out_and_reaps_descendant() {
2247
+ local dir daemon_log child_file child command
2248
+ dir=$(make_wedge_case wedge-backgrounded-timeout)
2249
+ daemon_log="$dir/daemon.log"
2250
+ child_file="$dir/notifier-child"
2251
+ command="sleep 30 & printf '%s' \"\$!\" > '$child_file'"
2252
+ LOG="$daemon_log" FM_WEDGE_ALARM_EXEC='' FM_WEDGE_ALARM_TIMEOUT_SECS=1 \
2253
+ wedge_alarm_via_command "$command" "away-mode WEDGED 900s"
2254
+ [ -s "$child_file" ] || fail "the backgrounded notifier did not record its descendant"
2255
+ child=$(cat "$child_file")
2256
+ grep -F 'command notifier timed out' "$daemon_log" >/dev/null \
2257
+ || fail "a backgrounded command notifier bypassed its timeout: $(cat "$daemon_log" 2>/dev/null)"
2258
+ if is_live_non_zombie "$child"; then
2259
+ kill -TERM "$child" 2>/dev/null || true
2260
+ fail "a timed-out command notifier left its descendant running (pid $child)"
2261
+ fi
2262
+ pass "a backgrounded command notifier remains bounded until its process group is reaped"
2263
+ }
2264
+
2265
+ test_wedge_alarm_hung_override_times_out_and_falls_through() {
2266
+ local dir blocker daemon_log start elapsed
2267
+ dir=$(make_wedge_case wedge-override-timeout)
2268
+ blocker="$dir/blocker"; daemon_log="$dir/daemon.log"
2269
+ cat > "$blocker" <<'SH'
2270
+ #!/usr/bin/env bash
2271
+ sleep 30
2272
+ SH
2273
+ chmod +x "$blocker"
2274
+ start=$SECONDS
2275
+ LOG="$daemon_log" FM_WEDGE_ALARM_EXEC="$blocker" FM_WEDGE_ALARM_TIMEOUT_SECS=1 \
2276
+ FM_WEDGE_ALARM_CHANNEL=$'osascript\nherdr' \
2277
+ wedge_alarm_notify "away-mode WEDGED 900s" "/s/.marker"
2278
+ elapsed=$((SECONDS - start))
2279
+ [ "$elapsed" -lt 6 ] || fail "a hung wedge notifier override blocked the alarm for ${elapsed}s"
2280
+ grep -F 'osascript notifier timed out' "$daemon_log" >/dev/null \
2281
+ || fail "a hung notifier override did not log its timeout: $(cat "$daemon_log" 2>/dev/null)"
2282
+ grep -F 'herdr notifier timed out' "$daemon_log" >/dev/null \
2283
+ || fail "a hung notifier override prevented the next channel: $(cat "$daemon_log" 2>/dev/null)"
2284
+ pass "a hung notifier override is bounded, logged, and proceeds to the next channel"
2285
+ }
2286
+
2287
+ test_wedge_alarm_shutdown_stops_active_notifier_group() {
2288
+ local dir child_file pid child
2289
+ dir=$(make_wedge_case wedge-shutdown)
2290
+ child_file="$dir/notifier-child"
2291
+ (
2292
+ set -m
2293
+ sh -c 'sleep 30 & printf "%s" "$!" > "$1"; wait' sh "$child_file" &
2294
+ pid=$!
2295
+ while [ ! -s "$child_file" ]; do sleep 0.05; done
2296
+ child=$(cat "$child_file")
2297
+ WEDGE_ALARM_NOTIFIER_PID=$pid
2298
+ wedge_alarm_stop_active_notifier
2299
+ if kill -0 "$child" 2>/dev/null; then
2300
+ fail "shutdown left a notifier descendant running (pid $child)"
2301
+ fi
2302
+ ) || fail "notifier shutdown cleanup helper failed"
2303
+ pass "daemon shutdown stops and reaps the active notifier process group"
2304
+ }
2305
+
2306
+ test_inject_wedge_alarm_fires_active_alert_on_non_tmux_backend() {
2307
+ # The whole incident: a non-tmux (herdr) primary gets NO tmux status-line
2308
+ # flash, so inject_wedge_alarm must still emit the backend-independent alert
2309
+ # alongside the durable marker.
2310
+ local dir state log
2311
+ dir=$(make_wedge_case wedge-integration); state="$dir/state"; log="$dir/alert.log"
2312
+ escalate_add "$state" "needs-decision: pick A"
2313
+ WEDGE_ALARM_LAST_EPOCH=0
2314
+ FM_WEDGE_ALARM_LOG="$log" FM_STATE_OVERRIDE="$state" \
2315
+ FM_WEDGE_ALARM_CHANNEL=osascript FM_SUPERVISOR_BACKEND=herdr \
2316
+ inject_wedge_alarm "$state" 30600
2317
+ [ -s "$state/.subsuper-inject-wedged" ] || fail "inject_wedge_alarm did not write the durable marker"
2318
+ grep -F 'osascript' "$log" >/dev/null || fail "inject_wedge_alarm did not emit the active alert on a non-tmux backend: $(cat "$log")"
2319
+ grep -F 'WEDGED 30600s' "$log" >/dev/null || fail "active alert missing the age and summary"
2320
+ pass "inject_wedge_alarm writes the marker AND emits the active alert even with no tmux status-line (herdr backend)"
2321
+ }
2322
+
2323
+ test_inject_wedge_alarm_throttles_when_marker_cannot_be_written() {
2324
+ local dir state log daemon_log alerts errors
2325
+ dir=$(make_wedge_case wedge-unwritable-marker)
2326
+ state="$dir/state"; log="$dir/alert.log"; daemon_log="$dir/daemon.log"
2327
+ escalate_add "$state" "needs-decision: pick A"
2328
+ chmod u-w "$state"
2329
+ WEDGE_ALARM_LAST_EPOCH=0
2330
+ LOG="$daemon_log" FM_WEDGE_ALARM_LOG="$log" FM_MAX_DEFER_SECS=600 \
2331
+ FM_WEDGE_ALARM_CHANNEL=osascript FM_SUPERVISOR_BACKEND=herdr \
2332
+ inject_wedge_alarm "$state" 30600
2333
+ LOG="$daemon_log" FM_WEDGE_ALARM_LOG="$log" FM_MAX_DEFER_SECS=600 \
2334
+ FM_WEDGE_ALARM_CHANNEL=osascript FM_SUPERVISOR_BACKEND=herdr \
2335
+ inject_wedge_alarm "$state" 30615
2336
+ chmod u+w "$state"
2337
+ [ ! -e "$state/.subsuper-inject-wedged" ] || fail "wedge marker unexpectedly persisted in an unwritable state directory"
2338
+ alerts=$(grep -c 'osascript' "$log" 2>/dev/null || true)
2339
+ [ "$alerts" -eq 1 ] || fail "unwritable marker emitted $alerts active alerts instead of one"
2340
+ errors=$(grep -c 'ERROR: away-mode escalation undelivered' "$daemon_log" 2>/dev/null || true)
2341
+ [ "$errors" -eq 1 ] || fail "unwritable marker logged $errors wedge errors instead of one"
2342
+ pass "in-process wedge throttle prevents alert spam when the marker cannot persist"
2343
+ }
2344
+
2345
+ test_fm_send_reports_delivered_unconfirmed_submit() {
2346
+ # When typed-plane text was typed and Enter sent but the submit read-back
2347
+ # remains pending, fm-send must return its documented delivered-unconfirmed status and prevent
2348
+ # a duplicate resend reflex. A synchronously confirmed submit remains zero.
2349
+ local dir fakebin err rc
2350
+ dir=$(make_bordered_case send-swallow)
2351
+ fakebin="$dir/fakebin"; err="$dir/send.err"
2352
+ # Clean submit -> exit 0.
2353
+ PATH="$fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$dir/state" FM_FAKE_COMPOSER="$dir/composer" \
2354
+ FM_SEND_SLEEP=0.05 "$ROOT/bin/fm-send.sh" sess:win 'route this work' >/dev/null 2>"$err" \
2355
+ || fail "fm-send exited non-zero on a clean submit: $(cat "$err")"
2356
+ # Persistent composer text after Enter -> delivered-unconfirmed exit 3 with
2357
+ # a non-error warning that explicitly tells the operator not to resend.
2358
+ printf '╭─────╮\n│ > │\n╰─────╯\n' > "$dir/composer"
2359
+ touch "$dir/.swallow"
2360
+ if PATH="$fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$dir/state" FM_FAKE_COMPOSER="$dir/composer" \
2361
+ FM_FAKE_SWALLOW="$dir/.swallow" FM_FAKE_PERSIST_SWALLOW=1 FM_SEND_SLEEP=0.05 \
2362
+ "$ROOT/bin/fm-send.sh" sess:win 'fix findings 1 and 3, skip 2' >/dev/null 2>"$err"; then
2363
+ rc=0
2364
+ else
2365
+ rc=$?
2366
+ fi
2367
+ [ "$rc" -eq 3 ] || fail "fm-send returned $rc instead of delivered-unconfirmed exit 3: $(cat "$err")"
2368
+ grep -F 'submission is unconfirmed' "$err" >/dev/null \
2369
+ || fail "fm-send did not explain the pending confirmation: $(cat "$err")"
2370
+ grep -F 'do not retype or blindly resend' "$err" >/dev/null \
2371
+ || fail "fm-send did not prevent a duplicate resend: $(cat "$err")"
2372
+ if grep -F 'error:' "$err" >/dev/null; then
2373
+ fail "fm-send mislabeled delivered-unconfirmed as an error: $(cat "$err")"
2374
+ fi
2375
+ pass "fm-send returns 3 with a non-error no-resend warning when confirmation stays pending"
2376
+ }
2377
+
2378
+ test_fm_send_exits_nonzero_on_initial_send_failure() {
2379
+ local dir fakebin err
2380
+ dir=$(make_bordered_case send-type-failure)
2381
+ fakebin="$dir/fakebin"; err="$dir/send.err"
2382
+ if PATH="$fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$dir/state" FM_FAKE_COMPOSER="$dir/composer" \
2383
+ FM_FAKE_SEND_FAIL=1 FM_SEND_SLEEP=0.05 \
2384
+ "$ROOT/bin/fm-send.sh" sess:win 'route this work' >/dev/null 2>"$err"; then
2385
+ fail "fm-send exited zero despite initial tmux send-keys failure"
2386
+ fi
2387
+ grep -F 'text not sent' "$err" >/dev/null || fail "fm-send did not explain initial send failure: $(cat "$err")"
2388
+ pass "fm-send exits non-zero when initial text send fails"
2389
+ }
2390
+
2391
+ test_fm_send_exits_nonzero_on_unproven_submit() {
2392
+ local dir fakebin err
2393
+ dir=$(make_bordered_case send-unproven)
2394
+ fakebin="$dir/fakebin"; err="$dir/send.err"
2395
+ touch "$dir/.swallow"
2396
+ if PATH="$fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$dir/state" FM_FAKE_COMPOSER="$dir/composer" \
2397
+ FM_FAKE_SWALLOW="$dir/.swallow" FM_FAKE_PERSIST_SWALLOW=1 FM_SEND_SLEEP=0.05 \
2398
+ "$ROOT/bin/fm-send.sh" sess:win '修复' >/dev/null 2>"$err"; then
2399
+ fail "fm-send exited zero when submit proof remained pending-unproven"
2400
+ fi
2401
+ grep -F 'verdict=pending-unproven' "$err" >/dev/null \
2402
+ || fail "fm-send did not preserve the unproven-submit verdict: $(cat "$err")"
2403
+ pass "fm-send exits non-zero unless delivery is proven empty"
2404
+ }
2405
+
2406
+ # --- herdr backend-awareness (fm-turnend-guard-h6-adjacent transport fix) ----
2407
+ # Discovery, busy/pending dispatch, and the full inject_msg guard chain must
2408
+ # work through the herdr backend, not just tmux. Env-var prefix assignments
2409
+ # (e.g. `TMUX_PANE= HERDR_ENV=1 ... discover_supervisor_target`) neutralize
2410
+ # whatever ambient TMUX_PANE/HERDR_ENV the CURRENT dev/CI shell happens to carry
2411
+ # for the duration of that one call only, so these tests are deterministic
2412
+ # regardless of what runtime backend is running this test suite itself.
2413
+
2414
+ test_discover_supervisor_backend_precedence() {
2415
+ local out
2416
+ out=$(FM_SUPERVISOR_BACKEND=herdr TMUX_PANE='%9' HERDR_ENV=1 HERDR_PANE_ID=w1:p1 discover_supervisor_backend)
2417
+ [ "$out" = herdr ] || fail "explicit FM_SUPERVISOR_BACKEND override was not honored: $out"
2418
+
2419
+ out=$(FM_SUPERVISOR_BACKEND='' TMUX_PANE='%9' HERDR_ENV=1 HERDR_PANE_ID=w1:p1 discover_supervisor_backend)
2420
+ [ "$out" = tmux ] || fail "TMUX_PANE should win over HERDR_ENV (tmux nested in herdr resolves to tmux): $out"
2421
+
2422
+ out=$(FM_SUPERVISOR_BACKEND='' TMUX_PANE='' HERDR_ENV=1 HERDR_PANE_ID=w1:p1 discover_supervisor_backend)
2423
+ [ "$out" = herdr ] || fail "HERDR_ENV=1 with HERDR_PANE_ID present should resolve to herdr: $out"
2424
+
2425
+ if out=$(FM_SUPERVISOR_BACKEND='' TMUX_PANE='' HERDR_ENV='' HERDR_PANE_ID='' discover_supervisor_backend); then
2426
+ fail "bare fallback (no override, no TMUX_PANE, no HERDR_ENV) should return non-zero"
2427
+ fi
2428
+ [ "$out" = tmux ] || fail "bare fallback should still print tmux: $out"
2429
+
2430
+ pass "discover_supervisor_backend: override > TMUX_PANE > HERDR_ENV+HERDR_PANE_ID > tmux fallback"
2431
+ }
2432
+
2433
+ test_discover_supervisor_target_herdr() {
2434
+ local out
2435
+ out=$(FM_SUPERVISOR_TARGET=explicit:target TMUX_PANE='' HERDR_ENV=1 HERDR_PANE_ID=w1:p9 discover_supervisor_target)
2436
+ [ "$out" = "explicit:target" ] || fail "explicit FM_SUPERVISOR_TARGET override was not honored: $out"
2437
+
2438
+ out=$(FM_SUPERVISOR_TARGET='' TMUX_PANE='%3' HERDR_ENV=1 HERDR_PANE_ID=w1:p9 discover_supervisor_target)
2439
+ [ "$out" = '%3' ] || fail "TMUX_PANE should win over herdr markers: $out"
2440
+
2441
+ out=$(FM_SUPERVISOR_TARGET='' TMUX_PANE='' HERDR_ENV=1 HERDR_PANE_ID=w1:p9 HERDR_SESSION='' discover_supervisor_target)
2442
+ [ "$out" = "default:w1:p9" ] || fail "herdr target should default HERDR_SESSION to 'default': $out"
2443
+
2444
+ out=$(FM_SUPERVISOR_TARGET='' TMUX_PANE='' HERDR_ENV=1 HERDR_PANE_ID=w1:p9 HERDR_SESSION=iso1 discover_supervisor_target)
2445
+ [ "$out" = "iso1:w1:p9" ] || fail "herdr target should use an explicit HERDR_SESSION: $out"
2446
+
2447
+ if out=$(FM_SUPERVISOR_TARGET='' TMUX_PANE='' HERDR_ENV='' HERDR_PANE_ID='' discover_supervisor_target); then
2448
+ fail "bare fallback should return non-zero"
2449
+ fi
2450
+ [ "$out" = "firstmate:0" ] || fail "bare fallback should still print firstmate:0: $out"
2451
+
2452
+ pass "discover_supervisor_target: override > TMUX_PANE > herdr '<session>:<pane-id>' composition > firstmate:0 fallback"
2453
+ }
2454
+
2455
+ test_pane_is_busy_herdr_native_busy_state() {
2456
+ local dir
2457
+ dir=$(make_supercase primary-herdr-busy)
2458
+ (
2459
+ fm_backend_busy_state() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected busy_state args: $1 $2"; printf 'busy'; }
2460
+ fm_backend_capture() { fail "capture should not be consulted when busy_state is conclusive"; }
2461
+ FM_STATE_OVERRIDE="$dir/state" FM_DAEMON_PRIMARY_HARNESS=claude pane_is_busy "default:w1:p2" herdr \
2462
+ || fail "pane_is_busy should report busy from herdr's native busy_state"
2463
+ ) || fail "herdr native-busy pane_is_busy subshell failed"
2464
+ pass "pane_is_busy: herdr native busy_state='busy' short-circuits without a capture fallback"
2465
+ }
2466
+
2467
+ test_primary_busy_guard_is_harness_scoped() {
2468
+ (
2469
+ fm_backend_busy_state() { printf 'unknown'; }
2470
+ fm_backend_capture() { printf 'esc interrupt\n'; }
2471
+ if FM_DAEMON_PRIMARY_HARNESS=claude pane_is_busy "default:w1:p2" herdr; then
2472
+ fail "OpenCode's rendered signature must not classify a Claude primary busy"
2473
+ fi
2474
+ FM_DAEMON_PRIMARY_HARNESS=opencode pane_is_busy "default:w1:p2" herdr \
2475
+ || fail "OpenCode's rendered signature should classify an OpenCode primary busy"
2476
+ ) || fail "harness-scoped primary busy guard subshell failed"
2477
+ pass "primary busy guard isolates rendered signatures by detected harness"
2478
+ }
2479
+
2480
+ test_pane_is_busy_defaults_to_tmux_when_backend_omitted() {
2481
+ local dir fakebin capture
2482
+ dir=$(make_supercase busy-default-backend)
2483
+ fakebin="$dir/fakebin"; capture="$dir/pane.txt"
2484
+ printf 'Ctrl+c:cancel\n' > "$capture"
2485
+ PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_STATE_OVERRIDE="$dir/state" FM_DAEMON_PRIMARY_HARNESS=grok pane_is_busy "fakepane" \
2486
+ || fail "pane_is_busy with no backend arg should still default to tmux"
2487
+ pass "pane_is_busy: omitted backend defaults to tmux for Grok's isolated fallback"
2488
+ }
2489
+
2490
+ test_pane_input_pending_herdr_dispatch() {
2491
+ (
2492
+ fm_backend_composer_state() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected composer_state args: $1 $2"; printf 'pending'; }
2493
+ pane_input_pending "default:w1:p2" herdr || fail "pane_input_pending should report pending from herdr composer_state"
2494
+ ) || fail "herdr pane_input_pending (pending case) subshell failed"
2495
+ (
2496
+ fm_backend_composer_state() { printf 'empty'; }
2497
+ if pane_input_pending "default:w1:p2" herdr; then
2498
+ fail "pane_input_pending should report not-pending for an empty herdr composer"
2499
+ fi
2500
+ ) || fail "herdr pane_input_pending (empty case) subshell failed"
2501
+ (
2502
+ fm_backend_composer_state() { printf 'future-state'; }
2503
+ pane_input_pending "default:w1:p2" herdr \
2504
+ || fail "pane_input_pending should defer on an unrecognized composer state"
2505
+ ) || fail "herdr pane_input_pending (future-state case) subshell failed"
2506
+ pass "pane_input_pending: dispatches through fm_backend_composer_state for backend=herdr"
2507
+ }
2508
+
2509
+ test_inject_msg_herdr_busy_guard_defers() {
2510
+ local dir state
2511
+ dir=$(make_supercase inject-herdr-busy)
2512
+ state="$dir/state"
2513
+ afk_enter "$state"
2514
+ (
2515
+ fm_backend_target_exists() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected target_exists args: $1 $2"; return 0; }
2516
+ pane_is_busy() { return 0; }
2517
+ fm_backend_composer_state() { fail "composer_state should not be consulted once the busy-guard already deferred"; }
2518
+ fm_backend_send_text_submit() { fail "send_text_submit should not run when the busy-guard defers"; }
2519
+ if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state"; then
2520
+ fail "inject_msg should defer (return non-zero) when the herdr supervisor pane is busy"
2521
+ fi
2522
+ ) || fail "herdr busy-guard inject_msg subshell failed"
2523
+ pass "inject_msg: herdr busy-guard defers before ever attempting a submit"
2524
+ }
2525
+
2526
+ test_inject_msg_herdr_composer_guard_defers() {
2527
+ local dir state
2528
+ dir=$(make_supercase inject-herdr-pending)
2529
+ state="$dir/state"
2530
+ afk_enter "$state"
2531
+ (
2532
+ fm_backend_target_exists() { return 0; }
2533
+ pane_is_busy() { return 1; }
2534
+ fm_backend_composer_state() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected composer_state args: $1 $2"; printf 'pending'; }
2535
+ fm_backend_send_text_submit() { fail "send_text_submit should not run when the composer-guard defers"; }
2536
+ if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state"; then
2537
+ fail "inject_msg should defer when the herdr composer has pending input"
2538
+ fi
2539
+ ) || fail "herdr composer-guard inject_msg subshell failed"
2540
+ pass "inject_msg: herdr composer-guard defers before ever attempting a submit"
2541
+ }
2542
+
2543
+ test_inject_msg_herdr_pane_gone_defers() {
2544
+ local dir state
2545
+ dir=$(make_supercase inject-herdr-gone)
2546
+ state="$dir/state"
2547
+ afk_enter "$state"
2548
+ (
2549
+ fm_backend_target_exists() { return 1; }
2550
+ pane_is_busy() { fail "busy guard should not be consulted once the pane-exists check already failed"; }
2551
+ fm_backend_send_text_submit() { fail "send_text_submit should not run when the pane does not exist"; }
2552
+ if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:gone" inject_msg "hello" "$state"; then
2553
+ fail "inject_msg should defer when the herdr target does not exist"
2554
+ fi
2555
+ ) || fail "herdr pane-gone inject_msg subshell failed"
2556
+ pass "inject_msg: herdr pane-gone check defers before any busy/composer/submit call"
2557
+ }
2558
+
2559
+ test_inject_msg_herdr_submits_through_backend_dispatch() {
2560
+ local dir state
2561
+ dir=$(make_supercase inject-herdr-submit)
2562
+ state="$dir/state"
2563
+ afk_enter "$state"
2564
+ (
2565
+ fm_backend_target_exists() { return 0; }
2566
+ pane_is_busy() { return 1; }
2567
+ fm_backend_composer_state() { printf 'empty'; }
2568
+ fm_backend_send_text_submit() {
2569
+ [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected send_text_submit args: $1 $2"
2570
+ case "$3" in *"hello"*) : ;; *) fail "digest text missing from send_text_submit: $3" ;; esac
2571
+ printf 'empty'
2572
+ }
2573
+ FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state" \
2574
+ || fail "inject_msg should succeed when send_text_submit confirms empty"
2575
+ ) || fail "herdr successful-submit inject_msg subshell failed"
2576
+ pass "inject_msg: dispatches busy-guard/composer-guard/submit through the herdr backend and succeeds on a confirmed empty composer"
2577
+ }
2578
+
2579
+ # Safety-critical (task fm-composer-shellglyph-safety): the away-mode injector
2580
+ # must NEVER type an escalation into a dead-shell pane. A bare shell prompt
2581
+ # classifies `unknown` (not `pending`), and inject_msg now defers on anything
2582
+ # that is not affirmatively `empty`, so a dead shell (or an unreadable pane) can
2583
+ # never be mistaken for a safe empty agent composer and typed into.
2584
+ test_inject_msg_defers_on_dead_shell_unknown() {
2585
+ local dir state
2586
+ dir=$(make_supercase inject-dead-shell)
2587
+ state="$dir/state"
2588
+ afk_enter "$state"
2589
+ (
2590
+ fm_backend_target_exists() { return 0; }
2591
+ pane_is_busy() { return 1; }
2592
+ fm_backend_composer_state() { printf 'unknown'; }
2593
+ fm_backend_send_text_submit() { fail "send_text_submit must NOT run when the composer is a dead shell (unknown)"; }
2594
+ if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state"; then
2595
+ fail "inject_msg should defer (never inject) when the composer reads unknown (dead shell / unreadable)"
2596
+ fi
2597
+ ) || fail "dead-shell inject_msg subshell failed"
2598
+ pass "inject_msg: defers on a dead-shell/unreadable composer (unknown), never typing the escalation into a shell"
2599
+ }
2600
+
2601
+ test_inject_msg_defers_on_unrecognized_composer_state() {
2602
+ local dir state
2603
+ dir=$(make_supercase inject-future-composer-state)
2604
+ state="$dir/state"
2605
+ afk_enter "$state"
2606
+ (
2607
+ fm_backend_target_exists() { return 0; }
2608
+ pane_is_busy() { return 1; }
2609
+ fm_backend_composer_state() { printf 'future-state'; }
2610
+ fm_backend_send_text_submit() { fail "send_text_submit must not run for an unrecognized composer state"; }
2611
+ if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state"; then
2612
+ fail "inject_msg should defer on an unrecognized composer state"
2613
+ fi
2614
+ ) || fail "unrecognized composer-state inject_msg subshell failed"
2615
+ pass "inject_msg: unrecognized composer states defer by default"
2616
+ }
2617
+
2618
+ test_afk_start_refuses_when_flag_cannot_be_written
2619
+ test_afk_start_ignores_stale_pidfile_without_lock
2620
+ test_afk_start_reclaims_stale_daemon_lock_reused_pid
2621
+ test_daemon_state_root_uses_fm_home
2622
+ test_classify_routine_signal_self
2623
+ test_classify_terminal_signal_escalates
2624
+ test_classify_check_and_unknown_escalate
2625
+ test_stale_transient_self_records_marker
2626
+ test_stale_diagnostic_wedge_survives_busy_housekeeping
2627
+ test_enriched_wedge_under_declared_wait_uses_pause_cadence
2628
+ test_stale_terminal_escalates
2629
+ test_stale_actionable_wait_escalates_and_keeps_pause_cadence
2630
+ test_stale_paused_classifies_pause
2631
+ test_stale_captain_held_classifies_pause
2632
+ test_handle_wake_paused_records_pause_marker
2633
+ test_handle_wake_paused_signal_records_pause_marker
2634
+ test_handle_wake_terminal_signal_clears_pause_tracking
2635
+ test_housekeeping_migrates_watcher_pause_marker
2636
+ test_housekeeping_migrates_watcher_unpaused_marker_to_clear
2637
+ test_housekeeping_seeds_pause_marker_from_status
2638
+ test_housekeeping_persistent_stale_escalates
2639
+ test_housekeeping_resumed_stale_cleared
2640
+ test_housekeeping_paused_resurfaces_and_resets
2641
+ test_housekeeping_captain_held_resurfaces_and_resets
2642
+ test_housekeeping_paused_resumed_cleared
2643
+ test_housekeeping_busy_declared_wait_matures_its_window
2644
+ test_housekeeping_paused_unpaused_cleared
2645
+ test_housekeeping_captain_held_resolved_cleared
2646
+ test_housekeeping_stale_marker_transitions_to_pause
2647
+ test_housekeeping_captain_held_stale_marker_transitions_to_pause
2648
+ test_housekeeping_pause_marker_transitions_to_clear
2649
+ test_housekeeping_herdr_persistent_stale_resolves_meta
2650
+ test_housekeeping_herdr_idle_busy_record_clears_stale
2651
+ test_housekeeping_herdr_resumed_stale_cleared
2652
+ test_housekeeping_orca_persistent_stale_resolves_terminal
2653
+ test_escalate_batches_into_one_digest
2654
+ test_escalate_batch_age_uses_first_append
2655
+ test_heartbeat_scan_dedup
2656
+ test_handle_wake_routes_self_and_escalate
2657
+ test_inject_skip_forces_self
2658
+ test_is_wake_reason_distinguishes_status_stdout
2659
+ test_terminal_stale_escalate_leaves_no_marker
2660
+ test_signal_escalate_marks_seen_no_catchall_refire
2661
+ test_collapse_newlines_pure
2662
+ test_afk_absent_daemon_does_not_inject
2663
+ test_busy_guard_defers_when_supervisor_busy
2664
+ test_marker_detection
2665
+ test_afk_turn_exemption
2666
+ test_should_exit_afk_when_afk_inactive
2667
+ test_strip_injection_marker
2668
+ test_pane_input_pending_detects_partial_input
2669
+ test_pane_input_pending_blank_defers_strict
2670
+ test_pane_input_pending_requires_proven_empty_prompt
2671
+ test_tmux_composer_state_bare_shell_is_unknown
2672
+ test_tmux_composer_state_bordered_and_agent_rows_are_empty
2673
+ test_tmux_composer_state_requires_matching_box_borders
2674
+ test_pane_input_pending_preserves_bright_placeholder_like_draft
2675
+ test_classify_signal_dedup_against_scan
2676
+ test_classify_signal_skips_turn_end_markers
2677
+ test_classify_signal_survives_a_later_routine_append
2678
+ test_classification_commits_its_captured_endpoint
2679
+ test_stale_masked_event_escalates_at_captured_endpoint
2680
+ test_stale_read_failure_surfaces_without_advancing_seen
2681
+ test_recreated_status_rejects_captured_identity
2682
+ test_unverifiable_identity_surfaces_without_marker
2683
+ test_status_read_failure_surfaces_without_advancing_seen
2684
+ test_catchall_advances_routine_then_surfaces_append
2685
+ test_escalation_buffer_failure_retains_wake_and_position
2686
+ test_catchall_buffer_failure_preserves_position
2687
+ test_durable_wake_failure_retains_entire_batch
2688
+ test_missing_status_stale_is_acknowledged_without_diagnostic
2689
+ test_transient_unreadable_signal_recovers_without_advancing
2690
+ test_permission_recovery_reclassifies_catchall_status
2691
+ test_permanent_classification_failure_is_reported_and_acknowledged
2692
+ test_catchall_scan_surfaces_a_masked_event
2693
+ test_classify_stale_dedup_against_signal
2694
+ test_afk_nonterminal_working_merged_keeps_wedge_aging
2695
+ test_afk_genuine_done_still_terminal_stale
2696
+ test_pane_input_pending_bordered_idle_not_pending
2697
+ test_pane_input_pending_bordered_with_text_is_pending
2698
+ test_submit_ack_confirms_on_bordered_empty_composer
2699
+ test_submit_ack_reports_pending_on_persistent_swallow
2700
+ test_max_defer_empty_swallow_types_once_and_alarms
2701
+ test_max_defer_flushes_empty_idle_pane
2702
+ test_max_defer_pending_composer_alarms_without_typing
2703
+ test_normal_flush_clears_stale_wedge_marker
2704
+ test_below_max_defer_does_nothing
2705
+ test_max_defer_afk_inactive_does_not_flush_or_alarm
2706
+ test_wedge_alarm_library_mode_defaults_to_discard
2707
+ test_wake_helpers_replace_inherited_notifier_override
2708
+ test_wedge_alarm_discard_seam_fires_nothing
2709
+ test_wedge_alarm_direct_notifiers_honor_discard_seam
2710
+ test_wedge_alarm_osascript_channel_selected
2711
+ test_wedge_alarm_herdr_channel_selected
2712
+ test_wedge_alarm_command_channel_receives_summary
2713
+ test_wedge_alarm_command_failure_hides_configured_command
2714
+ test_wedge_alarm_unknown_channel_hides_configured_directive
2715
+ test_wedge_alarm_off_disables_active_alert_regardless_of_position
2716
+ test_wedge_alarm_auto_darwin_selects_osascript
2717
+ test_wedge_alarm_auto_non_darwin_has_no_os_channel
2718
+ test_wedge_alarm_config_file_multi_channel
2719
+ test_wedge_alarm_failing_channel_degrades_gracefully
2720
+ test_wedge_alarm_hung_channel_times_out_and_falls_through
2721
+ test_wedge_alarm_backgrounded_command_times_out_and_reaps_descendant
2722
+ test_wedge_alarm_hung_override_times_out_and_falls_through
2723
+ test_wedge_alarm_shutdown_stops_active_notifier_group
2724
+ test_inject_wedge_alarm_fires_active_alert_on_non_tmux_backend
2725
+ test_inject_wedge_alarm_throttles_when_marker_cannot_be_written
2726
+ test_fm_send_reports_delivered_unconfirmed_submit
2727
+ test_fm_send_exits_nonzero_on_initial_send_failure
2728
+ test_fm_send_exits_nonzero_on_unproven_submit
2729
+ test_discover_supervisor_backend_precedence
2730
+ test_discover_supervisor_target_herdr
2731
+ test_pane_is_busy_herdr_native_busy_state
2732
+ test_primary_busy_guard_is_harness_scoped
2733
+ test_pane_is_busy_defaults_to_tmux_when_backend_omitted
2734
+ test_pane_input_pending_herdr_dispatch
2735
+ test_inject_msg_herdr_busy_guard_defers
2736
+ test_inject_msg_herdr_composer_guard_defers
2737
+ test_inject_msg_herdr_pane_gone_defers
2738
+ test_inject_msg_herdr_submits_through_backend_dispatch
2739
+ test_inject_msg_defers_on_dead_shell_unknown
2740
+ test_inject_msg_defers_on_unrecognized_composer_state