@zerwiz/ymir 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (850) hide show
  1. package/.agents/README.md +291 -0
  2. package/.agents/agents/bragi-marketer.md +111 -0
  3. package/.agents/agents/brokk.md +352 -0
  4. package/.agents/agents/forseti-reviewer.md +93 -0
  5. package/.agents/agents/frigg-consent.md +77 -0
  6. package/.agents/agents/groa-update.md +77 -0
  7. package/.agents/agents/hnoss-designer.md +69 -0
  8. package/.agents/agents/huginn-researcher.md +99 -0
  9. package/.agents/agents/jord-projects.md +78 -0
  10. package/.agents/agents/kvasir-scout.md +91 -0
  11. package/.agents/agents/mimir-planner.md +92 -0
  12. package/.agents/agents/muninn-stow.md +77 -0
  13. package/.agents/agents/saga-bearings.md +78 -0
  14. package/.agents/agents/sindri-developer.md +114 -0
  15. package/.agents/agents/snotra-documenter.md +105 -0
  16. package/.agents/agents/syn-recovery.md +78 -0
  17. package/.agents/agents/tyr-check.md +78 -0
  18. package/.agents/agents/urdh-hold.md +77 -0
  19. package/.agents/agents/volundr-smith.md +80 -0
  20. package/.agents/agents/vor-diagnostics.md +77 -0
  21. package/.agents/assets/agents/naming.md +50 -0
  22. package/.agents/assets/agents/registry.md +93 -0
  23. package/.agents/assets/agents/runtime.md +36 -0
  24. package/.agents/assets/agents/toon-tasks-cli.md +42 -0
  25. package/.agents/assets/templates/company_entity.template.md +37 -0
  26. package/.agents/backend/backends/cmux.sh +649 -0
  27. package/.agents/backend/backends/herdr-eventwait.py +157 -0
  28. package/.agents/backend/backends/herdr-workspace-move.py +114 -0
  29. package/.agents/backend/backends/herdr.sh +3386 -0
  30. package/.agents/backend/backends/orca.sh +290 -0
  31. package/.agents/backend/backends/tmux.sh +357 -0
  32. package/.agents/backend/backends/zellij.sh +700 -0
  33. package/.agents/backend/fm-afk-launch.sh +654 -0
  34. package/.agents/backend/fm-afk-return.sh +241 -0
  35. package/.agents/backend/fm-afk-start.sh +172 -0
  36. package/.agents/backend/fm-arm-command-policy.mjs +960 -0
  37. package/.agents/backend/fm-arm-pretool-check.sh +200 -0
  38. package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
  39. package/.agents/backend/fm-backend.sh +981 -0
  40. package/.agents/backend/fm-backlog-handoff.sh +909 -0
  41. package/.agents/backend/fm-backlog-receive.sh +187 -0
  42. package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
  43. package/.agents/backend/fm-bearings-board.sh +204 -0
  44. package/.agents/backend/fm-bearings-snapshot.sh +546 -0
  45. package/.agents/backend/fm-bootstrap.sh +1568 -0
  46. package/.agents/backend/fm-branch-outcome.sh +203 -0
  47. package/.agents/backend/fm-branch-prompt.sh +101 -0
  48. package/.agents/backend/fm-brief.sh +452 -0
  49. package/.agents/backend/fm-busy-event.sh +234 -0
  50. package/.agents/backend/fm-busy-lib.sh +986 -0
  51. package/.agents/backend/fm-captain-hold.sh +1001 -0
  52. package/.agents/backend/fm-cd-command-policy.mjs +153 -0
  53. package/.agents/backend/fm-cd-pretool-check.sh +190 -0
  54. package/.agents/backend/fm-check-lib.sh +74 -0
  55. package/.agents/backend/fm-check-register.sh +42 -0
  56. package/.agents/backend/fm-check-unregister.sh +52 -0
  57. package/.agents/backend/fm-classify-lib.sh +1770 -0
  58. package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
  59. package/.agents/backend/fm-composer-lib.sh +1416 -0
  60. package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
  61. package/.agents/backend/fm-config-push.sh +254 -0
  62. package/.agents/backend/fm-control-lib.sh +251 -0
  63. package/.agents/backend/fm-control.sh +878 -0
  64. package/.agents/backend/fm-crew-state.sh +625 -0
  65. package/.agents/backend/fm-cursor-lib.sh +243 -0
  66. package/.agents/backend/fm-decision-hold.sh +232 -0
  67. package/.agents/backend/fm-doc-audience-check.sh +269 -0
  68. package/.agents/backend/fm-dod-lib.sh +67 -0
  69. package/.agents/backend/fm-ensure-agents-md.sh +253 -0
  70. package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
  71. package/.agents/backend/fm-extension.mjs +2577 -0
  72. package/.agents/backend/fm-extension.sh +16 -0
  73. package/.agents/backend/fm-ff-lib.sh +420 -0
  74. package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
  75. package/.agents/backend/fm-fleet-sync.sh +458 -0
  76. package/.agents/backend/fm-fleet-view.sh +96 -0
  77. package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
  78. package/.agents/backend/fm-guard.sh +242 -0
  79. package/.agents/backend/fm-harness.sh +195 -0
  80. package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
  81. package/.agents/backend/fm-herdr-lab.sh +346 -0
  82. package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
  83. package/.agents/backend/fm-home-seed.sh +984 -0
  84. package/.agents/backend/fm-home-summary-refresh.sh +255 -0
  85. package/.agents/backend/fm-hook-host-lib.sh +36 -0
  86. package/.agents/backend/fm-inactive-reconcile.sh +531 -0
  87. package/.agents/backend/fm-inbox.sh +399 -0
  88. package/.agents/backend/fm-install-actionlint.sh +84 -0
  89. package/.agents/backend/fm-install-herdr.sh +96 -0
  90. package/.agents/backend/fm-install-shellcheck.sh +84 -0
  91. package/.agents/backend/fm-install-treehouse.sh +95 -0
  92. package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
  93. package/.agents/backend/fm-lease-lib.sh +218 -0
  94. package/.agents/backend/fm-lease.sh +189 -0
  95. package/.agents/backend/fm-line-cap-lib.sh +51 -0
  96. package/.agents/backend/fm-lint-workflows.sh +137 -0
  97. package/.agents/backend/fm-lint.sh +591 -0
  98. package/.agents/backend/fm-lock-lib.sh +104 -0
  99. package/.agents/backend/fm-lock.sh +107 -0
  100. package/.agents/backend/fm-marker-lib.sh +12 -0
  101. package/.agents/backend/fm-merge-local.sh +74 -0
  102. package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
  103. package/.agents/backend/fm-nm-run-lib.sh +124 -0
  104. package/.agents/backend/fm-on.sh +125 -0
  105. package/.agents/backend/fm-operational-input.sh +253 -0
  106. package/.agents/backend/fm-peek.sh +46 -0
  107. package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
  108. package/.agents/backend/fm-pr-check.sh +135 -0
  109. package/.agents/backend/fm-pr-lib.sh +1016 -0
  110. package/.agents/backend/fm-pr-merge.sh +707 -0
  111. package/.agents/backend/fm-pr-poll.sh +110 -0
  112. package/.agents/backend/fm-primary-scope-lib.sh +33 -0
  113. package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
  114. package/.agents/backend/fm-procevent-lavish.sh +628 -0
  115. package/.agents/backend/fm-procevent-lib.sh +892 -0
  116. package/.agents/backend/fm-procevent-quota.sh +290 -0
  117. package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
  118. package/.agents/backend/fm-procevent-when.sh +504 -0
  119. package/.agents/backend/fm-procevent.sh +1466 -0
  120. package/.agents/backend/fm-project-mode.sh +92 -0
  121. package/.agents/backend/fm-project-origin-lib.sh +180 -0
  122. package/.agents/backend/fm-promote.sh +282 -0
  123. package/.agents/backend/fm-public-followup-emit.sh +260 -0
  124. package/.agents/backend/fm-public-followup-lib.sh +398 -0
  125. package/.agents/backend/fm-public-followup.sh +1330 -0
  126. package/.agents/backend/fm-push-transition-lib.sh +168 -0
  127. package/.agents/backend/fm-quota-axi-lib.sh +93 -0
  128. package/.agents/backend/fm-quota-choose.sh +384 -0
  129. package/.agents/backend/fm-remote-delta-read.sh +189 -0
  130. package/.agents/backend/fm-remote-doctor.sh +800 -0
  131. package/.agents/backend/fm-remote-entrypoint.sh +193 -0
  132. package/.agents/backend/fm-remote-file.sh +234 -0
  133. package/.agents/backend/fm-remote-home-provision.sh +259 -0
  134. package/.agents/backend/fm-remote-home-seed.sh +259 -0
  135. package/.agents/backend/fm-remote-inherit-push.sh +90 -0
  136. package/.agents/backend/fm-remote-inherit.sh +179 -0
  137. package/.agents/backend/fm-remote-job-lib.sh +1241 -0
  138. package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
  139. package/.agents/backend/fm-remote-job-worker.sh +1124 -0
  140. package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
  141. package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
  142. package/.agents/backend/fm-review-diff.sh +158 -0
  143. package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
  144. package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
  145. package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
  146. package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
  147. package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
  148. package/.agents/backend/fm-secondmate-report.sh +88 -0
  149. package/.agents/backend/fm-send.sh +1059 -0
  150. package/.agents/backend/fm-session-lock-lib.sh +176 -0
  151. package/.agents/backend/fm-session-start.sh +970 -0
  152. package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
  153. package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
  154. package/.agents/backend/fm-sessionstart-run.sh +145 -0
  155. package/.agents/backend/fm-spawn.sh +3153 -0
  156. package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
  157. package/.agents/backend/fm-startup-memory-budget.sh +94 -0
  158. package/.agents/backend/fm-startup-network.sh +642 -0
  159. package/.agents/backend/fm-stow-cascade.sh +251 -0
  160. package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
  161. package/.agents/backend/fm-supervise-daemon.sh +1732 -0
  162. package/.agents/backend/fm-supervision-instructions.sh +215 -0
  163. package/.agents/backend/fm-supervision-lib.sh +90 -0
  164. package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
  165. package/.agents/backend/fm-tangle-lib.sh +53 -0
  166. package/.agents/backend/fm-task-inbox-lib.sh +402 -0
  167. package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
  168. package/.agents/backend/fm-teardown.sh +2922 -0
  169. package/.agents/backend/fm-test-isolation-proof.sh +588 -0
  170. package/.agents/backend/fm-test-run.sh +2191 -0
  171. package/.agents/backend/fm-timeout-lib.sh +141 -0
  172. package/.agents/backend/fm-timing-lib.sh +179 -0
  173. package/.agents/backend/fm-tmux-lib.sh +291 -0
  174. package/.agents/backend/fm-tool-update-check.sh +898 -0
  175. package/.agents/backend/fm-trace-context-lib.sh +227 -0
  176. package/.agents/backend/fm-transition-lib.sh +103 -0
  177. package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
  178. package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
  179. package/.agents/backend/fm-turnend-guard.sh +432 -0
  180. package/.agents/backend/fm-update.sh +110 -0
  181. package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
  182. package/.agents/backend/fm-voice-client.py +1373 -0
  183. package/.agents/backend/fm-voice-relay.py +1256 -0
  184. package/.agents/backend/fm-wake-drain.sh +592 -0
  185. package/.agents/backend/fm-wake-grant.sh +127 -0
  186. package/.agents/backend/fm-wake-lib.sh +1849 -0
  187. package/.agents/backend/fm-watch-arm.sh +602 -0
  188. package/.agents/backend/fm-watch-checkpoint.sh +109 -0
  189. package/.agents/backend/fm-watch.sh +1962 -0
  190. package/.agents/backend/fm-x-dismiss.sh +116 -0
  191. package/.agents/backend/fm-x-followup.sh +286 -0
  192. package/.agents/backend/fm-x-lib.sh +1000 -0
  193. package/.agents/backend/fm-x-link.sh +234 -0
  194. package/.agents/backend/fm-x-poll.sh +185 -0
  195. package/.agents/backend/fm-x-reply.sh +385 -0
  196. package/.agents/backend/fm_voice_frame.py +166 -0
  197. package/.agents/backend/fm_voice_records.py +574 -0
  198. package/.agents/backend/model-bridge.py +290 -0
  199. package/.agents/backend/opencode-go-bridge.py +30 -0
  200. package/.agents/bus/README.md +12 -0
  201. package/.agents/bus/messages.json +1 -0
  202. package/.agents/config/agents.machine.example.yaml +16 -0
  203. package/.agents/config/agents.yaml +50 -0
  204. package/.agents/config/agents.yaml.example +72 -0
  205. package/.agents/config/crew-dispatch.json +22 -0
  206. package/.agents/config/cron.yaml +9 -0
  207. package/.agents/config/eindri-dispatch.json +67 -0
  208. package/.agents/config/eindri-harness +1 -0
  209. package/.agents/config/startup-memory-budget +1 -0
  210. package/.agents/config/tailscale-sync.yaml +27 -0
  211. package/.agents/filebrowser/.filebrowser.json +6 -0
  212. package/.agents/filebrowser/README.md +11 -0
  213. package/.agents/gateway/README.md +12 -0
  214. package/.agents/github/README.md +15 -0
  215. package/.agents/github/webhooks/.gitkeep +0 -0
  216. package/.agents/github/workflows/.gitkeep +0 -0
  217. package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
  218. package/.agents/harness/opencode/plugins/package.json +4 -0
  219. package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
  220. package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
  221. package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
  222. package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
  223. package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
  224. package/.agents/memory/README.md +46 -0
  225. package/.agents/memory/kaia.engram +0 -0
  226. package/.agents/memory/kaia.engram-shm +0 -0
  227. package/.agents/memory/kaia.engram-wal +0 -0
  228. package/.agents/memory/runes_audit.md +5 -0
  229. package/.agents/memory/well/episodes.jsonl +367 -0
  230. package/.agents/memory/well/workspace.jsonl +27 -0
  231. package/.agents/migrations/0001-hodd-layout.sh +38 -0
  232. package/.agents/migrations/0002-a2a-mcp.sh +27 -0
  233. package/.agents/migrations/0003-private-data-separation.sh +111 -0
  234. package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
  235. package/.agents/sandbox/Dockerfile.utgard +19 -0
  236. package/.agents/sandbox/README.md +14 -0
  237. package/.agents/sandbox/utgard.config.json +14 -0
  238. package/.agents/skills/README.md +59 -0
  239. package/.agents/skills/bragi-marketing/README.md +3 -0
  240. package/.agents/skills/bragi-marketing/SKILL.md +82 -0
  241. package/.agents/skills/eindri-homes/README.md +3 -0
  242. package/.agents/skills/eindri-homes/SKILL.md +291 -0
  243. package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
  244. package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
  245. package/.agents/skills/frigg-consent/README.md +3 -0
  246. package/.agents/skills/frigg-consent/SKILL.md +56 -0
  247. package/.agents/skills/galdr-ymirsystem/README.md +3 -0
  248. package/.agents/skills/galdr-ymirsystem/SKILL.md +158 -0
  249. package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
  250. package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
  251. package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
  252. package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
  253. package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
  254. package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
  255. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +673 -0
  256. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
  257. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
  258. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
  259. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
  260. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
  261. package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +496 -0
  262. package/.agents/skills/galdr-ymirsystem/assets/installation.md +571 -0
  263. package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
  264. package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
  265. package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +161 -0
  266. package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +377 -0
  267. package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
  268. package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +129 -0
  269. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
  270. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
  271. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +96 -0
  272. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
  273. package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
  274. package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
  275. package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
  276. package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
  277. package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
  278. package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
  279. package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
  280. package/.agents/skills/galdr-ymirsystem/assets/smidja.md +175 -0
  281. package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
  282. package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
  283. package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
  284. package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +360 -0
  285. package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
  286. package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
  287. package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
  288. package/.agents/skills/gjallarhorn-relay/README.md +3 -0
  289. package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
  290. package/.agents/skills/groa-update/SKILL.md +70 -0
  291. package/.agents/skills/hamr-adapters/README.md +3 -0
  292. package/.agents/skills/hamr-adapters/SKILL.md +123 -0
  293. package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
  294. package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
  295. package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
  296. package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
  297. package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
  298. package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
  299. package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
  300. package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
  301. package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
  302. package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
  303. package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
  304. package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
  305. package/.agents/skills/herdr-panes/README.md +3 -0
  306. package/.agents/skills/herdr-panes/SKILL.md +92 -0
  307. package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
  308. package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
  309. package/.agents/skills/hnoss-design/DESIGN.md +19 -0
  310. package/.agents/skills/hnoss-design/README.md +3 -0
  311. package/.agents/skills/hnoss-design/SKILL.md +83 -0
  312. package/.agents/skills/hvild-afk/README.md +3 -0
  313. package/.agents/skills/hvild-afk/SKILL.md +241 -0
  314. package/.agents/skills/jord-projects/README.md +3 -0
  315. package/.agents/skills/jord-projects/SKILL.md +92 -0
  316. package/.agents/skills/lifecycle/SKILL.md +56 -0
  317. package/.agents/skills/lifecycle/smoke_test.sh +52 -0
  318. package/.agents/skills/lifecycle/start.sh +21 -0
  319. package/.agents/skills/lifecycle/status.sh +35 -0
  320. package/.agents/skills/lifecycle/stop.sh +16 -0
  321. package/.agents/skills/muninn-stow/README.md +3 -0
  322. package/.agents/skills/muninn-stow/SKILL.md +309 -0
  323. package/.agents/skills/no-mistakes/SKILL.md +379 -0
  324. package/.agents/skills/nornir-schedule/README.md +3 -0
  325. package/.agents/skills/nornir-schedule/SKILL.md +25 -0
  326. package/.agents/skills/nornir-schedule/assets/events.md +137 -0
  327. package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
  328. package/.agents/skills/nsr-compliance/README.md +3 -0
  329. package/.agents/skills/nsr-compliance/SKILL.md +29 -0
  330. package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
  331. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
  332. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
  333. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
  334. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
  335. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
  336. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
  337. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
  338. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
  339. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
  340. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
  341. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
  342. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
  343. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
  344. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
  345. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
  346. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
  347. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
  348. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
  349. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
  350. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
  351. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
  352. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
  353. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  354. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
  355. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
  356. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
  357. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
  358. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
  359. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
  360. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
  361. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
  362. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
  363. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
  364. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
  365. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
  366. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
  367. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
  368. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
  369. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
  370. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
  371. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
  372. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
  373. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
  374. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
  375. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
  376. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
  377. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
  378. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
  379. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
  380. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
  381. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
  382. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
  383. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
  384. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
  385. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
  386. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
  387. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
  388. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
  389. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
  390. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
  391. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
  392. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
  393. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
  394. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
  395. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
  396. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
  397. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
  398. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
  399. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
  400. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
  401. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
  402. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
  403. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
  404. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
  405. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
  406. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
  407. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
  408. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
  409. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
  410. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
  411. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
  412. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
  413. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
  414. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
  415. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
  416. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
  417. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
  418. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
  419. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
  420. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
  421. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
  422. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
  423. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
  424. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
  425. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
  426. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
  427. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
  428. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
  429. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
  430. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
  431. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
  432. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
  433. package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
  434. package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
  435. package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
  436. package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
  437. package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
  438. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
  439. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
  440. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
  441. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
  442. package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
  443. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
  444. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
  445. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
  446. package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
  447. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
  448. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
  449. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
  450. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
  451. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
  452. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
  453. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
  454. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  455. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
  456. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
  457. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
  458. package/.agents/skills/pr-ops/README.md +3 -0
  459. package/.agents/skills/pr-ops/SKILL.md +109 -0
  460. package/.agents/skills/ratatoskr-a2a/README.md +3 -0
  461. package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
  462. package/.agents/skills/rules-check-drift/SKILL.md +81 -0
  463. package/.agents/skills/saga-bearings/README.md +3 -0
  464. package/.agents/skills/saga-bearings/SKILL.md +26 -0
  465. package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
  466. package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
  467. package/.agents/skills/saga-bearings/assets/recap.md +56 -0
  468. package/.agents/skills/syn-recovery/README.md +3 -0
  469. package/.agents/skills/syn-recovery/SKILL.md +54 -0
  470. package/.agents/skills/tyr-check/README.md +3 -0
  471. package/.agents/skills/tyr-check/SKILL.md +218 -0
  472. package/.agents/skills/urdh-hold/README.md +3 -0
  473. package/.agents/skills/urdh-hold/SKILL.md +25 -0
  474. package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
  475. package/.agents/skills/urdh-hold/assets/hold.md +54 -0
  476. package/.agents/skills/vor-diagnostics/README.md +3 -0
  477. package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
  478. package/.agents/skills/ymir-host/README.md +3 -0
  479. package/.agents/skills/ymir-host/SKILL.md +34 -0
  480. package/.agents/skills/ymir-host/assets/desktop.md +36 -0
  481. package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
  482. package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
  483. package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
  484. package/.agents/skills/ymir-host/assets/update.md +64 -0
  485. package/.agents/tests/assets/board-render-harness.mjs +117 -0
  486. package/.agents/tests/cmux-test-safety.sh +51 -0
  487. package/.agents/tests/fixtures.sh +291 -0
  488. package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
  489. package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
  490. package/.agents/tests/fm-afk-launch.test.sh +961 -0
  491. package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
  492. package/.agents/tests/fm-afk-return.test.sh +283 -0
  493. package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
  494. package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
  495. package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
  496. package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
  497. package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
  498. package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
  499. package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
  500. package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
  501. package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
  502. package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
  503. package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
  504. package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
  505. package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
  506. package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
  507. package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
  508. package/.agents/tests/fm-backend-orca.test.sh +1357 -0
  509. package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
  510. package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
  511. package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
  512. package/.agents/tests/fm-backend.test.sh +1142 -0
  513. package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
  514. package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
  515. package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
  516. package/.agents/tests/fm-bearings-board.test.sh +414 -0
  517. package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
  518. package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
  519. package/.agents/tests/fm-bootstrap.test.sh +1178 -0
  520. package/.agents/tests/fm-branch-supervision.test.sh +552 -0
  521. package/.agents/tests/fm-brief.test.sh +785 -0
  522. package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
  523. package/.agents/tests/fm-busy-state.test.sh +465 -0
  524. package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
  525. package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
  526. package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
  527. package/.agents/tests/fm-check-unregister.test.sh +198 -0
  528. package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
  529. package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
  530. package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
  531. package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
  532. package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
  533. package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
  534. package/.agents/tests/fm-composer-ghost.test.sh +716 -0
  535. package/.agents/tests/fm-composer-lib.test.sh +666 -0
  536. package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
  537. package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
  538. package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
  539. package/.agents/tests/fm-control.test.sh +910 -0
  540. package/.agents/tests/fm-crew-state.test.sh +1610 -0
  541. package/.agents/tests/fm-cursor-harness.test.sh +404 -0
  542. package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
  543. package/.agents/tests/fm-cursor-primary.test.sh +708 -0
  544. package/.agents/tests/fm-daemon.test.sh +2740 -0
  545. package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
  546. package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
  547. package/.agents/tests/fm-extension-binding.test.sh +2187 -0
  548. package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
  549. package/.agents/tests/fm-fleet-sync.test.sh +721 -0
  550. package/.agents/tests/fm-gate-refuse.test.sh +356 -0
  551. package/.agents/tests/fm-gitignore-config.test.sh +89 -0
  552. package/.agents/tests/fm-gotmp.test.sh +230 -0
  553. package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
  554. package/.agents/tests/fm-grok-harness.test.sh +117 -0
  555. package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
  556. package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
  557. package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
  558. package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
  559. package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
  560. package/.agents/tests/fm-herdr-lab.test.sh +243 -0
  561. package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
  562. package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
  563. package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
  564. package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
  565. package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
  566. package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
  567. package/.agents/tests/fm-kimi-harness.test.sh +687 -0
  568. package/.agents/tests/fm-lint-workflows.test.sh +532 -0
  569. package/.agents/tests/fm-lint.test.sh +1023 -0
  570. package/.agents/tests/fm-muse-harness.test.sh +932 -0
  571. package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
  572. package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
  573. package/.agents/tests/fm-on.test.sh +507 -0
  574. package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
  575. package/.agents/tests/fm-operational-input.test.sh +160 -0
  576. package/.agents/tests/fm-peek-remote.test.sh +110 -0
  577. package/.agents/tests/fm-pending-reply.test.sh +1288 -0
  578. package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
  579. package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
  580. package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
  581. package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
  582. package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
  583. package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
  584. package/.agents/tests/fm-pr-merge.test.sh +2135 -0
  585. package/.agents/tests/fm-procevent-quota.test.sh +225 -0
  586. package/.agents/tests/fm-procevent-when.test.sh +406 -0
  587. package/.agents/tests/fm-procevent.test.sh +1745 -0
  588. package/.agents/tests/fm-project-origin.test.sh +110 -0
  589. package/.agents/tests/fm-public-followup.test.sh +2272 -0
  590. package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
  591. package/.agents/tests/fm-quota-choose.test.sh +626 -0
  592. package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
  593. package/.agents/tests/fm-remote-doctor.test.sh +625 -0
  594. package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
  595. package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
  596. package/.agents/tests/fm-remote-job.test.sh +768 -0
  597. package/.agents/tests/fm-remote-reply.test.sh +520 -0
  598. package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
  599. package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
  600. package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
  601. package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
  602. package/.agents/tests/fm-review-diff.test.sh +176 -0
  603. package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
  604. package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
  605. package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
  606. package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
  607. package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
  608. package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
  609. package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
  610. package/.agents/tests/fm-send-inbox.test.sh +352 -0
  611. package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
  612. package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
  613. package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
  614. package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
  615. package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
  616. package/.agents/tests/fm-send-settle.test.sh +147 -0
  617. package/.agents/tests/fm-send-strict.test.sh +241 -0
  618. package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
  619. package/.agents/tests/fm-session-start.test.sh +2515 -0
  620. package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
  621. package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
  622. package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
  623. package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
  624. package/.agents/tests/fm-spawn-batch.test.sh +148 -0
  625. package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
  626. package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
  627. package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
  628. package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
  629. package/.agents/tests/fm-startup-network.test.sh +712 -0
  630. package/.agents/tests/fm-stow-cascade.test.sh +370 -0
  631. package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
  632. package/.agents/tests/fm-supervision-events.test.sh +156 -0
  633. package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
  634. package/.agents/tests/fm-tangle-guard.test.sh +277 -0
  635. package/.agents/tests/fm-task-delivery.test.sh +420 -0
  636. package/.agents/tests/fm-task-inbox.test.sh +513 -0
  637. package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
  638. package/.agents/tests/fm-teardown.test.sh +2665 -0
  639. package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
  640. package/.agents/tests/fm-test-fixtures.test.sh +132 -0
  641. package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
  642. package/.agents/tests/fm-test-run.test.sh +1196 -0
  643. package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
  644. package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
  645. package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
  646. package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
  647. package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
  648. package/.agents/tests/fm-transition-lib.test.sh +54 -0
  649. package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
  650. package/.agents/tests/fm-update.test.sh +304 -0
  651. package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
  652. package/.agents/tests/fm-voice-relay.test.sh +4741 -0
  653. package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
  654. package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
  655. package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
  656. package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
  657. package/.agents/tests/fm-wake-queue.test.sh +1224 -0
  658. package/.agents/tests/fm-watch-arm.test.sh +821 -0
  659. package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
  660. package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
  661. package/.agents/tests/fm-watch-triage.test.sh +3889 -0
  662. package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
  663. package/.agents/tests/fm-x-mode.test.sh +3042 -0
  664. package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
  665. package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
  666. package/.agents/tests/herdr-test-safety.sh +42 -0
  667. package/.agents/tests/lib.sh +315 -0
  668. package/.agents/tests/remote-herdr-fixture.sh +125 -0
  669. package/.agents/tests/secondmate-helpers.sh +193 -0
  670. package/.agents/tests/smoke.test.sh +93 -0
  671. package/.agents/tests/wake-helpers.sh +338 -0
  672. package/.agents/tests/zellij-test-safety.sh +60 -0
  673. package/.agents/tools/README.md +10 -0
  674. package/.agents/tools/bin/tasks-cli +267 -0
  675. package/.agents/tools/tasks-cli.ts +216 -0
  676. package/AGENTS.md +454 -0
  677. package/CHANGELOG.md +881 -0
  678. package/LICENSE +1 -1
  679. package/NOTICE +63 -0
  680. package/README.md +113 -39
  681. package/RULES/01-domains.md +51 -0
  682. package/RULES/02-agents.md +78 -0
  683. package/RULES/03-houses.md +61 -0
  684. package/RULES/04-hoard.md +65 -0
  685. package/RULES/05-platforms.md +67 -0
  686. package/RULES/06-append-only.md +58 -0
  687. package/RULES/07-config.md +45 -0
  688. package/RULES/08-delivery-gate.md +75 -0
  689. package/RULES/README.md +20 -0
  690. package/bin/README.md +107 -0
  691. package/bin/a2a-mcp.sh +98 -0
  692. package/bin/a2a-serve.py +103 -0
  693. package/bin/a2a-serve.sh +8 -0
  694. package/bin/a2a-talk.sh +50 -0
  695. package/bin/a2abridge-ensure.sh +108 -0
  696. package/bin/agent-run.sh +43 -0
  697. package/bin/agents-config.sh +233 -0
  698. package/bin/apodex-smoke-test.sh +92 -0
  699. package/bin/bifrost-bridge.sh +112 -0
  700. package/bin/bifrost-ingress.sh +77 -0
  701. package/bin/bootstrap-macos.sh +107 -0
  702. package/bin/bootstrap-windows.ps1 +112 -0
  703. package/bin/branch-guard.sh +64 -0
  704. package/bin/brokk +73 -0
  705. package/bin/brokk-classify-lib.sh +1770 -0
  706. package/bin/brokk-lease-lib.sh +222 -0
  707. package/bin/brokk-lease.sh +193 -0
  708. package/bin/brokk-lint.sh +65 -0
  709. package/bin/brokk-send.sh +38 -0
  710. package/bin/brokk-update.sh +9 -0
  711. package/bin/brokk-wake-grant.sh +127 -0
  712. package/bin/brokk-wake-lib.sh +1849 -0
  713. package/bin/changelog-guard.sh +165 -0
  714. package/bin/crash-sense.sh +160 -0
  715. package/bin/desktop-place.sh +224 -0
  716. package/bin/docs-guard.sh +39 -0
  717. package/bin/editor-place.sh +207 -0
  718. package/bin/eindri-acclaim.sh +34 -0
  719. package/bin/eindri-control.sh +28 -0
  720. package/bin/eindri-role.sh +83 -0
  721. package/bin/eindri-seen.sh +41 -0
  722. package/bin/eindri-send.sh +15 -0
  723. package/bin/eindri-start.sh +85 -0
  724. package/bin/eindri-watch.sh +57 -0
  725. package/bin/einherjar-spawn.sh +576 -0
  726. package/bin/eir-doctor.sh +112 -0
  727. package/bin/erindi-brief.sh +271 -0
  728. package/bin/fleet-apply.sh +68 -0
  729. package/bin/github-deploy.sh +81 -0
  730. package/bin/gjallarhorn-expose.sh +102 -0
  731. package/bin/gjallarhorn-notify.sh +58 -0
  732. package/bin/gjallarhorn-purge.sh +54 -0
  733. package/bin/gjallarhorn-tunnel.sh +72 -0
  734. package/bin/gleipnir-lock-lib.sh +284 -0
  735. package/bin/groa-update.sh +122 -0
  736. package/bin/hall-snapshot.sh +92 -0
  737. package/bin/hamr-harness.sh +246 -0
  738. package/bin/heimdall-ssh-keys.sh +374 -0
  739. package/bin/herdr-agents.py +44 -0
  740. package/bin/herdr-ensure.sh +104 -0
  741. package/bin/herdr-run.sh +431 -0
  742. package/bin/hermes-ensure.sh +88 -0
  743. package/bin/hoard-lib.sh +25 -0
  744. package/bin/hodd.sh +78 -0
  745. package/bin/host-sense.sh +199 -0
  746. package/bin/huginn-research-worker.sh +108 -0
  747. package/bin/local-model-lock.sh +51 -0
  748. package/bin/mimir-bridge.py +226 -0
  749. package/bin/mimir-bridge.sh +87 -0
  750. package/bin/mimir-ingest.sh +131 -0
  751. package/bin/mimir.sh +113 -0
  752. package/bin/mjollnir-webhook.sh +59 -0
  753. package/bin/mjollnir.sh +85 -0
  754. package/bin/model-hardware.sh +82 -0
  755. package/bin/model-resolve.sh +147 -0
  756. package/bin/models-detect.sh +178 -0
  757. package/bin/nornir-cron-start.sh +162 -0
  758. package/bin/nornir-job-daily-briefing.sh +191 -0
  759. package/bin/nornir-job-git-sync.sh +152 -0
  760. package/bin/nornir-job-memory-housekeeping.sh +114 -0
  761. package/bin/nornir-job-observer.sh +166 -0
  762. package/bin/nornir-job-skillopt-sleep.sh +76 -0
  763. package/bin/omarchy-hook-install.sh +60 -0
  764. package/bin/omarchy-install.sh +148 -0
  765. package/bin/omarchy-plugins.sh +174 -0
  766. package/bin/omarchy-sense.sh +149 -0
  767. package/bin/perm-guard.sh +30 -0
  768. package/bin/pi-agent.sh +68 -0
  769. package/bin/pi-ensure.sh +77 -0
  770. package/bin/pi-local.sh +37 -0
  771. package/bin/pi-seat.sh +63 -0
  772. package/bin/prereq-ensure.sh +166 -0
  773. package/bin/private-guard.sh +38 -0
  774. package/bin/project-git.sh +55 -0
  775. package/bin/public-guard.sh +70 -0
  776. package/bin/ratatoskr.sh +49 -0
  777. package/bin/realm-lib.sh +32 -0
  778. package/bin/repo-scrub.sh +62 -0
  779. package/bin/rodd-operational-input.sh +250 -0
  780. package/bin/runes-append.sh +198 -0
  781. package/bin/saga-session-start.sh +172 -0
  782. package/bin/saga-sessionstart-run.sh +79 -0
  783. package/bin/saga-wake-drain.sh +41 -0
  784. package/bin/secret-guard.sh +63 -0
  785. package/bin/sessrumnir-ensure.sh +100 -0
  786. package/bin/sessrumnir-sync.sh +237 -0
  787. package/bin/sessrumnir.sh +91 -0
  788. package/bin/skillopt-setup.sh +76 -0
  789. package/bin/skuld-branch-outcome.sh +203 -0
  790. package/bin/skuld-branch-prompt.sh +101 -0
  791. package/bin/smidja-bootstrap.sh +90 -0
  792. package/bin/smidja-observe.sh +86 -0
  793. package/bin/syn-arm-pretool-check.sh +57 -0
  794. package/bin/syn-asset-pretool-check.sh +76 -0
  795. package/bin/syn-cd-pretool-check.sh +28 -0
  796. package/bin/syn-guard-pretool-check.sh +83 -0
  797. package/bin/syn-turnend-guard.sh +36 -0
  798. package/bin/syn-watch-arm.sh +99 -0
  799. package/bin/tailscale-sync.sh +190 -0
  800. package/bin/telegram-bot.sh +69 -0
  801. package/bin/toolchain.sh +74 -0
  802. package/bin/utgard.sh +111 -0
  803. package/bin/valhalla.sh +115 -0
  804. package/bin/valknut-load.sh +260 -0
  805. package/bin/vor-crew-state.sh +124 -0
  806. package/bin/wedge-notify.sh +47 -0
  807. package/bin/workspace-provision.sh +80 -0
  808. package/bin/workspace-rag.sh +150 -0
  809. package/bin/wyrd-db.sh +47 -0
  810. package/bin/yggdrasil.sh +152 -0
  811. package/bin/ymir-edit.sh +145 -0
  812. package/bin/ymir-install.sh +641 -0
  813. package/bin/ymir-invite.sh +93 -0
  814. package/bin/ymir-isolation.sh +88 -0
  815. package/bin/ymir-migrate.sh +61 -0
  816. package/bin/ymir-platform.sh +348 -0
  817. package/bin/ymir-say.sh +97 -0
  818. package/bin/ymir-setup-auth.sh +108 -0
  819. package/bin/ymir-validate.sh +173 -0
  820. package/bin/ymir.js +74 -16
  821. package/docs/Architecture.md +339 -0
  822. package/docs/design.md +351 -0
  823. package/docs/installations/README.md +63 -0
  824. package/docs/installations/ymir-home.md +93 -0
  825. package/docs/integration.md +105 -0
  826. package/docs/lore.md +1056 -0
  827. package/docs/research/agent-harnesses.md +290 -0
  828. package/docs/runbooks/README.md +19 -0
  829. package/docs/runbooks/a2a.md +91 -0
  830. package/docs/runbooks/agent-permissions.md +99 -0
  831. package/docs/runbooks/agents.md +57 -0
  832. package/docs/runbooks/models.md +69 -0
  833. package/docs/runbooks/realm-onboarding.md +60 -0
  834. package/docs/runbooks/secrets-and-hoard.md +60 -0
  835. package/docs/runbooks/updates-and-migrations.md +110 -0
  836. package/docs/runbooks/worktrees-isolation.md +72 -0
  837. package/docs/session-start.md +105 -0
  838. package/docs/skillopt-integration.md +321 -0
  839. package/docs/sync-system.md +66 -0
  840. package/docs/workspaces.md +104 -0
  841. package/docs/ymir-rut.md +260 -0
  842. package/package.json +44 -14
  843. package/packaging/build.sh +74 -0
  844. package/packaging/macos/Ymir Installer.command +43 -0
  845. package/packaging/windows/ymir-setup.nsi +74 -0
  846. package/scripts/README.md +22 -0
  847. package/scripts/electron.sh +210 -0
  848. package/scripts/start.sh +167 -0
  849. package/scripts/stop.sh +113 -0
  850. package/install.sh +0 -35
@@ -0,0 +1,1822 @@
1
+ #!/usr/bin/env bash
2
+ # Behavior tests for the primary turn-end supervision guard (docs/turnend-guard.md).
3
+ #
4
+ # Two layers:
5
+ # PREDICATE - bin/fm-supervision-lib.sh, the shared beacon/status computation
6
+ # used by fm-guard.sh and by the hook's banner details.
7
+ # HOOK - bin/fm-turnend-guard.sh, the shared primary hook predicate that
8
+ # scopes in-flight work to the PRIMARY checkout only and requires
9
+ # a live, identity-matched watcher lock plus a fresh beacon.
10
+ # All hermetic over temp dirs; no real agent session is invoked.
11
+ set -u
12
+
13
+ # shellcheck source=tests/lib.sh
14
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
15
+
16
+ # shellcheck source=/dev/null
17
+ . "$ROOT/bin/fm-supervision-lib.sh"
18
+
19
+ TMP_ROOT=$(fm_test_tmproot fm-turnend-guard)
20
+ fm_git_identity fmtest fmtest@example.invalid
21
+
22
+ REQUIRED_REASON='watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn'
23
+
24
+ # --- PREDICATE: bin/fm-supervision-lib.sh -----------------------------------
25
+
26
+ test_predicate_healthy_no_inflight() {
27
+ local state="$TMP_ROOT/pred-empty/state"
28
+ mkdir -p "$state"
29
+ if fm_supervision_unhealthy "$state" 300; then
30
+ fail "predicate reported unhealthy with zero in-flight tasks"
31
+ fi
32
+ [ "$FM_SUP_IN_FLIGHT" -eq 0 ] || fail "expected zero in-flight, got $FM_SUP_IN_FLIGHT"
33
+ pass "fm_supervision_unhealthy: false with no state/*.meta at all"
34
+ }
35
+
36
+ test_predicate_unhealthy_no_beacon() {
37
+ local state="$TMP_ROOT/pred-nobeat/state"
38
+ mkdir -p "$state"
39
+ : > "$state/task1.meta"
40
+ fm_supervision_unhealthy "$state" 300 || fail "predicate did not fire: in-flight task, beacon never seen"
41
+ [ "$FM_SUP_IN_FLIGHT" -eq 1 ] || fail "expected 1 in-flight, got $FM_SUP_IN_FLIGHT"
42
+ [ "$FM_SUP_WATCHER_FRESH" = false ] || fail "beacon absent must not read as fresh"
43
+ [ "$FM_SUP_BEACON_DESC" = never ] || fail "beacon description should be 'never', got $FM_SUP_BEACON_DESC"
44
+ pass "fm_supervision_unhealthy: true with in-flight task and no beacon ever"
45
+ }
46
+
47
+ test_predicate_unhealthy_stale_beacon() {
48
+ local state="$TMP_ROOT/pred-stale/state"
49
+ mkdir -p "$state"
50
+ : > "$state/task1.meta"
51
+ touch -t 202001010000 "$state/.last-watcher-beat"
52
+ fm_supervision_unhealthy "$state" 300 || fail "predicate did not fire: in-flight task, beacon far outside grace"
53
+ [ "$FM_SUP_WATCHER_FRESH" = false ] || fail "an ancient beacon must not read as fresh"
54
+ pass "fm_supervision_unhealthy: true with in-flight task and a beacon far outside the grace window"
55
+ }
56
+
57
+ test_predicate_healthy_fresh_beacon() {
58
+ local state="$TMP_ROOT/pred-fresh/state"
59
+ mkdir -p "$state"
60
+ : > "$state/task1.meta"
61
+ touch "$state/.last-watcher-beat"
62
+ if fm_supervision_unhealthy "$state" 300; then
63
+ fail "predicate fired despite a fresh beacon"
64
+ fi
65
+ [ "$FM_SUP_WATCHER_FRESH" = true ] || fail "a beacon touched just now must read as fresh"
66
+ pass "fm_supervision_unhealthy: false with in-flight task and a fresh beacon"
67
+ }
68
+
69
+ test_predicate_queue_pending_flag() {
70
+ local state="$TMP_ROOT/pred-queue/state"
71
+ mkdir -p "$state"
72
+ fm_supervision_status "$state" 300
73
+ [ "$FM_SUP_QUEUE_PENDING" = false ] || fail "empty/absent wake queue must not read as pending"
74
+ printf 'record\n' > "$state/.wake-queue"
75
+ fm_supervision_status "$state" 300
76
+ [ "$FM_SUP_QUEUE_PENDING" = true ] || fail "a non-empty wake queue must read as pending"
77
+ pass "fm_supervision_status: FM_SUP_QUEUE_PENDING tracks state/.wake-queue"
78
+ }
79
+
80
+ test_predicate_x_mode_needs_supervision() {
81
+ local state="$TMP_ROOT/pred-x-mode/state"
82
+ mkdir -p "$state"
83
+ : > "$state/x-watch.check.sh"
84
+ fm_supervision_needed "$state" 300 || fail "X-mode relay poll did not register as supervision need"
85
+ [ "$FM_SUP_IN_FLIGHT" -eq 0 ] || fail "X-mode relay poll must not count as an in-flight task"
86
+ [ "$FM_SUP_NEEDED" = true ] || fail "X-mode relay poll must set FM_SUP_NEEDED"
87
+ fm_supervision_unhealthy "$state" 300 || fail "X-mode relay poll with no beacon must be unhealthy"
88
+ pass "fm_supervision_needed: X-mode relay poll needs supervision"
89
+ }
90
+
91
+ test_predicate_source_needs_supervision() {
92
+ local state="$TMP_ROOT/pred-source/state"
93
+ mkdir -p "$state/procevent"
94
+ : > "$state/procevent/source-only.source"
95
+ fm_supervision_unhealthy "$state" 300 || fail "registered source with no beacon must be unhealthy"
96
+ [ "$FM_SUP_IN_FLIGHT" -eq 0 ] || fail "a process-event source must not count as a task"
97
+ [ "$FM_SUP_SOURCES" -eq 1 ] || fail "expected one registered process-event source"
98
+ pass "fm_supervision_unhealthy: source-only home needs supervision"
99
+ }
100
+
101
+ # --- HOOK: bin/fm-turnend-guard.sh ------------------------------------------
102
+ #
103
+ # Each scenario gets its own directory carrying a copy of the two guard scripts
104
+ # under bin/, so the hook (invoked by absolute path) resolves its own FM_ROOT to
105
+ # that scenario dir regardless of the test's cwd.
106
+
107
+ install_guard_scripts() {
108
+ local dir=$1
109
+ mkdir -p "$dir/bin"
110
+ cp "$ROOT/bin/fm-turnend-guard.sh" "$dir/bin/fm-turnend-guard.sh"
111
+ cp "$ROOT/bin/fm-turnend-guard-grok.sh" "$dir/bin/fm-turnend-guard-grok.sh"
112
+ cp "$ROOT/bin/fm-operational-input.sh" "$dir/bin/fm-operational-input.sh"
113
+ cp "$ROOT/bin/fm-supervision-instructions.sh" "$dir/bin/fm-supervision-instructions.sh"
114
+ cp "$ROOT/bin/fm-harness.sh" "$dir/bin/fm-harness.sh"
115
+ cp "$ROOT/bin/fm-primary-scope-lib.sh" "$dir/bin/fm-primary-scope-lib.sh"
116
+ cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh"
117
+ cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh"
118
+ cp "$ROOT/bin/fm-hook-host-lib.sh" "$dir/bin/fm-hook-host-lib.sh"
119
+ mkdir -p "$dir/docs"
120
+ cp -R "$ROOT/docs/supervision-protocols" "$dir/docs/supervision-protocols"
121
+ chmod +x "$dir/bin/fm-turnend-guard.sh" "$dir/bin/fm-turnend-guard-grok.sh" "$dir/bin/fm-operational-input.sh" "$dir/bin/fm-supervision-instructions.sh" "$dir/bin/fm-harness.sh"
122
+ }
123
+
124
+ mark_codex_hook_root() {
125
+ local dir=$1
126
+ mkdir -p "$dir/.codex"
127
+ printf '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"fm-turnend-guard.sh"}]}]}}\n' > "$dir/.codex/hooks.json"
128
+ }
129
+
130
+ # A primary-shaped checkout: plain (non-worktree) git repo, AGENTS.md, bin/,
131
+ # state/ - everything the hook's scoping check requires to treat it as primary.
132
+ make_primary_dir() {
133
+ local dir=$1
134
+ mkdir -p "$dir/state"
135
+ git init -q "$dir"
136
+ git -C "$dir" commit -q --allow-empty -m init
137
+ : > "$dir/AGENTS.md"
138
+ install_guard_scripts "$dir"
139
+ printf '%s\n' "$dir"
140
+ }
141
+
142
+ # Same shape as primary, plus the .fm-secondmate-home marker bin/fm-home-seed.sh
143
+ # writes at seed time (regardless of treehouse-lease or git-clone acquisition).
144
+ make_secondmate_dir() {
145
+ local dir=$1
146
+ make_primary_dir "$dir" >/dev/null
147
+ printf 'sm-test-1\n' > "$dir/.fm-secondmate-home"
148
+ printf '%s\n' "$dir"
149
+ }
150
+
151
+ # A genuine linked `git worktree` of a base repo - the shape bin/fm-spawn.sh
152
+ # always hands crewmate/scout tasks working on firstmate itself. git-dir and
153
+ # git-common-dir differ here, unlike a plain checkout.
154
+ make_crewmate_worktree_dir() {
155
+ local base=$1 dir=$2
156
+ fm_git_worktree "$base" "$dir" fm/turnend-guard-test-branch
157
+ mkdir -p "$dir/state"
158
+ : > "$dir/AGENTS.md"
159
+ install_guard_scripts "$dir"
160
+ printf '%s\n' "$dir"
161
+ }
162
+
163
+ # A secondmate home's OWN child crew/scout worktree: a genuine linked git
164
+ # worktree of the secondmate home, so git-dir != git-common-dir exactly as for a
165
+ # main-home child worktree. A child worktree never carries the gitignored
166
+ # .fm-secondmate-home marker, so the marker force-include never fires for it and
167
+ # it stays exempt through the linked-worktree git-dir test.
168
+ make_secondmate_child_worktree_dir() {
169
+ local home=$1 dir=$2
170
+ git -C "$home" worktree add --quiet -b fm/turnend-secondmate-child "$dir"
171
+ mkdir -p "$dir/state"
172
+ : > "$dir/AGENTS.md"
173
+ install_guard_scripts "$dir"
174
+ printf '%s\n' "$dir"
175
+ }
176
+
177
+ # A treehouse-leased secondmate HOME: a genuine linked `git worktree` (git-dir !=
178
+ # git-common-dir, exactly like a default treehouse-leased home) that DOES carry a
179
+ # valid .fm-secondmate-home marker. This is the production topology the plain
180
+ # git-init secondmate fixture cannot represent; the guard must force-INCLUDE it
181
+ # as a guarded primary via the marker, not exempt it as a linked worktree.
182
+ make_secondmate_linked_home_dir() {
183
+ local base=$1 dir=$2
184
+ fm_git_worktree "$base" "$dir" fm/turnend-secondmate-linked-home
185
+ mkdir -p "$dir/state"
186
+ : > "$dir/AGENTS.md"
187
+ install_guard_scripts "$dir"
188
+ printf 'sm-linked-1\n' > "$dir/.fm-secondmate-home"
189
+ printf '%s\n' "$dir"
190
+ }
191
+
192
+ run_hook() {
193
+ local dir=$1 stop_active=$2 home
194
+ home=$(cd "$dir" && pwd)
195
+ printf '{"stop_hook_active":%s}' "$stop_active" | CLAUDECODE=1 FM_HOME="$home" bash "$dir/bin/fm-turnend-guard.sh" 2>&1
196
+ }
197
+
198
+ nonexistent_pid() {
199
+ local pid=999999
200
+ while kill -0 "$pid" 2>/dev/null; do
201
+ pid=$((pid + 1))
202
+ done
203
+ printf '%s\n' "$pid"
204
+ }
205
+
206
+ watcher_identity() {
207
+ local dir=$1 pid=$2
208
+ FM_STATE_OVERRIDE="$dir/state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$dir/bin/fm-wake-lib.sh" "$pid"
209
+ }
210
+
211
+ record_watcher_lock() {
212
+ local dir=$1 pid=$2 identity=$3 root bin_dir
213
+ root=$(cd "$dir" && pwd)
214
+ bin_dir=$(cd "$dir/bin" && pwd)
215
+ mkdir -p "$dir/state/.watch.lock"
216
+ printf '%s\n' "$pid" > "$dir/state/.watch.lock/pid"
217
+ printf '%s\n' "$root" > "$dir/state/.watch.lock/fm-home"
218
+ printf '%s\n' "$bin_dir/fm-watch.sh" > "$dir/state/.watch.lock/watcher-path"
219
+ printf '%s\n' "$identity" > "$dir/state/.watch.lock/pid-identity"
220
+ }
221
+
222
+ test_hook_silent_when_no_work_in_flight() {
223
+ local dir out status
224
+ dir=$(make_primary_dir "$TMP_ROOT/hook-idle")
225
+ out=$(run_hook "$dir" false); status=$?
226
+ expect_code 0 "$status" "hook must exit 0 with no in-flight work"
227
+ [ -z "$out" ] || fail "hook produced output with no in-flight work: $out"
228
+ pass "fm-turnend-guard: silent no-op with nothing in flight"
229
+ }
230
+
231
+ test_hook_blocks_when_fresh_beacon_has_no_live_lock() {
232
+ local dir out status
233
+ dir=$(make_primary_dir "$TMP_ROOT/hook-fresh-no-lock")
234
+ : > "$dir/state/task1.meta"
235
+ touch "$dir/state/.last-watcher-beat"
236
+ out=$(run_hook "$dir" false); status=$?
237
+ expect_code 2 "$status" "hook must block when a fresh beacon has no live watcher lock"
238
+ assert_contains "$out" "$REQUIRED_REASON" "block reason must contain the exact required instruction"
239
+ pass "fm-turnend-guard: blocks when a fresh beacon has no live watcher lock"
240
+ }
241
+
242
+ test_hook_blocks_source_only_home() {
243
+ local dir out status
244
+ dir=$(make_primary_dir "$TMP_ROOT/hook-source-only")
245
+ mkdir -p "$dir/state/procevent"
246
+ : > "$dir/state/procevent/source-only.source"
247
+ out=$(run_hook "$dir" false); status=$?
248
+ expect_code 2 "$status" "non-Claude hook must block when a source-only home has no watcher"
249
+ assert_contains "$out" "1 process-event source(s) registered" "block reason must identify the source-only supervision need"
250
+ pass "fm-turnend-guard: non-Claude path blocks a source-only home"
251
+ }
252
+
253
+ test_hook_blocks_when_dead_lock_has_fresh_beacon() {
254
+ local dir dead out status
255
+ dir=$(make_primary_dir "$TMP_ROOT/hook-dead-lock-fresh")
256
+ dead=$(nonexistent_pid)
257
+ : > "$dir/state/task1.meta"
258
+ record_watcher_lock "$dir" "$dead" "dead watcher identity"
259
+ touch "$dir/state/.last-watcher-beat"
260
+ out=$(run_hook "$dir" false); status=$?
261
+ expect_code 2 "$status" "hook must block when the watcher lock pid is dead despite a fresh beacon"
262
+ assert_contains "$out" "$REQUIRED_REASON" "block reason must contain the exact required instruction"
263
+ pass "fm-turnend-guard: blocks on a dead watcher lock even when the beacon is fresh"
264
+ }
265
+
266
+ test_hook_silent_with_live_lock_and_fresh_beacon() {
267
+ local dir pid identity out status
268
+ dir=$(make_primary_dir "$TMP_ROOT/hook-live-lock-fresh")
269
+ : > "$dir/state/task1.meta"
270
+ sleep 60 &
271
+ pid=$!
272
+ identity=$(watcher_identity "$dir" "$pid") || {
273
+ kill "$pid" 2>/dev/null || true
274
+ wait "$pid" 2>/dev/null || true
275
+ fail "could not identify live watcher holder"
276
+ }
277
+ record_watcher_lock "$dir" "$pid" "$identity"
278
+ touch "$dir/state/.last-watcher-beat"
279
+ out=$(run_hook "$dir" false); status=$?
280
+ kill "$pid" 2>/dev/null || true
281
+ wait "$pid" 2>/dev/null || true
282
+ expect_code 0 "$status" "hook must exit 0 with a live identity-matched watcher lock and fresh beacon"
283
+ [ -z "$out" ] || fail "hook produced output despite a live fresh watcher lock: $out"
284
+ pass "fm-turnend-guard: silent no-op with a live watcher lock and fresh beacon"
285
+ }
286
+
287
+ test_hook_non_claude_health_ignores_claude_budget_contention() {
288
+ local dir home pid identity holder harness payload out status
289
+ dir=$(make_primary_dir "$TMP_ROOT/hook-non-claude-budget-contention")
290
+ home=$(cd "$dir" && pwd)
291
+ : > "$dir/state/task1.meta"
292
+ sleep 60 &
293
+ pid=$!
294
+ identity=$(watcher_identity "$dir" "$pid") || {
295
+ kill "$pid" 2>/dev/null || true
296
+ wait "$pid" 2>/dev/null || true
297
+ fail "could not identify non-Claude contention watcher"
298
+ }
299
+ record_watcher_lock "$dir" "$pid" "$identity"
300
+ touch "$dir/state/.last-watcher-beat"
301
+ printf 'session=claude-episode\ncount=3\nepoch=9\n' > "$dir/state/.turnend-claude-blocks"
302
+ printf 'notice-state\n' > "$dir/state/.claude-autoarm-failure-notified"
303
+ printf 'alarm-state\n' > "$dir/state/.claude-autoarm-failure-alarmed"
304
+ sleep 60 &
305
+ holder=$!
306
+ mkdir -p "$dir/state/.turnend-claude-blocks.lock"
307
+ printf '%s\n' "$holder" > "$dir/state/.turnend-claude-blocks.lock/pid"
308
+ while IFS='|' read -r harness payload; do
309
+ out=$(printf '%s' "$payload" | FM_HOME="$home" bash "$dir/bin/fm-turnend-guard.sh" 2>&1); status=$?
310
+ expect_code 0 "$status" "$harness healthy path must ignore Claude budget-lock contention"
311
+ [ -z "$out" ] || fail "$harness healthy path produced output: $out"
312
+ [ "$(cat "$dir/state/.turnend-claude-blocks")" = $'session=claude-episode\ncount=3\nepoch=9' ] \
313
+ || fail "$harness healthy path mutated the Claude block budget"
314
+ [ "$(cat "$dir/state/.claude-autoarm-failure-notified")" = notice-state ] \
315
+ || fail "$harness healthy path mutated the Claude failure notice"
316
+ [ "$(cat "$dir/state/.claude-autoarm-failure-alarmed")" = alarm-state ] \
317
+ || fail "$harness healthy path mutated the Claude attended alarm"
318
+ [ "$(cat "$dir/state/.turnend-claude-blocks.lock/pid")" = "$holder" ] \
319
+ || fail "$harness healthy path replaced the Claude budget-lock owner"
320
+ done <<EOF
321
+ default|{"stop_hook_active":false}
322
+ Codex|{"cwd":"$dir","stop_hook_active":false}
323
+ OpenCode|{"stop_hook_active":false}
324
+ Pi|{"stop_hook_active":false}
325
+ pi-signed|{"stop_hook_active":false}
326
+ Grok|{"sessionId":"grok-session","stopHookActive":false}
327
+ Kimi|{"stop_hook_active":false}
328
+ EOF
329
+ kill "$holder" "$pid" 2>/dev/null || true
330
+ wait "$holder" "$pid" 2>/dev/null || true
331
+ pass "fm-turnend-guard: healthy non-Claude harness paths ignore Claude episode contention"
332
+ }
333
+
334
+ test_hook_blocks_with_live_lock_and_stale_beacon() {
335
+ local dir pid identity out status
336
+ dir=$(make_primary_dir "$TMP_ROOT/hook-live-lock-stale")
337
+ : > "$dir/state/task1.meta"
338
+ sleep 60 &
339
+ pid=$!
340
+ identity=$(watcher_identity "$dir" "$pid") || {
341
+ kill "$pid" 2>/dev/null || true
342
+ wait "$pid" 2>/dev/null || true
343
+ fail "could not identify live watcher holder"
344
+ }
345
+ record_watcher_lock "$dir" "$pid" "$identity"
346
+ touch -t 202001010000 "$dir/state/.last-watcher-beat"
347
+ out=$(run_hook "$dir" false); status=$?
348
+ kill "$pid" 2>/dev/null || true
349
+ wait "$pid" 2>/dev/null || true
350
+ expect_code 2 "$status" "hook must block when a live watcher lock has an ancient beacon"
351
+ assert_contains "$out" "$REQUIRED_REASON" "block reason must contain the exact required instruction"
352
+ pass "fm-turnend-guard: blocks on a live watcher lock with an ancient beacon"
353
+ }
354
+
355
+ test_hook_blocks_when_unhealthy_in_primary() {
356
+ local dir out status
357
+ dir=$(make_primary_dir "$TMP_ROOT/hook-block")
358
+ : > "$dir/state/task1.meta"
359
+ out=$(run_hook "$dir" false); status=$?
360
+ expect_code 2 "$status" "hook must block (exit 2) when in-flight work has no live watcher"
361
+ assert_contains "$out" "$REQUIRED_REASON" "block reason must contain the exact required instruction"
362
+ assert_contains "$out" "TURN WOULD END BLIND" "block banner must read as an alarm"
363
+ pass "fm-turnend-guard: blocks with the exact required reason in the primary when unhealthy"
364
+ }
365
+
366
+ test_hook_blocks_from_fm_home_state() {
367
+ local dir home out status
368
+ dir=$(make_primary_dir "$TMP_ROOT/hook-fm-home")
369
+ home="$TMP_ROOT/hook-fm-home-op"
370
+ mkdir -p "$home/state"
371
+ : > "$home/state/task1.meta"
372
+ out=$(printf '{"stop_hook_active":false}' | CLAUDECODE=1 FM_HOME="$home" bash "$dir/bin/fm-turnend-guard.sh" 2>&1); status=$?
373
+ expect_code 2 "$status" "hook must inspect the active FM_HOME state dir"
374
+ assert_contains "$out" "$REQUIRED_REASON" "block reason must contain the exact required instruction"
375
+ pass "fm-turnend-guard: blocks from active FM_HOME state, not only repo-root state"
376
+ }
377
+
378
+ test_hook_x_mode_reason_sources_cadence() {
379
+ local dir home out status
380
+ dir=$(make_primary_dir "$TMP_ROOT/hook-x-mode")
381
+ home=$(cd "$dir" && pwd)
382
+ mkdir -p "$dir/config"
383
+ : > "$dir/config/x-mode.env"
384
+ : > "$dir/state/task1.meta"
385
+ out=$(run_hook "$dir" false); status=$?
386
+ expect_code 2 "$status" "hook must block when in-flight X-mode work has no live watcher"
387
+ assert_contains "$out" "source '$home/config/x-mode.env' first" "block reason must source the effective X-mode cadence"
388
+ pass "fm-turnend-guard: X-mode repair reason sources the cadence config"
389
+ }
390
+
391
+ test_hook_x_mode_only_blocks_in_default_mode() {
392
+ local dir out status
393
+ dir=$(make_primary_dir "$TMP_ROOT/hook-x-mode-only")
394
+ : > "$dir/state/x-watch.check.sh"
395
+ out=$(run_hook "$dir" false); status=$?
396
+ expect_code 2 "$status" "default hook mode must block an X-mode-only blind turn"
397
+ assert_contains "$out" "X-mode relay polling needs supervision" "X-mode-only blind stop must identify its supervision need"
398
+ pass "fm-turnend-guard: X-mode-only supervision remains guarded in default mode"
399
+ }
400
+
401
+ test_hook_ignores_repo_state_when_fm_home_set() {
402
+ local dir home out status
403
+ dir=$(make_primary_dir "$TMP_ROOT/hook-fm-home-ignore-root")
404
+ home="$TMP_ROOT/hook-fm-home-quiet"
405
+ mkdir -p "$home/state"
406
+ : > "$dir/state/task1.meta"
407
+ out=$(printf '{"stop_hook_active":false}' | FM_HOME="$home" bash "$dir/bin/fm-turnend-guard.sh" 2>&1); status=$?
408
+ expect_code 0 "$status" "hook must ignore repo-root state when FM_HOME selects another state dir"
409
+ [ -z "$out" ] || fail "hook produced output from stale repo-root state despite FM_HOME: $out"
410
+ pass "fm-turnend-guard: ignores stale repo-root state when FM_HOME is set"
411
+ }
412
+
413
+ test_hook_uses_state_override() {
414
+ local dir home state out status
415
+ dir=$(make_primary_dir "$TMP_ROOT/hook-state-override")
416
+ home="$TMP_ROOT/hook-state-override-home"
417
+ state="$TMP_ROOT/hook-state-override-active"
418
+ mkdir -p "$home/state" "$state"
419
+ : > "$state/task1.meta"
420
+ out=$(printf '{"stop_hook_active":false}' | CLAUDECODE=1 FM_HOME="$home" FM_STATE_OVERRIDE="$state" bash "$dir/bin/fm-turnend-guard.sh" 2>&1); status=$?
421
+ expect_code 2 "$status" "hook must let FM_STATE_OVERRIDE win over FM_HOME/state"
422
+ assert_contains "$out" "$REQUIRED_REASON" "block reason must contain the exact required instruction"
423
+ pass "fm-turnend-guard: uses FM_STATE_OVERRIDE ahead of FM_HOME/state"
424
+ }
425
+
426
+ test_hook_loop_guard_allows_retry() {
427
+ local dir out status
428
+ dir=$(make_primary_dir "$TMP_ROOT/hook-loopguard")
429
+ : > "$dir/state/task1.meta"
430
+ out=$(run_hook "$dir" true); status=$?
431
+ expect_code 0 "$status" "hook must allow the stop when stop_hook_active is already true"
432
+ [ -z "$out" ] || fail "hook produced output on the loop-guarded retry: $out"
433
+ pass "fm-turnend-guard: stop_hook_active=true always allows the stop (never blocks twice in one turn)"
434
+ }
435
+
436
+ # A secondmate's OWN home runs a primary firstmate session and must be guarded
437
+ # exactly like the main primary. This was the guard's proven blind spot: the
438
+ # .fm-secondmate-home marker used to early-exit here, so an overnight secondmate
439
+ # could end a turn with an unsupervised child and sit blind. Removing that marker
440
+ # check makes the guard fire, mirroring the cd-guard.
441
+ test_hook_blocks_in_secondmate_own_home() {
442
+ local dir out status
443
+ dir=$(make_secondmate_dir "$TMP_ROOT/hook-secondmate")
444
+ : > "$dir/state/task1.meta"
445
+ out=$(run_hook "$dir" false); status=$?
446
+ expect_code 2 "$status" "hook must guard a secondmate's own home like the main primary when unhealthy"
447
+ assert_contains "$out" "$REQUIRED_REASON" "block reason must contain the exact required instruction"
448
+ assert_contains "$out" "TURN WOULD END BLIND" "block banner must read as an alarm"
449
+ pass "fm-turnend-guard: blocks a blind turn end in a secondmate's own home (.fm-secondmate-home no longer excludes it)"
450
+ }
451
+
452
+ # Idle-by-default: an empty-queue secondmate has no in-flight meta, so the guard
453
+ # exits at the in-flight gate - never forcing a busy continuation loop.
454
+ test_hook_silent_in_idle_secondmate_home() {
455
+ local dir out status
456
+ dir=$(make_secondmate_dir "$TMP_ROOT/hook-secondmate-idle")
457
+ out=$(run_hook "$dir" false); status=$?
458
+ expect_code 0 "$status" "hook must stay silent in an idle, empty-queue secondmate home"
459
+ [ -z "$out" ] || fail "idle secondmate home produced guard output: $out"
460
+ pass "fm-turnend-guard: idle-by-default - silent in a secondmate home with nothing in flight"
461
+ }
462
+
463
+ # The stop_hook_active loop guard bounds the secondmate to one forced
464
+ # continuation per turn, exactly as it does for the main primary - no wedged,
465
+ # un-endable session.
466
+ test_hook_secondmate_loop_guard_allows_retry() {
467
+ local dir out status
468
+ dir=$(make_secondmate_dir "$TMP_ROOT/hook-secondmate-loopguard")
469
+ : > "$dir/state/task1.meta"
470
+ out=$(run_hook "$dir" true); status=$?
471
+ expect_code 0 "$status" "hook must allow the stop in a secondmate home when stop_hook_active is already true"
472
+ [ -z "$out" ] || fail "secondmate loop-guarded retry produced output: $out"
473
+ pass "fm-turnend-guard: stop_hook_active=true allows the stop in a secondmate home (never blocks twice in one turn)"
474
+ }
475
+
476
+ # The guard's half of the deferred-death recovery loop in a secondmate home,
477
+ # proven deterministically without a live model or any daemon: silent while the
478
+ # watcher is live (the secondmate ends its turn and relies on the background
479
+ # re-invoke), then blocks to force the re-arm once the watcher has exited and a
480
+ # second child event lands. The live half - that Claude Code autonomously
481
+ # re-invokes the model when the background watcher exits (Mechanism A) - is a
482
+ # harness property recorded empirically in docs/turnend-guard.md; it needs a live
483
+ # session and cannot be a hermetic CI assertion.
484
+ test_hook_secondmate_reinvoke_recovery_loop() {
485
+ local dir pid identity out status
486
+ dir=$(make_secondmate_dir "$TMP_ROOT/hook-secondmate-reinvoke")
487
+ : > "$dir/state/child1.meta"
488
+ sleep 60 &
489
+ pid=$!
490
+ identity=$(watcher_identity "$dir" "$pid") || {
491
+ kill "$pid" 2>/dev/null || true
492
+ wait "$pid" 2>/dev/null || true
493
+ fail "could not identify live watcher holder"
494
+ }
495
+ record_watcher_lock "$dir" "$pid" "$identity"
496
+ touch "$dir/state/.last-watcher-beat"
497
+ out=$(run_hook "$dir" false); status=$?
498
+ expect_code 0 "$status" "secondmate turn must end silently while its watcher is live (Stop #1)"
499
+ [ -z "$out" ] || {
500
+ kill "$pid" 2>/dev/null || true
501
+ wait "$pid" 2>/dev/null || true
502
+ fail "guard nagged a healthy secondmate at Stop #1: $out"
503
+ }
504
+ # The watcher exits on the wake (its normal lifecycle) and a SECOND child event
505
+ # lands. On the re-invoked recovery turn the secondmate must re-arm; if it did
506
+ # not, the guard blocks that turn's end and forces the re-arm (Stop #2).
507
+ kill "$pid" 2>/dev/null || true
508
+ wait "$pid" 2>/dev/null || true
509
+ rm -rf "$dir/state/.watch.lock"
510
+ : > "$dir/state/child2.meta"
511
+ touch "$dir/state/.last-watcher-beat"
512
+ out=$(run_hook "$dir" false); status=$?
513
+ expect_code 2 "$status" "secondmate recovery turn must not end blind after the watcher exits (Stop #2)"
514
+ assert_contains "$out" "$REQUIRED_REASON" "block reason must contain the exact required instruction"
515
+ pass "fm-turnend-guard: secondmate deferred-death recovery - silent while watched, forces re-arm once the watcher exits"
516
+ }
517
+
518
+ # The marker force-include must guard only the secondmate's OWN home, never its
519
+ # children: a secondmate's linked crew/scout worktree carries no marker, so it
520
+ # stays exempt by the same git-dir/git-common-dir test that exempts the main
521
+ # home's children.
522
+ test_hook_silent_in_secondmate_child_worktree() {
523
+ local home dir out status
524
+ home=$(make_secondmate_dir "$TMP_ROOT/hook-sm-child-home")
525
+ dir="$TMP_ROOT/hook-sm-child-wt"
526
+ make_secondmate_child_worktree_dir "$home" "$dir" >/dev/null
527
+ : > "$dir/state/task1.meta"
528
+ out=$(run_hook "$dir" false); status=$?
529
+ expect_code 0 "$status" "hook must stay exempt in a secondmate's own child crew/scout worktree"
530
+ [ -z "$out" ] || fail "hook produced output inside a secondmate's child worktree: $out"
531
+ pass "fm-turnend-guard: inert in a secondmate's own child worktree (linked git worktree) even when unhealthy"
532
+ }
533
+
534
+ # THE regression the plain git-init fixtures masked: a treehouse-leased secondmate
535
+ # home is a genuine LINKED worktree (git-dir != git-common-dir), which the
536
+ # remove-only form wrongly exempted. With the marker force-include, its own
537
+ # primary session is GUARDED. The test asserts the fixture really is a linked
538
+ # worktree so it can never silently regress back into a plain-checkout shape.
539
+ test_hook_blocks_in_treehouse_leased_secondmate_home() {
540
+ local base dir gd gcd out status
541
+ base="$TMP_ROOT/hook-sm-leased-base"
542
+ dir="$TMP_ROOT/hook-sm-leased-home"
543
+ make_secondmate_linked_home_dir "$base" "$dir" >/dev/null
544
+ gd=$(git -C "$dir" rev-parse --git-dir)
545
+ gcd=$(git -C "$dir" rev-parse --git-common-dir)
546
+ [ "$gd" != "$gcd" ] || fail "leased-home fixture must be a linked worktree (git-dir != git-common-dir), got equal: $gd"
547
+ : > "$dir/state/task1.meta"
548
+ out=$(run_hook "$dir" false); status=$?
549
+ expect_code 2 "$status" "hook must GUARD a treehouse-leased (linked) secondmate home via its marker when unhealthy"
550
+ assert_contains "$out" "$REQUIRED_REASON" "block reason must contain the exact required instruction"
551
+ assert_contains "$out" "TURN WOULD END BLIND" "block banner must read as an alarm"
552
+ pass "fm-turnend-guard: blocks a blind turn end in a treehouse-leased LINKED secondmate home (marker force-include)"
553
+ }
554
+
555
+ # Anti-spoof: a linked worktree with an INVALID (empty) marker must NOT be
556
+ # force-included. Marker validation rejects it, so it falls through to the
557
+ # linked-worktree exemption and stays exempt - a stray/empty marker file can
558
+ # never spoof a child worktree into being guarded.
559
+ test_hook_exempts_linked_worktree_with_stray_marker() {
560
+ local base dir out status
561
+ base="$TMP_ROOT/hook-stray-marker-base"
562
+ dir="$TMP_ROOT/hook-stray-marker-wt"
563
+ make_crewmate_worktree_dir "$base" "$dir" >/dev/null
564
+ : > "$dir/.fm-secondmate-home"
565
+ : > "$dir/state/task1.meta"
566
+ out=$(run_hook "$dir" false); status=$?
567
+ expect_code 0 "$status" "an empty/invalid marker must not spoof force-inclusion in a linked worktree"
568
+ [ -z "$out" ] || fail "stray empty marker wrongly force-included a linked worktree: $out"
569
+ pass "fm-turnend-guard: an invalid (empty) marker cannot spoof inclusion; linked worktree stays exempt"
570
+ }
571
+
572
+ # Anti-spoof under any locale: a NON-ASCII marker id must be REJECTED by the
573
+ # ASCII-only (C-collation) allowlist, so it can never force-include a linked
574
+ # worktree even where the ambient locale's collation would treat it as a letter.
575
+ # Rejection -> git-dir exemption -> the linked worktree stays exempt.
576
+ test_hook_exempts_linked_worktree_with_non_ascii_marker() {
577
+ local base dir out status
578
+ base="$TMP_ROOT/hook-nonascii-marker-base"
579
+ dir="$TMP_ROOT/hook-nonascii-marker-wt"
580
+ make_crewmate_worktree_dir "$base" "$dir" >/dev/null
581
+ printf 'caf\xc3\xa9\n' > "$dir/.fm-secondmate-home"
582
+ : > "$dir/state/task1.meta"
583
+ out=$(run_hook "$dir" false); status=$?
584
+ expect_code 0 "$status" "a non-ASCII marker id must not spoof force-inclusion in a linked worktree"
585
+ [ -z "$out" ] || fail "non-ASCII marker wrongly force-included a linked worktree: $out"
586
+ pass "fm-turnend-guard: a non-ASCII marker cannot spoof inclusion; linked worktree stays exempt"
587
+ }
588
+
589
+ test_hook_silent_in_crewmate_worktree() {
590
+ local base dir out status
591
+ base="$TMP_ROOT/hook-crew-base"
592
+ dir="$TMP_ROOT/hook-crew-wt"
593
+ make_crewmate_worktree_dir "$base" "$dir" >/dev/null
594
+ : > "$dir/state/task1.meta"
595
+ out=$(run_hook "$dir" false); status=$?
596
+ expect_code 0 "$status" "hook must never block inside a crewmate task worktree"
597
+ [ -z "$out" ] || fail "hook produced output inside a crewmate task worktree: $out"
598
+ pass "fm-turnend-guard: inert in a crewmate/scout task worktree (linked git worktree) even when unhealthy"
599
+ }
600
+
601
+ test_hook_silent_without_jq() {
602
+ local dir out status fakebin tool tool_path
603
+ dir=$(make_primary_dir "$TMP_ROOT/hook-nojq")
604
+ : > "$dir/state/task1.meta"
605
+ fakebin=$(fm_fakebin "$TMP_ROOT/hook-nojq-fake")
606
+ for tool in bash sh git cat printf date uname stat mkdir dirname; do
607
+ tool_path=$(command -v "$tool") || fail "test host must provide $tool"
608
+ ln -s "$tool_path" "$fakebin/$tool"
609
+ done
610
+ out=$(printf '{"stop_hook_active":false}' | PATH="$fakebin" bash "$dir/bin/fm-turnend-guard.sh" 2>&1)
611
+ status=$?
612
+ expect_code 0 "$status" "hook must fail open (exit 0) when jq is unavailable"
613
+ [ -z "$out" ] || fail "hook produced output without jq: $out"
614
+ pass "fm-turnend-guard: fails open (never blocks) when jq is missing"
615
+ }
616
+
617
+ test_hook_silent_without_stdin() {
618
+ local dir out status
619
+ dir=$(make_primary_dir "$TMP_ROOT/hook-nostdin")
620
+ : > "$dir/state/task1.meta"
621
+ out=$(bash "$dir/bin/fm-turnend-guard.sh" < /dev/null 2>&1); status=$?
622
+ expect_code 0 "$status" "hook must exit 0 on empty/absent stdin"
623
+ [ -z "$out" ] || fail "hook produced output on empty stdin: $out"
624
+ pass "fm-turnend-guard: silent no-op on empty stdin"
625
+ }
626
+
627
+ test_hook_runs_fast() {
628
+ local dir start elapsed_s
629
+ dir=$(make_primary_dir "$TMP_ROOT/hook-timing")
630
+ : > "$dir/state/task1.meta"
631
+ start=$SECONDS
632
+ run_hook "$dir" false >/dev/null
633
+ elapsed_s=$((SECONDS - start))
634
+ [ "$elapsed_s" -lt 3 ] || fail "hook took ${elapsed_s}s, expected well under a second (generous 3s CI margin)"
635
+ pass "fm-turnend-guard: runs well under the generous timing margin (${elapsed_s}s)"
636
+ }
637
+
638
+ test_grok_adapter_forces_one_resume_when_unhealthy() {
639
+ local dir fakebin log out status
640
+ dir=$(make_primary_dir "$TMP_ROOT/grok-adapter-block")
641
+ : > "$dir/state/task1.meta"
642
+ fakebin=$(fm_fakebin "$TMP_ROOT/grok-adapter-fakebin")
643
+ log="$TMP_ROOT/grok-adapter-call.log"
644
+ cat > "$fakebin/grok" <<EOF
645
+ #!/usr/bin/env bash
646
+ {
647
+ printf 'active=%s\n' "\${GROK_TURNEND_GUARD_ACTIVE:-}"
648
+ printf 'home=%s\n' "\${GROK_HOME:-}"
649
+ printf 'args:'
650
+ for arg in "\$@"; do
651
+ printf ' <%s>' "\$arg"
652
+ done
653
+ printf '\n'
654
+ } >> "$log"
655
+ EOF
656
+ chmod +x "$fakebin/grok"
657
+ out=$(printf '{"sessionId":"session-test","hookEventName":"stop"}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$?
658
+ expect_code 0 "$status" "grok adapter must fail open after queuing a forced resume"
659
+ [ -z "$out" ] || fail "grok adapter printed output: $out"
660
+ assert_contains "$(cat "$log")" 'active=1' "grok adapter must mark its forced resume as loop-guarded"
661
+ assert_contains "$(cat "$log")" '<--resume>' "grok adapter must resume the current session"
662
+ assert_contains "$(cat "$log")" '<session-test>' "grok adapter must pass the hook session id"
663
+ assert_not_contains "$(cat "$log")" '<--permission-mode>' "grok adapter must not add a stronger permission mode"
664
+ assert_not_contains "$(cat "$log")" '<bypassPermissions>' "grok adapter must not bypass permissions on forced resume"
665
+ assert_contains "$(cat "$log")" 'FIRSTMATE_OP: v1 turn-end-guard: TURN WOULD END BLIND' "grok adapter must retain the typed guard kind"
666
+ pass "fm-turnend-guard-grok: forces one explicitly marked same-session resume when the shared predicate blocks"
667
+ }
668
+
669
+ test_grok_adapter_loop_guard_skips_resume() {
670
+ local dir fakebin log out status
671
+ dir=$(make_primary_dir "$TMP_ROOT/grok-adapter-loop")
672
+ : > "$dir/state/task1.meta"
673
+ fakebin=$(fm_fakebin "$TMP_ROOT/grok-adapter-loop-fakebin")
674
+ log="$TMP_ROOT/grok-adapter-loop-call.log"
675
+ cat > "$fakebin/grok" <<EOF
676
+ #!/usr/bin/env bash
677
+ printf 'called\n' >> "$log"
678
+ EOF
679
+ chmod +x "$fakebin/grok"
680
+ out=$(printf '{"sessionId":"session-test","hookEventName":"stop"}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" GROK_TURNEND_GUARD_ACTIVE=1 bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$?
681
+ expect_code 0 "$status" "grok adapter must allow its own forced resume turn to end"
682
+ [ -z "$out" ] || fail "grok adapter printed output while loop-guarded: $out"
683
+ [ ! -e "$log" ] || fail "grok adapter spawned another resume while loop-guarded: $(cat "$log")"
684
+ pass "fm-turnend-guard-grok: legacy environment loop guard prevents a nested resume loop"
685
+ }
686
+
687
+ test_grok_adapter_native_false_blocks_without_resume() {
688
+ local dir fakebin log out status
689
+ dir=$(make_primary_dir "$TMP_ROOT/grok-native-false")
690
+ : > "$dir/state/task1.meta"
691
+ fakebin=$(fm_fakebin "$TMP_ROOT/grok-native-false-bin")
692
+ log="$TMP_ROOT/grok-native-false.log"
693
+ printf '#!/usr/bin/env bash\nprintf called >> %q\n' "$log" > "$fakebin/grok"
694
+ chmod +x "$fakebin/grok"
695
+ out=$(printf '%s' '{"sessionId":"native","stopHookActive":false}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$?
696
+ expect_code 2 "$status" "native stopHookActive=false must return the shared blocking status"
697
+ assert_contains "$out" 'TURN WOULD END BLIND' "native block must pass shared guard feedback to Grok"
698
+ [ ! -e "$log" ] || fail "native path started grok --resume"
699
+ pass "fm-turnend-guard-grok: native false delegates blocking feedback with zero resume processes"
700
+ }
701
+
702
+ test_grok_adapter_native_true_allows_without_resume() {
703
+ local dir fakebin log out status
704
+ dir=$(make_primary_dir "$TMP_ROOT/grok-native-true")
705
+ : > "$dir/state/task1.meta"
706
+ fakebin=$(fm_fakebin "$TMP_ROOT/grok-native-true-bin")
707
+ log="$TMP_ROOT/grok-native-true.log"
708
+ printf '#!/usr/bin/env bash\nprintf called >> %q\n' "$log" > "$fakebin/grok"
709
+ chmod +x "$fakebin/grok"
710
+ out=$(printf '%s' '{"sessionId":"native","stopHookActive":true}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$?
711
+ expect_code 0 "$status" "native stopHookActive=true must allow the bounded continuation to stop"
712
+ [ -z "$out" ] || fail "native true produced output: $out"
713
+ [ ! -e "$log" ] || fail "native true started grok --resume"
714
+ pass "fm-turnend-guard-grok: native true remains bounded and starts no resume process"
715
+ }
716
+
717
+ test_grok_adapter_snake_case_native_and_camel_precedence() {
718
+ local dir out status
719
+ dir=$(make_primary_dir "$TMP_ROOT/grok-native-spellings")
720
+ : > "$dir/state/task1.meta"
721
+ out=$(printf '%s' '{"sessionId":"native","stop_hook_active":false}' | GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$?
722
+ expect_code 2 "$status" "typed snake_case false must select native blocking"
723
+ assert_contains "$out" 'TURN WOULD END BLIND' "snake_case native block lost feedback"
724
+ out=$(printf '%s' '{"sessionId":"native","stopHookActive":true,"stop_hook_active":false}' | GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$?
725
+ expect_code 0 "$status" "camelCase true must win over snake_case false"
726
+ out=$(printf '%s' '{"sessionId":"native","stopHookActive":false,"stop_hook_active":true}' | GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$?
727
+ expect_code 2 "$status" "camelCase false must win over snake_case true"
728
+ pass "fm-turnend-guard-grok: both spellings are typed and camelCase has deterministic precedence"
729
+ }
730
+
731
+ test_grok_adapter_invalid_inputs_start_neither_path() {
732
+ local dir fakebin log payload out status
733
+ dir=$(make_primary_dir "$TMP_ROOT/grok-invalid-inputs")
734
+ : > "$dir/state/task1.meta"
735
+ fakebin=$(fm_fakebin "$TMP_ROOT/grok-invalid-bin")
736
+ log="$TMP_ROOT/grok-invalid.log"
737
+ printf '#!/usr/bin/env bash\nprintf called >> %q\n' "$log" > "$fakebin/grok"
738
+ chmod +x "$fakebin/grok"
739
+ for payload in \
740
+ ' ' \
741
+ '{' \
742
+ '{"sessionId":"x","stopHookActive":"false"}' \
743
+ '{"sessionId":"x","stop_hook_active":1}' \
744
+ '{"sessionId":"x"}{"sessionId":"y"}' \
745
+ '{"sessionId":"x","stopHookActive":false}{"sessionId":"y","stopHookActive":false}' \
746
+ '{"sessionId":"x","stopHookActive":"bad","stopHookActive":false}' \
747
+ '{"sessionId":"x","stop_hook_active":false,"stop_hook_active":false}' \
748
+ '{"sessionId":"x","sessionId":"y"}'
749
+ do
750
+ out=$(printf '%s' "$payload" | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$?
751
+ expect_code 0 "$status" "invalid Grok payload must conservatively allow without choosing a path"
752
+ [ -z "$out" ] || fail "invalid Grok payload produced output: $out"
753
+ done
754
+ [ ! -e "$log" ] || fail "invalid Grok payload started a resume process"
755
+ out=$(printf '%s' '{"sessionId":"x","stopHookActive":false}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$TMP_ROOT/missing-grok-root" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$?
756
+ expect_code 0 "$status" "missing shared-guard prerequisite must conservatively allow"
757
+ [ -z "$out" ] || fail "missing prerequisite produced output: $out"
758
+ [ ! -e "$log" ] || fail "missing prerequisite started a resume process"
759
+ pass "fm-turnend-guard-grok: malformed, invalidly typed, and missing-prerequisite payloads start neither path"
760
+ }
761
+
762
+ test_grok_adapter_missing_jq_and_no_supervision_allow() {
763
+ local dir fakebin log out status tool tool_path
764
+ dir=$(make_primary_dir "$TMP_ROOT/grok-nojq")
765
+ : > "$dir/state/task1.meta"
766
+ fakebin=$(fm_fakebin "$TMP_ROOT/grok-nojq-bin")
767
+ log="$TMP_ROOT/grok-nojq.log"
768
+ for tool in bash cat printf; do
769
+ tool_path=$(command -v "$tool") || fail "test host must provide $tool"
770
+ ln -s "$tool_path" "$fakebin/$tool"
771
+ done
772
+ printf '#!/usr/bin/env bash\nprintf called >> %q\n' "$log" > "$fakebin/grok"
773
+ chmod +x "$fakebin/grok"
774
+ out=$(printf '%s' '{"sessionId":"x","stopHookActive":false}' | PATH="$fakebin" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$?
775
+ expect_code 0 "$status" "missing jq must conservatively allow"
776
+ [ -z "$out" ] || fail "missing jq produced output: $out"
777
+ [ ! -e "$log" ] || fail "missing jq started a resume process"
778
+
779
+ dir=$(make_primary_dir "$TMP_ROOT/grok-native-no-work")
780
+ out=$(printf '%s' '{"sessionId":"x","stopHookActive":false}' | GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$?
781
+ expect_code 0 "$status" "healthy no-supervision-needed native stop must allow"
782
+ [ -z "$out" ] || fail "no-supervision-needed native stop produced output: $out"
783
+ pass "fm-turnend-guard-grok: missing jq and no-supervision-needed stops stay silent and bounded"
784
+ }
785
+
786
+ # Grok loads Claude-compatible settings, so a TRACKED .claude/settings.json entry
787
+ # that also has a .grok/hooks/ counterpart must refuse to run under Grok, or the
788
+ # home gets a duplicate path. The regression this pins: the guard once tested
789
+ # GROK_AGENT alone, which a grok 1.0.0 HOOK process does not carry, so the
790
+ # Claude-only Stop auto-arm ran synchronously under Grok, foregrounded the
791
+ # watcher, and wedged the Grok turn for its declared 28800-second timeout.
792
+ #
793
+ # bin/fm-subagent-pretool-check.sh is the deliberate exception: Grok has no
794
+ # counterpart registration, so guarding it would REMOVE the guard from Grok
795
+ # rather than deduplicate it (docs/subagent-guard.md "Known residual gap").
796
+ # It is asserted to stay unguarded so the exception cannot be closed silently.
797
+ test_tracked_claude_entries_inert_under_grok() {
798
+ local dir cmd script target guarded=0 unguarded=0
799
+ command -v jq >/dev/null 2>&1 || fail "test host must provide jq"
800
+ dir="$TMP_ROOT/claude-entries-grok-inert"
801
+ mkdir -p "$dir/bin"
802
+ for script in fm-turnend-guard.sh fm-claude-stop-autoarm.sh fm-sessionstart-run.sh \
803
+ fm-arm-pretool-check.sh fm-cd-pretool-check.sh fm-subagent-pretool-check.sh; do
804
+ printf '#!/usr/bin/env bash\nprintf ran >> %q\n' "$dir/invoked" > "$dir/bin/$script"
805
+ chmod +x "$dir/bin/$script"
806
+ done
807
+
808
+ # Runs one tracked command string and reports whether it reached its script.
809
+ ran_under() {
810
+ rm -f "$dir/invoked"
811
+ env "$@" CLAUDE_PROJECT_DIR="$dir" bash -c "$cmd" </dev/null >/dev/null 2>&1
812
+ [ -e "$dir/invoked" ]
813
+ }
814
+
815
+ while IFS= read -r cmd; do
816
+ [ -n "$cmd" ] || continue
817
+ target=$(printf '%s\n' "$cmd" | sed -n 's|.*/bin/\([a-z0-9-]*\.sh\).*|\1|p')
818
+ [ -n "$target" ] || fail "could not identify the target script of tracked entry: $cmd"
819
+
820
+ # Native Claude: EVERY tracked entry must still reach its script, or a guard
821
+ # has silently disarmed Claude's own protection.
822
+ ran_under -u GROK_AGENT -u GROK_HOOK_EVENT -u GROK_HOOK_NAME -u GROK_SESSION_ID \
823
+ -u GROK_WORKSPACE_ROOT \
824
+ || fail "tracked entry for $target did not run under a native Claude environment"
825
+
826
+ if [ "$target" = fm-subagent-pretool-check.sh ]; then
827
+ unguarded=$((unguarded + 1))
828
+ ran_under -u GROK_AGENT GROK_HOOK_EVENT=pre_tool_use GROK_SESSION_ID=grok-test-session \
829
+ || fail "the documented $target exception must stay unguarded; Grok has no counterpart to fall back to"
830
+ continue
831
+ fi
832
+
833
+ guarded=$((guarded + 1))
834
+ # grok 1.0.0 hook process: hook markers present, GROK_AGENT absent.
835
+ ! ran_under -u GROK_AGENT GROK_HOOK_EVENT=stop \
836
+ GROK_HOOK_NAME='project/settings:stop[0].hooks[0]' \
837
+ GROK_SESSION_ID=grok-test-session GROK_WORKSPACE_ROOT="$dir" \
838
+ || fail "tracked entry for $target ran under a grok 1.0.0 hook environment"
839
+ # grok 0.2.73 child/tool process: GROK_AGENT present, hook markers absent.
840
+ ! ran_under -u GROK_HOOK_EVENT -u GROK_HOOK_NAME GROK_AGENT=1 \
841
+ || fail "tracked entry for $target ran under a legacy GROK_AGENT environment"
842
+ done < <(jq -r '.hooks[][].hooks[].command' "$ROOT/.claude/settings.json")
843
+
844
+ [ "$guarded" -eq 5 ] || fail "expected 5 grok-guarded tracked entries, saw $guarded"
845
+ [ "$unguarded" -eq 1 ] || fail "expected 1 documented unguarded tracked entry, saw $unguarded"
846
+ pass "tracked .claude/settings.json entries: $guarded inert under grok, the documented subagent exception still armed, all live under Claude"
847
+ }
848
+
849
+ test_codex_hook_uses_process_pwd_when_payload_cwd_is_outside_root() {
850
+ local settings command dir expected_root outside payload out status
851
+ settings="$ROOT/.codex/hooks.json"
852
+ [ -f "$settings" ] || fail "tracked .codex/hooks.json is missing"
853
+ command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings")
854
+ [ -n "$command" ] || fail "Stop hook command is missing from .codex/hooks.json"
855
+ dir=$(make_primary_dir "$TMP_ROOT/codex-hook-root")
856
+ mark_codex_hook_root "$dir"
857
+ expected_root=$(cd "$dir" && pwd -P)
858
+ outside="$TMP_ROOT/codex-hook-outside"
859
+ mkdir -p "$outside"
860
+ cat > "$dir/bin/fm-turnend-guard.sh" <<'EOF'
861
+ #!/usr/bin/env bash
862
+ printf 'guard=%s\n' "$0"
863
+ cat
864
+ EOF
865
+ chmod +x "$dir/bin/fm-turnend-guard.sh"
866
+ payload=$(jq -cn --arg cwd "$outside" '{cwd:$cwd,stop_hook_active:false}')
867
+ out=$(printf '%s' "$payload" | (cd "$dir" && bash -c "$command") 2>&1); status=$?
868
+ expect_code 0 "$status" "codex hook must execute successfully when payload cwd is outside the firstmate root"
869
+ assert_contains "$out" "guard=$expected_root/bin/fm-turnend-guard.sh" "codex hook must use the hook process root"
870
+ assert_contains "$out" "$payload" "codex hook must pass the original payload to the guard"
871
+ pass ".codex/hooks.json: Stop hook uses hook process root when payload cwd is outside"
872
+ }
873
+
874
+ test_codex_hook_ignores_nested_git_root_guard() {
875
+ local settings command dir nested subdir expected_root payload out status
876
+ settings="$ROOT/.codex/hooks.json"
877
+ [ -f "$settings" ] || fail "tracked .codex/hooks.json is missing"
878
+ command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings")
879
+ [ -n "$command" ] || fail "Stop hook command is missing from .codex/hooks.json"
880
+ dir=$(make_primary_dir "$TMP_ROOT/codex-hook-outer")
881
+ mark_codex_hook_root "$dir"
882
+ expected_root=$(cd "$dir" && pwd -P)
883
+ nested="$dir/projects/other"
884
+ mkdir -p "$nested"
885
+ git init -q "$nested"
886
+ git -C "$nested" commit -q --allow-empty -m init
887
+ mkdir -p "$nested/bin" "$nested/.codex"
888
+ : > "$nested/AGENTS.md"
889
+ printf '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"fm-turnend-guard.sh"}]}]}}\n' > "$nested/.codex/hooks.json"
890
+ cat > "$nested/bin/fm-turnend-guard.sh" <<'EOF'
891
+ #!/usr/bin/env bash
892
+ printf 'nested guard executed\n'
893
+ exit 99
894
+ EOF
895
+ chmod +x "$nested/bin/fm-turnend-guard.sh"
896
+ cat > "$dir/bin/fm-turnend-guard.sh" <<'EOF'
897
+ #!/usr/bin/env bash
898
+ printf 'guard=%s\n' "$0"
899
+ cat
900
+ EOF
901
+ chmod +x "$dir/bin/fm-turnend-guard.sh"
902
+ subdir="$nested/deep/path"
903
+ mkdir -p "$subdir"
904
+ payload=$(jq -cn --arg cwd "$subdir" '{cwd:$cwd,stop_hook_active:false}')
905
+ out=$(printf '%s' "$payload" | (cd "$dir" && bash -c "$command") 2>&1); status=$?
906
+ expect_code 0 "$status" "codex hook must not execute a nested project guard"
907
+ assert_contains "$out" "guard=$expected_root/bin/fm-turnend-guard.sh" "codex hook must keep using the outer firstmate guard"
908
+ assert_not_contains "$out" "nested guard executed" "codex hook must not execute nested project code"
909
+ pass ".codex/hooks.json: Stop hook ignores nested git root guard scripts"
910
+ }
911
+
912
+ test_opencode_plugin_anchors_guard_to_worktree() {
913
+ local plugin parent worktree_dir wrong_dir out status
914
+ plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js"
915
+ [ -f "$plugin" ] || fail "tracked OpenCode primary plugin is missing"
916
+ parent="$TMP_ROOT/opencode-plugin-parent"
917
+ git init -q "$parent"
918
+ worktree_dir="$parent/nested/opencode-plugin-worktree"
919
+ wrong_dir="$TMP_ROOT/opencode-plugin-cwd/subdir"
920
+ mkdir -p "$worktree_dir/bin" "$wrong_dir"
921
+ cat > "$worktree_dir/bin/fm-turnend-guard.sh" <<'EOF'
922
+ #!/usr/bin/env bash
923
+ cat >/dev/null
924
+ printf 'guard-fired\n' >&2
925
+ exit 2
926
+ EOF
927
+ chmod +x "$worktree_dir/bin/fm-turnend-guard.sh"
928
+ # Runtime module-format warnings are host noise; this assertion owns plugin output only.
929
+ out=$(NODE_NO_WARNINGS=1 PLUGIN="$plugin" DIRECTORY="$wrong_dir" WORKTREE="$worktree_dir" node 2>&1 <<'EOF'
930
+ import { pathToFileURL } from "node:url";
931
+
932
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
933
+ let promptBody = "";
934
+ const client = {
935
+ session: {
936
+ promptAsync: async (request) => {
937
+ promptBody = request.body.parts[0].text;
938
+ },
939
+ },
940
+ };
941
+ const hooks = await mod.FmPrimaryTurnendGuard({
942
+ client,
943
+ directory: process.env.DIRECTORY,
944
+ worktree: process.env.WORKTREE,
945
+ });
946
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
947
+ if (!promptBody.startsWith("\u2063FIRSTMATE_OP: v1 turn-end-guard: ")) {
948
+ console.error(`untyped operational prompt: ${promptBody}`);
949
+ process.exit(1);
950
+ }
951
+ if (!promptBody.includes("guard-fired")) {
952
+ console.error(`missing prompt body: ${promptBody}`);
953
+ process.exit(1);
954
+ }
955
+ if (!promptBody.includes("watcher cycle is missing, failed, or unhealthy")) {
956
+ console.error(`missing recovery-only preamble: ${promptBody}`);
957
+ process.exit(1);
958
+ }
959
+ if (promptBody.includes("Resume supervision according to the session-start operating block")) {
960
+ console.error(`ordinary continuity leaked into guard follow-up: ${promptBody}`);
961
+ process.exit(1);
962
+ }
963
+ EOF
964
+ )
965
+ status=$?
966
+ expect_code 0 "$status" "OpenCode plugin must run the guard from worktree even when directory is elsewhere"
967
+ [ -z "$out" ] || fail "OpenCode plugin worktree-root test printed output: $out"
968
+ pass ".opencode primary plugin: guard path is anchored to worktree, not directory"
969
+ }
970
+
971
+ test_pi_extension_injects_once_per_logical_agent_run() {
972
+ local repo home ext log out status
973
+ repo="$TMP_ROOT/pi-logical-run-root"
974
+ home="$TMP_ROOT/pi-logical-run-home"
975
+ ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts"
976
+ log="$TMP_ROOT/pi-logical-run-guard.log"
977
+ mkdir -p "$repo/.pi/extensions/lib" "$repo/bin" "$home/state"
978
+ cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$ext"
979
+ cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts"
980
+ cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh"
981
+ cat > "$repo/bin/fm-turnend-guard.sh" <<'SH'
982
+ #!/usr/bin/env bash
983
+ cat >/dev/null
984
+ printf 'guard\n' >> "${FM_GUARD_LOG:?}"
985
+ printf 'logical-run guard fired\n' >&2
986
+ exit 2
987
+ SH
988
+ cat > "$repo/bin/fm-arm-pretool-check.sh" <<'SH'
989
+ #!/usr/bin/env bash
990
+ exit 0
991
+ SH
992
+ chmod +x "$repo/bin/fm-turnend-guard.sh" "$repo/bin/fm-arm-pretool-check.sh"
993
+ out=$(PLUGIN="$ext" FM_HOME="$home" FM_GUARD_LOG="$log" node --input-type=module 2>&1 <<'EOF'
994
+ import { readFileSync } from "node:fs";
995
+ import { pathToFileURL } from "node:url";
996
+
997
+ const handlers = new Map();
998
+ let prompts = 0;
999
+ const pi = {
1000
+ on(event, handler) {
1001
+ handlers.set(event, handler);
1002
+ },
1003
+ async sendUserMessage(message, options) {
1004
+ prompts += 1;
1005
+ if (!message.startsWith("\u2063FIRSTMATE_OP: v1 turn-end-guard: ")) throw new Error(`untyped operational prompt: ${message}`);
1006
+ if (!message.includes("TURN WOULD END BLIND")) throw new Error(`unexpected prompt: ${message}`);
1007
+ if (!message.includes("watcher cycle is missing, failed, or unhealthy")) throw new Error(`guard prompt omitted recovery-only state: ${message}`);
1008
+ if (message.includes("Resume supervision according to the session-start operating block")) throw new Error(`guard prompt used ordinary continuity: ${message}`);
1009
+ if (options?.deliverAs !== "followUp") throw new Error("guard prompt was not a follow-up");
1010
+ await handlers.get("agent_settled")?.({ type: "agent_settled" }, {});
1011
+ },
1012
+ };
1013
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1014
+ mod.default(pi);
1015
+ if (handlers.has("turn_end")) throw new Error("guard still treats internal Pi turns as logical runs");
1016
+ const settled = handlers.get("agent_settled");
1017
+ if (!settled) throw new Error("agent_settled handler was not registered");
1018
+
1019
+ await settled({ type: "agent_settled" }, {});
1020
+ if (prompts !== 1) throw new Error(`no-tool run injected ${prompts} follow-ups`);
1021
+
1022
+ for (let i = 0; i < 3; i += 1) {
1023
+ await handlers.get("turn_end")?.({ type: "turn_end", turnIndex: i }, {});
1024
+ }
1025
+ await settled({ type: "agent_settled" }, {});
1026
+ if (prompts !== 2) throw new Error(`multi-tool run produced ${prompts - 1} follow-ups`);
1027
+
1028
+ const guardRuns = readFileSync(process.env.FM_GUARD_LOG, "utf8").trim().split("\n").length;
1029
+ if (guardRuns !== 2) throw new Error(`guard predicate ran ${guardRuns} times for two logical runs`);
1030
+ EOF
1031
+ )
1032
+ status=$?
1033
+ expect_code 0 "$status" "Pi guard must inject once for no-tool and multi-tool logical runs"
1034
+ [ -z "$out" ] || fail "Pi logical-run guard test printed output: $out"
1035
+ pass ".pi primary extension: no-tool and multi-tool runs each inject exactly one guard follow-up"
1036
+ }
1037
+
1038
+ test_pi_extension_retries_after_followup_delivery_failure() {
1039
+ local repo home ext out status
1040
+ repo="$TMP_ROOT/pi-delivery-failure-root"
1041
+ home="$TMP_ROOT/pi-delivery-failure-home"
1042
+ ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts"
1043
+ mkdir -p "$repo/.pi/extensions/lib" "$repo/bin" "$home/state"
1044
+ cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$ext"
1045
+ cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts"
1046
+ cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh"
1047
+ cat > "$repo/bin/fm-turnend-guard.sh" <<'SH'
1048
+ #!/usr/bin/env bash
1049
+ cat >/dev/null
1050
+ printf 'delivery failure guard\n' >&2
1051
+ exit 2
1052
+ SH
1053
+ cat > "$repo/bin/fm-arm-pretool-check.sh" <<'SH'
1054
+ #!/usr/bin/env bash
1055
+ exit 0
1056
+ SH
1057
+ chmod +x "$repo/bin/fm-turnend-guard.sh" "$repo/bin/fm-arm-pretool-check.sh"
1058
+ out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF'
1059
+ import { pathToFileURL } from "node:url";
1060
+
1061
+ const handlers = new Map();
1062
+ let attempts = 0;
1063
+ const pi = {
1064
+ on(event, handler) {
1065
+ handlers.set(event, handler);
1066
+ },
1067
+ async sendUserMessage() {
1068
+ attempts += 1;
1069
+ if (attempts === 1) throw new Error("synthetic delivery failure");
1070
+ await handlers.get("agent_settled")?.({ type: "agent_settled" }, {});
1071
+ },
1072
+ };
1073
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1074
+ mod.default(pi);
1075
+ const settled = handlers.get("agent_settled");
1076
+ await settled({ type: "agent_settled" }, {});
1077
+ await settled({ type: "agent_settled" }, {});
1078
+ if (attempts !== 2) throw new Error(`expected delivery retry, saw ${attempts} attempts`);
1079
+ EOF
1080
+ )
1081
+ status=$?
1082
+ expect_code 0 "$status" "Pi guard latch must reset after follow-up delivery failure"
1083
+ [ -z "$out" ] || fail "Pi delivery-failure guard test printed output: $out"
1084
+ pass ".pi primary extension: delivery failure resets the logical-run latch"
1085
+ }
1086
+
1087
+ # --- --claude cooperative mode -----------------------------------------------
1088
+ # In --claude mode the guard ignores stop_hook_active (Claude marks every stop
1089
+ # after ANY stop-hook continuation true, including asyncRewake rewake turns) and
1090
+ # cooperates with the Stop-owned auto-arm instead: allow on health, live owner
1091
+ # claim, or a fresh rewake epoch; bounded re-block only when none materialize.
1092
+
1093
+ run_hook_claude() {
1094
+ local dir=$1 stop_active=$2 home
1095
+ home=$(cd "$dir" && pwd)
1096
+ printf '{"stop_hook_active":%s,"session_id":"sess-claude-mode"}' "$stop_active" | CLAUDECODE=1 FM_HOME="$home" bash "$dir/bin/fm-turnend-guard.sh" --claude 2>&1
1097
+ }
1098
+
1099
+ seed_claude_failure() {
1100
+ local dir=$1 outcome=${2:-failed-suppressed}
1101
+ : > "$dir/state/.claude-autoarm-failure-notified"
1102
+ printf 'epoch=3 owner_pid=999 outcome=%s updated_at=1\n' "$outcome" > "$dir/state/.claude-autoarm-epoch"
1103
+ touch -t 202001010000 "$dir/state/.claude-autoarm-epoch"
1104
+ }
1105
+
1106
+ seed_claude_budget() {
1107
+ local dir=$1 count=$2 epoch=${3:-2}
1108
+ printf 'session=sess-claude-mode\ncount=%s\nepoch=%s\n' "$count" "$epoch" > "$dir/state/.turnend-claude-blocks"
1109
+ }
1110
+
1111
+ record_autoarm_owner() {
1112
+ local dir=$1 pid=$2
1113
+ mkdir -p "$dir/state/.claude-autoarm.lock"
1114
+ printf '%s\n' "$pid" > "$dir/state/.claude-autoarm.lock/pid"
1115
+ printf 'autoarm\n' > "$dir/state/.claude-autoarm.lock/role"
1116
+ }
1117
+
1118
+ install_integrated_autoarm() {
1119
+ local dir=$1
1120
+ cp "$ROOT/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-claude-stop-autoarm.sh"
1121
+ cp "$ROOT/bin/fm-primary-scope-lib.sh" "$dir/bin/fm-primary-scope-lib.sh"
1122
+ cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh"
1123
+ cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh"
1124
+ cp "$ROOT/bin/fm-hook-host-lib.sh" "$dir/bin/fm-hook-host-lib.sh"
1125
+ cp "$ROOT/bin/fm-session-lock-lib.sh" "$dir/bin/fm-session-lock-lib.sh"
1126
+ cp "$ROOT/bin/fm-cursor-lib.sh" "$dir/bin/fm-cursor-lib.sh"
1127
+ cp "$ROOT/bin/fm-lock.sh" "$dir/bin/fm-lock.sh"
1128
+ chmod +x "$dir/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-lock.sh"
1129
+ ln -s /bin/bash "$dir/fake-claude"
1130
+ }
1131
+
1132
+ run_integrated_autoarm() {
1133
+ local dir=$1 home
1134
+ home=$(cd "$dir" && pwd)
1135
+ # shellcheck disable=SC2016 # the fake harness expands FM_HOME inside its child shell.
1136
+ printf '{"session_id":"sess-claude-mode","stop_hook_active":false}\n' \
1137
+ | FM_HOME="$home" "$dir/fake-claude" -c '
1138
+ printf "%s\n" "$$" > "$FM_HOME/state/.lock"
1139
+ "$FM_HOME/bin/fm-claude-stop-autoarm.sh"
1140
+ ' 2>&1
1141
+ }
1142
+
1143
+ write_integrated_failed_arm() {
1144
+ local dir=$1
1145
+ cat > "$dir/bin/fm-watch-arm.sh" <<'SH'
1146
+ #!/usr/bin/env bash
1147
+ printf 'watcher: FAILED - persistent fixture failure\n'
1148
+ exit 1
1149
+ SH
1150
+ chmod +x "$dir/bin/fm-watch-arm.sh"
1151
+ }
1152
+
1153
+ # The 2026-07-21 incident regression: after a spent forced continuation the old
1154
+ # one-shot loop guard ALLOWED a blind stop (stop_hook_active=true) while the
1155
+ # watcher was already dead. In --claude mode the guard must re-block instead.
1156
+ test_hook_claude_mode_reblocks_stop_hook_active_when_unhealthy() {
1157
+ local dir out status
1158
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-reblock")
1159
+ : > "$dir/state/task1.meta"
1160
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=200 run_hook_claude "$dir" true); status=$?
1161
+ expect_code 2 "$status" "--claude mode must re-block a stop_hook_active=true stop while unhealthy with no auto-arm claim"
1162
+ assert_contains "$out" "TURN WOULD END BLIND" "--claude re-block must carry the blind-turn banner"
1163
+ assert_contains "$out" "Stop-owned auto-arm did not claim" "--claude re-block must explain the missing auto-arm claim"
1164
+ pass "fm-turnend-guard --claude: re-blocks a loop-guarded stop while unhealthy and unclaimed (incident regression)"
1165
+ }
1166
+
1167
+ test_hook_claude_mode_reblocks_x_mode_without_tasks() {
1168
+ local dir out status
1169
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-x-mode")
1170
+ : > "$dir/state/x-watch.check.sh"
1171
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=200 run_hook_claude "$dir" true); status=$?
1172
+ expect_code 2 "$status" "--claude mode must re-block an X-mode-only stop when no auto-arm claims recovery"
1173
+ assert_contains "$out" "X-mode relay polling needs supervision" "--claude X-mode re-block must name the active supervision need"
1174
+ [ -f "$dir/state/.turnend-claude-blocks" ] || fail "--claude X-mode re-block must consume the shared block budget"
1175
+ pass "fm-turnend-guard --claude: X-mode-only homes re-block when auto-arm recovery is absent"
1176
+ }
1177
+
1178
+ test_hook_claude_mode_allows_when_autoarm_owner_alive() {
1179
+ local dir pid out out2 status status2 count count2
1180
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-owner")
1181
+ : > "$dir/state/task1.meta"
1182
+ seed_claude_failure "$dir"
1183
+ seed_claude_budget "$dir" 3
1184
+ sleep 60 &
1185
+ pid=$!
1186
+ record_autoarm_owner "$dir" "$pid"
1187
+ out=$(run_hook_claude "$dir" false); status=$?
1188
+ count=$(sed -n '2s/^count=//p' "$dir/state/.turnend-claude-blocks")
1189
+ out2=$(run_hook_claude "$dir" false); status2=$?
1190
+ count2=$(sed -n '2s/^count=//p' "$dir/state/.turnend-claude-blocks")
1191
+ kill "$pid" 2>/dev/null || true
1192
+ wait "$pid" 2>/dev/null || true
1193
+ expect_code 0 "$status" "--claude mode must allow when the auto-arm owner process is alive"
1194
+ expect_code 0 "$status2" "--claude mode must keep allowing the same live auto-arm epoch"
1195
+ [ -z "$out" ] || fail "--claude owner-claimed allow produced output: $out"
1196
+ [ -z "$out2" ] || fail "repeated same-owner allow produced output: $out2"
1197
+ [ "$count" = 4 ] || fail "new live auto-arm epoch did not advance failure progression from 3 to 4: $count"
1198
+ [ "$count2" = 4 ] || fail "repeated observation advanced the same auto-arm epoch twice: $count2"
1199
+ assert_present "$dir/state/.claude-autoarm-failure-notified" "live auto-arm owner cleared the failure episode"
1200
+ assert_absent "$dir/state/.claude-autoarm-failure-alarmed" "live automatic continuation emitted the attended fail-open alarm"
1201
+ pass "fm-turnend-guard --claude: a live arming epoch advances once and repeated observation is idempotent"
1202
+ }
1203
+
1204
+ test_hook_claude_mode_repeated_failed_to_arming_interleavings_reach_fail_open() {
1205
+ local dir out status pid i count epoch
1206
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-arming-interleavings")
1207
+ : > "$dir/state/task1.meta"
1208
+ : > "$dir/state/.claude-autoarm-failure-notified"
1209
+ printf 'epoch=3 owner_pid=999 outcome=failed updated_at=%s\n' "$(date +%s)" > "$dir/state/.claude-autoarm-epoch"
1210
+ out=$(run_hook_claude "$dir" true); status=$?
1211
+ expect_code 0 "$status" "the first verified failed epoch must own its automatic handoff"
1212
+
1213
+ epoch=3
1214
+ for i in 1 2 3 4; do
1215
+ epoch=$((epoch + 1))
1216
+ sleep 60 &
1217
+ pid=$!
1218
+ record_autoarm_owner "$dir" "$pid"
1219
+ printf 'epoch=%s owner_pid=%s outcome=arming updated_at=%s\n' "$epoch" "$pid" "$(date +%s)" > "$dir/state/.claude-autoarm-epoch"
1220
+ out=$(run_hook_claude "$dir" true); status=$?
1221
+ expect_code 0 "$status" "active arming epoch $i must own its Stop while advancing the failure budget"
1222
+ count=$(sed -n '2s/^count=//p' "$dir/state/.turnend-claude-blocks")
1223
+ [ "$count" = "$i" ] || fail "arming epoch $i produced non-monotonic count $count"
1224
+ kill "$pid" 2>/dev/null || true
1225
+ wait "$pid" 2>/dev/null || true
1226
+ rm -rf "$dir/state/.claude-autoarm.lock"
1227
+ epoch=$((epoch + 1))
1228
+ printf 'epoch=%s owner_pid=999 outcome=failed-suppressed updated_at=%s\n' "$epoch" "$(date +%s)" > "$dir/state/.claude-autoarm-epoch"
1229
+ done
1230
+
1231
+ out=$(run_hook_claude "$dir" true); status=$?
1232
+ expect_code 0 "$status" "repeated failed-to-arming interleavings must reach terminal fail-open"
1233
+ assert_contains "$out" 'FIRSTMATE SUPERVISION IS GENUINELY DOWN' "arming interleavings stalled before the bounded fail-open"
1234
+ assert_present "$dir/state/.claude-autoarm-failure-alarmed" "arming interleavings did not consume the one-time alarm"
1235
+ pass "fm-turnend-guard --claude: repeated failed-to-arming races make bounded monotonic progress"
1236
+ }
1237
+
1238
+ test_hook_claude_mode_terminal_boundary_excludes_starting_owner() {
1239
+ local dir fakebin ready release once guard_out guard_status auto_out auto_status guard_pid
1240
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-terminal-boundary")
1241
+ : > "$dir/state/task1.meta"
1242
+ : > "$dir/state/.claude-autoarm-failure-notified"
1243
+ printf 'epoch=3 owner_pid=999 outcome=failed-suppressed updated_at=%s\n' "$(date +%s)" > "$dir/state/.claude-autoarm-epoch"
1244
+ seed_claude_budget "$dir" 4 3
1245
+ install_integrated_autoarm "$dir"
1246
+ write_integrated_failed_arm "$dir"
1247
+ fakebin="$dir/fakebin"
1248
+ ready="$dir/terminal-ready"
1249
+ release="$dir/terminal-release"
1250
+ once="$dir/terminal-once"
1251
+ guard_out="$dir/guard.out"
1252
+ guard_status="$dir/guard.status"
1253
+ mkdir -p "$fakebin"
1254
+ mkfifo "$ready" "$release"
1255
+ cat > "$fakebin/cat" <<'SH'
1256
+ #!/usr/bin/env bash
1257
+ if [ "$1" = "$FM_TERMINAL_ROLE_PATH" ] \
1258
+ && [ "$(/bin/cat "$1" 2>/dev/null || true)" = terminal-check ] \
1259
+ && (set -C; : > "$FM_TERMINAL_ONCE") 2>/dev/null; then
1260
+ printf 'ready\n' > "$FM_TERMINAL_READY"
1261
+ IFS= read -r _ < "$FM_TERMINAL_RELEASE"
1262
+ fi
1263
+ exec /bin/cat "$@"
1264
+ SH
1265
+ chmod +x "$fakebin/cat"
1266
+ (
1267
+ printf '{"stop_hook_active":true,"session_id":"sess-claude-mode"}' \
1268
+ | PATH="$fakebin:$PATH" \
1269
+ FM_TERMINAL_ROLE_PATH="$dir/state/.claude-autoarm.lock/role" \
1270
+ FM_TERMINAL_READY="$ready" \
1271
+ FM_TERMINAL_RELEASE="$release" \
1272
+ FM_TERMINAL_ONCE="$once" \
1273
+ CLAUDECODE=1 FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard.sh" --claude \
1274
+ > "$guard_out" 2>&1
1275
+ printf '%s\n' "$?" > "$guard_status"
1276
+ ) &
1277
+ guard_pid=$!
1278
+ IFS= read -r _ < "$ready"
1279
+ auto_out=$(run_integrated_autoarm "$dir"); auto_status=$?
1280
+ printf 'release\n' > "$release"
1281
+ wait "$guard_pid"
1282
+ expect_code 0 "$auto_status" "an owner starting inside the terminal window must lose the existing owner boundary"
1283
+ [ -z "$auto_out" ] || fail "excluded terminal-window owner produced output: $auto_out"
1284
+ assert_absent "$dir/state/arm-ran" "excluded terminal-window owner started an arm cycle"
1285
+ expect_code 0 "$(cat "$guard_status")" "terminal boundary guard must complete without deadlock"
1286
+ assert_contains "$(cat "$guard_out")" 'FIRSTMATE SUPERVISION IS GENUINELY DOWN' "terminal boundary did not produce the one-time alarm"
1287
+ assert_absent "$dir/state/.claude-autoarm.lock" "terminal boundary left its owner lock behind"
1288
+ pass "fm-turnend-guard --claude: terminal owner boundary excludes a concurrent start without deadlock"
1289
+ }
1290
+
1291
+ test_hook_claude_mode_allows_on_fresh_rewake_epoch() {
1292
+ local dir out status
1293
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-epoch")
1294
+ : > "$dir/state/task1.meta"
1295
+ printf 'epoch=3 owner_pid=999 outcome=rewake updated_at=%s\n' "$(date +%s)" > "$dir/state/.claude-autoarm-epoch"
1296
+ out=$(run_hook_claude "$dir" true); status=$?
1297
+ expect_code 0 "$status" "--claude mode must allow the stop whose rewake the auto-arm already owns"
1298
+ [ -z "$out" ] || fail "--claude rewake-epoch allow produced output: $out"
1299
+ pass "fm-turnend-guard --claude: fresh rewake epoch prevents a duplicate continuation for the same event"
1300
+ }
1301
+
1302
+ # The 2026-08-14 lapse: a cycle armed, delivered one rewake, exited, and left its
1303
+ # owner lock behind holding a live pid. Both Stop participants read that lock as
1304
+ # "recovery is already under way", so with work in flight and a beacon 40 minutes
1305
+ # cold every turn ended blind and nothing re-armed. A stale ledger outcome for
1306
+ # the lock's own pid is the proof that no decision is in flight any more.
1307
+ test_hook_claude_mode_blocks_on_abandoned_autoarm_claim() {
1308
+ local dir out status pid
1309
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-abandoned-claim")
1310
+ : > "$dir/state/task1.meta"
1311
+ : > "$dir/state/task2.meta"
1312
+ sleep 60 &
1313
+ pid=$!
1314
+ record_autoarm_owner "$dir" "$pid"
1315
+ printf 'epoch=464 owner_pid=%s outcome=rewake updated_at=1\n' "$pid" > "$dir/state/.claude-autoarm-epoch"
1316
+ touch -t 202001010000 "$dir/state/.claude-autoarm-epoch"
1317
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=200 run_hook_claude "$dir" true); status=$?
1318
+ kill "$pid" 2>/dev/null || true
1319
+ wait "$pid" 2>/dev/null || true
1320
+ expect_code 2 "$status" "an owner lock left behind by a finished claim must not pass for recovery under way"
1321
+ assert_contains "$out" "TURN WOULD END BLIND" "abandoned-claim block must carry the blind-turn banner"
1322
+ assert_contains "$out" "2 task(s) in flight" "abandoned-claim block must name the unsupervised work"
1323
+ pass "fm-turnend-guard --claude: an abandoned auto-arm claim no longer allows a blind stop (incident regression)"
1324
+ }
1325
+
1326
+ # The ledger-blind variant of the same lapse: a session teardown killed the claim's
1327
+ # process group before it could record any outcome, so its entry still reads
1328
+ # "arming" - in flight however old, by contract - while the recorded pid now belongs
1329
+ # to an unrelated live process. The identity the claim wrote into its own lock is
1330
+ # the only thing that separates that from a real arm still running.
1331
+ test_hook_claude_mode_blocks_on_pid_reused_arming_claim() {
1332
+ local dir out status pid identity
1333
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-reused-pid-claim")
1334
+ : > "$dir/state/task1.meta"
1335
+ : > "$dir/state/task2.meta"
1336
+ sleep 60 &
1337
+ pid=$!
1338
+ record_autoarm_owner "$dir" "$pid"
1339
+ # The claim recorded ITS OWN identity; this test shell now stands in for the
1340
+ # unrelated process that inherited the number.
1341
+ identity=$(fm_test_pid_identity "$$") || fail "could not compute a claim pid-identity"
1342
+ printf '%s\n' "$identity" > "$dir/state/.claude-autoarm.lock/pid-identity"
1343
+ printf 'epoch=464 owner_pid=%s outcome=arming updated_at=1\n' "$pid" > "$dir/state/.claude-autoarm-epoch"
1344
+ touch -t 202001010000 "$dir/state/.claude-autoarm-epoch"
1345
+ : > "$dir/state/.last-watcher-beat"
1346
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=200 run_hook_claude "$dir" true); status=$?
1347
+ kill "$pid" 2>/dev/null || true
1348
+ wait "$pid" 2>/dev/null || true
1349
+ expect_code 2 "$status" "a claim whose recorded identity no longer matches its live pid must not pass for recovery under way"
1350
+ assert_contains "$out" "TURN WOULD END BLIND" "reused-pid claim block must carry the blind-turn banner"
1351
+ assert_contains "$out" "2 task(s) in flight" "reused-pid claim block must name the unsupervised work"
1352
+ pass "fm-turnend-guard --claude: a claim whose pid was reused stops counting as recovery even while its entry reads arming"
1353
+ }
1354
+
1355
+ # The legacy stuck-arming shape (the 2026-08-26 flap): a live identity-matched
1356
+ # lock-holding owner frozen at arming past grace with a beacon just as stale
1357
+ # must not count as recovery under way.
1358
+ test_hook_claude_mode_blocks_on_stuck_arming_claim() {
1359
+ local dir out status pid identity
1360
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-stuck-arming-claim")
1361
+ : > "$dir/state/task1.meta"
1362
+ : > "$dir/state/task2.meta"
1363
+ sleep 60 &
1364
+ pid=$!
1365
+ record_autoarm_owner "$dir" "$pid"
1366
+ identity=$(fm_test_pid_identity "$pid") || fail "could not compute a claim pid-identity"
1367
+ printf '%s\n' "$identity" > "$dir/state/.claude-autoarm.lock/pid-identity"
1368
+ printf 'epoch=464 owner_pid=%s outcome=arming updated_at=1\n' "$pid" > "$dir/state/.claude-autoarm-epoch"
1369
+ touch -t 202001010000 "$dir/state/.claude-autoarm-epoch"
1370
+ touch -t 202001010000 "$dir/state/.last-watcher-beat"
1371
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=200 run_hook_claude "$dir" true); status=$?
1372
+ kill "$pid" 2>/dev/null || true
1373
+ wait "$pid" 2>/dev/null || true
1374
+ expect_code 2 "$status" "a live owner stuck arming past grace with a stale beacon must not pass for recovery under way"
1375
+ assert_contains "$out" "TURN WOULD END BLIND" "stuck-arming claim block must carry the blind-turn banner"
1376
+ assert_contains "$out" "2 task(s) in flight" "stuck-arming claim block must name the unsupervised work"
1377
+ pass "fm-turnend-guard --claude: a hung owner frozen at arming with no watcher beat no longer allows a blind stop"
1378
+ }
1379
+
1380
+ # The generation model's ownership proof: a live open ledger claim (two-line
1381
+ # entry, identity-matched owner, watcher still beating) owns recovery with no
1382
+ # lock held at all.
1383
+ test_hook_claude_mode_allows_on_open_generation_claim() {
1384
+ local dir out status pid identity
1385
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-open-generation")
1386
+ : > "$dir/state/task1.meta"
1387
+ sleep 60 &
1388
+ pid=$!
1389
+ identity=$(fm_test_pid_identity "$pid") || fail "could not compute a claim pid-identity"
1390
+ printf 'epoch=464 owner_pid=%s outcome=arming updated_at=1\n%s\n' "$pid" "$identity" \
1391
+ > "$dir/state/.claude-autoarm-epoch"
1392
+ touch -t 202001010000 "$dir/state/.claude-autoarm-epoch"
1393
+ : > "$dir/state/.last-watcher-beat"
1394
+ [ ! -e "$dir/state/.claude-autoarm.lock" ] || fail "this case must start with no owner lock at all"
1395
+ out=$(run_hook_claude "$dir" false); status=$?
1396
+ kill "$pid" 2>/dev/null || true
1397
+ wait "$pid" 2>/dev/null || true
1398
+ expect_code 0 "$status" "--claude mode must allow when a live open generation claim owns recovery"
1399
+ [ -z "$out" ] || fail "open-generation-claim allow produced output: $out"
1400
+ pass "fm-turnend-guard --claude: a live open generation claim owns recovery with no lock held"
1401
+ }
1402
+
1403
+ # The same claim gone stuck (entry and beacon both past grace) stops counting
1404
+ # as recovery even though its owner is alive and identity-matched.
1405
+ test_hook_claude_mode_blocks_on_stuck_generation_claim() {
1406
+ local dir out status pid identity
1407
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-stuck-generation")
1408
+ : > "$dir/state/task1.meta"
1409
+ : > "$dir/state/task2.meta"
1410
+ sleep 60 &
1411
+ pid=$!
1412
+ identity=$(fm_test_pid_identity "$pid") || fail "could not compute a claim pid-identity"
1413
+ printf 'epoch=464 owner_pid=%s outcome=arming updated_at=1\n%s\n' "$pid" "$identity" \
1414
+ > "$dir/state/.claude-autoarm-epoch"
1415
+ touch -t 202001010000 "$dir/state/.claude-autoarm-epoch"
1416
+ touch -t 202001010000 "$dir/state/.last-watcher-beat"
1417
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=200 run_hook_claude "$dir" true); status=$?
1418
+ kill "$pid" 2>/dev/null || true
1419
+ wait "$pid" 2>/dev/null || true
1420
+ expect_code 2 "$status" "a stuck generation claim must not pass for recovery under way"
1421
+ assert_contains "$out" "TURN WOULD END BLIND" "stuck-generation-claim block must carry the blind-turn banner"
1422
+ assert_contains "$out" "2 task(s) in flight" "stuck-generation-claim block must name the unsupervised work"
1423
+ pass "fm-turnend-guard --claude: a stuck generation claim no longer allows a blind stop"
1424
+ }
1425
+
1426
+ # The same abandoned claim on the terminal path: stepping aside for it allowed the
1427
+ # stop silently AND spent no attended alarm, so a genuinely broken automatic
1428
+ # mechanism stayed invisible. The guard must clear the claim and finish instead.
1429
+ test_hook_claude_mode_terminal_fail_open_clears_abandoned_claim() {
1430
+ local dir out status pid
1431
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-abandoned-terminal")
1432
+ : > "$dir/state/task1.meta"
1433
+ : > "$dir/state/.claude-autoarm-failure-notified"
1434
+ seed_claude_budget "$dir" 4 3
1435
+ sleep 60 &
1436
+ pid=$!
1437
+ record_autoarm_owner "$dir" "$pid"
1438
+ printf 'epoch=3 owner_pid=%s outcome=failed-suppressed updated_at=1\n' "$pid" > "$dir/state/.claude-autoarm-epoch"
1439
+ touch -t 202001010000 "$dir/state/.claude-autoarm-epoch"
1440
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=200 run_hook_claude "$dir" true); status=$?
1441
+ kill "$pid" 2>/dev/null || true
1442
+ wait "$pid" 2>/dev/null || true
1443
+ expect_code 0 "$status" "the verified attended fail-open still ends the turn once it is spent"
1444
+ assert_contains "$out" 'FIRSTMATE SUPERVISION IS GENUINELY DOWN' "an abandoned claim suppressed the episode's attended alarm"
1445
+ assert_present "$dir/state/.claude-autoarm-failure-alarmed" "abandoned-claim terminal path did not consume the one-time alarm"
1446
+ assert_absent "$dir/state/.claude-autoarm.lock" "abandoned-claim terminal path left the stale claim in place"
1447
+ assert_absent "$dir/state/.claude-autoarm.lock.steal" "abandoned-claim reclaim left its serialization mutex behind"
1448
+ pass "fm-turnend-guard --claude: the terminal path clears an abandoned claim instead of stepping aside silently"
1449
+ }
1450
+
1451
+ test_hook_claude_mode_preserves_fresh_failed_progression() {
1452
+ local dir out status count
1453
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-failed-epoch")
1454
+ : > "$dir/state/task1.meta"
1455
+ : > "$dir/state/.claude-autoarm-failure-notified"
1456
+ printf 'epoch=3 owner_pid=999 outcome=failed updated_at=%s\n' "$(date +%s)" > "$dir/state/.claude-autoarm-epoch"
1457
+ out=$(run_hook_claude "$dir" true); status=$?
1458
+ expect_code 0 "$status" "the first fresh failed epoch must count as its automatic continuation"
1459
+ [ -z "$out" ] || fail "fresh failed-epoch allow produced output: $out"
1460
+ assert_present "$dir/state/.turnend-claude-blocks" "fresh failed epoch did not preserve bounded progression"
1461
+ count=$(sed -n '2s/^count=//p' "$dir/state/.turnend-claude-blocks")
1462
+ [ "$count" = 0 ] || fail "the owned first failed epoch must not consume a blocked-stop count, got $count"
1463
+ printf 'epoch=4 owner_pid=999 outcome=failed-suppressed updated_at=%s\n' "$(date +%s)" > "$dir/state/.claude-autoarm-epoch"
1464
+ out=$(run_hook_claude "$dir" true); status=$?
1465
+ expect_code 2 "$status" "a later fresh failed epoch must consume the bounded progression"
1466
+ assert_absent "$dir/state/.claude-autoarm-failure-alarmed" "fresh failure progression emitted the attended fail-open alarm too early"
1467
+ count=$(sed -n '2s/^count=//p' "$dir/state/.turnend-claude-blocks")
1468
+ [ "$count" = 1 ] || fail "the later failed epoch must advance the blocked-stop count, got $count"
1469
+ pass "fm-turnend-guard --claude: fresh failed epochs preserve and advance monotonic fail-open progression"
1470
+ }
1471
+
1472
+ test_hook_claude_mode_integrated_monotonic_fail_open() {
1473
+ local dir out status guard_out guard_status i pid identity count
1474
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-integrated-fail-open")
1475
+ : > "$dir/state/task1.meta"
1476
+ install_integrated_autoarm "$dir"
1477
+ write_integrated_failed_arm "$dir"
1478
+
1479
+ out=$(run_integrated_autoarm "$dir"); status=$?
1480
+ expect_code 2 "$status" "the first exhausted auto-arm cycle must emit its one failure notice"
1481
+ assert_contains "$out" "automatic supervision mechanism is broken" "the first integrated failure notice is missing"
1482
+ guard_out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" true); guard_status=$?
1483
+ expect_code 0 "$guard_status" "the first failed epoch must own its Stop handoff"
1484
+ count=$(sed -n '2s/^count=//p' "$dir/state/.turnend-claude-blocks")
1485
+ [ "$count" = 0 ] || fail "the first owned failure epoch must preserve a zero blocked-stop count, got $count"
1486
+
1487
+ for i in 1 2 3 4; do
1488
+ out=$(run_integrated_autoarm "$dir"); status=$?
1489
+ expect_code 2 "$status" "failed epoch $i must retain the automatic retry handoff"
1490
+ [ -z "$out" ] || fail "failed epoch $i repeated the operator notice: $out"
1491
+ guard_out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" true); guard_status=$?
1492
+ if [ "$i" -lt 4 ]; then
1493
+ expect_code 2 "$guard_status" "failed epoch $i must consume a bounded blind-stop block"
1494
+ assert_not_contains "$guard_out" 'FIRSTMATE SUPERVISION IS GENUINELY DOWN' "fail-open fired before the bounded progression ended"
1495
+ else
1496
+ expect_code 0 "$guard_status" "the bounded failure progression must reach the attended fail-open"
1497
+ assert_contains "$guard_out" 'FIRSTMATE SUPERVISION IS GENUINELY DOWN' "the integrated fail-open alarm is missing"
1498
+ assert_present "$dir/state/.claude-autoarm-failure-alarmed" "the integrated fail-open did not consume its episode alarm"
1499
+ fi
1500
+ done
1501
+
1502
+ out=$(run_integrated_autoarm "$dir"); status=$?
1503
+ expect_code 0 "$status" "the auto-arm must not re-trigger continuation after the final fail-open"
1504
+ [ -z "$out" ] || fail "post-fail-open auto-arm produced continuation output: $out"
1505
+ guard_out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" true); guard_status=$?
1506
+ expect_code 2 "$guard_status" "a later unhealthy stop in the same episode must remain attended"
1507
+ assert_not_contains "$guard_out" 'FIRSTMATE SUPERVISION IS GENUINELY DOWN' "the attended alarm repeated in the same episode"
1508
+
1509
+ sleep 60 &
1510
+ pid=$!
1511
+ identity=$(watcher_identity "$dir" "$pid") || {
1512
+ kill "$pid" 2>/dev/null || true
1513
+ wait "$pid" 2>/dev/null || true
1514
+ fail "could not identify the positive recovery watcher"
1515
+ }
1516
+ record_watcher_lock "$dir" "$pid" "$identity"
1517
+ touch "$dir/state/.last-watcher-beat"
1518
+ out=$(run_integrated_autoarm "$dir"); status=$?
1519
+ kill "$pid" 2>/dev/null || true
1520
+ wait "$pid" 2>/dev/null || true
1521
+ rm -rf "$dir/state/.watch.lock"
1522
+ expect_code 0 "$status" "positive watcher recovery must make the auto-arm silent"
1523
+ assert_absent "$dir/state/.claude-autoarm-failure-notified" "positive recovery left the failure notice marker"
1524
+ assert_absent "$dir/state/.claude-autoarm-failure-alarmed" "positive recovery left the attended alarm marker"
1525
+ assert_absent "$dir/state/.turnend-claude-blocks" "positive recovery left the bounded block budget"
1526
+ guard_out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" false); guard_status=$?
1527
+ expect_code 2 "$guard_status" "a guard after one-shot recovery must start a fresh failure budget"
1528
+ count=$(sed -n '2s/^count=//p' "$dir/state/.turnend-claude-blocks")
1529
+ [ "$count" = 1 ] || fail "the independent post-recovery failure must start at count 1, got $count"
1530
+
1531
+ out=$(run_integrated_autoarm "$dir"); status=$?
1532
+ expect_code 2 "$status" "a later failure after positive recovery must start a new episode"
1533
+ assert_contains "$out" "automatic supervision mechanism is broken" "the new failure episode notice was suppressed"
1534
+ pass "fm-turnend-guard --claude: integrated fresh failures reach one bounded fail-open, stop continuation, and reset on recovery"
1535
+ }
1536
+
1537
+ test_hook_claude_mode_recovery_contention_is_not_ordinary_allow() {
1538
+ local dir pid identity holder out status
1539
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-recovery-contention")
1540
+ : > "$dir/state/task1.meta"
1541
+ seed_claude_budget "$dir" 3
1542
+ : > "$dir/state/.claude-autoarm-failure-notified"
1543
+ : > "$dir/state/.claude-autoarm-failure-alarmed"
1544
+ sleep 60 &
1545
+ pid=$!
1546
+ identity=$(watcher_identity "$dir" "$pid") || fail "could not identify recovery-contention watcher"
1547
+ record_watcher_lock "$dir" "$pid" "$identity"
1548
+ touch "$dir/state/.last-watcher-beat"
1549
+ sleep 60 &
1550
+ holder=$!
1551
+ mkdir -p "$dir/state/.turnend-claude-blocks.lock"
1552
+ printf '%s\n' "$holder" > "$dir/state/.turnend-claude-blocks.lock/pid"
1553
+ out=$(run_hook_claude "$dir" false); status=$?
1554
+ expect_code 2 "$status" "a healthy guard must continue when the episode reset lock is busy"
1555
+ [ -z "$out" ] || fail "guard recovery contention produced output: $out"
1556
+ assert_present "$dir/state/.turnend-claude-blocks" "guard contention partially cleared the block budget"
1557
+ assert_present "$dir/state/.claude-autoarm-failure-notified" "guard contention partially cleared the failure notice"
1558
+ assert_present "$dir/state/.claude-autoarm-failure-alarmed" "guard contention partially cleared the attended alarm"
1559
+ kill "$holder" 2>/dev/null || true
1560
+ wait "$holder" 2>/dev/null || true
1561
+ out=$(run_hook_claude "$dir" false); status=$?
1562
+ kill "$pid" 2>/dev/null || true
1563
+ wait "$pid" 2>/dev/null || true
1564
+ expect_code 0 "$status" "the healthy guard must allow after completing the episode reset"
1565
+ assert_absent "$dir/state/.turnend-claude-blocks" "successful guard reset left the block budget"
1566
+ assert_absent "$dir/state/.claude-autoarm-failure-notified" "successful guard reset left the failure notice"
1567
+ assert_absent "$dir/state/.claude-autoarm-failure-alarmed" "successful guard reset left the attended alarm"
1568
+ pass "fm-turnend-guard --claude: reset contention preserves all episode state until retry"
1569
+ }
1570
+
1571
+ test_hook_claude_mode_concurrent_recovery_resets_are_idempotent() {
1572
+ local dir pid identity auto_pid guard_pid auto_status guard_status
1573
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-concurrent-recovery")
1574
+ : > "$dir/state/task1.meta"
1575
+ install_integrated_autoarm "$dir"
1576
+ write_integrated_failed_arm "$dir"
1577
+ seed_claude_budget "$dir" 3
1578
+ : > "$dir/state/.claude-autoarm-failure-notified"
1579
+ : > "$dir/state/.claude-autoarm-failure-alarmed"
1580
+ sleep 60 &
1581
+ pid=$!
1582
+ identity=$(watcher_identity "$dir" "$pid") || fail "could not identify concurrent recovery watcher"
1583
+ record_watcher_lock "$dir" "$pid" "$identity"
1584
+ touch "$dir/state/.last-watcher-beat"
1585
+ (run_integrated_autoarm "$dir" > "$dir/auto.out"; printf '%s\n' "$?" > "$dir/auto.status") &
1586
+ auto_pid=$!
1587
+ (FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" false > "$dir/guard.out"; printf '%s\n' "$?" > "$dir/guard.status") &
1588
+ guard_pid=$!
1589
+ wait "$auto_pid"
1590
+ wait "$guard_pid"
1591
+ kill "$pid" 2>/dev/null || true
1592
+ wait "$pid" 2>/dev/null || true
1593
+ auto_status=$(cat "$dir/auto.status")
1594
+ guard_status=$(cat "$dir/guard.status")
1595
+ case "$auto_status:$guard_status" in
1596
+ 0:0|0:2|2:0) : ;;
1597
+ *) fail "concurrent reset callers returned unsafe statuses auto=$auto_status guard=$guard_status" ;;
1598
+ esac
1599
+ assert_absent "$dir/state/.turnend-claude-blocks" "concurrent recovery left the block budget"
1600
+ assert_absent "$dir/state/.claude-autoarm-failure-notified" "concurrent recovery left the failure notice"
1601
+ assert_absent "$dir/state/.claude-autoarm-failure-alarmed" "concurrent recovery left the attended alarm"
1602
+ assert_absent "$dir/state/.claude-autoarm.lock" "concurrent recovery left the owner lock"
1603
+ assert_absent "$dir/state/.turnend-claude-blocks.lock" "concurrent recovery left the budget lock"
1604
+ pass "fm-turnend-guard --claude: concurrent auto-arm and guard resets are idempotent and deadlock-free"
1605
+ }
1606
+
1607
+ test_hook_claude_mode_stale_rewake_epoch_blocks() {
1608
+ local dir out status
1609
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-stale-epoch")
1610
+ : > "$dir/state/task1.meta"
1611
+ printf 'epoch=3 owner_pid=999 outcome=rewake updated_at=1\n' > "$dir/state/.claude-autoarm-epoch"
1612
+ touch -t 202001010000 "$dir/state/.claude-autoarm-epoch"
1613
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=200 run_hook_claude "$dir" true); status=$?
1614
+ expect_code 2 "$status" "--claude mode must not treat an ancient rewake epoch as this event's recovery"
1615
+ pass "fm-turnend-guard --claude: stale rewake epoch does not allow a blind stop"
1616
+ }
1617
+
1618
+ test_hook_claude_mode_budget_without_verified_failure_keeps_blocking() {
1619
+ local dir out status i
1620
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-budget")
1621
+ : > "$dir/state/task1.meta"
1622
+ for i in 1 2 3 4; do
1623
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" false); status=$?
1624
+ expect_code 2 "$status" "--claude block $i must exit 2 within the budget"
1625
+ done
1626
+ assert_not_contains "$out" 'systemMessage' "budget exhaustion without verified auto-arm failure must not fail open"
1627
+ assert_absent "$dir/state/.claude-autoarm-failure-alarmed" "unverified budget exhaustion recorded an attended alarm"
1628
+ pass "fm-turnend-guard --claude: budget exhaustion alone cannot permit a blind stop"
1629
+ }
1630
+
1631
+ test_hook_claude_mode_verified_failure_alarm_is_loud_and_once() {
1632
+ local dir out out2 status status2
1633
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-verified-alarm")
1634
+ : > "$dir/state/task1.meta"
1635
+ seed_claude_failure "$dir"
1636
+ seed_claude_budget "$dir" 3
1637
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" true); status=$?
1638
+ expect_code 0 "$status" "verified failure with exhausted budget must take the bounded attended fail-open"
1639
+ assert_contains "$out" 'FIRSTMATE SUPERVISION IS GENUINELY DOWN' "bounded fail-open alarm was not unmistakable"
1640
+ assert_contains "$out" 'Keep this session attended' "bounded fail-open alarm omitted the attended-session action"
1641
+ assert_contains "$out" 'diagnose the automatic Stop-hook and watcher startup' "bounded fail-open alarm omitted automatic-mechanism diagnosis"
1642
+ assert_not_contains "$out" 'fm-watch-arm.sh' "bounded fail-open alarm assigned a manual watcher launch"
1643
+ assert_present "$dir/state/.claude-autoarm-failure-alarmed" "bounded fail-open did not consume the episode alarm"
1644
+ out2=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" true); status2=$?
1645
+ expect_code 2 "$status2" "a consumed attended alarm must make later unhealthy stops block again"
1646
+ assert_not_contains "$out2" 'FIRSTMATE SUPERVISION IS GENUINELY DOWN' "attended failure alarm repeated in one episode"
1647
+ pass "fm-turnend-guard --claude: verified fail-open is loud, bounded, attended, and non-repeating"
1648
+ }
1649
+
1650
+ test_hook_claude_mode_fail_open_requires_notice_and_failure_epoch() {
1651
+ local no_notice notice_only out status
1652
+ no_notice=$(make_primary_dir "$TMP_ROOT/hook-claude-alarm-no-notice")
1653
+ : > "$no_notice/state/task1.meta"
1654
+ printf 'epoch=3 owner_pid=999 outcome=failed-suppressed updated_at=1\n' > "$no_notice/state/.claude-autoarm-epoch"
1655
+ touch -t 202001010000 "$no_notice/state/.claude-autoarm-epoch"
1656
+ seed_claude_budget "$no_notice" 3
1657
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$no_notice" true); status=$?
1658
+ expect_code 2 "$status" "an exhausted failure epoch without the consumed notice must remain blocking"
1659
+
1660
+ notice_only=$(make_primary_dir "$TMP_ROOT/hook-claude-alarm-no-epoch")
1661
+ : > "$notice_only/state/task1.meta"
1662
+ : > "$notice_only/state/.claude-autoarm-failure-notified"
1663
+ seed_claude_budget "$notice_only" 3
1664
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$notice_only" true); status=$?
1665
+ expect_code 2 "$status" "a consumed notice without an exhausted failure epoch must remain blocking"
1666
+ pass "fm-turnend-guard --claude: fail-open requires both exhausted retries and consumed notice"
1667
+ }
1668
+
1669
+ test_hook_claude_mode_away_mode_never_uses_stop_autoarm_fail_open() {
1670
+ local dir out status
1671
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-alarm-afk")
1672
+ : > "$dir/state/task1.meta"
1673
+ : > "$dir/state/.afk"
1674
+ seed_claude_failure "$dir"
1675
+ seed_claude_budget "$dir" 3
1676
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" true); status=$?
1677
+ expect_code 2 "$status" "away mode must not use a stale Stop-autoarm failure to fail open"
1678
+ assert_contains "$out" 'Away mode owns watcher supervision' "away-mode block lost its daemon ownership guidance"
1679
+ assert_absent "$dir/state/.claude-autoarm-failure-alarmed" "away mode consumed the Stop-autoarm attended alarm"
1680
+ pass "fm-turnend-guard --claude: away ownership excludes the Stop-autoarm fail-open"
1681
+ }
1682
+
1683
+ test_hook_claude_mode_allow_resets_budget() {
1684
+ local dir pid identity out status
1685
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-reset")
1686
+ : > "$dir/state/task1.meta"
1687
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" false); status=$?
1688
+ expect_code 2 "$status" "first --claude block must exit 2"
1689
+ [ -f "$dir/state/.turnend-claude-blocks" ] || fail "--claude block must record the consecutive-block budget"
1690
+ : > "$dir/state/.claude-autoarm-failure-notified"
1691
+ : > "$dir/state/.claude-autoarm-failure-alarmed"
1692
+ sleep 60 &
1693
+ pid=$!
1694
+ identity=$(watcher_identity "$dir" "$pid") || {
1695
+ kill "$pid" 2>/dev/null || true
1696
+ wait "$pid" 2>/dev/null || true
1697
+ fail "could not identify live watcher holder"
1698
+ }
1699
+ record_watcher_lock "$dir" "$pid" "$identity"
1700
+ touch "$dir/state/.last-watcher-beat"
1701
+ out=$(run_hook_claude "$dir" false); status=$?
1702
+ kill "$pid" 2>/dev/null || true
1703
+ wait "$pid" 2>/dev/null || true
1704
+ rm -rf "$dir/state/.watch.lock"
1705
+ expect_code 0 "$status" "--claude must allow once the watcher is healthy again"
1706
+ [ ! -f "$dir/state/.turnend-claude-blocks" ] || fail "--claude allow must reset the consecutive-block budget"
1707
+ [ ! -f "$dir/state/.claude-autoarm-failure-notified" ] || fail "positive watcher recovery must reset the failure notice"
1708
+ [ ! -f "$dir/state/.claude-autoarm-failure-alarmed" ] || fail "positive watcher recovery must reset the attended alarm"
1709
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" false); status=$?
1710
+ expect_code 2 "$status" "a later unhealthy chain must re-block from a fresh budget"
1711
+ pass "fm-turnend-guard --claude: positive watcher recovery resets failure episode state"
1712
+ }
1713
+
1714
+ test_hook_claude_mode_waits_for_late_claim() {
1715
+ local dir helper out status holder
1716
+ dir=$(make_primary_dir "$TMP_ROOT/hook-claude-wait")
1717
+ : > "$dir/state/task1.meta"
1718
+ (
1719
+ sleep 0.4
1720
+ sleep 60 &
1721
+ record_autoarm_owner "$dir" $!
1722
+ printf '%s\n' $! > "$dir/holder.pid"
1723
+ wait
1724
+ ) &
1725
+ helper=$!
1726
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=3000 run_hook_claude "$dir" false); status=$?
1727
+ holder=$(cat "$dir/holder.pid" 2>/dev/null || true)
1728
+ kill "$holder" 2>/dev/null || true
1729
+ kill "$helper" 2>/dev/null || true
1730
+ wait "$helper" 2>/dev/null || true
1731
+ expect_code 0 "$status" "--claude must wait briefly for a late auto-arm claim instead of forcing a continuation"
1732
+ [ -z "$out" ] || fail "--claude late-claim wait produced output: $out"
1733
+ pass "fm-turnend-guard --claude: bounded claim wait avoids a token-consuming forced continuation"
1734
+ }
1735
+
1736
+ test_hook_claude_mode_secondmate_reblocks_like_primary() {
1737
+ local dir pid out status
1738
+ dir=$(make_secondmate_dir "$TMP_ROOT/hook-claude-sm-reblock")
1739
+ : > "$dir/state/task1.meta"
1740
+ out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=200 run_hook_claude "$dir" true); status=$?
1741
+ expect_code 2 "$status" "--claude mode must re-block in a marked secondmate home exactly like the main primary"
1742
+ assert_contains "$out" "TURN WOULD END BLIND" "--claude secondmate re-block must carry the blind-turn banner"
1743
+ sleep 60 &
1744
+ pid=$!
1745
+ record_autoarm_owner "$dir" "$pid"
1746
+ out=$(run_hook_claude "$dir" false); status=$?
1747
+ kill "$pid" 2>/dev/null || true
1748
+ wait "$pid" 2>/dev/null || true
1749
+ expect_code 0 "$status" "--claude mode must allow a claimed secondmate home"
1750
+ pass "fm-turnend-guard --claude: secondmate home re-blocks unclaimed and allows auto-arm-claimed stops"
1751
+ }
1752
+
1753
+ test_predicate_healthy_no_inflight
1754
+ test_predicate_unhealthy_no_beacon
1755
+ test_predicate_unhealthy_stale_beacon
1756
+ test_predicate_healthy_fresh_beacon
1757
+ test_predicate_queue_pending_flag
1758
+ test_predicate_x_mode_needs_supervision
1759
+ test_predicate_source_needs_supervision
1760
+ test_hook_silent_when_no_work_in_flight
1761
+ test_hook_blocks_when_fresh_beacon_has_no_live_lock
1762
+ test_hook_blocks_source_only_home
1763
+ test_hook_blocks_when_dead_lock_has_fresh_beacon
1764
+ test_hook_silent_with_live_lock_and_fresh_beacon
1765
+ test_hook_non_claude_health_ignores_claude_budget_contention
1766
+ test_hook_blocks_with_live_lock_and_stale_beacon
1767
+ test_hook_blocks_when_unhealthy_in_primary
1768
+ test_hook_blocks_from_fm_home_state
1769
+ test_hook_x_mode_reason_sources_cadence
1770
+ test_hook_x_mode_only_blocks_in_default_mode
1771
+ test_hook_ignores_repo_state_when_fm_home_set
1772
+ test_hook_uses_state_override
1773
+ test_hook_loop_guard_allows_retry
1774
+ test_hook_blocks_in_secondmate_own_home
1775
+ test_hook_silent_in_idle_secondmate_home
1776
+ test_hook_secondmate_loop_guard_allows_retry
1777
+ test_hook_secondmate_reinvoke_recovery_loop
1778
+ test_hook_silent_in_secondmate_child_worktree
1779
+ test_hook_blocks_in_treehouse_leased_secondmate_home
1780
+ test_hook_exempts_linked_worktree_with_stray_marker
1781
+ test_hook_exempts_linked_worktree_with_non_ascii_marker
1782
+ test_hook_silent_in_crewmate_worktree
1783
+ test_hook_silent_without_jq
1784
+ test_hook_silent_without_stdin
1785
+ test_hook_runs_fast
1786
+ test_grok_adapter_forces_one_resume_when_unhealthy
1787
+ test_grok_adapter_loop_guard_skips_resume
1788
+ test_grok_adapter_native_false_blocks_without_resume
1789
+ test_grok_adapter_native_true_allows_without_resume
1790
+ test_grok_adapter_snake_case_native_and_camel_precedence
1791
+ test_grok_adapter_invalid_inputs_start_neither_path
1792
+ test_grok_adapter_missing_jq_and_no_supervision_allow
1793
+ test_tracked_claude_entries_inert_under_grok
1794
+ test_codex_hook_uses_process_pwd_when_payload_cwd_is_outside_root
1795
+ test_codex_hook_ignores_nested_git_root_guard
1796
+ test_opencode_plugin_anchors_guard_to_worktree
1797
+ test_pi_extension_injects_once_per_logical_agent_run
1798
+ test_pi_extension_retries_after_followup_delivery_failure
1799
+ test_hook_claude_mode_reblocks_stop_hook_active_when_unhealthy
1800
+ test_hook_claude_mode_reblocks_x_mode_without_tasks
1801
+ test_hook_claude_mode_allows_when_autoarm_owner_alive
1802
+ test_hook_claude_mode_repeated_failed_to_arming_interleavings_reach_fail_open
1803
+ test_hook_claude_mode_terminal_boundary_excludes_starting_owner
1804
+ test_hook_claude_mode_allows_on_fresh_rewake_epoch
1805
+ test_hook_claude_mode_blocks_on_abandoned_autoarm_claim
1806
+ test_hook_claude_mode_blocks_on_pid_reused_arming_claim
1807
+ test_hook_claude_mode_blocks_on_stuck_arming_claim
1808
+ test_hook_claude_mode_allows_on_open_generation_claim
1809
+ test_hook_claude_mode_blocks_on_stuck_generation_claim
1810
+ test_hook_claude_mode_terminal_fail_open_clears_abandoned_claim
1811
+ test_hook_claude_mode_preserves_fresh_failed_progression
1812
+ test_hook_claude_mode_integrated_monotonic_fail_open
1813
+ test_hook_claude_mode_recovery_contention_is_not_ordinary_allow
1814
+ test_hook_claude_mode_concurrent_recovery_resets_are_idempotent
1815
+ test_hook_claude_mode_stale_rewake_epoch_blocks
1816
+ test_hook_claude_mode_budget_without_verified_failure_keeps_blocking
1817
+ test_hook_claude_mode_verified_failure_alarm_is_loud_and_once
1818
+ test_hook_claude_mode_fail_open_requires_notice_and_failure_epoch
1819
+ test_hook_claude_mode_away_mode_never_uses_stop_autoarm_fail_open
1820
+ test_hook_claude_mode_allow_resets_budget
1821
+ test_hook_claude_mode_waits_for_late_claim
1822
+ test_hook_claude_mode_secondmate_reblocks_like_primary