@zerwiz/ymir 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (850) hide show
  1. package/.agents/README.md +291 -0
  2. package/.agents/agents/bragi-marketer.md +111 -0
  3. package/.agents/agents/brokk.md +352 -0
  4. package/.agents/agents/forseti-reviewer.md +93 -0
  5. package/.agents/agents/frigg-consent.md +77 -0
  6. package/.agents/agents/groa-update.md +77 -0
  7. package/.agents/agents/hnoss-designer.md +69 -0
  8. package/.agents/agents/huginn-researcher.md +99 -0
  9. package/.agents/agents/jord-projects.md +78 -0
  10. package/.agents/agents/kvasir-scout.md +91 -0
  11. package/.agents/agents/mimir-planner.md +92 -0
  12. package/.agents/agents/muninn-stow.md +77 -0
  13. package/.agents/agents/saga-bearings.md +78 -0
  14. package/.agents/agents/sindri-developer.md +114 -0
  15. package/.agents/agents/snotra-documenter.md +105 -0
  16. package/.agents/agents/syn-recovery.md +78 -0
  17. package/.agents/agents/tyr-check.md +78 -0
  18. package/.agents/agents/urdh-hold.md +77 -0
  19. package/.agents/agents/volundr-smith.md +80 -0
  20. package/.agents/agents/vor-diagnostics.md +77 -0
  21. package/.agents/assets/agents/naming.md +50 -0
  22. package/.agents/assets/agents/registry.md +93 -0
  23. package/.agents/assets/agents/runtime.md +36 -0
  24. package/.agents/assets/agents/toon-tasks-cli.md +42 -0
  25. package/.agents/assets/templates/company_entity.template.md +37 -0
  26. package/.agents/backend/backends/cmux.sh +649 -0
  27. package/.agents/backend/backends/herdr-eventwait.py +157 -0
  28. package/.agents/backend/backends/herdr-workspace-move.py +114 -0
  29. package/.agents/backend/backends/herdr.sh +3386 -0
  30. package/.agents/backend/backends/orca.sh +290 -0
  31. package/.agents/backend/backends/tmux.sh +357 -0
  32. package/.agents/backend/backends/zellij.sh +700 -0
  33. package/.agents/backend/fm-afk-launch.sh +654 -0
  34. package/.agents/backend/fm-afk-return.sh +241 -0
  35. package/.agents/backend/fm-afk-start.sh +172 -0
  36. package/.agents/backend/fm-arm-command-policy.mjs +960 -0
  37. package/.agents/backend/fm-arm-pretool-check.sh +200 -0
  38. package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
  39. package/.agents/backend/fm-backend.sh +981 -0
  40. package/.agents/backend/fm-backlog-handoff.sh +909 -0
  41. package/.agents/backend/fm-backlog-receive.sh +187 -0
  42. package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
  43. package/.agents/backend/fm-bearings-board.sh +204 -0
  44. package/.agents/backend/fm-bearings-snapshot.sh +546 -0
  45. package/.agents/backend/fm-bootstrap.sh +1568 -0
  46. package/.agents/backend/fm-branch-outcome.sh +203 -0
  47. package/.agents/backend/fm-branch-prompt.sh +101 -0
  48. package/.agents/backend/fm-brief.sh +452 -0
  49. package/.agents/backend/fm-busy-event.sh +234 -0
  50. package/.agents/backend/fm-busy-lib.sh +986 -0
  51. package/.agents/backend/fm-captain-hold.sh +1001 -0
  52. package/.agents/backend/fm-cd-command-policy.mjs +153 -0
  53. package/.agents/backend/fm-cd-pretool-check.sh +190 -0
  54. package/.agents/backend/fm-check-lib.sh +74 -0
  55. package/.agents/backend/fm-check-register.sh +42 -0
  56. package/.agents/backend/fm-check-unregister.sh +52 -0
  57. package/.agents/backend/fm-classify-lib.sh +1770 -0
  58. package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
  59. package/.agents/backend/fm-composer-lib.sh +1416 -0
  60. package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
  61. package/.agents/backend/fm-config-push.sh +254 -0
  62. package/.agents/backend/fm-control-lib.sh +251 -0
  63. package/.agents/backend/fm-control.sh +878 -0
  64. package/.agents/backend/fm-crew-state.sh +625 -0
  65. package/.agents/backend/fm-cursor-lib.sh +243 -0
  66. package/.agents/backend/fm-decision-hold.sh +232 -0
  67. package/.agents/backend/fm-doc-audience-check.sh +269 -0
  68. package/.agents/backend/fm-dod-lib.sh +67 -0
  69. package/.agents/backend/fm-ensure-agents-md.sh +253 -0
  70. package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
  71. package/.agents/backend/fm-extension.mjs +2577 -0
  72. package/.agents/backend/fm-extension.sh +16 -0
  73. package/.agents/backend/fm-ff-lib.sh +420 -0
  74. package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
  75. package/.agents/backend/fm-fleet-sync.sh +458 -0
  76. package/.agents/backend/fm-fleet-view.sh +96 -0
  77. package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
  78. package/.agents/backend/fm-guard.sh +242 -0
  79. package/.agents/backend/fm-harness.sh +195 -0
  80. package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
  81. package/.agents/backend/fm-herdr-lab.sh +346 -0
  82. package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
  83. package/.agents/backend/fm-home-seed.sh +984 -0
  84. package/.agents/backend/fm-home-summary-refresh.sh +255 -0
  85. package/.agents/backend/fm-hook-host-lib.sh +36 -0
  86. package/.agents/backend/fm-inactive-reconcile.sh +531 -0
  87. package/.agents/backend/fm-inbox.sh +399 -0
  88. package/.agents/backend/fm-install-actionlint.sh +84 -0
  89. package/.agents/backend/fm-install-herdr.sh +96 -0
  90. package/.agents/backend/fm-install-shellcheck.sh +84 -0
  91. package/.agents/backend/fm-install-treehouse.sh +95 -0
  92. package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
  93. package/.agents/backend/fm-lease-lib.sh +218 -0
  94. package/.agents/backend/fm-lease.sh +189 -0
  95. package/.agents/backend/fm-line-cap-lib.sh +51 -0
  96. package/.agents/backend/fm-lint-workflows.sh +137 -0
  97. package/.agents/backend/fm-lint.sh +591 -0
  98. package/.agents/backend/fm-lock-lib.sh +104 -0
  99. package/.agents/backend/fm-lock.sh +107 -0
  100. package/.agents/backend/fm-marker-lib.sh +12 -0
  101. package/.agents/backend/fm-merge-local.sh +74 -0
  102. package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
  103. package/.agents/backend/fm-nm-run-lib.sh +124 -0
  104. package/.agents/backend/fm-on.sh +125 -0
  105. package/.agents/backend/fm-operational-input.sh +253 -0
  106. package/.agents/backend/fm-peek.sh +46 -0
  107. package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
  108. package/.agents/backend/fm-pr-check.sh +135 -0
  109. package/.agents/backend/fm-pr-lib.sh +1016 -0
  110. package/.agents/backend/fm-pr-merge.sh +707 -0
  111. package/.agents/backend/fm-pr-poll.sh +110 -0
  112. package/.agents/backend/fm-primary-scope-lib.sh +33 -0
  113. package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
  114. package/.agents/backend/fm-procevent-lavish.sh +628 -0
  115. package/.agents/backend/fm-procevent-lib.sh +892 -0
  116. package/.agents/backend/fm-procevent-quota.sh +290 -0
  117. package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
  118. package/.agents/backend/fm-procevent-when.sh +504 -0
  119. package/.agents/backend/fm-procevent.sh +1466 -0
  120. package/.agents/backend/fm-project-mode.sh +92 -0
  121. package/.agents/backend/fm-project-origin-lib.sh +180 -0
  122. package/.agents/backend/fm-promote.sh +282 -0
  123. package/.agents/backend/fm-public-followup-emit.sh +260 -0
  124. package/.agents/backend/fm-public-followup-lib.sh +398 -0
  125. package/.agents/backend/fm-public-followup.sh +1330 -0
  126. package/.agents/backend/fm-push-transition-lib.sh +168 -0
  127. package/.agents/backend/fm-quota-axi-lib.sh +93 -0
  128. package/.agents/backend/fm-quota-choose.sh +384 -0
  129. package/.agents/backend/fm-remote-delta-read.sh +189 -0
  130. package/.agents/backend/fm-remote-doctor.sh +800 -0
  131. package/.agents/backend/fm-remote-entrypoint.sh +193 -0
  132. package/.agents/backend/fm-remote-file.sh +234 -0
  133. package/.agents/backend/fm-remote-home-provision.sh +259 -0
  134. package/.agents/backend/fm-remote-home-seed.sh +259 -0
  135. package/.agents/backend/fm-remote-inherit-push.sh +90 -0
  136. package/.agents/backend/fm-remote-inherit.sh +179 -0
  137. package/.agents/backend/fm-remote-job-lib.sh +1241 -0
  138. package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
  139. package/.agents/backend/fm-remote-job-worker.sh +1124 -0
  140. package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
  141. package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
  142. package/.agents/backend/fm-review-diff.sh +158 -0
  143. package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
  144. package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
  145. package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
  146. package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
  147. package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
  148. package/.agents/backend/fm-secondmate-report.sh +88 -0
  149. package/.agents/backend/fm-send.sh +1059 -0
  150. package/.agents/backend/fm-session-lock-lib.sh +176 -0
  151. package/.agents/backend/fm-session-start.sh +970 -0
  152. package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
  153. package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
  154. package/.agents/backend/fm-sessionstart-run.sh +145 -0
  155. package/.agents/backend/fm-spawn.sh +3153 -0
  156. package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
  157. package/.agents/backend/fm-startup-memory-budget.sh +94 -0
  158. package/.agents/backend/fm-startup-network.sh +642 -0
  159. package/.agents/backend/fm-stow-cascade.sh +251 -0
  160. package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
  161. package/.agents/backend/fm-supervise-daemon.sh +1732 -0
  162. package/.agents/backend/fm-supervision-instructions.sh +215 -0
  163. package/.agents/backend/fm-supervision-lib.sh +90 -0
  164. package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
  165. package/.agents/backend/fm-tangle-lib.sh +53 -0
  166. package/.agents/backend/fm-task-inbox-lib.sh +402 -0
  167. package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
  168. package/.agents/backend/fm-teardown.sh +2922 -0
  169. package/.agents/backend/fm-test-isolation-proof.sh +588 -0
  170. package/.agents/backend/fm-test-run.sh +2191 -0
  171. package/.agents/backend/fm-timeout-lib.sh +141 -0
  172. package/.agents/backend/fm-timing-lib.sh +179 -0
  173. package/.agents/backend/fm-tmux-lib.sh +291 -0
  174. package/.agents/backend/fm-tool-update-check.sh +898 -0
  175. package/.agents/backend/fm-trace-context-lib.sh +227 -0
  176. package/.agents/backend/fm-transition-lib.sh +103 -0
  177. package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
  178. package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
  179. package/.agents/backend/fm-turnend-guard.sh +432 -0
  180. package/.agents/backend/fm-update.sh +110 -0
  181. package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
  182. package/.agents/backend/fm-voice-client.py +1373 -0
  183. package/.agents/backend/fm-voice-relay.py +1256 -0
  184. package/.agents/backend/fm-wake-drain.sh +592 -0
  185. package/.agents/backend/fm-wake-grant.sh +127 -0
  186. package/.agents/backend/fm-wake-lib.sh +1849 -0
  187. package/.agents/backend/fm-watch-arm.sh +602 -0
  188. package/.agents/backend/fm-watch-checkpoint.sh +109 -0
  189. package/.agents/backend/fm-watch.sh +1962 -0
  190. package/.agents/backend/fm-x-dismiss.sh +116 -0
  191. package/.agents/backend/fm-x-followup.sh +286 -0
  192. package/.agents/backend/fm-x-lib.sh +1000 -0
  193. package/.agents/backend/fm-x-link.sh +234 -0
  194. package/.agents/backend/fm-x-poll.sh +185 -0
  195. package/.agents/backend/fm-x-reply.sh +385 -0
  196. package/.agents/backend/fm_voice_frame.py +166 -0
  197. package/.agents/backend/fm_voice_records.py +574 -0
  198. package/.agents/backend/model-bridge.py +290 -0
  199. package/.agents/backend/opencode-go-bridge.py +30 -0
  200. package/.agents/bus/README.md +12 -0
  201. package/.agents/bus/messages.json +1 -0
  202. package/.agents/config/agents.machine.example.yaml +16 -0
  203. package/.agents/config/agents.yaml +50 -0
  204. package/.agents/config/agents.yaml.example +72 -0
  205. package/.agents/config/crew-dispatch.json +22 -0
  206. package/.agents/config/cron.yaml +9 -0
  207. package/.agents/config/eindri-dispatch.json +67 -0
  208. package/.agents/config/eindri-harness +1 -0
  209. package/.agents/config/startup-memory-budget +1 -0
  210. package/.agents/config/tailscale-sync.yaml +27 -0
  211. package/.agents/filebrowser/.filebrowser.json +6 -0
  212. package/.agents/filebrowser/README.md +11 -0
  213. package/.agents/gateway/README.md +12 -0
  214. package/.agents/github/README.md +15 -0
  215. package/.agents/github/webhooks/.gitkeep +0 -0
  216. package/.agents/github/workflows/.gitkeep +0 -0
  217. package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
  218. package/.agents/harness/opencode/plugins/package.json +4 -0
  219. package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
  220. package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
  221. package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
  222. package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
  223. package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
  224. package/.agents/memory/README.md +46 -0
  225. package/.agents/memory/kaia.engram +0 -0
  226. package/.agents/memory/kaia.engram-shm +0 -0
  227. package/.agents/memory/kaia.engram-wal +0 -0
  228. package/.agents/memory/runes_audit.md +5 -0
  229. package/.agents/memory/well/episodes.jsonl +367 -0
  230. package/.agents/memory/well/workspace.jsonl +27 -0
  231. package/.agents/migrations/0001-hodd-layout.sh +38 -0
  232. package/.agents/migrations/0002-a2a-mcp.sh +27 -0
  233. package/.agents/migrations/0003-private-data-separation.sh +111 -0
  234. package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
  235. package/.agents/sandbox/Dockerfile.utgard +19 -0
  236. package/.agents/sandbox/README.md +14 -0
  237. package/.agents/sandbox/utgard.config.json +14 -0
  238. package/.agents/skills/README.md +59 -0
  239. package/.agents/skills/bragi-marketing/README.md +3 -0
  240. package/.agents/skills/bragi-marketing/SKILL.md +82 -0
  241. package/.agents/skills/eindri-homes/README.md +3 -0
  242. package/.agents/skills/eindri-homes/SKILL.md +291 -0
  243. package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
  244. package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
  245. package/.agents/skills/frigg-consent/README.md +3 -0
  246. package/.agents/skills/frigg-consent/SKILL.md +56 -0
  247. package/.agents/skills/galdr-ymirsystem/README.md +3 -0
  248. package/.agents/skills/galdr-ymirsystem/SKILL.md +158 -0
  249. package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
  250. package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
  251. package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
  252. package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
  253. package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
  254. package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
  255. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +673 -0
  256. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
  257. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
  258. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
  259. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
  260. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
  261. package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +496 -0
  262. package/.agents/skills/galdr-ymirsystem/assets/installation.md +571 -0
  263. package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
  264. package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
  265. package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +161 -0
  266. package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +377 -0
  267. package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
  268. package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +129 -0
  269. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
  270. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
  271. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +96 -0
  272. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
  273. package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
  274. package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
  275. package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
  276. package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
  277. package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
  278. package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
  279. package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
  280. package/.agents/skills/galdr-ymirsystem/assets/smidja.md +175 -0
  281. package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
  282. package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
  283. package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
  284. package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +360 -0
  285. package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
  286. package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
  287. package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
  288. package/.agents/skills/gjallarhorn-relay/README.md +3 -0
  289. package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
  290. package/.agents/skills/groa-update/SKILL.md +70 -0
  291. package/.agents/skills/hamr-adapters/README.md +3 -0
  292. package/.agents/skills/hamr-adapters/SKILL.md +123 -0
  293. package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
  294. package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
  295. package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
  296. package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
  297. package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
  298. package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
  299. package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
  300. package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
  301. package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
  302. package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
  303. package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
  304. package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
  305. package/.agents/skills/herdr-panes/README.md +3 -0
  306. package/.agents/skills/herdr-panes/SKILL.md +92 -0
  307. package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
  308. package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
  309. package/.agents/skills/hnoss-design/DESIGN.md +19 -0
  310. package/.agents/skills/hnoss-design/README.md +3 -0
  311. package/.agents/skills/hnoss-design/SKILL.md +83 -0
  312. package/.agents/skills/hvild-afk/README.md +3 -0
  313. package/.agents/skills/hvild-afk/SKILL.md +241 -0
  314. package/.agents/skills/jord-projects/README.md +3 -0
  315. package/.agents/skills/jord-projects/SKILL.md +92 -0
  316. package/.agents/skills/lifecycle/SKILL.md +56 -0
  317. package/.agents/skills/lifecycle/smoke_test.sh +52 -0
  318. package/.agents/skills/lifecycle/start.sh +21 -0
  319. package/.agents/skills/lifecycle/status.sh +35 -0
  320. package/.agents/skills/lifecycle/stop.sh +16 -0
  321. package/.agents/skills/muninn-stow/README.md +3 -0
  322. package/.agents/skills/muninn-stow/SKILL.md +309 -0
  323. package/.agents/skills/no-mistakes/SKILL.md +379 -0
  324. package/.agents/skills/nornir-schedule/README.md +3 -0
  325. package/.agents/skills/nornir-schedule/SKILL.md +25 -0
  326. package/.agents/skills/nornir-schedule/assets/events.md +137 -0
  327. package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
  328. package/.agents/skills/nsr-compliance/README.md +3 -0
  329. package/.agents/skills/nsr-compliance/SKILL.md +29 -0
  330. package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
  331. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
  332. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
  333. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
  334. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
  335. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
  336. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
  337. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
  338. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
  339. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
  340. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
  341. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
  342. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
  343. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
  344. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
  345. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
  346. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
  347. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
  348. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
  349. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
  350. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
  351. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
  352. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
  353. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  354. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
  355. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
  356. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
  357. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
  358. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
  359. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
  360. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
  361. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
  362. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
  363. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
  364. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
  365. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
  366. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
  367. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
  368. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
  369. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
  370. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
  371. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
  372. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
  373. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
  374. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
  375. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
  376. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
  377. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
  378. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
  379. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
  380. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
  381. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
  382. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
  383. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
  384. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
  385. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
  386. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
  387. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
  388. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
  389. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
  390. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
  391. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
  392. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
  393. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
  394. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
  395. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
  396. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
  397. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
  398. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
  399. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
  400. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
  401. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
  402. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
  403. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
  404. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
  405. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
  406. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
  407. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
  408. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
  409. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
  410. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
  411. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
  412. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
  413. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
  414. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
  415. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
  416. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
  417. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
  418. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
  419. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
  420. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
  421. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
  422. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
  423. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
  424. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
  425. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
  426. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
  427. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
  428. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
  429. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
  430. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
  431. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
  432. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
  433. package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
  434. package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
  435. package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
  436. package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
  437. package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
  438. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
  439. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
  440. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
  441. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
  442. package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
  443. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
  444. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
  445. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
  446. package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
  447. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
  448. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
  449. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
  450. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
  451. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
  452. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
  453. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
  454. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  455. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
  456. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
  457. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
  458. package/.agents/skills/pr-ops/README.md +3 -0
  459. package/.agents/skills/pr-ops/SKILL.md +109 -0
  460. package/.agents/skills/ratatoskr-a2a/README.md +3 -0
  461. package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
  462. package/.agents/skills/rules-check-drift/SKILL.md +81 -0
  463. package/.agents/skills/saga-bearings/README.md +3 -0
  464. package/.agents/skills/saga-bearings/SKILL.md +26 -0
  465. package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
  466. package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
  467. package/.agents/skills/saga-bearings/assets/recap.md +56 -0
  468. package/.agents/skills/syn-recovery/README.md +3 -0
  469. package/.agents/skills/syn-recovery/SKILL.md +54 -0
  470. package/.agents/skills/tyr-check/README.md +3 -0
  471. package/.agents/skills/tyr-check/SKILL.md +218 -0
  472. package/.agents/skills/urdh-hold/README.md +3 -0
  473. package/.agents/skills/urdh-hold/SKILL.md +25 -0
  474. package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
  475. package/.agents/skills/urdh-hold/assets/hold.md +54 -0
  476. package/.agents/skills/vor-diagnostics/README.md +3 -0
  477. package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
  478. package/.agents/skills/ymir-host/README.md +3 -0
  479. package/.agents/skills/ymir-host/SKILL.md +34 -0
  480. package/.agents/skills/ymir-host/assets/desktop.md +36 -0
  481. package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
  482. package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
  483. package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
  484. package/.agents/skills/ymir-host/assets/update.md +64 -0
  485. package/.agents/tests/assets/board-render-harness.mjs +117 -0
  486. package/.agents/tests/cmux-test-safety.sh +51 -0
  487. package/.agents/tests/fixtures.sh +291 -0
  488. package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
  489. package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
  490. package/.agents/tests/fm-afk-launch.test.sh +961 -0
  491. package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
  492. package/.agents/tests/fm-afk-return.test.sh +283 -0
  493. package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
  494. package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
  495. package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
  496. package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
  497. package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
  498. package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
  499. package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
  500. package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
  501. package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
  502. package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
  503. package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
  504. package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
  505. package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
  506. package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
  507. package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
  508. package/.agents/tests/fm-backend-orca.test.sh +1357 -0
  509. package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
  510. package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
  511. package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
  512. package/.agents/tests/fm-backend.test.sh +1142 -0
  513. package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
  514. package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
  515. package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
  516. package/.agents/tests/fm-bearings-board.test.sh +414 -0
  517. package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
  518. package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
  519. package/.agents/tests/fm-bootstrap.test.sh +1178 -0
  520. package/.agents/tests/fm-branch-supervision.test.sh +552 -0
  521. package/.agents/tests/fm-brief.test.sh +785 -0
  522. package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
  523. package/.agents/tests/fm-busy-state.test.sh +465 -0
  524. package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
  525. package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
  526. package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
  527. package/.agents/tests/fm-check-unregister.test.sh +198 -0
  528. package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
  529. package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
  530. package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
  531. package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
  532. package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
  533. package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
  534. package/.agents/tests/fm-composer-ghost.test.sh +716 -0
  535. package/.agents/tests/fm-composer-lib.test.sh +666 -0
  536. package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
  537. package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
  538. package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
  539. package/.agents/tests/fm-control.test.sh +910 -0
  540. package/.agents/tests/fm-crew-state.test.sh +1610 -0
  541. package/.agents/tests/fm-cursor-harness.test.sh +404 -0
  542. package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
  543. package/.agents/tests/fm-cursor-primary.test.sh +708 -0
  544. package/.agents/tests/fm-daemon.test.sh +2740 -0
  545. package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
  546. package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
  547. package/.agents/tests/fm-extension-binding.test.sh +2187 -0
  548. package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
  549. package/.agents/tests/fm-fleet-sync.test.sh +721 -0
  550. package/.agents/tests/fm-gate-refuse.test.sh +356 -0
  551. package/.agents/tests/fm-gitignore-config.test.sh +89 -0
  552. package/.agents/tests/fm-gotmp.test.sh +230 -0
  553. package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
  554. package/.agents/tests/fm-grok-harness.test.sh +117 -0
  555. package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
  556. package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
  557. package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
  558. package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
  559. package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
  560. package/.agents/tests/fm-herdr-lab.test.sh +243 -0
  561. package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
  562. package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
  563. package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
  564. package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
  565. package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
  566. package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
  567. package/.agents/tests/fm-kimi-harness.test.sh +687 -0
  568. package/.agents/tests/fm-lint-workflows.test.sh +532 -0
  569. package/.agents/tests/fm-lint.test.sh +1023 -0
  570. package/.agents/tests/fm-muse-harness.test.sh +932 -0
  571. package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
  572. package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
  573. package/.agents/tests/fm-on.test.sh +507 -0
  574. package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
  575. package/.agents/tests/fm-operational-input.test.sh +160 -0
  576. package/.agents/tests/fm-peek-remote.test.sh +110 -0
  577. package/.agents/tests/fm-pending-reply.test.sh +1288 -0
  578. package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
  579. package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
  580. package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
  581. package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
  582. package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
  583. package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
  584. package/.agents/tests/fm-pr-merge.test.sh +2135 -0
  585. package/.agents/tests/fm-procevent-quota.test.sh +225 -0
  586. package/.agents/tests/fm-procevent-when.test.sh +406 -0
  587. package/.agents/tests/fm-procevent.test.sh +1745 -0
  588. package/.agents/tests/fm-project-origin.test.sh +110 -0
  589. package/.agents/tests/fm-public-followup.test.sh +2272 -0
  590. package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
  591. package/.agents/tests/fm-quota-choose.test.sh +626 -0
  592. package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
  593. package/.agents/tests/fm-remote-doctor.test.sh +625 -0
  594. package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
  595. package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
  596. package/.agents/tests/fm-remote-job.test.sh +768 -0
  597. package/.agents/tests/fm-remote-reply.test.sh +520 -0
  598. package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
  599. package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
  600. package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
  601. package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
  602. package/.agents/tests/fm-review-diff.test.sh +176 -0
  603. package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
  604. package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
  605. package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
  606. package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
  607. package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
  608. package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
  609. package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
  610. package/.agents/tests/fm-send-inbox.test.sh +352 -0
  611. package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
  612. package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
  613. package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
  614. package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
  615. package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
  616. package/.agents/tests/fm-send-settle.test.sh +147 -0
  617. package/.agents/tests/fm-send-strict.test.sh +241 -0
  618. package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
  619. package/.agents/tests/fm-session-start.test.sh +2515 -0
  620. package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
  621. package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
  622. package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
  623. package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
  624. package/.agents/tests/fm-spawn-batch.test.sh +148 -0
  625. package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
  626. package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
  627. package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
  628. package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
  629. package/.agents/tests/fm-startup-network.test.sh +712 -0
  630. package/.agents/tests/fm-stow-cascade.test.sh +370 -0
  631. package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
  632. package/.agents/tests/fm-supervision-events.test.sh +156 -0
  633. package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
  634. package/.agents/tests/fm-tangle-guard.test.sh +277 -0
  635. package/.agents/tests/fm-task-delivery.test.sh +420 -0
  636. package/.agents/tests/fm-task-inbox.test.sh +513 -0
  637. package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
  638. package/.agents/tests/fm-teardown.test.sh +2665 -0
  639. package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
  640. package/.agents/tests/fm-test-fixtures.test.sh +132 -0
  641. package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
  642. package/.agents/tests/fm-test-run.test.sh +1196 -0
  643. package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
  644. package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
  645. package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
  646. package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
  647. package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
  648. package/.agents/tests/fm-transition-lib.test.sh +54 -0
  649. package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
  650. package/.agents/tests/fm-update.test.sh +304 -0
  651. package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
  652. package/.agents/tests/fm-voice-relay.test.sh +4741 -0
  653. package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
  654. package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
  655. package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
  656. package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
  657. package/.agents/tests/fm-wake-queue.test.sh +1224 -0
  658. package/.agents/tests/fm-watch-arm.test.sh +821 -0
  659. package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
  660. package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
  661. package/.agents/tests/fm-watch-triage.test.sh +3889 -0
  662. package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
  663. package/.agents/tests/fm-x-mode.test.sh +3042 -0
  664. package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
  665. package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
  666. package/.agents/tests/herdr-test-safety.sh +42 -0
  667. package/.agents/tests/lib.sh +315 -0
  668. package/.agents/tests/remote-herdr-fixture.sh +125 -0
  669. package/.agents/tests/secondmate-helpers.sh +193 -0
  670. package/.agents/tests/smoke.test.sh +93 -0
  671. package/.agents/tests/wake-helpers.sh +338 -0
  672. package/.agents/tests/zellij-test-safety.sh +60 -0
  673. package/.agents/tools/README.md +10 -0
  674. package/.agents/tools/bin/tasks-cli +267 -0
  675. package/.agents/tools/tasks-cli.ts +216 -0
  676. package/AGENTS.md +454 -0
  677. package/CHANGELOG.md +881 -0
  678. package/LICENSE +1 -1
  679. package/NOTICE +63 -0
  680. package/README.md +113 -59
  681. package/RULES/01-domains.md +51 -0
  682. package/RULES/02-agents.md +78 -0
  683. package/RULES/03-houses.md +61 -0
  684. package/RULES/04-hoard.md +65 -0
  685. package/RULES/05-platforms.md +67 -0
  686. package/RULES/06-append-only.md +58 -0
  687. package/RULES/07-config.md +45 -0
  688. package/RULES/08-delivery-gate.md +75 -0
  689. package/RULES/README.md +20 -0
  690. package/bin/README.md +107 -0
  691. package/bin/a2a-mcp.sh +98 -0
  692. package/bin/a2a-serve.py +103 -0
  693. package/bin/a2a-serve.sh +8 -0
  694. package/bin/a2a-talk.sh +50 -0
  695. package/bin/a2abridge-ensure.sh +108 -0
  696. package/bin/agent-run.sh +43 -0
  697. package/bin/agents-config.sh +233 -0
  698. package/bin/apodex-smoke-test.sh +92 -0
  699. package/bin/bifrost-bridge.sh +112 -0
  700. package/bin/bifrost-ingress.sh +77 -0
  701. package/bin/bootstrap-macos.sh +107 -0
  702. package/bin/bootstrap-windows.ps1 +112 -0
  703. package/bin/branch-guard.sh +64 -0
  704. package/bin/brokk +73 -0
  705. package/bin/brokk-classify-lib.sh +1770 -0
  706. package/bin/brokk-lease-lib.sh +222 -0
  707. package/bin/brokk-lease.sh +193 -0
  708. package/bin/brokk-lint.sh +65 -0
  709. package/bin/brokk-send.sh +38 -0
  710. package/bin/brokk-update.sh +9 -0
  711. package/bin/brokk-wake-grant.sh +127 -0
  712. package/bin/brokk-wake-lib.sh +1849 -0
  713. package/bin/changelog-guard.sh +165 -0
  714. package/bin/crash-sense.sh +160 -0
  715. package/bin/desktop-place.sh +224 -0
  716. package/bin/docs-guard.sh +39 -0
  717. package/bin/editor-place.sh +207 -0
  718. package/bin/eindri-acclaim.sh +34 -0
  719. package/bin/eindri-control.sh +28 -0
  720. package/bin/eindri-role.sh +83 -0
  721. package/bin/eindri-seen.sh +41 -0
  722. package/bin/eindri-send.sh +15 -0
  723. package/bin/eindri-start.sh +85 -0
  724. package/bin/eindri-watch.sh +57 -0
  725. package/bin/einherjar-spawn.sh +576 -0
  726. package/bin/eir-doctor.sh +112 -0
  727. package/bin/erindi-brief.sh +271 -0
  728. package/bin/fleet-apply.sh +68 -0
  729. package/bin/github-deploy.sh +81 -0
  730. package/bin/gjallarhorn-expose.sh +102 -0
  731. package/bin/gjallarhorn-notify.sh +58 -0
  732. package/bin/gjallarhorn-purge.sh +54 -0
  733. package/bin/gjallarhorn-tunnel.sh +72 -0
  734. package/bin/gleipnir-lock-lib.sh +284 -0
  735. package/bin/groa-update.sh +122 -0
  736. package/bin/hall-snapshot.sh +92 -0
  737. package/bin/hamr-harness.sh +246 -0
  738. package/bin/heimdall-ssh-keys.sh +374 -0
  739. package/bin/herdr-agents.py +44 -0
  740. package/bin/herdr-ensure.sh +104 -0
  741. package/bin/herdr-run.sh +431 -0
  742. package/bin/hermes-ensure.sh +88 -0
  743. package/bin/hoard-lib.sh +25 -0
  744. package/bin/hodd.sh +78 -0
  745. package/bin/host-sense.sh +199 -0
  746. package/bin/huginn-research-worker.sh +108 -0
  747. package/bin/local-model-lock.sh +51 -0
  748. package/bin/mimir-bridge.py +226 -0
  749. package/bin/mimir-bridge.sh +87 -0
  750. package/bin/mimir-ingest.sh +131 -0
  751. package/bin/mimir.sh +113 -0
  752. package/bin/mjollnir-webhook.sh +59 -0
  753. package/bin/mjollnir.sh +85 -0
  754. package/bin/model-hardware.sh +82 -0
  755. package/bin/model-resolve.sh +147 -0
  756. package/bin/models-detect.sh +178 -0
  757. package/bin/nornir-cron-start.sh +162 -0
  758. package/bin/nornir-job-daily-briefing.sh +191 -0
  759. package/bin/nornir-job-git-sync.sh +152 -0
  760. package/bin/nornir-job-memory-housekeeping.sh +114 -0
  761. package/bin/nornir-job-observer.sh +166 -0
  762. package/bin/nornir-job-skillopt-sleep.sh +76 -0
  763. package/bin/omarchy-hook-install.sh +60 -0
  764. package/bin/omarchy-install.sh +148 -0
  765. package/bin/omarchy-plugins.sh +174 -0
  766. package/bin/omarchy-sense.sh +149 -0
  767. package/bin/perm-guard.sh +30 -0
  768. package/bin/pi-agent.sh +68 -0
  769. package/bin/pi-ensure.sh +77 -0
  770. package/bin/pi-local.sh +37 -0
  771. package/bin/pi-seat.sh +63 -0
  772. package/bin/prereq-ensure.sh +166 -0
  773. package/bin/private-guard.sh +38 -0
  774. package/bin/project-git.sh +55 -0
  775. package/bin/public-guard.sh +70 -0
  776. package/bin/ratatoskr.sh +49 -0
  777. package/bin/realm-lib.sh +32 -0
  778. package/bin/repo-scrub.sh +62 -0
  779. package/bin/rodd-operational-input.sh +250 -0
  780. package/bin/runes-append.sh +198 -0
  781. package/bin/saga-session-start.sh +172 -0
  782. package/bin/saga-sessionstart-run.sh +79 -0
  783. package/bin/saga-wake-drain.sh +41 -0
  784. package/bin/secret-guard.sh +63 -0
  785. package/bin/sessrumnir-ensure.sh +100 -0
  786. package/bin/sessrumnir-sync.sh +237 -0
  787. package/bin/sessrumnir.sh +91 -0
  788. package/bin/skillopt-setup.sh +76 -0
  789. package/bin/skuld-branch-outcome.sh +203 -0
  790. package/bin/skuld-branch-prompt.sh +101 -0
  791. package/bin/smidja-bootstrap.sh +90 -0
  792. package/bin/smidja-observe.sh +86 -0
  793. package/bin/syn-arm-pretool-check.sh +57 -0
  794. package/bin/syn-asset-pretool-check.sh +76 -0
  795. package/bin/syn-cd-pretool-check.sh +28 -0
  796. package/bin/syn-guard-pretool-check.sh +83 -0
  797. package/bin/syn-turnend-guard.sh +36 -0
  798. package/bin/syn-watch-arm.sh +99 -0
  799. package/bin/tailscale-sync.sh +190 -0
  800. package/bin/telegram-bot.sh +69 -0
  801. package/bin/toolchain.sh +74 -0
  802. package/bin/utgard.sh +111 -0
  803. package/bin/valhalla.sh +115 -0
  804. package/bin/valknut-load.sh +260 -0
  805. package/bin/vor-crew-state.sh +124 -0
  806. package/bin/wedge-notify.sh +47 -0
  807. package/bin/workspace-provision.sh +80 -0
  808. package/bin/workspace-rag.sh +150 -0
  809. package/bin/wyrd-db.sh +47 -0
  810. package/bin/yggdrasil.sh +152 -0
  811. package/bin/ymir-edit.sh +145 -0
  812. package/bin/ymir-install.sh +641 -0
  813. package/bin/ymir-invite.sh +93 -0
  814. package/bin/ymir-isolation.sh +88 -0
  815. package/bin/ymir-migrate.sh +61 -0
  816. package/bin/ymir-platform.sh +348 -0
  817. package/bin/ymir-say.sh +97 -0
  818. package/bin/ymir-setup-auth.sh +108 -0
  819. package/bin/ymir-validate.sh +173 -0
  820. package/bin/ymir.js +74 -16
  821. package/docs/Architecture.md +339 -0
  822. package/docs/design.md +351 -0
  823. package/docs/installations/README.md +63 -0
  824. package/docs/installations/ymir-home.md +93 -0
  825. package/docs/integration.md +105 -0
  826. package/docs/lore.md +1056 -0
  827. package/docs/research/agent-harnesses.md +290 -0
  828. package/docs/runbooks/README.md +19 -0
  829. package/docs/runbooks/a2a.md +91 -0
  830. package/docs/runbooks/agent-permissions.md +99 -0
  831. package/docs/runbooks/agents.md +57 -0
  832. package/docs/runbooks/models.md +69 -0
  833. package/docs/runbooks/realm-onboarding.md +60 -0
  834. package/docs/runbooks/secrets-and-hoard.md +60 -0
  835. package/docs/runbooks/updates-and-migrations.md +110 -0
  836. package/docs/runbooks/worktrees-isolation.md +72 -0
  837. package/docs/session-start.md +105 -0
  838. package/docs/skillopt-integration.md +321 -0
  839. package/docs/sync-system.md +66 -0
  840. package/docs/workspaces.md +104 -0
  841. package/docs/ymir-rut.md +260 -0
  842. package/package.json +44 -14
  843. package/packaging/build.sh +74 -0
  844. package/packaging/macos/Ymir Installer.command +43 -0
  845. package/packaging/windows/ymir-setup.nsi +74 -0
  846. package/scripts/README.md +22 -0
  847. package/scripts/electron.sh +210 -0
  848. package/scripts/start.sh +167 -0
  849. package/scripts/stop.sh +113 -0
  850. package/install.sh +0 -35
@@ -0,0 +1,2843 @@
1
+ #!/usr/bin/env bash
2
+ # Tests for the tracked Pi primary watcher extension and Pi secondmate wiring.
3
+ set -u
4
+
5
+ # shellcheck source=tests/lib.sh
6
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
7
+
8
+ TMP_ROOT=$(fm_test_tmproot fm-pi-watch-extension)
9
+ EXT="$ROOT/.pi/extensions/fm-primary-pi-watch.ts"
10
+ # Node 24 warns when these test-only dynamic imports load tracked ESM plugins
11
+ # from a clean checkout with no tracked .opencode/package.json. The warning is
12
+ # unrelated to plugin output, which the assertions intentionally require empty.
13
+ export NODE_NO_WARNINGS=1
14
+
15
+ # One owner for the readiness budget every unready-successor test below spends
16
+ # on purpose. Both plugins start a successor arm through a login shell and
17
+ # SIGTERM it when it stays silent past this budget, so the budget has to outlast
18
+ # a cold login-shell start. A successor killed before its first statement never
19
+ # appends its arm row and never installs the TERM trap these tests observe, so
20
+ # too small a budget reports a lost successor instead of the bounded recovery
21
+ # under test. A stock login shell already costs about 200ms on an idle
22
+ # workstation, and a loaded CI runner is slower, so keep an order of magnitude
23
+ # over that rather than a value that only holds locally. The wait loops in those
24
+ # tests are sized against this number.
25
+ ARM_READY_TIMEOUT_MS=2000
26
+
27
+ install_pi_watch_extension_fixture() {
28
+ local repo=$1
29
+ mkdir -p \
30
+ "$repo/.pi/extensions/lib" \
31
+ "$repo/node_modules/@earendil-works/pi-coding-agent" \
32
+ "$repo/node_modules/@earendil-works/pi-tui" \
33
+ "$repo/node_modules/typebox"
34
+ cp "$EXT" "$repo/.pi/extensions/fm-primary-pi-watch.ts"
35
+ cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$repo/.pi/extensions/lib/fm-branch-dispatch.ts"
36
+ cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$repo/.pi/extensions/lib/fm-calm-visibility.ts"
37
+ cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts"
38
+ mkdir -p "$repo/bin"
39
+ cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh"
40
+ chmod +x "$repo/bin/fm-operational-input.sh"
41
+ cat > "$repo/node_modules/@earendil-works/pi-coding-agent/package.json" <<'JSON'
42
+ {"name":"@earendil-works/pi-coding-agent","type":"module","exports":"./index.js"}
43
+ JSON
44
+ cat > "$repo/node_modules/@earendil-works/pi-coding-agent/index.js" <<'JS'
45
+ export function getMarkdownTheme() { return {}; }
46
+ export class UserMessageComponent {
47
+ render() { return []; }
48
+ invalidate() {}
49
+ }
50
+ JS
51
+ cat > "$repo/node_modules/@earendil-works/pi-tui/package.json" <<'JSON'
52
+ {"name":"@earendil-works/pi-tui","type":"module","exports":"./index.js"}
53
+ JSON
54
+ cat > "$repo/node_modules/@earendil-works/pi-tui/index.js" <<'JS'
55
+ export class Box {
56
+ addChild() {}
57
+ clear() {}
58
+ setBgFn() {}
59
+ }
60
+ export class Container {}
61
+ export class Text {}
62
+ JS
63
+ cat > "$repo/node_modules/typebox/package.json" <<'JSON'
64
+ {"name":"typebox","type":"module","exports":"./index.js"}
65
+ JSON
66
+ cat > "$repo/node_modules/typebox/index.js" <<'JS'
67
+ export const Type = {
68
+ Object(properties) {
69
+ return { type: "object", properties, additionalProperties: false };
70
+ },
71
+ };
72
+ JS
73
+ }
74
+
75
+ test_pi_extension_reports_external_healthy_watcher() {
76
+ local repo home plugin out status
77
+ repo="$TMP_ROOT/pi-external-healthy-root"
78
+ home="$TMP_ROOT/pi-external-healthy-home"
79
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
80
+ install_pi_watch_extension_fixture "$repo"
81
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
82
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
83
+ #!/usr/bin/env bash
84
+ printf 'watcher: healthy pid=1 (beacon 0s)\n'
85
+ SH
86
+ chmod +x "$repo/bin/fm-watch-arm.sh"
87
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
88
+ import { writeFileSync } from "node:fs";
89
+ import { pathToFileURL } from "node:url";
90
+
91
+ let handler = null;
92
+ let notification = "";
93
+ let prompt = "";
94
+ const pi = {
95
+ on() {},
96
+ registerCommand(name, options) {
97
+ if (name === "fm-watch-arm-pi") handler = options.handler;
98
+ },
99
+ registerTool() {},
100
+ sendUserMessage: async (message) => {
101
+ prompt = message;
102
+ },
103
+ };
104
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
105
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
106
+ mod.default(pi);
107
+ if (!handler) {
108
+ console.error("Pi watch command was not registered");
109
+ process.exit(1);
110
+ }
111
+ const result = await handler("", {
112
+ ui: {
113
+ notify(message) {
114
+ notification = message;
115
+ },
116
+ },
117
+ });
118
+ if (result !== undefined) {
119
+ console.error(`Pi command returned a value: ${String(result)}`);
120
+ process.exit(1);
121
+ }
122
+ if (!notification.includes("started Pi extension arm child")) {
123
+ console.error(notification);
124
+ process.exit(1);
125
+ }
126
+ for (let i = 0; i < 250 && !prompt; i += 1) {
127
+ await new Promise((resolve) => setTimeout(resolve, 20));
128
+ }
129
+ if (!prompt.startsWith("\u2063FIRSTMATE_OP: v1 watcher: ")) {
130
+ console.error(`untyped operational follow-up: ${prompt}`);
131
+ process.exit(1);
132
+ }
133
+ if (!prompt.includes("FIRSTMATE WATCHER WAKE")) {
134
+ console.error(`missing follow-up prompt: ${prompt}`);
135
+ process.exit(1);
136
+ }
137
+ if (!prompt.includes("external healthy watcher")) {
138
+ console.error(prompt);
139
+ process.exit(1);
140
+ }
141
+ if (!prompt.includes("watcher: healthy pid=1")) {
142
+ console.error(prompt);
143
+ process.exit(1);
144
+ }
145
+ EOF
146
+ )
147
+ status=$?
148
+ expect_code 0 "$status" "Pi extension must surface an external healthy watcher as an owned-wake failure"
149
+ [ -z "$out" ] || fail "Pi external-healthy test printed output: $out"
150
+ pass "Pi extension reports external healthy watcher output"
151
+ }
152
+
153
+ test_pi_tool_returns_agent_tool_result() {
154
+ local repo home plugin out status
155
+ repo="$TMP_ROOT/pi-tool-result-root"
156
+ home="$TMP_ROOT/pi-tool-result-home"
157
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
158
+ install_pi_watch_extension_fixture "$repo"
159
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
160
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
161
+ #!/usr/bin/env bash
162
+ exit 0
163
+ SH
164
+ chmod +x "$repo/bin/fm-watch-arm.sh"
165
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" node --input-type=module 2>&1 <<'EOF'
166
+ import { writeFileSync } from "node:fs";
167
+ import { pathToFileURL } from "node:url";
168
+
169
+ let tool = null;
170
+ const pi = {
171
+ on() {},
172
+ registerCommand() {},
173
+ registerTool(candidate) {
174
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
175
+ },
176
+ sendUserMessage: async () => {},
177
+ };
178
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
179
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
180
+ mod.default(pi);
181
+ if (!tool) throw new Error("Pi watch tool was not registered");
182
+ if (tool.label !== "Arm firstmate watcher") throw new Error(`unexpected label: ${tool.label}`);
183
+ if (tool.parameters?.type !== "object") throw new Error("tool parameters are not a TypeBox object schema");
184
+ const metadata = [tool.description, tool.promptSnippet, ...(tool.promptGuidelines ?? [])].join("\n");
185
+ if (metadata.includes("Always use this tool")) throw new Error(`broad tool-selection metadata remained visible: ${metadata}`);
186
+ if (!tool.description.includes("first required Pi watcher cycle")) throw new Error(`tool description omitted the first-cycle condition: ${tool.description}`);
187
+ if (!tool.promptSnippet.includes("ordinary re-arming is automatic")) throw new Error(`tool snippet omitted automatic continuation: ${tool.promptSnippet}`);
188
+ if (!tool.promptGuidelines.some((guideline) => guideline.includes("ordinary signal, stale, check, or heartbeat handling"))) {
189
+ throw new Error(`tool guidelines omitted ordinary-notification prevention: ${tool.promptGuidelines}`);
190
+ }
191
+ const result = await tool.execute("tool-call-1", {}, undefined, undefined, {});
192
+ if (!Array.isArray(result.content) || result.content[0]?.type !== "text") {
193
+ throw new Error(`invalid tool content: ${JSON.stringify(result)}`);
194
+ }
195
+ if (!result.content[0].text.includes("started Pi extension arm child")) {
196
+ throw new Error(`unexpected tool text: ${result.content[0].text}`);
197
+ }
198
+ if (!result.content[0].text.includes("future ordinary re-arms are automatic")) {
199
+ throw new Error(`initial tool result omitted automatic continuation guidance: ${result.content[0].text}`);
200
+ }
201
+ if (!result.content[0].text.includes("only after a later notification says the cycle is missing, failed, or unhealthy")) {
202
+ throw new Error(`initial tool result omitted the repair-only condition: ${result.content[0].text}`);
203
+ }
204
+ if (result.details?.ok !== true || result.details?.message !== result.content[0].text) {
205
+ throw new Error(`invalid tool details: ${JSON.stringify(result.details)}`);
206
+ }
207
+ EOF
208
+ )
209
+ status=$?
210
+ expect_code 0 "$status" "Pi custom tool must expose first-cycle or repair-only metadata and return Pi's AgentToolResult shape"
211
+ [ -z "$out" ] || fail "Pi tool-result test printed output: $out"
212
+ pass "Pi custom tool exposes repair-only metadata and returns automatic-continuation guidance"
213
+ }
214
+
215
+ test_pi_redundant_tool_call_is_owned_noop() {
216
+ local repo home plugin log stop out status
217
+ repo="$TMP_ROOT/pi-redundant-tool-root"
218
+ home="$TMP_ROOT/pi-redundant-tool-home"
219
+ log="$TMP_ROOT/pi-redundant-tool.log"
220
+ stop="$TMP_ROOT/pi-redundant-tool.stop"
221
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
222
+ install_pi_watch_extension_fixture "$repo"
223
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
224
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
225
+ #!/usr/bin/env bash
226
+ printf 'arm\n' >> "${FM_ARM_LOG:?}"
227
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
228
+ trap 'exit 0' TERM INT
229
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
230
+ SH
231
+ chmod +x "$repo/bin/fm-watch-arm.sh"
232
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF'
233
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
234
+ import { pathToFileURL } from "node:url";
235
+
236
+ let tool = null;
237
+ const pi = {
238
+ on() {},
239
+ registerCommand() {},
240
+ registerTool(candidate) {
241
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
242
+ },
243
+ sendUserMessage: async () => {},
244
+ };
245
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
246
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
247
+ mod.default(pi);
248
+ const initial = await tool.execute("tool-call-first", {}, undefined, undefined, {});
249
+ if (!initial.content[0]?.text.includes("started Pi extension arm child")) {
250
+ throw new Error(`initial call did not start the arm child: ${initial.content[0]?.text}`);
251
+ }
252
+ const redundant = await tool.execute("tool-call-redundant", {}, undefined, undefined, {});
253
+ if (!redundant.content[0]?.text.includes("Pi extension already owns an arm child; no manual re-arm needed")) {
254
+ throw new Error(`redundant call omitted ownership-based no-op guidance: ${redundant.content[0]?.text}`);
255
+ }
256
+ if (/^watcher: healthy\b/.test(redundant.content[0]?.text)) {
257
+ throw new Error(`redundant call overclaimed independent health: ${redundant.content[0]?.text}`);
258
+ }
259
+ if (!redundant.content[0]?.text.includes("only after a later notification says the cycle is missing, failed, or unhealthy")) {
260
+ throw new Error(`redundant call omitted the repair-only condition: ${redundant.content[0]?.text}`);
261
+ }
262
+ for (let i = 0; i < 100 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
263
+ await new Promise((resolve) => setTimeout(resolve, 10));
264
+ }
265
+ if (!existsSync(process.env.FM_ARM_LOG)) throw new Error("initial arm child did not start");
266
+ await new Promise((resolve) => setTimeout(resolve, 100));
267
+ const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
268
+ if (rows.length !== 1) throw new Error(`redundant call spawned ${rows.length} arm children`);
269
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
270
+ EOF
271
+ )
272
+ status=$?
273
+ expect_code 0 "$status" "Pi redundant tool call must remain an ownership-based no-op with repair-only guidance"
274
+ [ -z "$out" ] || fail "Pi redundant-call test printed output: $out"
275
+ pass "Pi redundant tool call returns ownership guidance and spawns no second child"
276
+ }
277
+
278
+ test_pi_scheduled_retry_call_is_owned_noop() {
279
+ local repo home plugin log out status
280
+ repo="$TMP_ROOT/pi-scheduled-retry-root"
281
+ home="$TMP_ROOT/pi-scheduled-retry-home"
282
+ log="$TMP_ROOT/pi-scheduled-retry.log"
283
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
284
+ install_pi_watch_extension_fixture "$repo"
285
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
286
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
287
+ #!/usr/bin/env bash
288
+ printf 'arm\n' >> "${FM_ARM_LOG:?}"
289
+ exit 0
290
+ SH
291
+ chmod +x "$repo/bin/fm-watch-arm.sh"
292
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_WATCH_REARM_RETRY_BASE_MS=10000 FM_WATCH_REARM_RETRY_MAX_MS=10000 node --input-type=module 2>&1 <<'EOF'
293
+ import { readFileSync, writeFileSync } from "node:fs";
294
+ import { pathToFileURL } from "node:url";
295
+
296
+ let tool = null;
297
+ const pi = {
298
+ on() {},
299
+ registerCommand() {},
300
+ registerTool(candidate) {
301
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
302
+ },
303
+ sendUserMessage: async () => {},
304
+ };
305
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
306
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
307
+ mod.default(pi);
308
+ await tool.execute("tool-call-first", {}, undefined, undefined, {});
309
+ let redundant = null;
310
+ for (let i = 0; i < 100; i += 1) {
311
+ await new Promise((resolve) => setTimeout(resolve, 10));
312
+ redundant = await tool.execute("tool-call-during-retry", {}, undefined, undefined, {});
313
+ if (redundant.content[0]?.text.includes("scheduled continuity retry")) break;
314
+ }
315
+ if (!redundant?.content[0]?.text.includes("Pi extension already owns a scheduled continuity retry; no manual re-arm needed")) {
316
+ throw new Error(`scheduled retry did not return ownership-based no-op guidance: ${redundant?.content[0]?.text}`);
317
+ }
318
+ if (/^watcher: healthy\b/.test(redundant.content[0]?.text)) {
319
+ throw new Error(`scheduled retry call overclaimed independent health: ${redundant.content[0]?.text}`);
320
+ }
321
+ if (!redundant.content[0]?.text.includes("only after a later notification says the cycle is missing, failed, or unhealthy")) {
322
+ throw new Error(`scheduled retry call omitted the repair-only condition: ${redundant.content[0]?.text}`);
323
+ }
324
+ await new Promise((resolve) => setTimeout(resolve, 100));
325
+ const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
326
+ if (rows.length !== 1) throw new Error(`scheduled retry call spawned ${rows.length} arm children`);
327
+ EOF
328
+ )
329
+ status=$?
330
+ expect_code 0 "$status" "Pi scheduled-retry call must not duplicate the extension-owned retry"
331
+ [ -z "$out" ] || fail "Pi scheduled-retry test printed output: $out"
332
+ pass "Pi scheduled retry remains extension-owned after another tool call"
333
+ }
334
+
335
+ test_pi_actionable_close_starts_single_successor_before_delivery() {
336
+ local repo home plugin log stop out status
337
+ repo="$TMP_ROOT/pi-continuous-rearm-root"
338
+ home="$TMP_ROOT/pi-continuous-rearm-home"
339
+ log="$TMP_ROOT/pi-continuous-rearm.log"
340
+ stop="$TMP_ROOT/pi-continuous-rearm.stop"
341
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
342
+ install_pi_watch_extension_fixture "$repo"
343
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
344
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
345
+ #!/usr/bin/env bash
346
+ if [ "${1:-}" = --handling-delivered ]; then
347
+ printf 'confirmed generation=%s watcher=%s\n' "$2" "$4" >> "${FM_ARM_LOG:?}"
348
+ exit 0
349
+ fi
350
+ printf 'arm=%s predecessor=%s\n' "$$" "${FM_WATCH_PREDECESSOR_ARM_PID:-none}" >> "${FM_ARM_LOG:?}"
351
+ count=$(grep -c '^arm=' "$FM_ARM_LOG")
352
+ if [ "$count" -eq 1 ]; then
353
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
354
+ printf 'signal: synthetic actionable close\n'
355
+ exit 0
356
+ fi
357
+ printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
358
+ trap 'exit 0' TERM INT
359
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
360
+ SH
361
+ chmod +x "$repo/bin/fm-watch-arm.sh"
362
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF'
363
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
364
+ import { pathToFileURL } from "node:url";
365
+
366
+ let tool = null;
367
+ let deliveryStarted = false;
368
+ let rowsAtDelivery = 0;
369
+ let releaseDelivery = () => {};
370
+ const deliveryBlocked = new Promise((resolve) => {
371
+ releaseDelivery = resolve;
372
+ });
373
+ const pi = {
374
+ on() {},
375
+ registerCommand() {},
376
+ registerTool(candidate) {
377
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
378
+ },
379
+ sendUserMessage: async () => {
380
+ rowsAtDelivery = existsSync(process.env.FM_ARM_LOG)
381
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n").filter((row) => row.startsWith("arm=")).length
382
+ : 0;
383
+ deliveryStarted = true;
384
+ await deliveryBlocked;
385
+ },
386
+ };
387
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
388
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
389
+ mod.default(pi);
390
+ await tool.execute("tool-call-continuity", {}, undefined, undefined, {});
391
+ for (let i = 0; i < 250; i += 1) {
392
+ const rows = existsSync(process.env.FM_ARM_LOG)
393
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
394
+ : [];
395
+ if (rows.length >= 2 && deliveryStarted) break;
396
+ await new Promise((resolve) => setTimeout(resolve, 10));
397
+ }
398
+ const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
399
+ const armRows = rows.filter((row) => row.startsWith("arm="));
400
+ if (armRows.length !== 2) throw new Error(`expected one successor arm, got ${armRows.length}: ${rows.join(" | ")}`);
401
+ if (!deliveryStarted) throw new Error("wake delivery did not begin");
402
+ if (rowsAtDelivery !== 2) throw new Error(`wake delivery began before successor establishment (${rowsAtDelivery} arm rows)`);
403
+ if (!/predecessor=[0-9]+/.test(armRows[1])) throw new Error(`successor did not receive predecessor identity: ${armRows[1]}`);
404
+ if (!rows.some((row) => row.startsWith("confirmed generation=fixture-generation"))) {
405
+ throw new Error(`handling delivery was not confirmed before the follow-up: ${rows.join(" | ")}`);
406
+ }
407
+ await new Promise((resolve) => setTimeout(resolve, 100));
408
+ const stableRows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
409
+ if (stableRows.filter((row) => row.startsWith("arm=")).length !== 2) {
410
+ throw new Error(`blocked follow-up started extra arm work: ${stableRows.join(" | ")}`);
411
+ }
412
+ if (stableRows.filter((row) => row.startsWith("confirmed ")).length !== 1) {
413
+ throw new Error(`successful prompt delivery was not confirmed exactly once: ${stableRows.join(" | ")}`);
414
+ }
415
+ releaseDelivery();
416
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
417
+ process.exit(0);
418
+ EOF
419
+ )
420
+ status=$?
421
+ expect_code 0 "$status" "Pi actionable close must start one successor before wake delivery settles"
422
+ [ -z "$out" ] || fail "Pi continuous-rearm test printed output: $out"
423
+ pass "Pi actionable close starts one successor before wake delivery settles"
424
+ }
425
+
426
+ test_pi_branch_offer_owns_actionable_wake() {
427
+ local repo home plugin log stop out status
428
+ repo="$TMP_ROOT/pi-branch-offer-root"
429
+ home="$TMP_ROOT/pi-branch-offer-home"
430
+ log="$TMP_ROOT/pi-branch-offer.log"
431
+ stop="$TMP_ROOT/pi-branch-offer.stop"
432
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
433
+ install_pi_watch_extension_fixture "$repo"
434
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
435
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
436
+ #!/usr/bin/env bash
437
+ if [ "${1:-}" = --handling-delivered ]; then
438
+ printf 'confirmed generation=%s watcher=%s\n' "$2" "$4" >> "${FM_ARM_LOG:?}"
439
+ exit 0
440
+ fi
441
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
442
+ count=$(grep -c '^arm=' "$FM_ARM_LOG")
443
+ if [ "$count" -eq 1 ]; then
444
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
445
+ printf 'signal: branch-offer synthetic wake\n'
446
+ exit 0
447
+ fi
448
+ printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
449
+ trap 'exit 0' TERM INT
450
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
451
+ SH
452
+ chmod +x "$repo/bin/fm-watch-arm.sh"
453
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF'
454
+ import { readFileSync, writeFileSync } from "node:fs";
455
+ import { pathToFileURL } from "node:url";
456
+
457
+ // Two independent runs against the SAME dispatcher build: with an accepting
458
+ // branch listener the wake must be owned by the branch (no main follow-up);
459
+ // with a bus but no acceptor the dispatcher must fall back to main. The
460
+ // divergence between the two runs is asserted, so the case cannot go vacuous.
461
+ async function runScenario(withAcceptor) {
462
+ writeFileSync(process.env.FM_ARM_LOG, "");
463
+ const offers = [];
464
+ let mainPrompt = "";
465
+ let tool = null;
466
+ const handlers = new Map();
467
+ const bus = {
468
+ on(channel, handler) {
469
+ handlers.set(channel, [...(handlers.get(channel) ?? []), handler]);
470
+ return () => {};
471
+ },
472
+ emit(channel, data) {
473
+ for (const handler of handlers.get(channel) ?? []) handler(data);
474
+ },
475
+ };
476
+ if (withAcceptor) {
477
+ bus.on("fm-branch-supervision:dispatch", (offer) => {
478
+ offers.push({ message: offer.message, projects: offer.projects });
479
+ offer.accept();
480
+ });
481
+ }
482
+ const pi = {
483
+ on() {},
484
+ events: bus,
485
+ registerCommand() {},
486
+ registerTool(candidate) {
487
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
488
+ },
489
+ sendUserMessage: async (message) => {
490
+ mainPrompt = message;
491
+ },
492
+ };
493
+ const mod = await import(`${pathToFileURL(process.env.PLUGIN).href}?scenario=${withAcceptor}`);
494
+ mod.default(pi);
495
+ await tool.execute("tool-call-branch-offer", {}, undefined, undefined, {});
496
+ for (let i = 0; i < 250; i += 1) {
497
+ const settled = withAcceptor ? offers.length > 0 : mainPrompt !== "";
498
+ if (settled) break;
499
+ await new Promise((resolve) => setTimeout(resolve, 10));
500
+ }
501
+ const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
502
+ return { offers, mainPrompt, rows };
503
+ }
504
+
505
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
506
+ writeFileSync(`${process.env.FM_HOME}/state/branch-offer.meta`, "project=/projects/approved\nwindow=fm-branch-offer\n");
507
+ writeFileSync(`${process.env.FM_HOME}/state/.wake-queue`, "1\t1\tsignal\tbranch-offer.status\tsignal: branch-offer synthetic wake\n");
508
+ const accepted = await runScenario(true);
509
+ if (accepted.offers.length !== 1) throw new Error(`expected one branch offer, got ${accepted.offers.length}`);
510
+ if (!accepted.offers[0].message.includes("signal: branch-offer synthetic wake")) {
511
+ throw new Error(`offer missed the wake reason: ${accepted.offers[0].message}`);
512
+ }
513
+ if (JSON.stringify(accepted.offers[0].projects) !== JSON.stringify(["/projects/approved"])) {
514
+ throw new Error(`offer did not carry the queued task project: ${JSON.stringify(accepted.offers[0].projects)}`);
515
+ }
516
+ if (accepted.mainPrompt !== "") throw new Error(`accepted offer still reached main: ${accepted.mainPrompt}`);
517
+ if (!accepted.rows.some((row) => row.startsWith("confirmed generation=fixture-generation"))) {
518
+ throw new Error(`handling delivery was not confirmed before the branch handoff: ${accepted.rows.join(" | ")}`);
519
+ }
520
+ const declined = await runScenario(false);
521
+ if (declined.offers.length !== 0) throw new Error("no-acceptor scenario recorded an offer");
522
+ if (!declined.mainPrompt.includes("FIRSTMATE WATCHER WAKE")) {
523
+ throw new Error(`unaccepted offer did not fall back to main: ${declined.mainPrompt}`);
524
+ }
525
+ if (!declined.mainPrompt.includes("signal: branch-offer synthetic wake")) {
526
+ throw new Error(`fallback wake lost the reason line: ${declined.mainPrompt}`);
527
+ }
528
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
529
+ process.exit(0);
530
+ EOF
531
+ )
532
+ status=$?
533
+ expect_code 0 "$status" "Pi dispatcher must hand an accepted wake to the branch and fall back to main otherwise"
534
+ [ -z "$out" ] || fail "Pi branch-offer test printed output: $out"
535
+ pass "Pi dispatcher branch offer owns accepted wakes and falls back to main"
536
+ }
537
+
538
+ test_pi_branch_offer_flags_heartbeat() {
539
+ local repo home plugin log stop out status
540
+ repo="$TMP_ROOT/pi-branch-heartbeat-root"
541
+ home="$TMP_ROOT/pi-branch-heartbeat-home"
542
+ log="$TMP_ROOT/pi-branch-heartbeat.log"
543
+ stop="$TMP_ROOT/pi-branch-heartbeat.stop"
544
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
545
+ install_pi_watch_extension_fixture "$repo"
546
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
547
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
548
+ #!/usr/bin/env bash
549
+ if [ "${1:-}" = --handling-delivered ]; then
550
+ printf 'confirmed generation=%s watcher=%s\n' "$2" "$4" >> "${FM_ARM_LOG:?}"
551
+ exit 0
552
+ fi
553
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
554
+ count=$(grep -c '^arm=' "$FM_ARM_LOG")
555
+ if [ "$count" -eq 1 ]; then
556
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
557
+ printf 'heartbeat\n'
558
+ exit 0
559
+ fi
560
+ printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
561
+ trap 'exit 0' TERM INT
562
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
563
+ SH
564
+ chmod +x "$repo/bin/fm-watch-arm.sh"
565
+ # A bare heartbeat is the real wake emitted after the cheap bash scan flags
566
+ # a fleet pass as possibly captain-relevant. It has no task-scoped queue row,
567
+ # so branch eligibility must remain independent of project resolution.
568
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF'
569
+ import { writeFileSync } from "node:fs";
570
+ import { pathToFileURL } from "node:url";
571
+
572
+ const offers = [];
573
+ const handlers = new Map();
574
+ const bus = {
575
+ on(channel, handler) {
576
+ handlers.set(channel, [...(handlers.get(channel) ?? []), handler]);
577
+ return () => {};
578
+ },
579
+ emit(channel, data) {
580
+ for (const handler of handlers.get(channel) ?? []) handler(data);
581
+ },
582
+ };
583
+ bus.on("fm-branch-supervision:dispatch", (offer) => {
584
+ offers.push({ message: offer.message, projects: offer.projects, heartbeat: offer.heartbeat, eligible: offer.eligible });
585
+ offer.accept();
586
+ });
587
+ let tool = null;
588
+ const pi = {
589
+ on() {},
590
+ events: bus,
591
+ registerCommand() {},
592
+ registerTool(candidate) {
593
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
594
+ },
595
+ sendUserMessage: async () => {},
596
+ };
597
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
598
+ writeFileSync(`${process.env.FM_HOME}/state/.wake-queue`, "1\t1\theartbeat\theartbeat\theartbeat\n");
599
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
600
+ mod.default(pi);
601
+ await tool.execute("tool-call-branch-heartbeat", {}, undefined, undefined, {});
602
+ for (let i = 0; i < 250 && offers.length === 0; i += 1) {
603
+ await new Promise((resolve) => setTimeout(resolve, 10));
604
+ }
605
+ if (offers.length !== 1) throw new Error(`expected one branch offer, got ${offers.length}`);
606
+ if (offers[0].message !== "heartbeat") {
607
+ throw new Error(`offer changed the bare heartbeat wake reason: ${offers[0].message}`);
608
+ }
609
+ if (offers[0].heartbeat !== true || offers[0].eligible !== true) throw new Error(`heartbeat offer was not eligible: ${JSON.stringify(offers[0])}`);
610
+ if (JSON.stringify(offers[0].projects) !== JSON.stringify([])) {
611
+ throw new Error(`heartbeat offer unexpectedly carried scoped projects: ${JSON.stringify(offers[0].projects)}`);
612
+ }
613
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
614
+ process.exit(0);
615
+ EOF
616
+ )
617
+ status=$?
618
+ expect_code 0 "$status" "Pi dispatcher must flag a heartbeat offer independent of task scoping"
619
+ [ -z "$out" ] || fail "Pi branch-heartbeat test printed output: $out"
620
+ pass "Pi dispatcher flags a fleet-wide heartbeat offer as branch-eligible"
621
+ }
622
+
623
+ test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check() {
624
+ local repo home plugin log stop out status
625
+ repo="$TMP_ROOT/pi-heartbeat-mixed-queue-root"
626
+ home="$TMP_ROOT/pi-heartbeat-mixed-queue-home"
627
+ log="$TMP_ROOT/pi-heartbeat-mixed-queue.log"
628
+ stop="$TMP_ROOT/pi-heartbeat-mixed-queue.stop"
629
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
630
+ install_pi_watch_extension_fixture "$repo"
631
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
632
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
633
+ #!/usr/bin/env bash
634
+ if [ "${1:-}" = --handling-delivered ]; then exit 0; fi
635
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
636
+ count=$(grep -c '^arm=' "$FM_ARM_LOG")
637
+ if [ "$count" -eq 1 ]; then
638
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
639
+ printf 'heartbeat\n'
640
+ exit 0
641
+ fi
642
+ printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
643
+ trap 'exit 0' TERM INT
644
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
645
+ SH
646
+ chmod +x "$repo/bin/fm-watch-arm.sh"
647
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF'
648
+ import { writeFileSync } from "node:fs";
649
+ import { pathToFileURL } from "node:url";
650
+
651
+ const offers = [];
652
+ let prompt = "";
653
+ let tool = null;
654
+ const handlers = new Map();
655
+ const bus = {
656
+ on(channel, handler) {
657
+ handlers.set(channel, [...(handlers.get(channel) ?? []), handler]);
658
+ return () => {};
659
+ },
660
+ emit(channel, data) {
661
+ for (const handler of handlers.get(channel) ?? []) handler(data);
662
+ },
663
+ };
664
+ bus.on("fm-branch-supervision:dispatch", (offer) => {
665
+ offers.push({ message: offer.message, heartbeat: offer.heartbeat, eligible: offer.eligible });
666
+ if (offer.eligible) offer.accept();
667
+ });
668
+ const pi = {
669
+ on() {},
670
+ events: bus,
671
+ registerCommand() {},
672
+ registerTool(candidate) {
673
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
674
+ },
675
+ sendUserMessage: async (message) => {
676
+ prompt = message;
677
+ },
678
+ };
679
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
680
+ writeFileSync(
681
+ `${process.env.FM_HOME}/state/.wake-queue`,
682
+ "1\t1\theartbeat\theartbeat\theartbeat\n2\t2\tcheck\tx-inbox\tcheck: pending x mention\n",
683
+ );
684
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
685
+ mod.default(pi);
686
+ await tool.execute("tool-call-heartbeat-mixed-queue", {}, undefined, undefined, {});
687
+ // The offer is what this asserts on, so wait for it and then give any
688
+ // erroneous main delivery a real chance to land before calling it absent.
689
+ for (let i = 0; i < 250 && offers.length === 0; i += 1) {
690
+ await new Promise((resolve) => setTimeout(resolve, 10));
691
+ }
692
+ for (let i = 0; i < 25 && !prompt; i += 1) {
693
+ await new Promise((resolve) => setTimeout(resolve, 10));
694
+ }
695
+ if (offers.length !== 1 || offers[0].heartbeat !== true || offers[0].eligible !== true) {
696
+ throw new Error(`a co-present check row made the heartbeat offer ineligible: ${JSON.stringify(offers)}`);
697
+ }
698
+ if (prompt) {
699
+ throw new Error(`a co-present check row rode the heartbeat into main: ${prompt}`);
700
+ }
701
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
702
+ process.exit(0);
703
+ EOF
704
+ )
705
+ status=$?
706
+ expect_code 0 "$status" "a heartbeat must not ride a co-present check row into main: $out"
707
+ [ -z "$out" ] || fail "Pi mixed heartbeat-queue test printed output: $out"
708
+ pass "a co-present check row neither vetoes nor rides a heartbeat into main"
709
+ }
710
+
711
+ # Every check the main session alone can act on stays on main, even when an
712
+ # unrelated task-local row in the same queue is perfectly branch-eligible. The
713
+ # no-op checks the branch never sees are suppressed at their source instead
714
+ # (bin/fm-procevent-lavish.sh "silent"), so what remains under a `check:`
715
+ # trigger is exactly the main-only set, and each named class is driven here
716
+ # through the real dispatcher.
717
+ test_pi_main_only_check_classes_stay_on_main() {
718
+ local repo home plugin log stop out status reason label
719
+ repo="$TMP_ROOT/pi-main-only-check-root"
720
+ home="$TMP_ROOT/pi-main-only-check-home"
721
+ mkdir -p "$repo/bin" "$home/state" "$home/config" "$home/projects/approved"
722
+ install_pi_watch_extension_fixture "$repo"
723
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
724
+ printf 'project=%s/projects/approved\nwindow=fm-window\n' "$home" > "$home/state/task-a.meta"
725
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
726
+ #!/usr/bin/env bash
727
+ if [ "${1:-}" = --handling-delivered ]; then exit 0; fi
728
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
729
+ count=$(grep -c '^arm=' "$FM_ARM_LOG")
730
+ if [ "$count" -eq 1 ]; then
731
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
732
+ printf '%s\n' "${FM_TEST_REASON:?}"
733
+ exit 0
734
+ fi
735
+ printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
736
+ trap 'exit 0' TERM INT
737
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
738
+ SH
739
+ chmod +x "$repo/bin/fm-watch-arm.sh"
740
+ while IFS='|' read -r label reason; do
741
+ [ -n "$label" ] || continue
742
+ log="$TMP_ROOT/pi-main-only-check-$label.log"
743
+ stop="$TMP_ROOT/pi-main-only-check-$label.stop"
744
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" \
745
+ FM_TEST_REASON="$reason" node --input-type=module 2>&1 <<'EOF'
746
+ import { writeFileSync } from "node:fs";
747
+ import { pathToFileURL } from "node:url";
748
+
749
+ const offers = [];
750
+ let prompt = "";
751
+ let tool = null;
752
+ const handlers = new Map();
753
+ const bus = {
754
+ on(channel, handler) {
755
+ handlers.set(channel, [...(handlers.get(channel) ?? []), handler]);
756
+ return () => {};
757
+ },
758
+ emit(channel, data) {
759
+ for (const handler of handlers.get(channel) ?? []) handler(data);
760
+ },
761
+ };
762
+ bus.on("fm-branch-supervision:dispatch", (offer) => {
763
+ offers.push({ message: offer.message, eligible: offer.eligible });
764
+ if (offer.eligible) offer.accept();
765
+ });
766
+ const pi = {
767
+ on() {},
768
+ events: bus,
769
+ registerCommand() {},
770
+ registerTool(candidate) {
771
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
772
+ },
773
+ sendUserMessage: async (message) => {
774
+ prompt = message;
775
+ },
776
+ };
777
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
778
+ // A task-local row the branch would happily take sits in the same queue, so
779
+ // only the check-kind TRIGGER itself can be what keeps this wake on main.
780
+ writeFileSync(
781
+ `${process.env.FM_HOME}/state/.wake-queue`,
782
+ "1\t1\tsignal\ttask-a.status\tsignal: task-a.status\n2\t2\tcheck\tmain-only\t" + process.env.FM_TEST_REASON + "\n",
783
+ );
784
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
785
+ mod.default(pi);
786
+ await tool.execute("tool-call-main-only-check", {}, undefined, undefined, {});
787
+ for (let i = 0; i < 250 && !prompt; i += 1) {
788
+ await new Promise((resolve) => setTimeout(resolve, 10));
789
+ }
790
+ if (offers.length !== 1 || offers[0].eligible !== false) {
791
+ throw new Error(`a main-only check was offered to the branch: ${JSON.stringify(offers)}`);
792
+ }
793
+ if (!prompt.includes(`FIRSTMATE WATCHER WAKE: ${process.env.FM_TEST_REASON}`)) {
794
+ throw new Error(`a main-only check did not reach main: ${prompt}`);
795
+ }
796
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
797
+ process.exit(0);
798
+ EOF
799
+ )
800
+ status=$?
801
+ expect_code 0 "$status" "the $label check class must stay on main: $out"
802
+ [ -z "$out" ] || fail "Pi main-only check test ($label) printed output: $out"
803
+ done <<'CLASSES'
804
+ relay-mention|check: x-mention 1234567890
805
+ credential-failure|check: gh auth check failed; re-authenticate before dispatch
806
+ merge-confirmation|check: task-a.check.sh: PR merged
807
+ real-board-answer|check: procevent lavish lavish-abcdef0123456789 1
808
+ CLASSES
809
+ pass "every main-only check class still reaches main, never the supervision branch"
810
+ }
811
+
812
+ test_pi_heartbeat_restoration_failure_stays_on_main() {
813
+ local repo home plugin log out status
814
+ repo="$TMP_ROOT/pi-heartbeat-restoration-failure-root"
815
+ home="$TMP_ROOT/pi-heartbeat-restoration-failure-home"
816
+ log="$TMP_ROOT/pi-heartbeat-restoration-failure.log"
817
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
818
+ install_pi_watch_extension_fixture "$repo"
819
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
820
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
821
+ #!/usr/bin/env bash
822
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
823
+ count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
824
+ if [ "$count" -eq 1 ]; then
825
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
826
+ printf 'heartbeat\n'
827
+ exit 0
828
+ fi
829
+ printf 'synthetic successor startup failure\n' >&2
830
+ exit 1
831
+ SH
832
+ chmod +x "$repo/bin/fm-watch-arm.sh"
833
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
834
+ import { writeFileSync } from "node:fs";
835
+ import { pathToFileURL } from "node:url";
836
+
837
+ const offers = [];
838
+ let prompt = "";
839
+ let tool = null;
840
+ const handlers = new Map();
841
+ const bus = {
842
+ on(channel, handler) {
843
+ handlers.set(channel, [...(handlers.get(channel) ?? []), handler]);
844
+ return () => {};
845
+ },
846
+ emit(channel, data) {
847
+ for (const handler of handlers.get(channel) ?? []) handler(data);
848
+ },
849
+ };
850
+ bus.on("fm-branch-supervision:dispatch", (offer) => {
851
+ offers.push({ message: offer.message, heartbeat: offer.heartbeat });
852
+ offer.accept();
853
+ });
854
+ const pi = {
855
+ on() {},
856
+ events: bus,
857
+ registerCommand() {},
858
+ registerTool(candidate) {
859
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
860
+ },
861
+ sendUserMessage: async (message) => {
862
+ prompt = message;
863
+ },
864
+ };
865
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
866
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
867
+ mod.default(pi);
868
+ await tool.execute("tool-call-heartbeat-restoration-failure", {}, undefined, undefined, {});
869
+ for (let i = 0; i < 500 && !prompt; i += 1) {
870
+ await new Promise((resolve) => setTimeout(resolve, 10));
871
+ }
872
+ if (offers.length !== 0) {
873
+ throw new Error(`heartbeat restoration failure was offered to the branch: ${JSON.stringify(offers)}`);
874
+ }
875
+ if (!prompt.includes("FIRSTMATE WATCHER WAKE: heartbeat\n\nwatcher: FAILED")) {
876
+ throw new Error(`main wake lost the bare heartbeat reason: ${prompt}`);
877
+ }
878
+ if (!prompt.includes("watcher: FAILED - Pi extension could not restore watcher continuity after 2 retries")) {
879
+ throw new Error(`main wake lost the restoration failure: ${prompt}`);
880
+ }
881
+ EOF
882
+ )
883
+ status=$?
884
+ expect_code 0 "$status" "heartbeat restoration failure must bypass an accepting branch: $out"
885
+ [ -z "$out" ] || fail "Pi heartbeat restoration-failure test printed output: $out"
886
+ pass "heartbeat restoration failure stays on main"
887
+ }
888
+
889
+ test_pi_watcher_failure_never_offered_to_branch() {
890
+ local repo home plugin out status
891
+ repo="$TMP_ROOT/pi-watcher-failure-root"
892
+ home="$TMP_ROOT/pi-watcher-failure-home"
893
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
894
+ install_pi_watch_extension_fixture "$repo"
895
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
896
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
897
+ #!/usr/bin/env bash
898
+ printf 'watcher: healthy pid=1 (beacon 0s)\n'
899
+ SH
900
+ chmod +x "$repo/bin/fm-watch-arm.sh"
901
+ # Only "actionable" closes (signal/stale/check/heartbeat) ever reach
902
+ # offerWakeToBranch; only main can repair the watcher cycle
903
+ # (docs/pi-supervision-branch.md), and default-on eligibility must not
904
+ # change that. A live, always-accepting bus listener proves the negative:
905
+ # even with an acceptor present, a watcher-failure close is never offered.
906
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
907
+ import { writeFileSync } from "node:fs";
908
+ import { pathToFileURL } from "node:url";
909
+
910
+ const offers = [];
911
+ let prompt = "";
912
+ let handler = null;
913
+ const handlers = new Map();
914
+ const bus = {
915
+ on(channel, h) {
916
+ handlers.set(channel, [...(handlers.get(channel) ?? []), h]);
917
+ return () => {};
918
+ },
919
+ emit(channel, data) {
920
+ for (const h of handlers.get(channel) ?? []) h(data);
921
+ },
922
+ };
923
+ bus.on("fm-branch-supervision:dispatch", (offer) => {
924
+ offers.push({ message: offer.message, heartbeat: offer.heartbeat });
925
+ offer.accept();
926
+ });
927
+ const pi = {
928
+ on() {},
929
+ events: bus,
930
+ registerCommand(name, options) {
931
+ if (name === "fm-watch-arm-pi") handler = options.handler;
932
+ },
933
+ registerTool() {},
934
+ sendUserMessage: async (message) => {
935
+ prompt = message;
936
+ },
937
+ };
938
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
939
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
940
+ mod.default(pi);
941
+ await handler("", { ui: { notify() {} } });
942
+ for (let i = 0; i < 250 && !prompt; i += 1) {
943
+ await new Promise((resolve) => setTimeout(resolve, 20));
944
+ }
945
+ if (!prompt.includes("external healthy watcher")) {
946
+ throw new Error(`watcher failure did not reach main: ${prompt}`);
947
+ }
948
+ if (offers.length !== 0) {
949
+ throw new Error(`watcher failure was offered to the branch: ${JSON.stringify(offers)}`);
950
+ }
951
+ EOF
952
+ )
953
+ status=$?
954
+ expect_code 0 "$status" "watcher-failure repair must never be offered to the branch: $out"
955
+ [ -z "$out" ] || fail "Pi watcher-failure test printed output: $out"
956
+ pass "watcher-failure repair stays with main even with a live, accepting branch listener"
957
+ }
958
+
959
+ test_pi_handling_delivery_failure_is_typed_once() {
960
+ local repo home plugin log stop out status
961
+ repo="$TMP_ROOT/pi-handling-fail-root"
962
+ home="$TMP_ROOT/pi-handling-fail-home"
963
+ log="$TMP_ROOT/pi-handling-fail.log"
964
+ stop="$TMP_ROOT/pi-handling-fail.stop"
965
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
966
+ install_pi_watch_extension_fixture "$repo"
967
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
968
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
969
+ #!/usr/bin/env bash
970
+ if [ "${1:-}" = --handling-delivered ]; then
971
+ printf 'refused generation=%s watcher=%s\n' "$2" "$4" >> "${FM_ARM_LOG:?}"
972
+ exit 1
973
+ fi
974
+ printf 'arm=%s predecessor=%s\n' "$$" "${FM_WATCH_PREDECESSOR_ARM_PID:-none}" >> "${FM_ARM_LOG:?}"
975
+ count=$(grep -c '^arm=' "$FM_ARM_LOG")
976
+ if [ "$count" -eq 1 ]; then
977
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
978
+ printf 'signal: synthetic actionable close\n'
979
+ exit 0
980
+ fi
981
+ printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
982
+ trap 'exit 0' TERM INT
983
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
984
+ SH
985
+ chmod +x "$repo/bin/fm-watch-arm.sh"
986
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF'
987
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
988
+ import { pathToFileURL } from "node:url";
989
+
990
+ let tool = null;
991
+ let prompt = "";
992
+ const pi = {
993
+ on() {},
994
+ registerCommand() {},
995
+ registerTool(candidate) {
996
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
997
+ },
998
+ sendUserMessage: async (message) => {
999
+ prompt += message;
1000
+ },
1001
+ };
1002
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
1003
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1004
+ mod.default(pi);
1005
+ await tool.execute("tool-call-handling-fail", {}, undefined, undefined, {});
1006
+ for (let i = 0; i < 250 && !prompt.includes("handling delivery confirmation was rejected"); i += 1) {
1007
+ await new Promise((resolve) => setTimeout(resolve, 20));
1008
+ }
1009
+ if (!prompt.includes("FIRSTMATE WATCHER WAKE")) throw new Error(`missing follow-up: ${prompt}`);
1010
+ if (!prompt.includes("handling delivery confirmation was rejected")) {
1011
+ throw new Error(`failed handshake was swallowed: ${prompt}`);
1012
+ }
1013
+ if ((prompt.match(/FIRSTMATE WATCHER WAKE/g) || []).length !== 1) {
1014
+ throw new Error(`failed handshake was not a single typed message: ${prompt}`);
1015
+ }
1016
+ const rows = existsSync(process.env.FM_ARM_LOG)
1017
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
1018
+ : [];
1019
+ if (rows.filter((row) => row.startsWith("refused ")).length < 1) {
1020
+ throw new Error(`handling-delivered was never attempted: ${rows.join(" | ")}`);
1021
+ }
1022
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
1023
+ process.exit(0);
1024
+ EOF
1025
+ )
1026
+ status=$?
1027
+ expect_code 0 "$status" "Pi must surface a refused handling handshake as one typed failure"
1028
+ [ -z "$out" ] || fail "Pi handling-delivery failure test printed output: $out"
1029
+ pass "Pi refused handling handshake is classified and not swallowed"
1030
+ }
1031
+
1032
+ test_pi_hung_successor_falls_back_to_typed_wake() {
1033
+ local repo home plugin log out status
1034
+ repo="$TMP_ROOT/pi-hung-successor-root"
1035
+ home="$TMP_ROOT/pi-hung-successor-home"
1036
+ log="$TMP_ROOT/pi-hung-successor.log"
1037
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
1038
+ install_pi_watch_extension_fixture "$repo"
1039
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
1040
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
1041
+ #!/usr/bin/env bash
1042
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
1043
+ count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
1044
+ if [ "$count" -eq 1 ]; then
1045
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
1046
+ printf 'signal: synthetic wake\n'
1047
+ exit 0
1048
+ fi
1049
+ trap 'exit 0' TERM INT
1050
+ while :; do sleep 0.02; done
1051
+ SH
1052
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1053
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_PI_ARM_READY_TIMEOUT_MS="$ARM_READY_TIMEOUT_MS" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
1054
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
1055
+ import { pathToFileURL } from "node:url";
1056
+
1057
+ let tool = null;
1058
+ let prompt = "";
1059
+ let rowsAtPrompt = 0;
1060
+ const pi = {
1061
+ on() {},
1062
+ registerCommand() {},
1063
+ registerTool(candidate) {
1064
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
1065
+ },
1066
+ sendUserMessage: async (message) => {
1067
+ prompt += message;
1068
+ rowsAtPrompt = existsSync(process.env.FM_ARM_LOG)
1069
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n").length
1070
+ : 0;
1071
+ },
1072
+ };
1073
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
1074
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1075
+ mod.default(pi);
1076
+ await tool.execute("tool-call-hung-successor", {}, undefined, undefined, {});
1077
+ // Three unready successors each cost the full readiness budget, so wait well
1078
+ // past their sum. The wait ends as soon as the wake lands.
1079
+ for (let i = 0; i < 1500 && !prompt; i += 1) {
1080
+ await new Promise((resolve) => setTimeout(resolve, 10));
1081
+ }
1082
+ const rows = existsSync(process.env.FM_ARM_LOG)
1083
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
1084
+ : [];
1085
+ if (rows.length !== 4) throw new Error(`expected one successor plus two retries, got ${rows.length}: ${rows.join(" | ")}`);
1086
+ if (rowsAtPrompt !== 4) throw new Error(`wake arrived before restoration exhausted (${rowsAtPrompt} arm rows)`);
1087
+ if (!prompt.includes("signal: synthetic wake")) throw new Error(`original wake was lost: ${prompt}`);
1088
+ if (!prompt.includes("could not restore watcher continuity after 2 retries")) throw new Error(`missing typed restoration failure: ${prompt}`);
1089
+ await new Promise((resolve) => setTimeout(resolve, 100));
1090
+ const stableRows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
1091
+ if (stableRows.length !== 4) throw new Error(`single-flight recovery launched ${stableRows.length} arms`);
1092
+ EOF
1093
+ )
1094
+ status=$?
1095
+ expect_code 0 "$status" "Pi must deliver the actionable wake after bounded hung-successor recovery"
1096
+ [ -z "$out" ] || fail "Pi hung-successor test printed output: $out"
1097
+ pass "Pi hung successor falls back to one typed actionable wake"
1098
+ }
1099
+
1100
+ test_pi_unretired_successor_falls_back_without_retry() {
1101
+ local repo home plugin log release out status
1102
+ repo="$TMP_ROOT/pi-unretired-successor-root"
1103
+ home="$TMP_ROOT/pi-unretired-successor-home"
1104
+ log="$TMP_ROOT/pi-unretired-successor.log"
1105
+ release="$TMP_ROOT/pi-unretired-successor.release"
1106
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
1107
+ install_pi_watch_extension_fixture "$repo"
1108
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
1109
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
1110
+ #!/usr/bin/env bash
1111
+ if [ -f "$FM_ARM_LOG" ]; then
1112
+ count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
1113
+ else
1114
+ count=0
1115
+ fi
1116
+ if [ "$count" -eq 0 ]; then
1117
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
1118
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
1119
+ printf 'signal: synthetic wake\n'
1120
+ exit 0
1121
+ fi
1122
+ trap '' TERM INT
1123
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
1124
+ while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.1; done
1125
+ SH
1126
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1127
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_RELEASE_FILE="$release" FM_PI_ARM_READY_TIMEOUT_MS="$ARM_READY_TIMEOUT_MS" FM_WATCH_ARM_RETIRE_TIMEOUT_MS=20 FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
1128
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
1129
+ import { pathToFileURL } from "node:url";
1130
+
1131
+ let tool = null;
1132
+ let prompt = "";
1133
+ let rowsAtPrompt = 0;
1134
+ const pi = {
1135
+ on() {},
1136
+ registerCommand() {},
1137
+ registerTool(candidate) {
1138
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
1139
+ },
1140
+ sendUserMessage: async (message) => {
1141
+ prompt += message;
1142
+ rowsAtPrompt = existsSync(process.env.FM_ARM_LOG)
1143
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n").length
1144
+ : 0;
1145
+ },
1146
+ };
1147
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
1148
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1149
+ mod.default(pi);
1150
+ await tool.execute("tool-call-unretired-successor", {}, undefined, undefined, {});
1151
+ for (let i = 0; i < 500 && !prompt; i += 1) {
1152
+ await new Promise((resolve) => setTimeout(resolve, 10));
1153
+ }
1154
+ const rows = existsSync(process.env.FM_ARM_LOG)
1155
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
1156
+ : [];
1157
+ if (rows.length !== 2) throw new Error(`unretired arm overlapped a retry: ${rows.join(" | ")}`);
1158
+ if (rowsAtPrompt !== 2) throw new Error(`wake arrived after an overlapping retry (${rowsAtPrompt} arm rows)`);
1159
+ if (!prompt.includes("signal: synthetic wake")) throw new Error(`original wake was lost: ${prompt}`);
1160
+ if (!prompt.includes("unready successor arm did not exit within 20ms")) throw new Error(`missing unretired-arm failure: ${prompt}`);
1161
+ writeFileSync(process.env.FM_RELEASE_FILE, "release\n");
1162
+ await new Promise((resolve) => setTimeout(resolve, 80));
1163
+ EOF
1164
+ )
1165
+ status=$?
1166
+ expect_code 0 "$status" "Pi must fall back without overlapping an unretired successor"
1167
+ [ -z "$out" ] || fail "Pi unretired-successor test printed output: $out"
1168
+ pass "Pi unretired successor falls back without an overlapping retry"
1169
+ }
1170
+
1171
+ test_pi_late_unretired_close_resumes_supervision() {
1172
+ local kind repo home plugin log ready retired release stop out status
1173
+ for kind in actionable non-actionable; do
1174
+ repo="$TMP_ROOT/pi-late-$kind-root"
1175
+ home="$TMP_ROOT/pi-late-$kind-home"
1176
+ log="$TMP_ROOT/pi-late-$kind.log"
1177
+ ready="$TMP_ROOT/pi-late-$kind.ready"
1178
+ retired="$TMP_ROOT/pi-late-$kind.retired"
1179
+ release="$TMP_ROOT/pi-late-$kind.release"
1180
+ stop="$TMP_ROOT/pi-late-$kind.stop"
1181
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
1182
+ install_pi_watch_extension_fixture "$repo"
1183
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
1184
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
1185
+ #!/usr/bin/env bash
1186
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
1187
+ count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
1188
+ if [ "$count" -eq 1 ]; then
1189
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
1190
+ printf 'signal: original wake\n'
1191
+ exit 0
1192
+ fi
1193
+ if [ "$count" -eq 2 ]; then
1194
+ trap 'printf "retired\\n" > "${FM_UNRETIRED_RETIRE_FILE:?}"' TERM INT
1195
+ printf 'ready\n' > "${FM_UNRETIRED_READY_FILE:?}"
1196
+ while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.02; done
1197
+ [ "$FM_LATE_KIND" = actionable ] && printf 'signal: late wake\n'
1198
+ exit 0
1199
+ fi
1200
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
1201
+ trap 'exit 0' TERM INT
1202
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
1203
+ SH
1204
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1205
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_UNRETIRED_READY_FILE="$ready" FM_UNRETIRED_RETIRE_FILE="$retired" FM_RELEASE_FILE="$release" FM_STOP_FILE="$stop" FM_LATE_KIND="$kind" FM_PI_ARM_READY_TIMEOUT_MS="$ARM_READY_TIMEOUT_MS" FM_WATCH_ARM_RETIRE_TIMEOUT_MS=20 FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
1206
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
1207
+ import { pathToFileURL } from "node:url";
1208
+
1209
+ let tool = null;
1210
+ const prompts = [];
1211
+ const pi = {
1212
+ on() {},
1213
+ registerCommand() {},
1214
+ registerTool(candidate) {
1215
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
1216
+ },
1217
+ sendUserMessage: async (message) => {
1218
+ prompts.push(message);
1219
+ },
1220
+ };
1221
+ const rows = () => existsSync(process.env.FM_ARM_LOG)
1222
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
1223
+ : [];
1224
+ async function waitFor(predicate, message) {
1225
+ for (let i = 0; i < 500; i += 1) {
1226
+ if (predicate()) return;
1227
+ await new Promise((resolve) => setTimeout(resolve, 10));
1228
+ }
1229
+ throw new Error(message);
1230
+ }
1231
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
1232
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1233
+ mod.default(pi);
1234
+ await tool.execute("tool-call-late-close", {}, undefined, undefined, {});
1235
+ await waitFor(
1236
+ () => existsSync(process.env.FM_UNRETIRED_READY_FILE),
1237
+ "unretired successor did not enter its retirement wait",
1238
+ );
1239
+ await waitFor(() => prompts.length >= 1, "original fallback was not delivered");
1240
+ await waitFor(
1241
+ () => existsSync(process.env.FM_UNRETIRED_RETIRE_FILE),
1242
+ "unretired successor was not asked to retire before fallback",
1243
+ );
1244
+ if (rows().length !== 2) throw new Error(`unretired arm overlapped before fallback: ${rows().join(" | ")}`);
1245
+ if (!prompts[0]?.includes("original wake")) throw new Error(`missing original fallback: ${prompts.join(" | ")}`);
1246
+ writeFileSync(process.env.FM_RELEASE_FILE, "release\n");
1247
+ for (let i = 0; i < 500; i += 1) {
1248
+ if (rows().length >= 3 && (process.env.FM_LATE_KIND !== "actionable" || prompts.some((message) => message.includes("late wake")))) break;
1249
+ await new Promise((resolve) => setTimeout(resolve, 10));
1250
+ }
1251
+ if (rows().length !== 3) throw new Error(`late close did not restore one successor: ${rows().join(" | ")}`);
1252
+ if (process.env.FM_LATE_KIND === "actionable") {
1253
+ if (prompts.length !== 2 || !prompts[1].includes("late wake")) throw new Error(`late actionable close was not delivered: ${prompts.join(" | ")}`);
1254
+ } else if (prompts.length !== 1) {
1255
+ throw new Error(`late non-actionable close sent an extra wake: ${prompts.join(" | ")}`);
1256
+ }
1257
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
1258
+ await new Promise((resolve) => setTimeout(resolve, 80));
1259
+ EOF
1260
+ )
1261
+ status=$?
1262
+ expect_code 0 "$status" "Pi late $kind close must remain supervised after fallback"
1263
+ [ -z "$out" ] || fail "Pi late-$kind test printed output: $out"
1264
+ done
1265
+ pass "Pi late unretired closes resume classified supervision"
1266
+ }
1267
+
1268
+ test_pi_empty_close_retries_instead_of_disappearing() {
1269
+ local repo home plugin log stop out status
1270
+ repo="$TMP_ROOT/pi-empty-close-root"
1271
+ home="$TMP_ROOT/pi-empty-close-home"
1272
+ log="$TMP_ROOT/pi-empty-close.log"
1273
+ stop="$TMP_ROOT/pi-empty-close.stop"
1274
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
1275
+ install_pi_watch_extension_fixture "$repo"
1276
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
1277
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
1278
+ #!/usr/bin/env bash
1279
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
1280
+ count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
1281
+ if [ "$count" -eq 1 ]; then exit 0; fi
1282
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
1283
+ trap 'exit 0' TERM INT
1284
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
1285
+ SH
1286
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1287
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
1288
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
1289
+ import { pathToFileURL } from "node:url";
1290
+
1291
+ let tool = null;
1292
+ let prompts = 0;
1293
+ const pi = {
1294
+ on() {},
1295
+ registerCommand() {},
1296
+ registerTool(candidate) {
1297
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
1298
+ },
1299
+ sendUserMessage: async () => {
1300
+ prompts += 1;
1301
+ },
1302
+ };
1303
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
1304
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1305
+ mod.default(pi);
1306
+ await tool.execute("tool-call-empty", {}, undefined, undefined, {});
1307
+ for (let i = 0; i < 250; i += 1) {
1308
+ const rows = existsSync(process.env.FM_ARM_LOG)
1309
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
1310
+ : [];
1311
+ if (rows.length >= 2) break;
1312
+ await new Promise((resolve) => setTimeout(resolve, 10));
1313
+ }
1314
+ const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
1315
+ if (rows.length !== 2) throw new Error(`clean empty close was ignored: ${rows.join(" | ")}`);
1316
+ if (prompts !== 0) throw new Error(`restored transient close surfaced ${prompts} failure prompts`);
1317
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
1318
+ process.exit(0);
1319
+ EOF
1320
+ )
1321
+ status=$?
1322
+ expect_code 0 "$status" "Pi clean empty close must trigger a bounded continuity retry"
1323
+ [ -z "$out" ] || fail "Pi empty-close retry test printed output: $out"
1324
+ pass "Pi clean empty close triggers a bounded continuity retry"
1325
+ }
1326
+
1327
+ test_pi_established_empty_close_honors_retry_limit() {
1328
+ local repo home plugin log out status
1329
+ repo="$TMP_ROOT/pi-established-empty-close-root"
1330
+ home="$TMP_ROOT/pi-established-empty-close-home"
1331
+ log="$TMP_ROOT/pi-established-empty-close.log"
1332
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
1333
+ install_pi_watch_extension_fixture "$repo"
1334
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
1335
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
1336
+ #!/usr/bin/env bash
1337
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
1338
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
1339
+ exit 0
1340
+ SH
1341
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1342
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
1343
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
1344
+ import { pathToFileURL } from "node:url";
1345
+
1346
+ let tool = null;
1347
+ let prompt = "";
1348
+ const pi = {
1349
+ on() {},
1350
+ registerCommand() {},
1351
+ registerTool(candidate) {
1352
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
1353
+ },
1354
+ sendUserMessage: async (message) => {
1355
+ prompt += message;
1356
+ },
1357
+ };
1358
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
1359
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1360
+ mod.default(pi);
1361
+ await tool.execute("tool-call-established-empty", {}, undefined, undefined, {});
1362
+ for (let i = 0; i < 250 && !prompt; i += 1) {
1363
+ await new Promise((resolve) => setTimeout(resolve, 10));
1364
+ }
1365
+ const rows = existsSync(process.env.FM_ARM_LOG)
1366
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
1367
+ : [];
1368
+ if (rows.length !== 3) throw new Error(`retry limit launched ${rows.length} arm cycles: ${rows.join(" | ")}`);
1369
+ if (!prompt.includes("after 2 retries")) throw new Error(`retry exhaustion was not surfaced: ${prompt}`);
1370
+ EOF
1371
+ )
1372
+ status=$?
1373
+ expect_code 0 "$status" "Pi established clean closes must honor the continuity retry limit"
1374
+ [ -z "$out" ] || fail "Pi established-empty-close retry test printed output: $out"
1375
+ pass "Pi established clean closes stop at the configured retry limit"
1376
+ }
1377
+
1378
+ test_pi_actionable_close_rechecks_session_lock() {
1379
+ local repo home plugin log release out status
1380
+ repo="$TMP_ROOT/pi-close-lock-root"
1381
+ home="$TMP_ROOT/pi-close-lock-home"
1382
+ log="$TMP_ROOT/pi-close-lock.log"
1383
+ release="$TMP_ROOT/pi-close-lock.release"
1384
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
1385
+ install_pi_watch_extension_fixture "$repo"
1386
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
1387
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
1388
+ #!/usr/bin/env bash
1389
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
1390
+ while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.02; done
1391
+ printf 'signal: lock handoff\n'
1392
+ SH
1393
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1394
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_RELEASE_FILE="$release" node --input-type=module 2>&1 <<'EOF'
1395
+ import { spawn } from "node:child_process";
1396
+ import { readFileSync, writeFileSync } from "node:fs";
1397
+ import { pathToFileURL } from "node:url";
1398
+
1399
+ let tool = null;
1400
+ let prompt = "";
1401
+ const pi = {
1402
+ on() {},
1403
+ registerCommand() {},
1404
+ registerTool(candidate) {
1405
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
1406
+ },
1407
+ sendUserMessage: async (message) => {
1408
+ prompt += message;
1409
+ },
1410
+ };
1411
+ const lock = `${process.env.FM_HOME}/state/.lock`;
1412
+ writeFileSync(lock, `${process.pid}\n`);
1413
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1414
+ mod.default(pi);
1415
+ await tool.execute("tool-call-lock-close", {}, undefined, undefined, {});
1416
+ const other = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
1417
+ try {
1418
+ writeFileSync(lock, `${other.pid}\n`);
1419
+ writeFileSync(process.env.FM_RELEASE_FILE, "release\n");
1420
+ for (let i = 0; i < 250 && !prompt.includes("no longer owns the lock"); i += 1) {
1421
+ await new Promise((resolve) => setTimeout(resolve, 10));
1422
+ }
1423
+ const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
1424
+ if (rows.length !== 1) throw new Error(`successor launched after lock loss: ${rows.join(" | ")}`);
1425
+ if (!prompt.includes("no longer owns the lock")) throw new Error(`missing lock-loss failure: ${prompt}`);
1426
+ } finally {
1427
+ other.kill("SIGTERM");
1428
+ }
1429
+ EOF
1430
+ )
1431
+ status=$?
1432
+ [ "$status" -eq 0 ] || fail "Pi close handler must verify session-lock ownership before successor launch: $out"
1433
+ [ -z "$out" ] || fail "Pi close lock test printed output: $out"
1434
+ pass "Pi close handler verifies session-lock ownership before successor launch"
1435
+ }
1436
+
1437
+ test_pi_arm_distinguishes_session_lock_ownership() {
1438
+ local repo home plugin log out status
1439
+ repo="$TMP_ROOT/pi-lock-ownership-root"
1440
+ home="$TMP_ROOT/pi-lock-ownership-home"
1441
+ log="$TMP_ROOT/pi-lock-ownership.log"
1442
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
1443
+ install_pi_watch_extension_fixture "$repo"
1444
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
1445
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
1446
+ #!/usr/bin/env bash
1447
+ printf 'arm\n' >> "${FM_ARM_LOG:?}"
1448
+ SH
1449
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1450
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" node --input-type=module 2>&1 <<'EOF'
1451
+ import { existsSync, unlinkSync, writeFileSync } from "node:fs";
1452
+ import { spawn } from "node:child_process";
1453
+ import { pathToFileURL } from "node:url";
1454
+
1455
+ let tool = null;
1456
+ const pi = {
1457
+ on() {},
1458
+ registerCommand() {},
1459
+ registerTool(candidate) {
1460
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
1461
+ },
1462
+ sendUserMessage: async () => {},
1463
+ };
1464
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1465
+ mod.default(pi);
1466
+ if (!tool) throw new Error("Pi watch tool was not registered");
1467
+
1468
+ const lock = `${process.env.FM_HOME}/state/.lock`;
1469
+ const callArm = () => tool.execute("tool-call-lock", {}, undefined, undefined, {});
1470
+ const assertMissingLock = (result, label) => {
1471
+ if (result.details?.ok !== false) throw new Error(`${label} unexpectedly armed: ${JSON.stringify(result.details)}`);
1472
+ if (!result.details.message.includes("no live session holds the lock")) {
1473
+ throw new Error(`${label} missing no-live-session guidance: ${result.details.message}`);
1474
+ }
1475
+ if (!result.details.message.includes("bin/fm-session-start.sh") || !result.details.message.includes("re-arm")) {
1476
+ throw new Error(`${label} missing reclaim and re-arm guidance: ${result.details.message}`);
1477
+ }
1478
+ if (result.details.message.includes("held by another firstmate session")) {
1479
+ throw new Error(`${label} was misreported as a live other holder: ${result.details.message}`);
1480
+ }
1481
+ };
1482
+
1483
+ if (existsSync(lock)) unlinkSync(lock);
1484
+ assertMissingLock(await callArm(), "absent lock");
1485
+ writeFileSync(lock, "999999\n");
1486
+ assertMissingLock(await callArm(), "dead lock holder");
1487
+
1488
+ const other = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
1489
+ try {
1490
+ writeFileSync(lock, `${other.pid}\n`);
1491
+ const liveOther = await callArm();
1492
+ if (liveOther.details?.ok !== false) throw new Error(`live other holder unexpectedly armed: ${JSON.stringify(liveOther.details)}`);
1493
+ if (liveOther.details.message !== "watcher: read-only - session lock is held by another firstmate session") {
1494
+ throw new Error(`unexpected live-other response: ${liveOther.details.message}`);
1495
+ }
1496
+ } finally {
1497
+ other.kill("SIGTERM");
1498
+ }
1499
+
1500
+ if (existsSync(process.env.FM_ARM_LOG)) throw new Error("watcher arm ran without lock ownership");
1501
+ writeFileSync(lock, `${process.pid}\n`);
1502
+ const owned = await callArm();
1503
+ if (owned.details?.ok !== true || !owned.details.message.includes("started Pi extension arm child")) {
1504
+ throw new Error(`owned lock did not arm: ${JSON.stringify(owned.details)}`);
1505
+ }
1506
+ for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
1507
+ await new Promise((resolve) => setTimeout(resolve, 20));
1508
+ }
1509
+ if (!existsSync(process.env.FM_ARM_LOG)) throw new Error("owned lock did not run the watcher arm");
1510
+ EOF
1511
+ )
1512
+ status=$?
1513
+ expect_code 0 "$status" "Pi watcher arm must distinguish owned, live-other, and missing or dead session locks"
1514
+ [ -z "$out" ] || fail "Pi lock-ownership arm test printed output: $out"
1515
+ pass "Pi watcher arm distinguishes all session lock ownership states"
1516
+ }
1517
+
1518
+ test_pi_session_transition_generation_owner() {
1519
+ local repo home plugin child_pid_file child_marker_file marker_root arm_log out status
1520
+ repo="$TMP_ROOT/pi-session-transition-root"
1521
+ home="$TMP_ROOT/pi-session-transition-home"
1522
+ child_pid_file="$TMP_ROOT/pi-session-transition-child.pid"
1523
+ child_marker_file="$TMP_ROOT/pi-session-transition-child.marker"
1524
+ marker_root="$TMP_ROOT/pi-session-transition-markers"
1525
+ arm_log="$TMP_ROOT/pi-session-transition-arm.log"
1526
+ mkdir -p "$repo/bin" "$home/state" "$home/config" "$marker_root"
1527
+ install_pi_watch_extension_fixture "$repo"
1528
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
1529
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
1530
+ #!/usr/bin/env bash
1531
+ # The marker identifies this exact process lifetime after its PID is recycled.
1532
+ marker=$(mktemp "${FM_MARKER_ROOT:?}/arm.XXXXXX") || exit 1
1533
+ cleanup() { rm -f "$marker"; }
1534
+ trap cleanup EXIT
1535
+ trap 'exit 0' TERM INT
1536
+ printf 'watcher: started pid=%s\n' "$$"
1537
+ printf '%s\n' "$$" > "${FM_CHILD_PID_FILE:?}"
1538
+ printf '%s\n' "$marker" > "${FM_CHILD_MARKER_FILE:?}"
1539
+ printf 'arm pid=%s marker=%s\n' "$$" "$marker" >> "${FM_ARM_LOG:?}"
1540
+ while :; do sleep 0.2; done
1541
+ SH
1542
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1543
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_CHILD_PID_FILE="$child_pid_file" FM_CHILD_MARKER_FILE="$child_marker_file" FM_MARKER_ROOT="$marker_root" FM_ARM_LOG="$arm_log" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
1544
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
1545
+ import { pathToFileURL } from "node:url";
1546
+
1547
+ function makePi() {
1548
+ const handlers = new Map();
1549
+ let tool = null;
1550
+ const pi = {
1551
+ on(event, handler) {
1552
+ handlers.set(event, handler);
1553
+ },
1554
+ registerCommand() {},
1555
+ registerTool(candidate) {
1556
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
1557
+ },
1558
+ sendUserMessage: async () => {},
1559
+ events: { on() {} },
1560
+ };
1561
+ return { pi, handlers, getTool: () => tool };
1562
+ }
1563
+
1564
+ function pidAlive(pid) {
1565
+ try {
1566
+ process.kill(Number(pid), 0);
1567
+ return true;
1568
+ } catch {
1569
+ return false;
1570
+ }
1571
+ }
1572
+
1573
+ async function waitFor(pred, label, attempts = 250) {
1574
+ for (let i = 0; i < attempts; i += 1) {
1575
+ if (pred()) return;
1576
+ await new Promise((resolve) => setTimeout(resolve, 20));
1577
+ }
1578
+ throw new Error(`timeout waiting for ${label}`);
1579
+ }
1580
+
1581
+ function currentArm() {
1582
+ try {
1583
+ return {
1584
+ pid: readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim(),
1585
+ marker: readFileSync(process.env.FM_CHILD_MARKER_FILE, "utf8").trim(),
1586
+ };
1587
+ } catch {
1588
+ return { pid: "", marker: "" };
1589
+ }
1590
+ }
1591
+
1592
+ function liveArmPids() {
1593
+ if (!existsSync(process.env.FM_ARM_LOG)) return [];
1594
+ return readFileSync(process.env.FM_ARM_LOG, "utf8")
1595
+ .trim()
1596
+ .split(/\n/)
1597
+ .filter(Boolean)
1598
+ .map((line) => {
1599
+ const match = /pid=(\d+) marker=(\S+)/.exec(line);
1600
+ return match ? { pid: match[1], marker: match[2] } : { pid: "", marker: "" };
1601
+ })
1602
+ .filter((arm) => arm.pid && arm.marker && existsSync(arm.marker) && pidAlive(arm.pid))
1603
+ .map((arm) => arm.pid);
1604
+ }
1605
+
1606
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
1607
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1608
+
1609
+ const startup = makePi();
1610
+ mod.default(startup.pi);
1611
+ await startup.handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, {});
1612
+ const first = await startup.getTool().execute("startup", {}, undefined, undefined, {});
1613
+ if (!first.details?.ok || !String(first.details.message).includes("started Pi extension arm child")) {
1614
+ throw new Error(`startup arm failed: ${JSON.stringify(first.details)}`);
1615
+ }
1616
+ await waitFor(() => {
1617
+ const arm = currentArm();
1618
+ return arm.pid && arm.marker && existsSync(arm.marker) && pidAlive(arm.pid);
1619
+ }, "startup child");
1620
+ const { pid: startupChild, marker: startupMarker } = currentArm();
1621
+ if (!pidAlive(startupChild)) throw new Error("startup child was not alive");
1622
+ const staleTool = startup.getTool();
1623
+
1624
+ async function replaceSession(previous, reason) {
1625
+ const previousArm = currentArm();
1626
+ await previous.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason }, {});
1627
+ if (previousArm.marker) {
1628
+ await waitFor(() => !existsSync(previousArm.marker), `${reason} previous child exit`);
1629
+ }
1630
+ const next = makePi();
1631
+ mod.default(next.pi);
1632
+ await next.handlers.get("session_start")?.({
1633
+ type: "session_start",
1634
+ reason,
1635
+ previousSessionFile: `/tmp/previous-${reason}.jsonl`,
1636
+ }, {});
1637
+ const armed = await next.getTool().execute(`arm-${reason}`, {}, undefined, undefined, {});
1638
+ if (!armed.details?.ok) {
1639
+ throw new Error(`${reason} replacement arm failed: ${JSON.stringify(armed.details)}`);
1640
+ }
1641
+ if (String(armed.details.message).includes("shutting down")) {
1642
+ throw new Error(`${reason} replacement still refused with shutting-down latch`);
1643
+ }
1644
+ await waitFor(() => {
1645
+ const arm = currentArm();
1646
+ return arm.pid && arm.marker && arm.marker !== previousArm.marker && existsSync(arm.marker) && pidAlive(arm.pid) && liveArmPids().includes(arm.pid);
1647
+ }, `${reason} replacement child and arm record`);
1648
+ const live = liveArmPids();
1649
+ if (live.length !== 1) {
1650
+ throw new Error(`${reason} expected exactly one live arm child, got ${live.join(",") || "(none)"}`);
1651
+ }
1652
+ return next;
1653
+ }
1654
+
1655
+ let current = await replaceSession(startup, "new");
1656
+ current = await replaceSession(current, "resume");
1657
+ current = await replaceSession(current, "fork");
1658
+
1659
+ // Same bound instance: ordinary shutdown then session_start without a fresh smidja.
1660
+ const sameInstanceArm = currentArm();
1661
+ await current.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "new" }, {});
1662
+ await current.handlers.get("session_start")?.({ type: "session_start", reason: "new" }, {});
1663
+ const sameInstanceResult = await current.getTool().execute("same-instance", {}, undefined, undefined, {});
1664
+ if (!sameInstanceResult.details?.ok || String(sameInstanceResult.details.message).includes("shutting down")) {
1665
+ throw new Error(`same-instance replacement arm failed: ${JSON.stringify(sameInstanceResult.details)}`);
1666
+ }
1667
+ await waitFor(() => {
1668
+ const arm = currentArm();
1669
+ return arm.pid && arm.marker && arm.marker !== sameInstanceArm.marker && existsSync(arm.marker) && pidAlive(arm.pid) && liveArmPids().includes(arm.pid);
1670
+ }, "same-instance replacement child and arm record");
1671
+ await waitFor(() => !existsSync(sameInstanceArm.marker), "same-instance previous child exit");
1672
+ if (liveArmPids().length !== 1) {
1673
+ throw new Error(`same-instance expected one live arm child, got ${liveArmPids().join(",")}`);
1674
+ }
1675
+
1676
+ // Stale prior-generation callback must not stop, rearm, or clear the active generation.
1677
+ const activeChild = currentArm().pid;
1678
+ const stale = await staleTool.execute("stale-prior-generation", {}, undefined, undefined, {});
1679
+ if (stale.details?.ok !== false || !String(stale.details.message).includes("shutting down")) {
1680
+ throw new Error(`stale prior generation did not refuse: ${JSON.stringify(stale.details)}`);
1681
+ }
1682
+ if (!pidAlive(activeChild)) throw new Error("active generation child died after stale callback");
1683
+ if (existsSync(startupMarker)) throw new Error("startup generation child was resurrected");
1684
+ // Model the old generation PID being recycled for the active child.
1685
+ // Its retired lifetime marker must keep the historical row from counting live.
1686
+ writeFileSync(process.env.FM_ARM_LOG, `arm pid=${activeChild} marker=${startupMarker}\n`, { flag: "a" });
1687
+ if (liveArmPids().length !== 1 || liveArmPids()[0] !== activeChild) {
1688
+ throw new Error(`stale callback mutated live arm set: ${liveArmPids().join(",")}`);
1689
+ }
1690
+ const redundant = await current.getTool().execute("redundant", {}, undefined, undefined, {});
1691
+ if (!redundant.details?.ok || !String(redundant.details.message).includes("unchanged")) {
1692
+ throw new Error(`active generation lost single-flight ownership: ${JSON.stringify(redundant.details)}`);
1693
+ }
1694
+
1695
+ // Repeated transitions keep exactly one live cycle and never revive the refusal.
1696
+ for (const reason of ["resume", "fork", "new", "resume"]) {
1697
+ current = await replaceSession(current, reason);
1698
+ }
1699
+
1700
+ // Real terminal shutdown still blocks late rearming.
1701
+ const finalArm = currentArm();
1702
+ await current.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, {});
1703
+ await waitFor(() => !existsSync(finalArm.marker), "terminal shutdown child exit");
1704
+ const quitArm = await current.getTool().execute("after-quit", {}, undefined, undefined, {});
1705
+ if (quitArm.details?.ok !== false || quitArm.details.message !== "watcher: not armed - Pi session is shutting down") {
1706
+ throw new Error(`terminal quit must keep the shutting-down refusal: ${JSON.stringify(quitArm.details)}`);
1707
+ }
1708
+ if (liveArmPids().length !== 0) {
1709
+ throw new Error(`terminal quit left live arm children: ${liveArmPids().join(",")}`);
1710
+ }
1711
+ EOF
1712
+ )
1713
+ status=$?
1714
+ [ "$status" -eq 0 ] || fail "Pi session transitions must rearm through an explicit generation owner (exit $status): $out"
1715
+ [ -z "$out" ] || fail "Pi session-transition generation owner test printed output: $out"
1716
+ pass "Pi session transitions use a generation owner across /new /resume /fork, stale callbacks, and quit"
1717
+ }
1718
+
1719
+ test_pi_process_exit_cleanup_listener_lifecycle() {
1720
+ local repo home plugin out status
1721
+ repo="$TMP_ROOT/pi-exit-listener-root"
1722
+ home="$TMP_ROOT/pi-exit-listener-home"
1723
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
1724
+ install_pi_watch_extension_fixture "$repo"
1725
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
1726
+ : > "$repo/bin/fm-watch-arm.sh"
1727
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1728
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" node --input-type=module 2>&1 <<'EOF'
1729
+ import { pathToFileURL } from "node:url";
1730
+
1731
+ const handlers = new Map();
1732
+ const pi = {
1733
+ on(event, handler) {
1734
+ handlers.set(event, handler);
1735
+ },
1736
+ registerCommand() {},
1737
+ registerTool() {},
1738
+ sendUserMessage: async () => {},
1739
+ };
1740
+ const before = process.listenerCount("exit");
1741
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1742
+ mod.default(pi);
1743
+ if (process.listenerCount("exit") !== before + 1) {
1744
+ throw new Error("Pi extension did not install exactly one process-exit fallback");
1745
+ }
1746
+ await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, {});
1747
+ if (process.listenerCount("exit") !== before + 1) {
1748
+ throw new Error("session_shutdown removed the process-lifetime exit fallback");
1749
+ }
1750
+ await handlers.get("session_start")?.({ type: "session_start" }, {});
1751
+ if (process.listenerCount("exit") !== before + 1) {
1752
+ throw new Error("replacement activation duplicated the process-exit fallback");
1753
+ }
1754
+ EOF
1755
+ )
1756
+ status=$?
1757
+ expect_code 0 "$status" "Pi cleanup fallback listener must remain singular across session replacement"
1758
+ [ -z "$out" ] || fail "Pi listener-lifecycle test printed output: $out"
1759
+ pass "Pi process-exit cleanup listener remains singular across session replacement"
1760
+ }
1761
+
1762
+ test_pi_process_exit_cleanup_stops_arm_child() {
1763
+ local repo home plugin cleanup_log pid_file out status pid i
1764
+ repo="$TMP_ROOT/pi-process-exit-root"
1765
+ home="$TMP_ROOT/pi-process-exit-home"
1766
+ cleanup_log="$TMP_ROOT/pi-process-exit-cleaned"
1767
+ pid_file="$TMP_ROOT/pi-process-exit-child.pid"
1768
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
1769
+ install_pi_watch_extension_fixture "$repo"
1770
+ plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
1771
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
1772
+ #!/usr/bin/env bash
1773
+ trap 'printf "%s\n" "$$" >> "$FM_CLEANUP_LOG"; exit 0' TERM
1774
+ printf '%s\n' "$$" > "$FM_CHILD_PID_FILE"
1775
+ while :; do sleep 1; done
1776
+ SH
1777
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1778
+ out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_CLEANUP_LOG="$cleanup_log" FM_CHILD_PID_FILE="$pid_file" node --input-type=module 2>&1 <<'EOF'
1779
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
1780
+ import { pathToFileURL } from "node:url";
1781
+
1782
+ let tool = null;
1783
+ const handlers = new Map();
1784
+ const pi = {
1785
+ on(event, handler) {
1786
+ handlers.set(event, handler);
1787
+ },
1788
+ registerCommand() {},
1789
+ registerTool(candidate) {
1790
+ if (candidate.name === "fm_watch_arm_pi") tool = candidate;
1791
+ },
1792
+ sendUserMessage: async () => {},
1793
+ };
1794
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
1795
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1796
+ mod.default(pi);
1797
+ await tool.execute("tool-call-exit", {}, undefined, undefined, {});
1798
+ for (let i = 0; i < 250 && !existsSync(process.env.FM_CHILD_PID_FILE); i += 1) {
1799
+ await new Promise((resolve) => setTimeout(resolve, 20));
1800
+ }
1801
+ if (!existsSync(process.env.FM_CHILD_PID_FILE)) throw new Error("arm child did not start");
1802
+ const firstChild = readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim();
1803
+ await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, {});
1804
+ await handlers.get("session_start")?.({ type: "session_start" }, {});
1805
+ await tool.execute("tool-call-replacement", {}, undefined, undefined, {});
1806
+ for (let i = 0; i < 250; i += 1) {
1807
+ const currentChild = readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim();
1808
+ if (currentChild !== firstChild) break;
1809
+ await new Promise((resolve) => setTimeout(resolve, 20));
1810
+ }
1811
+ if (readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim() === firstChild) {
1812
+ throw new Error("replacement arm child did not start");
1813
+ }
1814
+ process.exit(0);
1815
+ EOF
1816
+ )
1817
+ status=$?
1818
+ expect_code 0 "$status" "Pi process exit must run the watcher cleanup fallback"
1819
+ [ -z "$out" ] || fail "Pi process-exit cleanup test printed output: $out"
1820
+ pid=$(cat "$pid_file")
1821
+ i=0
1822
+ while [ "$i" -lt 250 ] && ! grep -qx "$pid" "$cleanup_log" 2>/dev/null; do
1823
+ sleep 0.02
1824
+ i=$((i + 1))
1825
+ done
1826
+ grep -qx "$pid" "$cleanup_log" 2>/dev/null || fail "Pi process-exit fallback did not deliver TERM to the replacement arm child"
1827
+ if kill -0 "$pid" 2>/dev/null; then
1828
+ kill -TERM "$pid" 2>/dev/null || true
1829
+ fail "Pi arm child $pid survived process-exit cleanup"
1830
+ fi
1831
+ pass "Pi process-exit cleanup stops the attached arm child"
1832
+ }
1833
+
1834
+ test_opencode_plugin_package_boundary_is_explicit_esm() {
1835
+ local fixture plugin out status
1836
+ fixture="$TMP_ROOT/opencode-esm-boundary/.opencode"
1837
+ plugin="$fixture/plugins/fm-primary-watch-arm.js"
1838
+ mkdir -p "$fixture/plugins/lib"
1839
+ printf '%s\n' '{"dependencies":{}}' > "$fixture/package.json"
1840
+ cp "$ROOT/.opencode/plugins/package.json" "$fixture/plugins/package.json"
1841
+ cp "$ROOT/.opencode/plugins/fm-primary-watch-arm.js" "$plugin"
1842
+ cp "$ROOT/.opencode/plugins/lib/fm-operational-input.js" "$fixture/plugins/lib/fm-operational-input.js"
1843
+ out=$(PLUGIN="$plugin" node --input-type=module 2>&1 <<'EOF'
1844
+ import { pathToFileURL } from "node:url";
1845
+ await import(pathToFileURL(process.env.PLUGIN).href);
1846
+ EOF
1847
+ )
1848
+ status=$?
1849
+ expect_code 0 "$status" "OpenCode plugin must import beneath an explicit ESM package boundary"
1850
+ [ -z "$out" ] || fail "OpenCode ESM boundary import printed output: $out"
1851
+ pass "OpenCode plugins have an explicit ESM boundary even under a typeless parent package"
1852
+ }
1853
+
1854
+ test_opencode_primary_watch_plugin_uses_effective_state_home() {
1855
+ local plugin repo home log out status
1856
+ plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
1857
+ repo="$TMP_ROOT/opencode-effective-state-root"
1858
+ home="$TMP_ROOT/opencode-effective-state-home"
1859
+ log="$TMP_ROOT/opencode-effective-state.log"
1860
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
1861
+ git init -q "$repo"
1862
+ : > "$repo/AGENTS.md"
1863
+ : > "$home/state/task.meta"
1864
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
1865
+ #!/usr/bin/env bash
1866
+ printf 'home=%s root=%s\n' "${FM_HOME:-}" "${FM_ROOT_OVERRIDE:-}" >> "${FM_ARM_LOG:?}"
1867
+ printf 'watcher: healthy pid=1 (beacon 0s)\n'
1868
+ SH
1869
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1870
+ out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" node 2>&1 <<'EOF'
1871
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
1872
+ import { pathToFileURL } from "node:url";
1873
+
1874
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1875
+ const client = { session: { promptAsync: async () => {} } };
1876
+ const hooks = await mod.FmPrimaryWatchArm({
1877
+ client,
1878
+ directory: process.env.WORKTREE,
1879
+ worktree: process.env.WORKTREE,
1880
+ });
1881
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
1882
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
1883
+ for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
1884
+ await new Promise((resolve) => setTimeout(resolve, 20));
1885
+ }
1886
+ if (!existsSync(process.env.FM_ARM_LOG)) {
1887
+ console.error("watch arm did not run");
1888
+ process.exit(1);
1889
+ }
1890
+ const text = readFileSync(process.env.FM_ARM_LOG, "utf8");
1891
+ const expectedRoot = realpathSync(process.env.WORKTREE);
1892
+ if (!text.includes(`home=${process.env.FM_HOME}`) || !text.includes(`root=${expectedRoot}`)) {
1893
+ console.error(text);
1894
+ process.exit(1);
1895
+ }
1896
+ EOF
1897
+ )
1898
+ status=$?
1899
+ expect_code 0 "$status" "OpenCode watch plugin must use FM_HOME state outside the repo root"
1900
+ [ -z "$out" ] || fail "OpenCode effective-state test printed output: $out"
1901
+ pass "OpenCode watcher plugin uses the effective FM_HOME state"
1902
+ }
1903
+
1904
+ test_opencode_primary_watch_plugin_sources_effective_config() {
1905
+ local plugin repo home log out status
1906
+ plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
1907
+ repo="$TMP_ROOT/opencode-effective-config-root"
1908
+ home="$TMP_ROOT/opencode-effective-config-home"
1909
+ log="$TMP_ROOT/opencode-effective-config.log"
1910
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
1911
+ git init -q "$repo"
1912
+ : > "$repo/AGENTS.md"
1913
+ printf 'export FM_POLL=7\n' > "$home/config/x-mode.env"
1914
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
1915
+ #!/usr/bin/env bash
1916
+ printf 'poll=%s\n' "${FM_POLL:-missing}" >> "${FM_ARM_LOG:?}"
1917
+ printf 'watcher: healthy pid=1 (beacon 0s)\n'
1918
+ SH
1919
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1920
+ out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" node 2>&1 <<'EOF'
1921
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
1922
+ import { pathToFileURL } from "node:url";
1923
+
1924
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1925
+ const client = { session: { promptAsync: async () => {} } };
1926
+ const hooks = await mod.FmPrimaryWatchArm({
1927
+ client,
1928
+ directory: process.env.WORKTREE,
1929
+ worktree: process.env.WORKTREE,
1930
+ });
1931
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
1932
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
1933
+ for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
1934
+ await new Promise((resolve) => setTimeout(resolve, 20));
1935
+ }
1936
+ if (!existsSync(process.env.FM_ARM_LOG)) {
1937
+ console.error("watch arm did not run");
1938
+ process.exit(1);
1939
+ }
1940
+ const text = readFileSync(process.env.FM_ARM_LOG, "utf8");
1941
+ if (!text.includes("poll=7")) {
1942
+ console.error(text);
1943
+ process.exit(1);
1944
+ }
1945
+ EOF
1946
+ )
1947
+ status=$?
1948
+ expect_code 0 "$status" "OpenCode watch plugin must source FM_HOME config outside the repo root"
1949
+ [ -z "$out" ] || fail "OpenCode effective-config test printed output: $out"
1950
+ pass "OpenCode watcher plugin sources the effective config"
1951
+ }
1952
+
1953
+ test_opencode_primary_watch_plugin_requires_session_lock() {
1954
+ local plugin repo home log out status
1955
+ plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
1956
+ repo="$TMP_ROOT/opencode-lock-root"
1957
+ home="$TMP_ROOT/opencode-lock-home"
1958
+ log="$TMP_ROOT/opencode-lock.log"
1959
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
1960
+ git init -q "$repo"
1961
+ : > "$repo/AGENTS.md"
1962
+ : > "$home/state/task.meta"
1963
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
1964
+ #!/usr/bin/env bash
1965
+ printf 'arm\n' >> "${FM_ARM_LOG:?}"
1966
+ printf 'watcher: healthy pid=1 (beacon 0s)\n'
1967
+ SH
1968
+ chmod +x "$repo/bin/fm-watch-arm.sh"
1969
+ out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" node 2>&1 <<'EOF'
1970
+ import { existsSync, writeFileSync } from "node:fs";
1971
+ import { pathToFileURL } from "node:url";
1972
+
1973
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
1974
+ const client = { session: { promptAsync: async () => {} } };
1975
+ const hooks = await mod.FmPrimaryWatchArm({
1976
+ client,
1977
+ directory: process.env.WORKTREE,
1978
+ worktree: process.env.WORKTREE,
1979
+ });
1980
+ const event = { event: { type: "session.idle", properties: { sessionID: "session-test" } } };
1981
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, "999999\n");
1982
+ await hooks.event(event);
1983
+ // The hook starts its attempt without awaiting it, and the plugin answers a
1984
+ // second attempt from the one already in flight. Join that attempt through the
1985
+ // coordinator rather than waiting a fixed span: refusing an unowned lock walks
1986
+ // git and ps probes that can outlast any such span, and the owned-lock event
1987
+ // below would then be answered from the refusal instead of arming.
1988
+ const refusal = await globalThis.__firstmateOpenCodeWatchArm.ensureArmed("session-test", client);
1989
+ if (refusal !== "read-only") {
1990
+ console.error(`expected a read-only refusal without the session lock, got ${refusal}`);
1991
+ process.exit(1);
1992
+ }
1993
+ if (existsSync(process.env.FM_ARM_LOG)) {
1994
+ console.error("watch arm ran without owning the session lock");
1995
+ process.exit(1);
1996
+ }
1997
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
1998
+ await hooks.event(event);
1999
+ for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
2000
+ await new Promise((resolve) => setTimeout(resolve, 20));
2001
+ }
2002
+ if (!existsSync(process.env.FM_ARM_LOG)) {
2003
+ console.error("watch arm did not run after the session lock matched");
2004
+ process.exit(1);
2005
+ }
2006
+ EOF
2007
+ )
2008
+ status=$?
2009
+ expect_code 0 "$status" "OpenCode watch plugin must arm only when this session owns the fleet lock"
2010
+ [ -z "$out" ] || fail "OpenCode session-lock test printed output: $out"
2011
+ pass "OpenCode watcher plugin requires session lock ownership"
2012
+ }
2013
+
2014
+ test_opencode_watch_arm_coordinator_respects_primary_scope() {
2015
+ local plugin base repo home log out status
2016
+ plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
2017
+ base="$TMP_ROOT/opencode-coordinator-base"
2018
+ repo="$TMP_ROOT/opencode-coordinator-wt"
2019
+ home="$TMP_ROOT/opencode-coordinator-home"
2020
+ log="$TMP_ROOT/opencode-coordinator.log"
2021
+ fm_git_worktree "$base" "$repo" fm/opencode-coordinator
2022
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
2023
+ : > "$repo/AGENTS.md"
2024
+ : > "$home/state/task.meta"
2025
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
2026
+ #!/usr/bin/env bash
2027
+ printf 'arm\n' >> "${FM_ARM_LOG:?}"
2028
+ printf 'watcher: healthy pid=1 (beacon 0s)\n'
2029
+ SH
2030
+ chmod +x "$repo/bin/fm-watch-arm.sh"
2031
+ out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" node 2>&1 <<'EOF'
2032
+ import { existsSync, writeFileSync } from "node:fs";
2033
+ import { pathToFileURL } from "node:url";
2034
+
2035
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
2036
+ const client = { session: { promptAsync: async () => {} } };
2037
+ await mod.FmPrimaryWatchArm({
2038
+ client,
2039
+ directory: process.env.WORKTREE,
2040
+ worktree: process.env.WORKTREE,
2041
+ });
2042
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
2043
+ const status = await globalThis.__firstmateOpenCodeWatchArm.ensureArmed("session-test", client);
2044
+ await new Promise((resolve) => setTimeout(resolve, 120));
2045
+ if (status !== "not-primary") {
2046
+ console.error(`expected not-primary, got ${status}`);
2047
+ process.exit(1);
2048
+ }
2049
+ if (existsSync(process.env.FM_ARM_LOG)) {
2050
+ console.error("coordinator armed from a linked worktree");
2051
+ process.exit(1);
2052
+ }
2053
+ EOF
2054
+ )
2055
+ status=$?
2056
+ expect_code 0 "$status" "OpenCode watch coordinator must keep primary scope checks in the shared arm path"
2057
+ [ -z "$out" ] || fail "OpenCode coordinator-scope test printed output: $out"
2058
+ pass "OpenCode watcher coordinator respects primary scope"
2059
+ }
2060
+
2061
+ test_opencode_primary_watch_plugin_rearms_after_wake() {
2062
+ local plugin repo home log stop out status
2063
+ plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
2064
+ repo="$TMP_ROOT/opencode-rearm-root"
2065
+ home="$TMP_ROOT/opencode-rearm-home"
2066
+ log="$TMP_ROOT/opencode-rearm.log"
2067
+ stop="$TMP_ROOT/opencode-rearm.stop"
2068
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
2069
+ git init -q "$repo"
2070
+ : > "$repo/AGENTS.md"
2071
+ : > "$home/state/task.meta"
2072
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
2073
+ #!/usr/bin/env bash
2074
+ if [ "${1:-}" = --handling-delivered ]; then
2075
+ printf 'confirmed generation=%s watcher=%s\n' "$2" "$4" >> "${FM_ARM_LOG:?}"
2076
+ exit 0
2077
+ fi
2078
+ printf 'arm=%s predecessor=%s\n' "$$" "${FM_WATCH_PREDECESSOR_ARM_PID:-none}" >> "${FM_ARM_LOG:?}"
2079
+ count=$(grep -c '^arm=' "$FM_ARM_LOG")
2080
+ if [ "$count" -eq 1 ]; then
2081
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
2082
+ printf 'signal: synthetic wake\n'
2083
+ exit 0
2084
+ fi
2085
+ printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
2086
+ trap 'exit 0' TERM INT
2087
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
2088
+ SH
2089
+ chmod +x "$repo/bin/fm-watch-arm.sh"
2090
+ out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node 2>&1 <<'EOF'
2091
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2092
+ import { pathToFileURL } from "node:url";
2093
+
2094
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
2095
+ let prompts = 0;
2096
+ let rowsAtPrompt = 0;
2097
+ let releasePrompt = () => {};
2098
+ const promptBlocked = new Promise((resolve) => {
2099
+ releasePrompt = resolve;
2100
+ });
2101
+ const client = {
2102
+ session: {
2103
+ promptAsync: async () => {
2104
+ rowsAtPrompt = existsSync(process.env.FM_ARM_LOG)
2105
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n").filter((row) => row.startsWith("arm=")).length
2106
+ : 0;
2107
+ prompts += 1;
2108
+ await promptBlocked;
2109
+ },
2110
+ },
2111
+ };
2112
+ const hooks = await mod.FmPrimaryWatchArm({
2113
+ client,
2114
+ directory: process.env.WORKTREE,
2115
+ worktree: process.env.WORKTREE,
2116
+ });
2117
+ const event = { event: { type: "session.idle", properties: { sessionID: "session-test" } } };
2118
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
2119
+ await hooks.event(event);
2120
+ for (let i = 0; i < 250; i += 1) {
2121
+ const rows = existsSync(process.env.FM_ARM_LOG)
2122
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
2123
+ : [];
2124
+ if (rows.length >= 2 && prompts >= 1) break;
2125
+ await new Promise((resolve) => setTimeout(resolve, 10));
2126
+ }
2127
+ const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
2128
+ const armRows = rows.filter((row) => row.startsWith("arm="));
2129
+ if (armRows.length !== 2) throw new Error(`expected one successor arm, got ${armRows.length}: ${rows.join(" | ")}`);
2130
+ if (prompts !== 1) throw new Error(`expected one blocked wake prompt, got ${prompts}`);
2131
+ if (rowsAtPrompt !== 2) throw new Error(`wake prompt began before successor establishment (${rowsAtPrompt} arm rows)`);
2132
+ if (!/predecessor=[0-9]+/.test(armRows[1])) throw new Error(`successor did not receive predecessor identity: ${armRows[1]}`);
2133
+ if (!rows.some((row) => row.startsWith("confirmed generation=fixture-generation"))) {
2134
+ throw new Error(`handling delivery was not confirmed before the follow-up: ${rows.join(" | ")}`);
2135
+ }
2136
+ await new Promise((resolve) => setTimeout(resolve, 100));
2137
+ const stableRows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
2138
+ if (stableRows.filter((row) => row.startsWith("arm=")).length !== 2) {
2139
+ throw new Error(`blocked follow-up started extra arm work: ${stableRows.join(" | ")}`);
2140
+ }
2141
+ if (stableRows.filter((row) => row.startsWith("confirmed ")).length !== 1) {
2142
+ throw new Error(`successful prompt delivery was not confirmed exactly once: ${stableRows.join(" | ")}`);
2143
+ }
2144
+ releasePrompt();
2145
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
2146
+ EOF
2147
+ )
2148
+ status=$?
2149
+ [ "$status" -eq 0 ] || fail "OpenCode watch plugin must start one successor before wake prompt delivery settles: $out"
2150
+ [ -z "$out" ] || fail "OpenCode rearm test printed output: $out"
2151
+ pass "OpenCode watcher plugin starts one successor before wake prompt delivery settles"
2152
+ }
2153
+
2154
+ test_opencode_pre_ready_actionable_close_preserves_its_successor() {
2155
+ local plugin repo home log release retired stop out status
2156
+ plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
2157
+ repo="$TMP_ROOT/opencode-pre-ready-actionable-root"
2158
+ home="$TMP_ROOT/opencode-pre-ready-actionable-home"
2159
+ log="$TMP_ROOT/opencode-pre-ready-actionable.log"
2160
+ release="$TMP_ROOT/opencode-pre-ready-actionable.release"
2161
+ retired="$TMP_ROOT/opencode-pre-ready-actionable.retired"
2162
+ stop="$TMP_ROOT/opencode-pre-ready-actionable.stop"
2163
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
2164
+ git init -q "$repo"
2165
+ : > "$repo/AGENTS.md"
2166
+ : > "$home/state/task.meta"
2167
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
2168
+ #!/usr/bin/env bash
2169
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
2170
+ count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
2171
+ if [ "$count" -eq 1 ]; then
2172
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
2173
+ printf 'signal: original wake\n'
2174
+ exit 0
2175
+ fi
2176
+ if [ "$count" -eq 2 ]; then
2177
+ printf 'signal: pre-ready successor wake\n'
2178
+ trap 'printf "retired\\n" > "${FM_PRE_READY_RETIRED_FILE:?}"; exit 0' TERM INT
2179
+ while [ ! -e "$FM_PRE_READY_RELEASE_FILE" ]; do sleep 0.02; done
2180
+ exit 0
2181
+ fi
2182
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
2183
+ trap 'exit 0' TERM INT
2184
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
2185
+ SH
2186
+ chmod +x "$repo/bin/fm-watch-arm.sh"
2187
+ out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_PRE_READY_RELEASE_FILE="$release" FM_PRE_READY_RETIRED_FILE="$retired" FM_STOP_FILE="$stop" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node 2>&1 <<'EOF'
2188
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2189
+ import { pathToFileURL } from "node:url";
2190
+
2191
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
2192
+ const prompts = [];
2193
+ const client = {
2194
+ session: {
2195
+ promptAsync: async (request) => {
2196
+ prompts.push(request.body.parts[0].text);
2197
+ },
2198
+ },
2199
+ };
2200
+ const hooks = await mod.FmPrimaryWatchArm({
2201
+ client,
2202
+ directory: process.env.WORKTREE,
2203
+ worktree: process.env.WORKTREE,
2204
+ });
2205
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
2206
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
2207
+ for (let i = 0; i < 500; i += 1) {
2208
+ const rows = existsSync(process.env.FM_ARM_LOG)
2209
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
2210
+ : [];
2211
+ if (rows.length >= 2 && prompts.some((message) => message.includes("original wake"))) break;
2212
+ await new Promise((resolve) => setTimeout(resolve, 10));
2213
+ }
2214
+ const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
2215
+ if (rows.length !== 2) throw new Error(`pre-ready successor was replaced before its close: ${rows.join(" | ")}`);
2216
+ if (!prompts.some((message) => message.includes("original wake"))) throw new Error(`original actionable wake was not delivered: ${prompts.join(" | ")}`);
2217
+ await new Promise((resolve) => setTimeout(resolve, 150));
2218
+ if (existsSync(process.env.FM_PRE_READY_RETIRED_FILE)) throw new Error("pre-ready actionable successor was retired before its close");
2219
+ writeFileSync(process.env.FM_PRE_READY_RELEASE_FILE, "release\n");
2220
+ for (let i = 0; i < 500; i += 1) {
2221
+ const successorRows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
2222
+ if (successorRows.length >= 3 && prompts.some((message) => message.includes("pre-ready successor wake"))) break;
2223
+ await new Promise((resolve) => setTimeout(resolve, 10));
2224
+ }
2225
+ const stableRows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
2226
+ if (stableRows.length !== 3) throw new Error(`pre-ready close did not create exactly one successor: ${stableRows.join(" | ")}`);
2227
+ if (!prompts.some((message) => message.includes("pre-ready successor wake"))) throw new Error(`pre-ready actionable wake was not delivered: ${prompts.join(" | ")}`);
2228
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
2229
+ EOF
2230
+ )
2231
+ status=$?
2232
+ expect_code 0 "$status" "OpenCode must retire the pre-ready arm, not its actionable successor"
2233
+ [ -z "$out" ] || fail "OpenCode pre-ready actionable test printed output: $out"
2234
+ pass "OpenCode pre-ready actionable close preserves its successor"
2235
+ }
2236
+
2237
+ test_opencode_hung_successor_falls_back_to_typed_wake() {
2238
+ local plugin repo home log out status
2239
+ plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
2240
+ repo="$TMP_ROOT/opencode-hung-successor-root"
2241
+ home="$TMP_ROOT/opencode-hung-successor-home"
2242
+ log="$TMP_ROOT/opencode-hung-successor.log"
2243
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
2244
+ git init -q "$repo"
2245
+ : > "$repo/AGENTS.md"
2246
+ : > "$home/state/task.meta"
2247
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
2248
+ #!/usr/bin/env bash
2249
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
2250
+ count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
2251
+ if [ "$count" -eq 1 ]; then
2252
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
2253
+ printf 'signal: synthetic wake\n'
2254
+ exit 0
2255
+ fi
2256
+ trap 'exit 0' TERM INT
2257
+ while :; do sleep 0.02; done
2258
+ SH
2259
+ chmod +x "$repo/bin/fm-watch-arm.sh"
2260
+ out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_OPENCODE_ARM_READY_TIMEOUT_MS="$ARM_READY_TIMEOUT_MS" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node 2>&1 <<'EOF'
2261
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2262
+ import { pathToFileURL } from "node:url";
2263
+
2264
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
2265
+ let prompt = "";
2266
+ let rowsAtPrompt = 0;
2267
+ const client = {
2268
+ session: {
2269
+ promptAsync: async (request) => {
2270
+ prompt += request.body.parts[0].text;
2271
+ rowsAtPrompt = existsSync(process.env.FM_ARM_LOG)
2272
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n").length
2273
+ : 0;
2274
+ },
2275
+ },
2276
+ };
2277
+ const hooks = await mod.FmPrimaryWatchArm({
2278
+ client,
2279
+ directory: process.env.WORKTREE,
2280
+ worktree: process.env.WORKTREE,
2281
+ });
2282
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
2283
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
2284
+ // Three unready successors each cost the full readiness budget, so wait well
2285
+ // past their sum. The wait ends as soon as the wake lands.
2286
+ for (let i = 0; i < 1500 && !prompt; i += 1) {
2287
+ await new Promise((resolve) => setTimeout(resolve, 10));
2288
+ }
2289
+ const rows = existsSync(process.env.FM_ARM_LOG)
2290
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
2291
+ : [];
2292
+ if (rows.length !== 4) throw new Error(`expected one successor plus two retries, got ${rows.length}: ${rows.join(" | ")}`);
2293
+ if (rowsAtPrompt !== 4) throw new Error(`wake arrived before restoration exhausted (${rowsAtPrompt} arm rows)`);
2294
+ if (!prompt.includes("signal: synthetic wake")) throw new Error(`original wake was lost: ${prompt}`);
2295
+ if (!prompt.includes("could not restore watcher continuity after 2 retries")) throw new Error(`missing typed restoration failure: ${prompt}`);
2296
+ await new Promise((resolve) => setTimeout(resolve, 100));
2297
+ const stableRows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
2298
+ if (stableRows.length !== 4) throw new Error(`single-flight recovery launched ${stableRows.length} arms`);
2299
+ EOF
2300
+ )
2301
+ status=$?
2302
+ expect_code 0 "$status" "OpenCode must deliver the actionable wake after bounded hung-successor recovery"
2303
+ [ -z "$out" ] || fail "OpenCode hung-successor test printed output: $out"
2304
+ pass "OpenCode hung successor falls back to one typed actionable wake"
2305
+ }
2306
+
2307
+ test_opencode_unretired_successor_falls_back_without_retry() {
2308
+ local plugin repo home log release out status
2309
+ plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
2310
+ repo="$TMP_ROOT/opencode-unretired-successor-root"
2311
+ home="$TMP_ROOT/opencode-unretired-successor-home"
2312
+ log="$TMP_ROOT/opencode-unretired-successor.log"
2313
+ release="$TMP_ROOT/opencode-unretired-successor.release"
2314
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
2315
+ git init -q "$repo"
2316
+ : > "$repo/AGENTS.md"
2317
+ : > "$home/state/task.meta"
2318
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
2319
+ #!/usr/bin/env bash
2320
+ if [ -f "$FM_ARM_LOG" ]; then
2321
+ count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
2322
+ else
2323
+ count=0
2324
+ fi
2325
+ if [ "$count" -eq 0 ]; then
2326
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
2327
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
2328
+ printf 'signal: synthetic wake\n'
2329
+ exit 0
2330
+ fi
2331
+ trap '' TERM INT
2332
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
2333
+ while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.1; done
2334
+ SH
2335
+ chmod +x "$repo/bin/fm-watch-arm.sh"
2336
+ out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_RELEASE_FILE="$release" FM_OPENCODE_ARM_READY_TIMEOUT_MS="$ARM_READY_TIMEOUT_MS" FM_WATCH_ARM_RETIRE_TIMEOUT_MS=20 FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node 2>&1 <<'EOF'
2337
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2338
+ import { pathToFileURL } from "node:url";
2339
+
2340
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
2341
+ let prompt = "";
2342
+ let rowsAtPrompt = 0;
2343
+ const client = {
2344
+ session: {
2345
+ promptAsync: async (request) => {
2346
+ prompt += request.body.parts[0].text;
2347
+ rowsAtPrompt = existsSync(process.env.FM_ARM_LOG)
2348
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n").length
2349
+ : 0;
2350
+ },
2351
+ },
2352
+ };
2353
+ const hooks = await mod.FmPrimaryWatchArm({
2354
+ client,
2355
+ directory: process.env.WORKTREE,
2356
+ worktree: process.env.WORKTREE,
2357
+ });
2358
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
2359
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
2360
+ for (let i = 0; i < 500 && !prompt; i += 1) {
2361
+ await new Promise((resolve) => setTimeout(resolve, 10));
2362
+ }
2363
+ const rows = existsSync(process.env.FM_ARM_LOG)
2364
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
2365
+ : [];
2366
+ if (rows.length !== 2) throw new Error(`unretired arm overlapped a retry: ${rows.join(" | ")}`);
2367
+ if (rowsAtPrompt !== 2) throw new Error(`wake arrived after an overlapping retry (${rowsAtPrompt} arm rows)`);
2368
+ if (!prompt.includes("signal: synthetic wake")) throw new Error(`original wake was lost: ${prompt}`);
2369
+ if (!prompt.includes("unready successor arm did not exit within 20ms")) throw new Error(`missing unretired-arm failure: ${prompt}`);
2370
+ writeFileSync(process.env.FM_RELEASE_FILE, "release\n");
2371
+ await new Promise((resolve) => setTimeout(resolve, 80));
2372
+ EOF
2373
+ )
2374
+ status=$?
2375
+ expect_code 0 "$status" "OpenCode must fall back without overlapping an unretired successor"
2376
+ [ -z "$out" ] || fail "OpenCode unretired-successor test printed output: $out"
2377
+ pass "OpenCode unretired successor falls back without an overlapping retry"
2378
+ }
2379
+
2380
+ test_opencode_late_unretired_close_resumes_supervision() {
2381
+ local kind plugin repo home log ready retired release stop out status
2382
+ for kind in actionable non-actionable; do
2383
+ plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
2384
+ repo="$TMP_ROOT/opencode-late-$kind-root"
2385
+ home="$TMP_ROOT/opencode-late-$kind-home"
2386
+ log="$TMP_ROOT/opencode-late-$kind.log"
2387
+ ready="$TMP_ROOT/opencode-late-$kind.ready"
2388
+ retired="$TMP_ROOT/opencode-late-$kind.retired"
2389
+ release="$TMP_ROOT/opencode-late-$kind.release"
2390
+ stop="$TMP_ROOT/opencode-late-$kind.stop"
2391
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
2392
+ git init -q "$repo"
2393
+ : > "$repo/AGENTS.md"
2394
+ : > "$home/state/task.meta"
2395
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
2396
+ #!/usr/bin/env bash
2397
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
2398
+ count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
2399
+ if [ "$count" -eq 1 ]; then
2400
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
2401
+ printf 'signal: original wake\n'
2402
+ exit 0
2403
+ fi
2404
+ if [ "$count" -eq 2 ]; then
2405
+ trap 'printf "retired\\n" > "${FM_UNRETIRED_RETIRE_FILE:?}"' TERM INT
2406
+ printf 'ready\n' > "${FM_UNRETIRED_READY_FILE:?}"
2407
+ while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.02; done
2408
+ [ "$FM_LATE_KIND" = actionable ] && printf 'signal: late wake\n'
2409
+ exit 0
2410
+ fi
2411
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
2412
+ trap 'exit 0' TERM INT
2413
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
2414
+ SH
2415
+ chmod +x "$repo/bin/fm-watch-arm.sh"
2416
+ out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_UNRETIRED_READY_FILE="$ready" FM_UNRETIRED_RETIRE_FILE="$retired" FM_RELEASE_FILE="$release" FM_STOP_FILE="$stop" FM_LATE_KIND="$kind" FM_OPENCODE_ARM_READY_TIMEOUT_MS="$ARM_READY_TIMEOUT_MS" FM_WATCH_ARM_RETIRE_TIMEOUT_MS=20 FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node 2>&1 <<'EOF'
2417
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2418
+ import { pathToFileURL } from "node:url";
2419
+
2420
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
2421
+ const prompts = [];
2422
+ const client = {
2423
+ session: {
2424
+ promptAsync: async (request) => {
2425
+ prompts.push(request.body.parts[0].text);
2426
+ },
2427
+ },
2428
+ };
2429
+ const rows = () => existsSync(process.env.FM_ARM_LOG)
2430
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
2431
+ : [];
2432
+ async function waitFor(predicate, message) {
2433
+ for (let i = 0; i < 500; i += 1) {
2434
+ if (predicate()) return;
2435
+ await new Promise((resolve) => setTimeout(resolve, 10));
2436
+ }
2437
+ throw new Error(message);
2438
+ }
2439
+ const hooks = await mod.FmPrimaryWatchArm({
2440
+ client,
2441
+ directory: process.env.WORKTREE,
2442
+ worktree: process.env.WORKTREE,
2443
+ });
2444
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
2445
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
2446
+ await waitFor(
2447
+ () => existsSync(process.env.FM_UNRETIRED_READY_FILE),
2448
+ "unretired successor did not enter its retirement wait",
2449
+ );
2450
+ await waitFor(() => prompts.length >= 1, "original fallback was not delivered");
2451
+ await waitFor(
2452
+ () => existsSync(process.env.FM_UNRETIRED_RETIRE_FILE),
2453
+ "unretired successor was not asked to retire before fallback",
2454
+ );
2455
+ if (rows().length !== 2) throw new Error(`unretired arm overlapped before fallback: ${rows().join(" | ")}`);
2456
+ if (!prompts[0]?.includes("original wake")) throw new Error(`missing original fallback: ${prompts.join(" | ")}`);
2457
+ writeFileSync(process.env.FM_RELEASE_FILE, "release\n");
2458
+ for (let i = 0; i < 500; i += 1) {
2459
+ if (rows().length >= 3 && (process.env.FM_LATE_KIND !== "actionable" || prompts.some((message) => message.includes("late wake")))) break;
2460
+ await new Promise((resolve) => setTimeout(resolve, 10));
2461
+ }
2462
+ if (rows().length !== 3) throw new Error(`late close did not restore one successor: ${rows().join(" | ")}`);
2463
+ if (process.env.FM_LATE_KIND === "actionable") {
2464
+ if (prompts.length !== 2 || !prompts[1].includes("late wake")) throw new Error(`late actionable close was not delivered: ${prompts.join(" | ")}`);
2465
+ } else if (prompts.length !== 1) {
2466
+ throw new Error(`late non-actionable close sent an extra wake: ${prompts.join(" | ")}`);
2467
+ }
2468
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
2469
+ await new Promise((resolve) => setTimeout(resolve, 80));
2470
+ EOF
2471
+ )
2472
+ status=$?
2473
+ expect_code 0 "$status" "OpenCode late $kind close must remain supervised after fallback"
2474
+ [ -z "$out" ] || fail "OpenCode late-$kind test printed output: $out"
2475
+ done
2476
+ pass "OpenCode late unretired closes resume classified supervision"
2477
+ }
2478
+
2479
+ test_opencode_empty_close_retries_instead_of_disappearing() {
2480
+ local plugin repo home log stop out status
2481
+ plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
2482
+ repo="$TMP_ROOT/opencode-empty-close-root"
2483
+ home="$TMP_ROOT/opencode-empty-close-home"
2484
+ log="$TMP_ROOT/opencode-empty-close.log"
2485
+ stop="$TMP_ROOT/opencode-empty-close.stop"
2486
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
2487
+ git init -q "$repo"
2488
+ : > "$repo/AGENTS.md"
2489
+ : > "$home/state/task.meta"
2490
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
2491
+ #!/usr/bin/env bash
2492
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
2493
+ count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
2494
+ if [ "$count" -eq 1 ]; then exit 0; fi
2495
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
2496
+ trap 'exit 0' TERM INT
2497
+ while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
2498
+ SH
2499
+ chmod +x "$repo/bin/fm-watch-arm.sh"
2500
+ out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node 2>&1 <<'EOF'
2501
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2502
+ import { pathToFileURL } from "node:url";
2503
+
2504
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
2505
+ let prompts = 0;
2506
+ const client = {
2507
+ session: {
2508
+ promptAsync: async () => {
2509
+ prompts += 1;
2510
+ },
2511
+ },
2512
+ };
2513
+ const hooks = await mod.FmPrimaryWatchArm({
2514
+ client,
2515
+ directory: process.env.WORKTREE,
2516
+ worktree: process.env.WORKTREE,
2517
+ });
2518
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
2519
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
2520
+ for (let i = 0; i < 250; i += 1) {
2521
+ const rows = existsSync(process.env.FM_ARM_LOG)
2522
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
2523
+ : [];
2524
+ if (rows.length >= 2) break;
2525
+ await new Promise((resolve) => setTimeout(resolve, 10));
2526
+ }
2527
+ const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
2528
+ if (rows.length !== 2) throw new Error(`clean empty close was ignored: ${rows.join(" | ")}`);
2529
+ if (prompts !== 0) throw new Error(`restored transient close surfaced ${prompts} failure prompts`);
2530
+ writeFileSync(process.env.FM_STOP_FILE, "stop\n");
2531
+ EOF
2532
+ )
2533
+ status=$?
2534
+ expect_code 0 "$status" "OpenCode clean empty close must trigger a bounded continuity retry"
2535
+ [ -z "$out" ] || fail "OpenCode empty-close retry test printed output: $out"
2536
+ pass "OpenCode clean empty close triggers a bounded continuity retry"
2537
+ }
2538
+
2539
+ test_opencode_established_empty_close_honors_retry_limit() {
2540
+ local plugin repo home log out status
2541
+ plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
2542
+ repo="$TMP_ROOT/opencode-established-empty-close-root"
2543
+ home="$TMP_ROOT/opencode-established-empty-close-home"
2544
+ log="$TMP_ROOT/opencode-established-empty-close.log"
2545
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
2546
+ git init -q "$repo"
2547
+ : > "$repo/AGENTS.md"
2548
+ : > "$home/state/task.meta"
2549
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
2550
+ #!/usr/bin/env bash
2551
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
2552
+ printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
2553
+ exit 0
2554
+ SH
2555
+ chmod +x "$repo/bin/fm-watch-arm.sh"
2556
+ out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node 2>&1 <<'EOF'
2557
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2558
+ import { pathToFileURL } from "node:url";
2559
+
2560
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
2561
+ let prompt = "";
2562
+ const client = {
2563
+ session: {
2564
+ promptAsync: async (request) => {
2565
+ prompt += request.body.parts[0].text;
2566
+ },
2567
+ },
2568
+ };
2569
+ const hooks = await mod.FmPrimaryWatchArm({
2570
+ client,
2571
+ directory: process.env.WORKTREE,
2572
+ worktree: process.env.WORKTREE,
2573
+ });
2574
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
2575
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
2576
+ for (let i = 0; i < 250 && !prompt; i += 1) {
2577
+ await new Promise((resolve) => setTimeout(resolve, 10));
2578
+ }
2579
+ const rows = existsSync(process.env.FM_ARM_LOG)
2580
+ ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
2581
+ : [];
2582
+ if (rows.length !== 3) throw new Error(`retry limit launched ${rows.length} arm cycles: ${rows.join(" | ")}`);
2583
+ if (!prompt.includes("after 2 retries")) throw new Error(`retry exhaustion was not surfaced: ${prompt}`);
2584
+ EOF
2585
+ )
2586
+ status=$?
2587
+ expect_code 0 "$status" "OpenCode established clean closes must honor the continuity retry limit"
2588
+ [ -z "$out" ] || fail "OpenCode established-empty-close retry test printed output: $out"
2589
+ pass "OpenCode established clean closes stop at the configured retry limit"
2590
+ }
2591
+
2592
+ test_opencode_actionable_close_rechecks_session_lock() {
2593
+ local plugin repo home log release out status
2594
+ plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
2595
+ repo="$TMP_ROOT/opencode-close-lock-root"
2596
+ home="$TMP_ROOT/opencode-close-lock-home"
2597
+ log="$TMP_ROOT/opencode-close-lock.log"
2598
+ release="$TMP_ROOT/opencode-close-lock.release"
2599
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
2600
+ git init -q "$repo"
2601
+ : > "$repo/AGENTS.md"
2602
+ : > "$home/state/task.meta"
2603
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
2604
+ #!/usr/bin/env bash
2605
+ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
2606
+ while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.02; done
2607
+ printf 'signal: lock handoff\n'
2608
+ SH
2609
+ chmod +x "$repo/bin/fm-watch-arm.sh"
2610
+ out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_RELEASE_FILE="$release" node 2>&1 <<'EOF'
2611
+ import { spawn } from "node:child_process";
2612
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2613
+ import { pathToFileURL } from "node:url";
2614
+
2615
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
2616
+ let prompt = "";
2617
+ const client = {
2618
+ session: {
2619
+ promptAsync: async (request) => {
2620
+ prompt += request.body.parts[0].text;
2621
+ },
2622
+ },
2623
+ };
2624
+ const hooks = await mod.FmPrimaryWatchArm({
2625
+ client,
2626
+ directory: process.env.WORKTREE,
2627
+ worktree: process.env.WORKTREE,
2628
+ });
2629
+ const lock = `${process.env.FM_HOME}/state/.lock`;
2630
+ writeFileSync(lock, `${process.pid}\n`);
2631
+ const eventPromise = hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
2632
+ for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
2633
+ await new Promise((resolve) => setTimeout(resolve, 10));
2634
+ }
2635
+ const other = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
2636
+ try {
2637
+ writeFileSync(lock, `${other.pid}\n`);
2638
+ writeFileSync(process.env.FM_RELEASE_FILE, "release\n");
2639
+ await eventPromise;
2640
+ for (let i = 0; i < 250 && !prompt.includes("no longer owns the lock"); i += 1) {
2641
+ await new Promise((resolve) => setTimeout(resolve, 10));
2642
+ }
2643
+ const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
2644
+ if (rows.length !== 1) throw new Error(`successor launched after lock loss: ${rows.join(" | ")}`);
2645
+ if (!prompt.includes("no longer owns the lock")) throw new Error(`missing lock-loss failure: ${prompt}`);
2646
+ } finally {
2647
+ other.kill("SIGTERM");
2648
+ }
2649
+ EOF
2650
+ )
2651
+ status=$?
2652
+ expect_code 0 "$status" "OpenCode close handler must verify session-lock ownership before successor launch"
2653
+ [ -z "$out" ] || fail "OpenCode close lock test printed output: $out"
2654
+ pass "OpenCode close handler verifies session-lock ownership before successor launch"
2655
+ }
2656
+
2657
+ test_opencode_watch_arm_coordinates_with_turnend_guard() {
2658
+ local arm_plugin guard_plugin repo home log guard_log out status
2659
+ arm_plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
2660
+ guard_plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js"
2661
+ repo="$TMP_ROOT/opencode-coordinate-root"
2662
+ home="$TMP_ROOT/opencode-coordinate-home"
2663
+ log="$TMP_ROOT/opencode-coordinate-arm.log"
2664
+ guard_log="$TMP_ROOT/opencode-coordinate-guard.log"
2665
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
2666
+ git init -q "$repo"
2667
+ : > "$repo/AGENTS.md"
2668
+ : > "$home/state/task.meta"
2669
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
2670
+ #!/usr/bin/env bash
2671
+ printf 'arm\n' >> "${FM_ARM_LOG:?}"
2672
+ printf 'watcher: started pid=1 (beacon fresh)\n'
2673
+ SH
2674
+ cat > "$repo/bin/fm-turnend-guard.sh" <<'SH'
2675
+ #!/usr/bin/env bash
2676
+ printf 'guard\n' >> "${FM_GUARD_LOG:?}"
2677
+ printf 'guard should not run\n' >&2
2678
+ exit 2
2679
+ SH
2680
+ chmod +x "$repo/bin/fm-watch-arm.sh" "$repo/bin/fm-turnend-guard.sh"
2681
+ out=$(ARM_PLUGIN="$arm_plugin" GUARD_PLUGIN="$guard_plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_GUARD_LOG="$guard_log" node 2>&1 <<'EOF'
2682
+ import { existsSync, writeFileSync } from "node:fs";
2683
+ import { pathToFileURL } from "node:url";
2684
+
2685
+ const armMod = await import(pathToFileURL(process.env.ARM_PLUGIN).href);
2686
+ const guardMod = await import(pathToFileURL(process.env.GUARD_PLUGIN).href);
2687
+ let promptBody = "";
2688
+ const client = {
2689
+ session: {
2690
+ promptAsync: async (request) => {
2691
+ promptBody = request.body.parts[0].text;
2692
+ },
2693
+ },
2694
+ };
2695
+ await armMod.FmPrimaryWatchArm({
2696
+ client,
2697
+ directory: process.env.WORKTREE,
2698
+ worktree: process.env.WORKTREE,
2699
+ });
2700
+ const guardHooks = await guardMod.FmPrimaryTurnendGuard({
2701
+ client,
2702
+ directory: process.env.WORKTREE,
2703
+ worktree: process.env.WORKTREE,
2704
+ });
2705
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
2706
+ await guardHooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
2707
+ for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
2708
+ await new Promise((resolve) => setTimeout(resolve, 20));
2709
+ }
2710
+ if (!existsSync(process.env.FM_ARM_LOG)) {
2711
+ console.error("watch arm did not run");
2712
+ process.exit(1);
2713
+ }
2714
+ if (existsSync(process.env.FM_GUARD_LOG)) {
2715
+ console.error("turn-end guard ran before the watch arm could establish supervision");
2716
+ process.exit(1);
2717
+ }
2718
+ if (promptBody) {
2719
+ console.error(`unexpected prompt: ${promptBody}`);
2720
+ process.exit(1);
2721
+ }
2722
+ EOF
2723
+ )
2724
+ status=$?
2725
+ expect_code 0 "$status" "OpenCode turn-end guard must let the auto-arm plugin establish supervision first"
2726
+ [ -z "$out" ] || fail "OpenCode coordination test printed output: $out"
2727
+ pass "OpenCode watcher plugin coordinates with the turn-end guard"
2728
+ }
2729
+
2730
+ test_opencode_healthy_arm_output_does_not_suppress_guard() {
2731
+ local arm_plugin guard_plugin repo home log guard_log out status
2732
+ arm_plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
2733
+ guard_plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js"
2734
+ repo="$TMP_ROOT/opencode-external-healthy-root"
2735
+ home="$TMP_ROOT/opencode-external-healthy-home"
2736
+ log="$TMP_ROOT/opencode-external-healthy-arm.log"
2737
+ guard_log="$TMP_ROOT/opencode-external-healthy-guard.log"
2738
+ mkdir -p "$repo/bin" "$home/state" "$home/config"
2739
+ git init -q "$repo"
2740
+ : > "$repo/AGENTS.md"
2741
+ : > "$home/state/task.meta"
2742
+ cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
2743
+ #!/usr/bin/env bash
2744
+ printf 'args=%s\n' "$*" >> "${FM_ARM_LOG:?}"
2745
+ printf 'watcher: healthy pid=1 (beacon 0s)\n'
2746
+ SH
2747
+ cat > "$repo/bin/fm-turnend-guard.sh" <<'SH'
2748
+ #!/usr/bin/env bash
2749
+ printf 'guard\n' >> "${FM_GUARD_LOG:?}"
2750
+ printf 'guard ran after external healthy watcher\n' >&2
2751
+ exit 2
2752
+ SH
2753
+ chmod +x "$repo/bin/fm-watch-arm.sh" "$repo/bin/fm-turnend-guard.sh"
2754
+ out=$(ARM_PLUGIN="$arm_plugin" GUARD_PLUGIN="$guard_plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_GUARD_LOG="$guard_log" node 2>&1 <<'EOF'
2755
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2756
+ import { pathToFileURL } from "node:url";
2757
+
2758
+ const armMod = await import(pathToFileURL(process.env.ARM_PLUGIN).href);
2759
+ const guardMod = await import(pathToFileURL(process.env.GUARD_PLUGIN).href);
2760
+ let promptBody = "";
2761
+ const client = {
2762
+ session: {
2763
+ promptAsync: async (request) => {
2764
+ promptBody = request.body.parts[0].text;
2765
+ },
2766
+ },
2767
+ };
2768
+ await armMod.FmPrimaryWatchArm({
2769
+ client,
2770
+ directory: process.env.WORKTREE,
2771
+ worktree: process.env.WORKTREE,
2772
+ });
2773
+ const guardHooks = await guardMod.FmPrimaryTurnendGuard({
2774
+ client,
2775
+ directory: process.env.WORKTREE,
2776
+ worktree: process.env.WORKTREE,
2777
+ });
2778
+ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
2779
+ await guardHooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
2780
+ for (let i = 0; i < 250 && !existsSync(process.env.FM_GUARD_LOG); i += 1) {
2781
+ await new Promise((resolve) => setTimeout(resolve, 20));
2782
+ }
2783
+ if (!existsSync(process.env.FM_ARM_LOG)) {
2784
+ console.error("watch arm did not run");
2785
+ process.exit(1);
2786
+ }
2787
+ if (!readFileSync(process.env.FM_ARM_LOG, "utf8").includes("args=--restart")) {
2788
+ console.error("watch arm was not asked to restart into an owned child");
2789
+ process.exit(1);
2790
+ }
2791
+ if (!existsSync(process.env.FM_GUARD_LOG)) {
2792
+ console.error("turn-end guard was suppressed by an external healthy watcher");
2793
+ process.exit(1);
2794
+ }
2795
+ if (!promptBody.includes("TURN WOULD END BLIND")) {
2796
+ console.error(`missing blind-turn prompt: ${promptBody}`);
2797
+ process.exit(1);
2798
+ }
2799
+ EOF
2800
+ )
2801
+ status=$?
2802
+ expect_code 0 "$status" "OpenCode watch plugin must not treat external healthy output as an owned arm"
2803
+ [ -z "$out" ] || fail "OpenCode external-healthy test printed output: $out"
2804
+ pass "OpenCode healthy arm output does not suppress the turn-end guard"
2805
+ }
2806
+
2807
+ test_pi_extension_reports_external_healthy_watcher
2808
+ test_pi_tool_returns_agent_tool_result
2809
+ test_pi_redundant_tool_call_is_owned_noop
2810
+ test_pi_scheduled_retry_call_is_owned_noop
2811
+ test_pi_actionable_close_starts_single_successor_before_delivery
2812
+ test_pi_branch_offer_owns_actionable_wake
2813
+ test_pi_branch_offer_flags_heartbeat
2814
+ test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check
2815
+ test_pi_main_only_check_classes_stay_on_main
2816
+ test_pi_heartbeat_restoration_failure_stays_on_main
2817
+ test_pi_watcher_failure_never_offered_to_branch
2818
+ test_pi_handling_delivery_failure_is_typed_once
2819
+ test_pi_hung_successor_falls_back_to_typed_wake
2820
+ test_pi_unretired_successor_falls_back_without_retry
2821
+ test_pi_late_unretired_close_resumes_supervision
2822
+ test_pi_empty_close_retries_instead_of_disappearing
2823
+ test_pi_established_empty_close_honors_retry_limit
2824
+ test_pi_actionable_close_rechecks_session_lock
2825
+ test_pi_arm_distinguishes_session_lock_ownership
2826
+ test_pi_session_transition_generation_owner
2827
+ test_pi_process_exit_cleanup_listener_lifecycle
2828
+ test_pi_process_exit_cleanup_stops_arm_child
2829
+ test_opencode_plugin_package_boundary_is_explicit_esm
2830
+ test_opencode_primary_watch_plugin_uses_effective_state_home
2831
+ test_opencode_primary_watch_plugin_sources_effective_config
2832
+ test_opencode_primary_watch_plugin_requires_session_lock
2833
+ test_opencode_watch_arm_coordinator_respects_primary_scope
2834
+ test_opencode_primary_watch_plugin_rearms_after_wake
2835
+ test_opencode_pre_ready_actionable_close_preserves_its_successor
2836
+ test_opencode_hung_successor_falls_back_to_typed_wake
2837
+ test_opencode_unretired_successor_falls_back_without_retry
2838
+ test_opencode_late_unretired_close_resumes_supervision
2839
+ test_opencode_empty_close_retries_instead_of_disappearing
2840
+ test_opencode_established_empty_close_honors_retry_limit
2841
+ test_opencode_actionable_close_rechecks_session_lock
2842
+ test_opencode_watch_arm_coordinates_with_turnend_guard
2843
+ test_opencode_healthy_arm_output_does_not_suppress_guard